diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml deleted file mode 100644 index 2b4c7a9..0000000 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: "报告 Bug" -description: "代码出现了非预期的问题、崩溃或报错" -title: "[Bug]: " -labels: ["type: bug", "status: needs info"] -body: - - type: markdown - attributes: - value: | - 请提供尽可能详细的信息,帮助我们快速定位和修复问题。 - - type: input - id: confirm-unique - attributes: - label: 确认唯一性 - description: 请输入:我已确认没有相同问题出现 - placeholder: 我已确认没有相同问题出现 - validations: - required: true - - type: dropdown - id: standard-operation - attributes: - label: 这是标准操作流程下出现的问题吗? - description: 确认你是按照正常方式使用,而不是非常规操作 - options: - - 是,标准操作 - - 否,非标准操作 - validations: - required: true - - type: dropdown - id: code-issue-confirm - attributes: - label: 你确认这真的是我们的代码导致的吗? - description: 请仔细思考,排除网络、系统、第三方服务等外部因素 - options: - - 是,确认是代码问题 - - 不确定 - - 否,可能是其他原因 - validations: - required: true - - type: checkboxes - id: pre-check - attributes: - label: 基础确认 - options: - - label: 我使用的是最新版本 - required: true - - label: 我已阅读过相关文档 - required: true - - type: dropdown - id: platform - attributes: - label: 使用平台 - description: 选择出现问题的平台 - options: - - Windows - - macOS - - Linux - validations: - required: true - - type: dropdown - id: severity - attributes: - label: 问题严重程度 - description: 这个问题对你的使用造成了多大影响? - options: - - 严重崩溃或数据丢失(无法使用) - - 核心功能受影响(在下一个常规发布中必须修复) - - 边缘场景或轻微问题(等待空闲时修复) - validations: - required: true - - type: textarea - id: description - attributes: - label: 问题描述 - description: 清晰描述你遇到的问题,包括实际发生了什么 - placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏 - validations: - required: true - - type: textarea - id: reproduction - attributes: - label: 复现步骤 - description: 提供详细的操作步骤,让我们能够重现这个问题 - placeholder: | - 1. 打开应用并登录账号 - 2. 进入聊天页面 - 3. 点击发送按钮 - 4. 观察到应用崩溃 - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: 预期行为 - description: 描述你期望的正确行为应该是什么样的 - placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中 - validations: - required: true - - type: textarea - id: actual-behavior - attributes: - label: 实际行为 - description: 描述实际发生的错误行为 - placeholder: 例如:点击后应用直接崩溃,显示白屏 - validations: - required: true - - type: textarea - id: logs - attributes: - label: 错误日志或截图 - description: 粘贴控制台错误信息、崩溃日志,或拖入截图 - placeholder: 请粘贴完整的错误堆栈信息 - render: shell - - type: input - id: os - attributes: - label: 操作系统版本 - description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04 - placeholder: Windows 11 24H2 - validations: - required: true - - type: input - id: app-version - attributes: - label: 应用版本 - description: 在关于页面或设置中查看版本号 - placeholder: v1.2.3 - validations: - required: true - - type: input - id: architecture - attributes: - label: 系统架构 - description: 例如:x64、arm64 - placeholder: x64 - - type: textarea - id: additional-context - attributes: - label: 补充信息 - description: 其他可能有助于定位问题的信息 - placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 3ba13e0..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml deleted file mode 100644 index e3b9db7..0000000 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: "文档反馈" -description: "文档存在错别字、描述不清晰或缺少必要的示例" -title: "[Docs]: " -labels: ["type: docs"] -body: - - type: markdown - attributes: - value: | - 优秀的文档和代码一样重要。感谢你帮助我们完善文档! - - type: input - id: confirm-unique - attributes: - label: 确认唯一性 - description: 请输入:我已确认没有相同问题出现 - placeholder: 我已确认没有相同问题出现 - validations: - required: true - - type: dropdown - id: doc-type - attributes: - label: 文档类型 - description: 问题出现在哪类文档中? - options: - - README 或项目说明 - - 安装部署文档 - - 使用教程 - - API 文档 - - 开发者文档 - - 其他 - validations: - required: true - - type: input - id: doc-link - attributes: - label: 文档位置 - description: 提供文档的 URL 或文件路径 - placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx - validations: - required: true - - type: dropdown - id: issue-type - attributes: - label: 问题类型 - description: 文档存在什么问题? - options: - - 错别字或语法错误 - - 内容过时或不准确 - - 描述不清晰或有歧义 - - 缺少必要的示例代码 - - 缺少重要的说明或警告 - - 链接失效或错误 - - 其他 - validations: - required: true - - type: textarea - id: issue-desc - attributes: - label: 问题描述 - description: 详细说明文档中存在的问题 - placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal" - validations: - required: true - - type: textarea - id: suggestion - attributes: - label: 修改建议 - description: 你认为应该如何修改? - placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异 - validations: - required: true - - type: textarea - id: additional - attributes: - label: 补充说明 - description: 其他需要补充的信息 diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml deleted file mode 100644 index f37722d..0000000 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: "功能与体验优化" -description: "对现有的功能逻辑进行优化,或改进用户体验" -title: "[Enhancement]: " -labels: ["type: enhancement"] -body: - - type: markdown - attributes: - value: | - 持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。 - - type: input - id: confirm-unique - attributes: - label: 确认唯一性 - description: 请输入:我已确认没有相同问题出现 - placeholder: 我已确认没有相同问题出现 - validations: - required: true - - type: input - id: confirm-beneficial - attributes: - label: 确认有益性 - description: 请输入:我确认这真的对应用有益 - placeholder: 我确认这真的对应用有益 - validations: - required: true - - type: checkboxes - id: pre-check - attributes: - label: 提交前确认 - options: - - label: 这是对现有功能的改进,而不是全新功能 - required: true - - type: dropdown - id: category - attributes: - label: 优化类别 - description: 这个优化主要属于哪个方面? - options: - - 性能优化(速度、内存、资源占用) - - 交互体验(操作流程、界面布局) - - 视觉设计(样式、动画、美观度) - - 易用性(降低使用门槛、减少操作步骤) - - 稳定性(减少崩溃、提高可靠性) - - 其他 - validations: - required: true - - type: textarea - id: target - attributes: - label: 目标功能或模块 - description: 你希望优化的具体功能或页面是哪个? - placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能 - validations: - required: true - - type: textarea - id: current-behavior - attributes: - label: 当前表现 - description: 描述当前功能的不足之处或存在的问题 - placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒 - validations: - required: true - - type: textarea - id: improvement - attributes: - label: 优化建议 - description: 详细说明你的优化方案和预期效果 - placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内 - validations: - required: true - - type: textarea - id: benefits - attributes: - label: 优化收益 - description: 这个优化会带来什么具体好处? - placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步 - validations: - required: true - - type: textarea - id: impact - attributes: - label: 影响范围 - description: 这个优化会影响哪些用户或场景? - placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景 - - type: checkboxes - id: contribution - attributes: - label: 参与贡献 - options: - - label: 我愿意提交 Pull Request 来实现这个优化 - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml deleted file mode 100644 index 216f223..0000000 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: "全新功能请求" -description: "提议一个目前项目中完全没有的新特性" -title: "[Feature]: " -labels: ["type: feature"] -body: - - type: markdown - attributes: - value: | - 感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。 - - type: input - id: confirm-unique - attributes: - label: 确认唯一性 - description: 请输入:我已确认没有相同问题出现 - placeholder: 我已确认没有相同问题出现 - validations: - required: true - - type: input - id: confirm-useful - attributes: - label: 确认实用性 - description: 请输入:我认为这会对大部分人都有用 - placeholder: 我认为这会对大部分人都有用 - validations: - required: true - - type: checkboxes - id: pre-check - attributes: - label: 提交前确认 - options: - - label: 这是一个全新的功能,而不是对现有功能的改进 - required: true - - type: dropdown - id: priority - attributes: - label: 功能优先级 - description: 你认为这个功能有多重要? - options: - - 高优先级(核心功能缺失,严重影响使用体验) - - 中优先级(有助于提升使用体验) - - 低优先级(锦上添花的功能) - validations: - required: true - - type: textarea - id: problem - attributes: - label: 问题或痛点 - description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力? - placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时 - validations: - required: true - - type: textarea - id: solution - attributes: - label: 期望的解决方案 - description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等 - placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地 - validations: - required: true - - type: textarea - id: use-case - attributes: - label: 使用场景 - description: 【什么时候用】你会在哪些具体情况下使用这个功能? - placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容 - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: 替代方案 - description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式? - placeholder: 例如:目前只能手动截图或逐条复制粘贴 - - type: textarea - id: reference - attributes: - label: 参考示例 - description: 其他应用中是否有类似功能可以参考? - placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能 - - type: checkboxes - id: contribution - attributes: - label: 参与贡献 - options: - - label: 我愿意提交 Pull Request 来实现这个功能 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml deleted file mode 100644 index 43ca2e1..0000000 --- a/.github/ISSUE_TEMPLATE/question.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: "使用答疑" -description: "关于如何配置、如何使用项目的求助" -title: "[Question]: " -labels: ["type: question"] -body: - - type: markdown - attributes: - value: | - 在提问之前,请确保你已经仔细阅读过我们的官方文档。 - - type: input - id: confirm-unique - attributes: - label: 确认唯一性 - description: 请输入:我已确认没有相同问题出现 - placeholder: 我已确认没有相同问题出现 - validations: - required: true - - type: checkboxes - id: pre-check - attributes: - label: 提交前确认 - options: - - label: 我已阅读过相关文档 - required: true - - type: dropdown - id: question-type - attributes: - label: 问题类型 - description: 你的问题属于哪个方面? - options: - - 安装部署问题 - - 配置相关问题 - - 功能使用问题 - - API 调用问题 - - 错误排查问题 - - 其他 - validations: - required: true - - type: textarea - id: question - attributes: - label: 问题描述 - description: 清晰描述你遇到的问题或疑问 - placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应 - validations: - required: true - - type: textarea - id: attempts - attributes: - label: 已尝试的方法 - description: 你已经尝试过哪些解决方法? - placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在 - validations: - required: true - - type: textarea - id: environment - attributes: - label: 运行环境 - description: 提供你的系统环境信息 - placeholder: | - 操作系统:Windows 11 - 应用版本:v1.2.3 - 系统架构:x64 - validations: - required: true - - type: textarea - id: code-snippet - attributes: - label: 相关配置或代码 - description: 如果涉及配置或代码问题,请粘贴相关内容 - placeholder: 粘贴你的配置文件或代码片段 - render: javascript - - type: textarea - id: screenshots - attributes: - label: 截图或日志 - description: 如有必要,请提供截图或错误日志 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index cefd3f9..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - target-branch: "dev" \ No newline at end of file diff --git a/.github/scripts/release-utils.sh b/.github/scripts/release-utils.sh deleted file mode 100644 index 390b56a..0000000 --- a/.github/scripts/release-utils.sh +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -retry_cmd() { - local attempts="$1" - local delay_seconds="$2" - shift 2 - - local i - local exit_code - for ((i = 1; i <= attempts; i++)); do - if "$@"; then - return 0 - fi - exit_code=$? - if [ "$i" -lt "$attempts" ]; then - echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2 - echo "Retrying in ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - done - - echo "Command failed after $attempts attempts: $*" >&2 - return "$exit_code" -} - -capture_cmd_with_retry() { - local result_var="$1" - local attempts="$2" - local delay_seconds="$3" - shift 3 - - local i - local output="" - local exit_code=1 - for ((i = 1; i <= attempts; i++)); do - if output="$("$@" 2>/dev/null)"; then - printf -v "$result_var" "%s" "$output" - return 0 - fi - exit_code=$? - if [ "$i" -lt "$attempts" ]; then - echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2 - echo "Retrying in ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - done - - echo "Capture command failed after $attempts attempts: $*" >&2 - return "$exit_code" -} - -wait_for_release_id() { - local repo="$1" - local tag="$2" - local attempts="${3:-12}" - local delay_seconds="${4:-2}" - - local i - local release_id - local release_api_url - for ((i = 1; i <= attempts; i++)); do - release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)" - if [[ "$release_id" =~ ^[0-9]+$ ]]; then - echo "$release_id" - return 0 - fi - - release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)" - if [[ "$release_id" =~ ^[0-9]+$ ]]; then - echo "$release_id" - return 0 - fi - - release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)" - if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then - echo "${BASH_REMATCH[1]}" - return 0 - fi - - if [ "$i" -lt "$attempts" ]; then - echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - done - - echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2 - gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true - gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true - return 1 -} - -settle_release_state() { - local repo="$1" - local release_id="$2" - local tag="$3" - local attempts="${4:-12}" - local delay_seconds="${5:-2}" - local endpoint="repos/$repo/releases/tags/$tag" - - local i - local draft_state - local prerelease_state - for ((i = 1; i <= attempts; i++)); do - gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true - gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true - draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)" - prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)" - if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then - return 0 - fi - if [ "$i" -lt "$attempts" ]; then - echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - done - - echo "Failed to settle release state for tag '$tag'." >&2 - gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true - gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true - return 1 -} - -print_release_state() { - local repo="$1" - local tag="$2" - - gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \ - || gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}' -} - -wait_for_release_absent() { - local repo="$1" - local tag="$2" - local attempts="${3:-12}" - local delay_seconds="${4:-2}" - - local i - for ((i = 1; i <= attempts; i++)); do - if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then - if [ "$i" -lt "$attempts" ]; then - echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - continue - fi - return 0 - done - - echo "Release '$tag' still exists after waiting." >&2 - gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true - return 1 -} - -wait_for_git_tag_absent() { - local repo="$1" - local tag="$2" - local attempts="${3:-12}" - local delay_seconds="${4:-2}" - - local i - for ((i = 1; i <= attempts; i++)); do - if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then - if [ "$i" -lt "$attempts" ]; then - echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2 - sleep "$delay_seconds" - fi - continue - fi - return 0 - done - - echo "Git tag '$tag' still exists after waiting." >&2 - gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true - return 1 -} - -recreate_fixed_prerelease() { - local repo="$1" - local tag="$2" - local target_branch="$3" - local release_title="$4" - local release_notes="$5" - - if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then - retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag - fi - - wait_for_release_absent "$repo" "$tag" 12 2 - - if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then - retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag" - fi - - wait_for_git_tag_absent "$repo" "$tag" 12 2 - - local created="false" - local i - for ((i = 1; i <= 6; i++)); do - if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then - created="true" - break - fi - if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then - echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2 - created="true" - break - fi - if [ "$i" -lt 6 ]; then - echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2 - sleep 3 - fi - done - - if [ "$created" != "true" ]; then - echo "Failed to create release '$tag'." >&2 - return 1 - fi - - local release_id - release_id="$(wait_for_release_id "$repo" "$tag" 12 2)" - settle_release_state "$repo" "$release_id" "$tag" 12 2 -} - -upload_release_assets_with_retry() { - local repo="$1" - local tag="$2" - shift 2 - - if [ "$#" -eq 0 ]; then - echo "No release assets provided for upload." >&2 - return 1 - fi - - wait_for_release_id "$repo" "$tag" 12 2 >/dev/null - retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber -} diff --git a/.github/workflows/anti-spam.yml b/.github/workflows/anti-spam.yml deleted file mode 100644 index 5fdd21c..0000000 --- a/.github/workflows/anti-spam.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Anti-Spam - -on: - issues: - types: [opened, edited] - -permissions: - issues: write - -jobs: - check-spam: - runs-on: ubuntu-latest - steps: - - name: Check for spam - uses: actions/github-script@v7 - with: - script: | - const issue = context.payload.issue; - const title = (issue.title || '').toLowerCase(); - const body = (issue.body || '').toLowerCase(); - const text = title + ' ' + body; - - // 博彩/赌球类 - const gamblingPatterns = [ - /世界杯.*买球/, /买球.*世界杯/, - /世界杯.*下注/, /世界杯.*竞猜/, - /世界杯.*投注/, /世界杯.*押注/, - /世界杯.*彩票/, /世界杯.*平台/, - /世界杯.*app/, /世界杯.*软件/, - /世界杯.*网站/, /世界杯.*网址/, - /足球.*买球/, /买球.*足球/, - /足球.*投注/, /足球.*押注/, - /足球.*竞猜/, /足球.*平台/, - /篮球.*买球/, /篮球.*投注/, - /体育.*投注/, /体育.*竞猜/, - /体育.*买球/, /体育.*押注/, - /赌球/, /赌博.*网站/, /赌博.*平台/, - /博彩/, /博彩.*网站/, /博彩.*平台/, - /正规.*买球/, /官方.*买球/, - /买球.*网站/, /买球.*app/, - /买球.*软件/, /买球.*网址/, - /买球.*平台/, /买球.*技巧/, - /投注.*网站/, /投注.*平台/, - /押注.*网站/, /押注.*平台/, - /竞猜.*网站/, /竞猜.*平台/, - /彩票.*网站/, /彩票.*平台/, - /欧洲杯.*买球/, /欧冠.*买球/, - /nba.*买球/, /nba.*投注/, - ]; - - // 色情/交友类 - const adultPatterns = [ - /约炮/, /一夜情/, /外围/, - /包养/, /援交/, /陪聊/, - /成人.*网站/, /成人.*视频/, - /av.*网站/, /黄色.*网站/, - ]; - - // 贷款/金融诈骗类 - const financePatterns = [ - /秒到账.*贷款/, /无抵押.*贷款/, - /征信.*贷款/, /黑户.*贷款/, - /快速.*放款/, /私人.*放贷/, - /刷单/, /兼职.*日入/, /兼职.*月入/, - /网赚/, /躺赚/, /被动收入.*平台/, - /虚拟货币.*投资/, /usdt.*投资/, - /炒币.*平台/, /数字货币.*平台/, - ]; - - // 垃圾推广类 - const spamPromoPatterns = [ - /代刷/, /粉丝.*购买/, /涨粉/, - /seo.*优化/, /快速排名/, - /微商/, /代理.*招募/, - ]; - - // 账号特征检测(新账号 + 无 contribution) - const allPatterns = [ - ...gamblingPatterns, - ...adultPatterns, - ...financePatterns, - ...spamPromoPatterns, - ]; - - const isSpam = allPatterns.some(pattern => pattern.test(text)); - - // 额外检测:标题超短且含可疑关键词(常见于批量刷单) - const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title); - - if (isSpam || suspiciousShort) { - // 确保 spam label 存在 - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'spam', - color: 'e4e669', - description: 'Spam issue' - }); - } catch (e) { - // label 已存在,忽略 - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.' - }); - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['spam'] - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned' - }); - - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - lock_reason: 'spam' - }); - - console.log(`Closed spam issue #${issue.number}: ${issue.title}`); - } diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml deleted file mode 100644 index 67243ca..0000000 --- a/.github/workflows/dev-daily-fixed.yml +++ /dev/null @@ -1,389 +0,0 @@ -name: Dev Daily - -on: - schedule: - # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00 - - cron: "0 16 * * *" - workflow_dispatch: - -concurrency: - group: dev-nightly-fixed-release - cancel-in-progress: true - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - FIXED_DEV_TAG: nightly-dev - TARGET_BRANCH: dev - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - dev_version: ${{ steps.meta.outputs.dev_version }} - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - - name: Generate daily dev version - id: meta - shell: bash - run: | - set -euo pipefail - YEAR_2="$(TZ=Asia/Shanghai date +%y)" - MONTH="$(TZ=Asia/Shanghai date +%-m)" - DAY="$(TZ=Asia/Shanghai date +%-d)" - DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}" - echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT" - echo "Dev version: $DEV_VERSION" - - - name: Recreate fixed prerelease - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页" - - dev-mac-arm64: - needs: prepare - runs-on: macos-14 - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Ensure mac key helpers are executable - shell: bash - run: | - set -euo pipefail - for file in \ - resources/key/macos/universal/xkey_helper \ - resources/key/macos/universal/image_scan_helper \ - resources/key/macos/universal/xkey_helper_macos \ - resources/key/macos/universal/libwx_key.dylib - do - if [ -f "$file" ]; then - chmod +x "$file" - ls -l "$file" - fi - done - - - name: Set dev version - shell: bash - run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package macOS arm64 dev artifacts - shell: bash - run: | - set -euo pipefail - export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" - echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then - echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." - npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' - fi - - - name: Upload macOS arm64 assets to fixed release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}" - - dev-linux: - needs: prepare - runs-on: ubuntu-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set dev version - shell: bash - run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Linux dev artifacts - run: | - npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}' - - - name: Upload Linux assets to fixed release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}" - - dev-win-x64: - needs: prepare - runs-on: windows-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set dev version - shell: bash - run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Windows x64 dev artifacts - run: | - npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}' - - - name: Upload Windows x64 assets to fixed release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}" - - dev-win-arm64: - needs: prepare - runs-on: windows-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set dev version - shell: bash - run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Windows arm64 dev artifacts - run: | - npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}' - - - name: Upload Windows arm64 assets to fixed release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}" - - update-dev-release-notes: - needs: - - prepare - - dev-mac-arm64 - - dev-linux - - dev-win-x64 - - dev-win-arm64 - if: always() && needs.prepare.result == 'success' - runs-on: ubuntu-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 1 - - - name: Update fixed dev release notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - TAG="${FIXED_DEV_TAG:-}" - if [ -z "$TAG" ]; then - echo "FIXED_DEV_TAG is empty, abort." - exit 1 - fi - REPO="$GITHUB_REPOSITORY" - RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" - echo "Using release tag: $TAG" - - if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then - echo "Release $TAG not found, skip notes update." - exit 0 - fi - - ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" - - pick_asset() { - local pattern="$1" - echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' - } - - WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")" - WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")" - MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")" - if [ -z "$MAC_ASSET" ]; then - MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")" - fi - LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")" - LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")" - - build_link() { - local name="$1" - if [ -n "$name" ]; then - echo "https://github.com/$REPO/releases/download/$TAG/$name" - fi - } - - WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" - WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" - MAC_URL="$(build_link "$MAC_ASSET")" - LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" - LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" - - cat > dev_release_notes.md </dev/null 2>&1; then - return 0 - fi - if [ "$i" -lt "$attempts" ]; then - echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..." - sleep "$delay_seconds" - fi - done - return 1 - } - - update_release_notes - source .github/scripts/release-utils.sh - RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)" - settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2 - print_release_state "$REPO" "$TAG" diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml deleted file mode 100644 index cc76345..0000000 --- a/.github/workflows/issue-auto-assign.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Issue Auto Assign - -on: - issues: - types: [opened, edited, reopened] - -permissions: - issues: write - -jobs: - assign-by-platform: - runs-on: ubuntu-latest - steps: - - name: Assign issue by selected platform - uses: actions/github-script@v7 - env: - ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }} - ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }} - ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }} - with: - script: | - const issue = context.payload.issue; - if (!issue) { - core.info("No issue payload."); - return; - } - - const labels = (issue.labels || []).map((l) => l.name); - if (!labels.includes("type: bug")) { - core.info("Skip non-bug issue."); - return; - } - - const body = issue.body || ""; - const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i); - if (!match) { - core.info("No platform field found in issue body."); - return; - } - - const rawPlatform = match[1].trim().toLowerCase(); - let platformKey = null; - if (rawPlatform.includes("windows")) platformKey = "windows"; - if (rawPlatform.includes("macos")) platformKey = "macos"; - if (rawPlatform.includes("linux")) platformKey = "linux"; - - if (!platformKey) { - core.info(`Unrecognized platform value: ${rawPlatform}`); - return; - } - - const parseAssignees = (value) => - (value || "") - .split(",") - .map((v) => v.trim()) - .filter(Boolean); - - const assigneeMap = { - windows: parseAssignees(process.env.ASSIGNEE_WINDOWS), - macos: parseAssignees(process.env.ASSIGNEE_MACOS), - linux: parseAssignees(process.env.ASSIGNEE_LINUX), - }; - - const candidates = assigneeMap[platformKey] || []; - if (candidates.length === 0) { - core.info(`No assignee configured for platform: ${platformKey}`); - return; - } - - const existing = new Set((issue.assignees || []).map((a) => a.login)); - const toAdd = candidates.filter((u) => !existing.has(u)); - if (toAdd.length === 0) { - core.info("All configured assignees already assigned."); - return; - } - - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: toAdd, - }); - - core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`); diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml deleted file mode 100644 index 13bc270..0000000 --- a/.github/workflows/preview-nightly-main.yml +++ /dev/null @@ -1,432 +0,0 @@ -name: Preview Nightly - -on: - schedule: - # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00 - - cron: "0 16 * * *" - workflow_dispatch: - -concurrency: - group: preview-nightly-fixed-release - cancel-in-progress: true - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - FIXED_PREVIEW_TAG: nightly-preview - TARGET_BRANCH: main - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - should_build: ${{ steps.meta.outputs.should_build }} - preview_version: ${{ steps.meta.outputs.preview_version }} - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - - name: Decide whether to build and generate preview version - id: meta - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - git fetch origin main --depth=1 - COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - SHOULD_BUILD=true - elif [ "$COMMITS_24H" -gt 0 ]; then - SHOULD_BUILD=true - else - SHOULD_BUILD=false - fi - - YEAR_2="$(TZ=Asia/Shanghai date +%y)" - YEARLY_RUN_COUNT=1 - LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)" - if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then - LAST_YEAR="${BASH_REMATCH[1]}" - LAST_COUNT="${BASH_REMATCH[2]}" - if [ "$LAST_YEAR" = "$YEAR_2" ]; then - YEARLY_RUN_COUNT=$((LAST_COUNT + 1)) - fi - fi - - PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}" - - echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT" - echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT" - echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)" - - - name: Recreate fixed preview prerelease - if: steps.meta.outputs.should_build == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页" - - preview-mac-arm64: - needs: prepare - if: needs.prepare.outputs.should_build == 'true' - runs-on: macos-14 - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Ensure mac key helpers are executable - shell: bash - run: | - set -euo pipefail - for file in \ - resources/key/macos/universal/xkey_helper \ - resources/key/macos/universal/image_scan_helper \ - resources/key/macos/universal/xkey_helper_macos \ - resources/key/macos/universal/libwx_key.dylib - do - if [ -f "$file" ]; then - chmod +x "$file" - ls -l "$file" - fi - done - - - name: Set preview version - shell: bash - run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package macOS arm64 preview artifacts - env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - shell: bash - run: | - set -euo pipefail - export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" - echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then - echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." - npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' - fi - - - name: Upload macOS arm64 assets to fixed preview release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}" - - preview-linux: - needs: prepare - if: needs.prepare.outputs.should_build == 'true' - runs-on: ubuntu-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set preview version - shell: bash - run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Linux preview artifacts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}' - - - name: Upload Linux assets to fixed preview release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}" - - preview-win-x64: - needs: prepare - if: needs.prepare.outputs.should_build == 'true' - runs-on: windows-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set preview version - shell: bash - run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Windows x64 preview artifacts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}' - - - name: Upload Windows x64 assets to fixed preview release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}" - - preview-win-arm64: - needs: prepare - if: needs.prepare.outputs.should_build == 'true' - runs-on: windows-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Set preview version - shell: bash - run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package Windows arm64 preview artifacts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}' - - - name: Upload Windows arm64 assets to fixed preview release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - assets=() - while IFS= read -r file; do - assets+=("$file") - done < <(find release -maxdepth 1 -type f | sort) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No release files found in ./release" - exit 1 - fi - upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}" - - update-preview-release-notes: - needs: - - prepare - - preview-mac-arm64 - - preview-linux - - preview-win-x64 - - preview-win-arm64 - if: needs.prepare.outputs.should_build == 'true' && always() - runs-on: ubuntu-latest - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - ref: ${{ env.TARGET_BRANCH }} - fetch-depth: 1 - - - name: Update preview release notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - TAG="${FIXED_PREVIEW_TAG:-}" - if [ -z "$TAG" ]; then - echo "FIXED_PREVIEW_TAG is empty, abort." - exit 1 - fi - CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}" - REPO="$GITHUB_REPOSITORY" - RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" - echo "Using release tag: $TAG" - - if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then - echo "Release $TAG not found (possibly all publish jobs failed), skip notes update." - exit 0 - fi - - ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" - - pick_asset() { - local pattern="$1" - echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' - } - - WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")" - if [ -z "$WINDOWS_ASSET" ]; then - WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')" - fi - WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")" - MAC_ASSET="$(pick_asset "[.]dmg$")" - if [ -z "$MAC_ASSET" ]; then - MAC_ASSET="$(pick_asset "[.]zip$")" - fi - LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")" - LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")" - - build_link() { - local name="$1" - if [ -n "$name" ]; then - echo "https://github.com/$REPO/releases/download/$TAG/$name" - fi - } - - WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" - WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" - MAC_URL="$(build_link "$MAC_ASSET")" - LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" - LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" - - cat > preview_release_notes.md < 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 - EOF - - update_release_notes() { - local attempts=5 - local delay_seconds=2 - local i - for ((i=1; i<=attempts; i++)); do - if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then - return 0 - fi - if [ "$i" -lt "$attempts" ]; then - echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..." - sleep "$delay_seconds" - fi - done - return 1 - } - - update_release_notes - source .github/scripts/release-utils.sh - RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)" - settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2 - print_release_state "$REPO" "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 6627afa..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,391 +0,0 @@ -name: Build and Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - -jobs: - release-mac-arm64: - runs-on: macos-14 - - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install --ignore-scripts - - - name: Ensure mac key helpers are executable - shell: bash - run: | - set -euo pipefail - for file in \ - resources/key/macos/universal/xkey_helper \ - resources/key/macos/universal/image_scan_helper \ - resources/key/macos/universal/xkey_helper_macos \ - resources/key/macos/universal/libwx_key.dylib - do - if [ -f "$file" ]; then - chmod +x "$file" - ls -l "$file" - fi - done - - - name: Sync version with tag - shell: bash - run: | - VERSION=${GITHUB_REF_NAME#v} - echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package and Publish macOS arm64 (unsigned DMG + ZIP) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_IDENTITY_AUTO_DISCOVERY: "false" - shell: bash - run: | - set -euo pipefail - export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" - echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then - echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only." - npx electron-builder --mac zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' - fi - - - name: Inject minimumVersion into latest yml - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - TAG=${GITHUB_REF_NAME} - REPO=${{ github.repository }} - MINIMUM_VERSION="4.1.7" - wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null - for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do - if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then - echo "Skip $YML_FILE because download failed after retries." - continue - fi - if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then - echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE" - fi - retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber - done - - release-linux: - runs-on: ubuntu-latest - - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: "npm" - - name: Install Dependencies - run: npm install - - - name: Ensure linux key helper is executable - shell: bash - run: | - [ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found" - - - name: Sync version with tag - shell: bash - run: | - VERSION=${GITHUB_REF_NAME#v} - echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package and Publish Linux - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' - - - name: Inject minimumVersion into latest yml - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - TAG=${GITHUB_REF_NAME} - REPO=${{ github.repository }} - MINIMUM_VERSION="4.1.7" - wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null - retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true - if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then - echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml - retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber - fi - - release: - runs-on: windows-latest - - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: 'npm' - - name: Install Dependencies - run: npm install - - - name: Sync version with tag - shell: bash - run: | - VERSION=${GITHUB_REF_NAME#v} - echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package and Publish - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' - - - name: Inject minimumVersion into latest yml - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - TAG=${GITHUB_REF_NAME} - REPO=${{ github.repository }} - MINIMUM_VERSION="4.1.7" - wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null - retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true - if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then - echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml - retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber - fi - - release-windows-arm64: - runs-on: windows-latest - - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Install Node.js - uses: actions/setup-node@v5 - with: - node-version: 24 - cache: 'npm' - - name: Install Dependencies - run: npm install - - - name: Sync version with tag - shell: bash - run: | - VERSION=${GITHUB_REF_NAME#v} - echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - - - name: Build Frontend & Type Check - shell: bash - run: | - npx tsc - npx vite build - - - name: Package and Publish Windows arm64 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' - - - name: Inject minimumVersion into latest yml - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - TAG=${GITHUB_REF_NAME} - REPO=${{ github.repository }} - MINIMUM_VERSION="4.1.7" - wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null - retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true - if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then - echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml - retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber - fi - - update-release-notes: - runs-on: ubuntu-latest - needs: - - release-mac-arm64 - - release-linux - - release - - release-windows-arm64 - - steps: - - name: Check out git repository - uses: actions/checkout@v5 - with: - fetch-depth: 1 - - - name: Generate release notes with platform download links - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - source .github/scripts/release-utils.sh - - TAG="$GITHUB_REF_NAME" - REPO="$GITHUB_REPOSITORY" - RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" - wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null - - capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets - - pick_asset() { - local pattern="$1" - echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' - } - - WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')" - if [ -z "$WINDOWS_ASSET" ]; then - WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')" - fi - WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" - MAC_ASSET="$(pick_asset "\\.dmg$")" - if [ -z "$MAC_ASSET" ]; then - MAC_ASSET="$(pick_asset "arm64\\.zip$")" - fi - LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" - LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" - - build_link() { - local name="$1" - if [ -n "$name" ]; then - echo "https://github.com/$REPO/releases/download/$TAG/$name" - fi - } - - WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" - WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" - MAC_URL="$(build_link "$MAC_ASSET")" - LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" - LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" - - cat > release_notes.md < 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源 - EOF - - retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md - - deploy-aur: - runs-on: ubuntu-latest - needs: [release-linux] - if: startsWith(github.ref, 'refs/tags/v') - steps: - - name: Check AUR credentials - id: aur-credentials - shell: bash - env: - AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - run: | - if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then - echo "::notice::AUR_SSH_PRIVATE_KEY is not configured; skipping AUR publish." - echo "enabled=false" >> "$GITHUB_OUTPUT" - else - echo "enabled=true" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout code - if: steps.aur-credentials.outputs.enabled == 'true' - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Update PKGBUILD version - if: steps.aur-credentials.outputs.enabled == 'true' - run: | - NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//') - sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD - sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD - - - name: Publish AUR package - if: steps.aur-credentials.outputs.enabled == 'true' - uses: KSXGitHub/github-actions-deploy-aur@master - with: - pkgname: weflow - pkgbuild: resources/installer/linux/PKGBUILD - updpkgsums: true - assets: | - resources/installer/linux/weflow.desktop - resources/installer/linux/icon.png - resources/installer/linux/.gitignore - - ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} - commit_username: H3CoF6 - commit_email: h3cof6@gmail.com - ssh_keyscan_types: ed25519 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 14d720f..0000000 --- a/.gitignore +++ /dev/null @@ -1,80 +0,0 @@ -# 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 -test/ - -# 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 - -# Electron dev cache -.electron/ -.cache/ - - - -# 忽略 Visual Studio 临时文件夹 -.vs/ -# 忽略 IntelliSense 缓存文件 -*.ipch -*.aps - -wcdb/ -!resources/wcdb/ -!resources/wcdb/** -xkey/ -server/ -*info -chatlab-format.md -*.bak -AGENTS.md -AGENT.md -.claude/ -CLAUDE.md -.agents/ -resources/wx_send -概述.md -pnpm-lock.yaml -/pnpm-workspace.yaml -wechat-research-site -.codex -weflow-web-offical -/Wedecrypt -/scripts/syncwcdb.py -/scripts/syncWedecrypt.py \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index c127870..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,23 +0,0 @@ -title = "Gitleaks Config" - -[extend] -# 继承默认规则 -useDefault = true - -# 排除误报路径 -[[rules]] -id = "curl-auth-header" -[rules.allowlist] -paths = [ - '''docs/HTTP-API\.md''' -] -regexes = [ - '''YOUR_TOKEN''' -] - -[[rules]] -id = "generic-api-key" -[rules.allowlist] -paths = [ - '''src/pages/ChatPage\.tsx''' -] diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 38f11c6..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -registry=https://registry.npmjs.org diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c956c6d..0000000 --- a/LICENSE +++ /dev/null @@ -1,141 +0,0 @@ -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 index 2c2d9ee..237ab79 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。 +> 📢 [加入电报群](https://t.me/weflow_cc)以下载和获取最新信息 + --- **WeFlow** is a fully local tool for viewing, analyzing, and exporting WeChat chat history in real time. It generates unique analysis reports based on your chat history. @@ -16,7 +18,6 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 Issues Downloads

- Telegram Channel Star History Rank

diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md deleted file mode 100644 index da739e2..0000000 --- a/docs/HTTP-API.md +++ /dev/null @@ -1,804 +0,0 @@ -# WeFlow HTTP API / Push 文档 - -WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 - -## 启用方式 - -在应用设置页启用 `API 服务`。 - -- 默认监听地址:`127.0.0.1` -- 默认端口:`5031` -- 基础地址:`http://127.0.0.1:5031` -- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 - -**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。 - -## 鉴权规范 - -**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一): - -1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>` -2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式) -3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求) - -## 接口列表 - -- `GET|POST /health` -- `GET|POST /api/v1/health` -- `GET|POST /api/v1/push/messages` -- `GET|POST /api/v1/messages` -- `GET|POST /api/v1/sessions` -- `GET /api/v1/sessions/:id/messages` (ChatLab Pull) -- `GET|POST /api/v1/contacts` -- `GET|POST /api/v1/group-members` -- `GET|POST /api/v1/media/*` - ---- - -## 1. 健康检查 - -**请求** - -```http -GET /health -``` - -或 - -```http -GET /api/v1/health -``` - -**响应** - -```json -{ - "status": "ok" -} -``` - ---- - -## 2. 主动推送 - -通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。 - -**请求** - -```http -GET /api/v1/push/messages -``` - -### 说明 - -- 需要先在设置页开启 `HTTP API 服务` -- 同时需要开启 `主动推送` -- 响应类型为 `text/event-stream` -- 事件名包含 `message.new` 和 `message.revoke` -- 建议接收端按 `event + rawid` 去重 - -### 事件字段 - -- `event` -- `sessionId` -- `rawid` -- `avatarUrl` -- `sourceName` -- `groupName`(仅群聊) -- `content` -- `timestamp`(消息时间,秒级 Unix 时间戳) - -### 示例 - -```bash -curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN -``` - -示例事件: - -```text -event: message.new -data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123} -``` - -撤回事件示例: - -```text -event: message.revoke -data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息(rawid:1234567890123456789) 内容为“你好”","timestamp":1760000180} -``` - ---- - -## 3. 获取消息 - -> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) - -读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 - -**请求** - -```http -GET /api/v1/messages -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | ----------------------------------------------------- | -| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | -| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | -| `offset` | number | 否 | 分页偏移,默认 `0` | -| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | -| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | -| `keyword` | string | 否 | 基于消息显示文本过滤 | -| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | -| `format` | string | 否 | `json` 或 `chatlab` | -| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | -| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | -| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | -| `video` | string | 否 | 在 `media=1` 时控制视频导出 | -| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | - -### 示例 - -```bash -curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20" -curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1" -curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131" -curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0" -``` - -### JSON 响应字段 - -顶层字段: - -- `success` -- `talker` -- `count` -- `hasMore` -- `media.enabled` -- `media.exportPath` -- `media.count` -- `messages` - -单条消息字段: - -- `localId` -- `serverId` -- `localType` -- `createTime` -- `isSend` -- `senderUsername` -- `content` -- `rawContent` -- `parsedContent` -- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回) -- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型) -- `mediaType` -- `mediaFileName` -- `mediaUrl` -- `mediaLocalPath` - -**示例响应** - -```json -{ - "success": true, - "talker": "xxx@chatroom", - "count": 3, - "hasMore": true, - "media": { - "enabled": true, - "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 1 - }, - "messages": [ - { - "localId": 123, - "serverId": "6116895530414915131", - "localType": 1, - "createTime": 1738713600, - "isSend": 0, - "senderUsername": "wxid_member", - "content": "你好", - "rawContent": "你好", - "parsedContent": "你好" - }, - { - "localId": 125, - "serverId": "6116895530414915133", - "localType": 244813135921, - "createTime": 1738713700, - "isSend": 0, - "senderUsername": "wxid_member", - "content": "收到", - "rawContent": "...", - "parsedContent": "收到", - "replyToMessageId": "6116895530414915131", - "quote": { - "platformMessageId": "6116895530414915131", - "sender": "wxid_other", - "accountName": "张三", - "content": "你好", - "type": 0 - } - }, - { - "localId": 124, - "localType": 3, - "createTime": 1738713660, - "isSend": 0, - "senderUsername": "wxid_member", - "content": "[图片]", - "mediaType": "image", - "mediaFileName": "abc123.jpg", - "mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg", - "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg" - } - ] -} -``` - -### ChatLab 响应 - -当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构: - -- `chatlab.version` -- `chatlab.exportedAt` -- `chatlab.generator` -- `meta.name` -- `meta.platform` -- `meta.type` -- `meta.groupId` -- `meta.groupAvatar` -- `meta.ownerId` -- `members[].platformId` -- `members[].accountName` -- `members[].groupNickname` -- `members[].avatar` -- `messages[].sender` -- `messages[].accountName` -- `messages[].groupNickname` -- `messages[].timestamp` -- `messages[].type` -- `messages[].content` -- `messages[].platformMessageId` -- `messages[].replyToMessageId` -- `messages[].mediaPath` - -群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。 - ---- - -## 4. 获取会话列表 - -> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) - -**请求** - -```http -GET /api/v1/sessions -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | -------------------------------- | -| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | -| `limit` | number | 否 | 默认 `100` | - -### 响应字段 - -- `success` -- `count` -- `sessions[].username` -- `sessions[].displayName` -- `sessions[].type` -- `sessions[].lastTimestamp` -- `sessions[].unreadCount` - -**示例响应** - -```json -{ - "success": true, - "count": 1, - "sessions": [ - { - "username": "xxx@chatroom", - "displayName": "项目群", - "type": 2, - "lastTimestamp": 1738713600, - "unreadCount": 0 - } - ] -} -``` - ---- - -## 4.1 获取会话列表(ChatLab 格式) - -当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。 - -**请求** - -```http -GET /api/v1/sessions?format=chatlab -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | -------------------------------- | -| `format` | string | 是 | 设为 `chatlab` | -| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | -| `limit` | number | 否 | 默认 `100` | - -### 响应 - -```json -{ - "sessions": [ - { - "id": "xxx@chatroom", - "name": "项目群", - "platform": "wechat", - "type": "group", - "messageCount": 58000, - "lastMessageAt": 1738713600 - } - ] -} -``` - -| 字段 | 说明 | -| --------------- | ----------------------------------- | -| `id` | 会话 ID(微信 username) | -| `name` | 会话显示名称 | -| `platform` | 固定 `wechat` | -| `type` | `group`(群聊)或 `private`(私聊) | -| `messageCount` | 消息数量(估算值,可能不精确) | -| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 | - ---- - -## 4.2 拉取会话消息(ChatLab Pull) - -返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。 - -**请求** - -```http -GET /api/v1/sessions/:id/messages -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| -------- | ------ | ---- | ---------------------------------------- | -| `:id` | string | 是 | 会话 ID(Path 参数) | -| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 | -| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 | -| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` | -| `offset` | number | 否 | 分页偏移,默认 `0` | - -### 响应 - -返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块: - -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1738713600, - "generator": "WeFlow" - }, - "meta": { - "name": "项目群", - "platform": "wechat", - "type": "group", - "groupId": "xxx@chatroom", - "ownerId": "wxid_xxx" - }, - "members": [ - { - "platformId": "wxid_a", - "accountName": "张三", - "groupNickname": "产品", - "avatar": "https://example.com/avatar.jpg" - } - ], - "messages": [ - { - "sender": "wxid_a", - "accountName": "张三", - "timestamp": 1738713600, - "type": 0, - "content": "你好", - "platformMessageId": "123456" - } - ], - "sync": { - "hasMore": true, - "nextSince": 1738713600, - "nextOffset": 5000, - "watermark": 1738714000 - } -} -``` - -### sync 块 - -| 字段 | 说明 | -| ------------ | -------------------------------- | -| `hasMore` | 是否还有更多数据 | -| `nextSince` | 下次请求的 `since` 值 | -| `nextOffset` | 下次请求的 `offset` 值 | -| `watermark` | 本次拉取的时间上界(秒级时间戳) | - -**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。 - ---- - -## 5. 获取联系人列表 - -> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) - -**请求** - -```http -GET /api/v1/contacts -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | ---------------------------------------------------- | -| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | -| `limit` | number | 否 | 默认 `100` | - -### 响应字段 - -- `success` -- `count` -- `contacts[].username` -- `contacts[].displayName` -- `contacts[].remark` -- `contacts[].nickname` -- `contacts[].alias` -- `contacts[].avatarUrl` -- `contacts[].type` - -**示例响应** - -```json -{ - "success": true, - "count": 1, - "contacts": [ - { - "username": "wxid_xxx", - "displayName": "张三", - "remark": "客户张三", - "nickname": "张三", - "alias": "zhangsan", - "avatarUrl": "https://example.com/avatar.jpg", - "type": "friend" - } - ] -} -``` - ---- - -## 6. 获取群成员列表 - -> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) - -返回群成员的 `wxid`、群昵称、备注、微信号等信息。 - -**请求** - -```http -GET /api/v1/group-members -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -| ---------------------- | ------ | ---- | ------------------------------- | -| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | -| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | -| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | -| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | - -### 响应字段 - -- `success` -- `chatroomId` -- `count` -- `fromCache` -- `updatedAt` -- `members[].wxid` -- `members[].displayName` -- `members[].nickname` -- `members[].remark` -- `members[].alias` -- `members[].groupNickname` -- `members[].avatarUrl` -- `members[].isOwner` -- `members[].isFriend` -- `members[].messageCount` - -**示例请求** - -```bash -curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" -curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1" -``` - -**示例响应** - -```json -{ - "success": true, - "chatroomId": "xxx@chatroom", - "count": 2, - "fromCache": false, - "updatedAt": 1760000000000, - "members": [ - { - "wxid": "wxid_member_a", - "displayName": "客户A", - "nickname": "阿甲", - "remark": "客户A", - "alias": "kehua", - "groupNickname": "甲方", - "avatarUrl": "https://example.com/a.jpg", - "isOwner": true, - "isFriend": true, - "messageCount": 128 - }, - { - "wxid": "wxid_member_b", - "displayName": "李四", - "nickname": "李四", - "remark": "", - "alias": "", - "groupNickname": "", - "avatarUrl": "", - "isOwner": false, - "isFriend": false, - "messageCount": 0 - } - ] -} -``` - -说明: - -- `displayName` 是当前应用内的主展示名。 -- `groupNickname` 是成员在该群里的群昵称。 -- `remark` 是你对该联系人的备注。 -- `alias` 是微信号。 -- 当微信源数据里没有群昵称时,`groupNickname` 会为空。 - ---- - -## 7. 朋友圈接口 - -### 7.1 获取朋友圈时间线 - -```http -GET /api/v1/sns/timeline -``` - -参数: - -| 参数 | 类型 | 必填 | 说明 | -| ----------- | ------ | ---- | ------------------------------------------------------------ | -| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | -| `offset` | number | 否 | 偏移量,默认 0 | -| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | -| `keyword` | string | 否 | 关键词过滤(正文) | -| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | -| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | -| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | -| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | -| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | - -示例: - -```bash -curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5" -curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行" -curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1" -curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1" -``` - -媒体字段说明(`media=1`): - -- `media[].url/thumb`:你应该优先直接使用的字段。 -- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。 -- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。 -- `media[].rawUrl/rawThumb`:原始朋友圈地址 -- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 -- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) -- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。 -- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。 -- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。 - -### 7.2 获取朋友圈发布者 - -```http -GET /api/v1/sns/usernames -``` - -### 7.3 获取朋友圈导出统计 - -```http -GET /api/v1/sns/export/stats -``` - -参数: - -| 参数 | 类型 | 必填 | 说明 | -| ------ | ------ | ---- | ---------------------------- | -| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | - -### 7.4 朋友圈媒体代理 - -```http -GET /api/v1/sns/media/proxy -``` - -参数: - -| 参数 | 类型 | 必填 | 说明 | -| ----- | ------------- | ---- | ------------------------ | -| `url` | string | 是 | 媒体原始 URL | -| `key` | string/number | 否 | 解密 key(部分资源需要) | - -### 7.5 导出朋友圈 - -```http -POST /api/v1/sns/export -Content-Type: application/json -``` - -Body 示例: - -```json -{ - "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export", - "format": "json", - "usernames": "wxid_a,wxid_b", - "keyword": "旅行", - "exportMedia": true, - "exportImages": true, - "exportLivePhotos": true, - "exportVideos": true, - "start": "20250101", - "end": "20251231" -} -``` - -`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。 - -### 7.6 朋友圈防删开关 - -```http -GET /api/v1/sns/block-delete/status -POST /api/v1/sns/block-delete/install -POST /api/v1/sns/block-delete/uninstall -``` - -### 7.7 删除单条朋友圈 - -```http -DELETE /api/v1/sns/post/{postId} -``` - ---- - -## 8. 访问导出媒体 - -> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) - -通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 - -**请求** - -```http -GET /api/v1/media/{relativePath} -``` - -### 示例 - -```bash -curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg" -curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav" -curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4" -curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" -``` - -### 支持的 Content-Type - -| 扩展名 | Content-Type | -| ---------------- | ------------ | -| `.png` | `image/png` | -| `.jpg` / `.jpeg` | `image/jpeg` | -| `.gif` | `image/gif` | -| `.webp` | `image/webp` | -| `.wav` | `audio/wav` | -| `.mp3` | `audio/mpeg` | -| `.mp4` | `video/mp4` | - -常见错误响应: - -```json -{ - "error": "Media not found" -} -``` - ---- - -## 9. 使用示例 - -### PowerShell - -```powershell -$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" } -$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json - -Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json" -``` - -### cURL - -```bash -# GET 带 Token Header -curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx" - -# POST 带 JSON Body -curl -X POST http://127.0.0.1:5031/api/v1/messages \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"talker": "xxx@chatroom", "chatlab": true}' -``` - -### Python - -```python -import requests - -BASE_URL = "http://127.0.0.1:5031" -headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"} - -# POST 方式获取消息 -messages = requests.post( - f"{BASE_URL}/api/v1/messages", - json={"talker": "xxx@chatroom", "limit": 50}, - headers=headers -).json() - -# GET 方式获取群成员 -members = requests.get( - f"{BASE_URL}/api/v1/group-members", - params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}, - headers=headers -).json() -``` - ---- - -## 10. 注意事项 - -1. API 仅监听本机 `127.0.0.1`,不对外网开放。 -2. 使用前需要先在 WeFlow 中完成数据库连接。 -3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。 -4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。 -5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。 diff --git a/docs/MAC-KEY-FAQ.md b/docs/MAC-KEY-FAQ.md deleted file mode 100644 index fb287de..0000000 --- a/docs/MAC-KEY-FAQ.md +++ /dev/null @@ -1,54 +0,0 @@ -# macOS 微信密钥自动获取失败排障指南 - -如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。 - -### 请立刻停止连续重试 - -当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取: - -- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found -- HOOK_FAILED 或 Native Hook Failed -- patch_breakpoint_failed -- thread_get_state_failed - -现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。 - -### 可能的尝试流程 - -根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题: - -1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。 -2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。 -3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。 -4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。 -5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。 -6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信 -7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。 - -### 常见报错与应对方法 - -为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策: - -**SCAN_FAILED: No suitable module found** -这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。 - -**SCAN_FAILED: Sink pattern not found** -这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。 - -**patch_breakpoint_failed 或 thread_get_state_failed** -这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。 - -**task_for_pid:5** -这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。 - -### 关于推荐版本的补充说明 - -截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。 - -这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。 - -### 最后的几点建议 - -首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。 - -最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。 diff --git a/electron/annualReportWorker.ts b/electron/annualReportWorker.ts deleted file mode 100644 index 37db57d..0000000 --- a/electron/annualReportWorker.ts +++ /dev/null @@ -1,43 +0,0 @@ -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/assets/wasm/wasm_video_decode.js b/electron/assets/wasm/wasm_video_decode.js deleted file mode 100644 index 0a2aee2..0000000 --- a/electron/assets/wasm/wasm_video_decode.js +++ /dev/null @@ -1,4707 +0,0 @@ - - -// The Module object: Our interface to the outside world. We import -// and export values on it. There are various ways Module can be used: -// 1. Not defined. We create it here -// 2. A function parameter, function(Module) { ..generated code.. } -// 3. pre-run appended it, var Module = {}; ..generated code.. -// 4. External script tag defines var Module. -// We need to check if Module already exists (e.g. case 3 above). -// Substitution will be replaced with actual code on later stage of the build, -// this way Closure Compiler will not mangle it (e.g. case 4. above). -// Note that if you want to run closure, and also to use Module -// after the generated code, you will need to define var Module = {}; -// before the code. Then that object will be used in the code, and you -// can continue to use Module afterwards as well. -var Module = typeof Module !== 'undefined' ? Module : {}; - -// --pre-jses are emitted after the Module integration code, so that they can -// refer to Module (if they choose; they can also define Module) -// {{PRE_JSES}} - -// Sometimes an existing Module object exists with properties -// meant to overwrite the default module functionality. Here -// we collect those properties and reapply _after_ we configure -// the current environment's defaults to avoid having to be so -// defensive during initialization. -var moduleOverrides = {}; -var key; -for (key in Module) { - if (Module.hasOwnProperty(key)) { - moduleOverrides[key] = Module[key]; - } -} - -var arguments_ = []; -var thisProgram = './this.program'; -var quit_ = function(status, toThrow) { - throw toThrow; -}; - -// Determine the runtime environment we are in. You can customize this by -// setting the ENVIRONMENT setting at compile time (see settings.js). - -var ENVIRONMENT_IS_WEB = false; -var ENVIRONMENT_IS_WORKER = true; -var ENVIRONMENT_IS_NODE = false; -var ENVIRONMENT_IS_SHELL = false; - -// `/` should be present at the end if `scriptDirectory` is not empty -var scriptDirectory = ''; -function locateFile(path) { - if (Module['locateFile']) { - return Module['locateFile'](path, scriptDirectory); - } - return scriptDirectory + path; -} - -// Hooks that are implemented differently in different runtime environments. -var read_, - readAsync, - readBinary, - setWindowTitle; - -// Note that this includes Node.js workers when relevant (pthreads is enabled). -// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and -// ENVIRONMENT_IS_NODE. -if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { - if (ENVIRONMENT_IS_WORKER) { // Check worker, not web, since window could be polyfilled - scriptDirectory = self.location.href; - } else if (typeof document !== 'undefined' && document.currentScript) { // web - scriptDirectory = document.currentScript.src; - } - // blob urls look like blob:http://site.com/etc/etc and we cannot infer anything from them. - // otherwise, slice off the final part of the url to find the script directory. - // if scriptDirectory does not contain a slash, lastIndexOf will return -1, - // and scriptDirectory will correctly be replaced with an empty string. - // If scriptDirectory contains a query (starting with ?) or a fragment (starting with #), - // they are removed because they could contain a slash. - if (scriptDirectory.indexOf('blob:') !== 0) { - scriptDirectory = scriptDirectory.substr(0, scriptDirectory.replace(/[?#].*/, "").lastIndexOf('/')+1); - } else { - scriptDirectory = ''; - } - - // Differentiate the Web Worker from the Node Worker case, as reading must - // be done differently. - { - -// include: web_or_worker_shell_read.js - - - read_ = function(url) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); - xhr.send(null); - return xhr.responseText; - }; - - if (ENVIRONMENT_IS_WORKER) { - readBinary = function(url) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); - xhr.responseType = 'arraybuffer'; - xhr.send(null); - return new Uint8Array(/** @type{!ArrayBuffer} */(xhr.response)); - }; - } - - readAsync = function(url, onload, onerror) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', url, true); - xhr.responseType = 'arraybuffer'; - xhr.onload = function() { - if (xhr.status == 200 || (xhr.status == 0 && xhr.response)) { // file URLs can return 0 - onload(xhr.response); - return; - } - onerror(); - }; - xhr.onerror = onerror; - xhr.send(null); - }; - -// end include: web_or_worker_shell_read.js - } - - setWindowTitle = function(title) { document.title = title }; -} else -{ -} - -var out = Module['print'] || console.log.bind(console); -var err = Module['printErr'] || console.warn.bind(console); - -// Merge back in the overrides -for (key in moduleOverrides) { - if (moduleOverrides.hasOwnProperty(key)) { - Module[key] = moduleOverrides[key]; - } -} -// Free the object hierarchy contained in the overrides, this lets the GC -// reclaim data used e.g. in memoryInitializerRequest, which is a large typed array. -moduleOverrides = null; - -// Emit code to handle expected values on the Module object. This applies Module.x -// to the proper local x. This has two benefits: first, we only emit it if it is -// expected to arrive, and second, by using a local everywhere else that can be -// minified. - -if (Module['arguments']) arguments_ = Module['arguments']; - -if (Module['thisProgram']) thisProgram = Module['thisProgram']; - -if (Module['quit']) quit_ = Module['quit']; - -// perform assertions in shell.js after we set up out() and err(), as otherwise if an assertion fails it cannot print the message - - - - -var STACK_ALIGN = 16; -var POINTER_SIZE = 4; - -function getNativeTypeSize(type) { - switch (type) { - case 'i1': case 'i8': return 1; - case 'i16': return 2; - case 'i32': return 4; - case 'i64': return 8; - case 'float': return 4; - case 'double': return 8; - default: { - if (type[type.length-1] === '*') { - return POINTER_SIZE; - } else if (type[0] === 'i') { - var bits = Number(type.substr(1)); - assert(bits % 8 === 0, 'getNativeTypeSize invalid bits ' + bits + ', type ' + type); - return bits / 8; - } else { - return 0; - } - } - } -} - -function warnOnce(text) { - if (!warnOnce.shown) warnOnce.shown = {}; - if (!warnOnce.shown[text]) { - warnOnce.shown[text] = 1; - err(text); - } -} - -// include: runtime_functions.js - - -// Wraps a JS function as a wasm function with a given signature. -function convertJsFunctionToWasm(func, sig) { - - // If the type reflection proposal is available, use the new - // "WebAssembly.Function" constructor. - // Otherwise, construct a minimal wasm module importing the JS function and - // re-exporting it. - if (typeof WebAssembly.Function === "function") { - var typeNames = { - 'i': 'i32', - 'j': 'i64', - 'f': 'f32', - 'd': 'f64' - }; - var type = { - parameters: [], - results: sig[0] == 'v' ? [] : [typeNames[sig[0]]] - }; - for (var i = 1; i < sig.length; ++i) { - type.parameters.push(typeNames[sig[i]]); - } - return new WebAssembly.Function(type, func); - } - - // The module is static, with the exception of the type section, which is - // generated based on the signature passed in. - var typeSection = [ - 0x01, // id: section, - 0x00, // length: 0 (placeholder) - 0x01, // count: 1 - 0x60, // form: func - ]; - var sigRet = sig.slice(0, 1); - var sigParam = sig.slice(1); - var typeCodes = { - 'i': 0x7f, // i32 - 'j': 0x7e, // i64 - 'f': 0x7d, // f32 - 'd': 0x7c, // f64 - }; - - // Parameters, length + signatures - typeSection.push(sigParam.length); - for (var i = 0; i < sigParam.length; ++i) { - typeSection.push(typeCodes[sigParam[i]]); - } - - // Return values, length + signatures - // With no multi-return in MVP, either 0 (void) or 1 (anything else) - if (sigRet == 'v') { - typeSection.push(0x00); - } else { - typeSection = typeSection.concat([0x01, typeCodes[sigRet]]); - } - - // Write the overall length of the type section back into the section header - // (excepting the 2 bytes for the section id and length) - typeSection[1] = typeSection.length - 2; - - // Rest of the module is static - var bytes = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, // magic ("\0asm") - 0x01, 0x00, 0x00, 0x00, // version: 1 - ].concat(typeSection, [ - 0x02, 0x07, // import section - // (import "e" "f" (func 0 (type 0))) - 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00, - 0x07, 0x05, // export section - // (export "f" (func 0 (type 0))) - 0x01, 0x01, 0x66, 0x00, 0x00, - ])); - - // We can compile this wasm module synchronously because it is very small. - // This accepts an import (at "e.f"), that it reroutes to an export (at "f") - var module = new WebAssembly.Module(bytes); - var instance = new WebAssembly.Instance(module, { - 'e': { - 'f': func - } - }); - var wrappedFunc = instance.exports['f']; - return wrappedFunc; -} - -var freeTableIndexes = []; - -// Weak map of functions in the table to their indexes, created on first use. -var functionsInTableMap; - -function getEmptyTableSlot() { - // Reuse a free index if there is one, otherwise grow. - if (freeTableIndexes.length) { - return freeTableIndexes.pop(); - } - // Grow the table - try { - wasmTable.grow(1); - } catch (err) { - if (!(err instanceof RangeError)) { - throw err; - } - throw 'Unable to grow wasm table. Set ALLOW_TABLE_GROWTH.'; - } - return wasmTable.length - 1; -} - -function updateTableMap(offset, count) { - for (var i = offset; i < offset + count; i++) { - var item = getWasmTableEntry(i); - // Ignore null values. - if (item) { - functionsInTableMap.set(item, i); - } - } -} - -// Add a function to the table. -// 'sig' parameter is required if the function being added is a JS function. -function addFunction(func, sig) { - - // Check if the function is already in the table, to ensure each function - // gets a unique index. First, create the map if this is the first use. - if (!functionsInTableMap) { - functionsInTableMap = new WeakMap(); - updateTableMap(0, wasmTable.length); - } - if (functionsInTableMap.has(func)) { - return functionsInTableMap.get(func); - } - - // It's not in the table, add it now. - - var ret = getEmptyTableSlot(); - - // Set the new value. - try { - // Attempting to call this with JS function will cause of table.set() to fail - setWasmTableEntry(ret, func); - } catch (err) { - if (!(err instanceof TypeError)) { - throw err; - } - var wrapped = convertJsFunctionToWasm(func, sig); - setWasmTableEntry(ret, wrapped); - } - - functionsInTableMap.set(func, ret); - - return ret; -} - -function removeFunction(index) { - functionsInTableMap.delete(getWasmTableEntry(index)); - freeTableIndexes.push(index); -} - -// end include: runtime_functions.js -// include: runtime_debug.js - - -// end include: runtime_debug.js -var tempRet0 = 0; - -var setTempRet0 = function(value) { - tempRet0 = value; -}; - -var getTempRet0 = function() { - return tempRet0; -}; - - - -// === Preamble library stuff === - -// Documentation for the public APIs defined in this file must be updated in: -// site/source/docs/api_reference/preamble.js.rst -// A prebuilt local version of the documentation is available at: -// site/build/text/docs/api_reference/preamble.js.txt -// You can also build docs locally as HTML or other formats in site/ -// An online HTML version (which may be of a different version of Emscripten) -// is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html - -var wasmBinary; -if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; -var noExitRuntime = Module['noExitRuntime'] || true; - -if (typeof WebAssembly !== 'object') { - abort('no native wasm support detected'); -} - -// include: runtime_safe_heap.js - - -// In MINIMAL_RUNTIME, setValue() and getValue() are only available when building with safe heap enabled, for heap safety checking. -// In traditional runtime, setValue() and getValue() are always available (although their use is highly discouraged due to perf penalties) - -/** @param {number} ptr - @param {number} value - @param {string} type - @param {number|boolean=} noSafe */ -function setValue(ptr, value, type, noSafe) { - type = type || 'i8'; - if (type.charAt(type.length-1) === '*') type = 'i32'; - switch (type) { - case 'i1': HEAP8[((ptr)>>0)] = value; break; - case 'i8': HEAP8[((ptr)>>0)] = value; break; - case 'i16': HEAP16[((ptr)>>1)] = value; break; - case 'i32': HEAP32[((ptr)>>2)] = value; break; - case 'i64': (tempI64 = [value>>>0,(tempDouble=value,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[((ptr)>>2)] = tempI64[0],HEAP32[(((ptr)+(4))>>2)] = tempI64[1]); break; - case 'float': HEAPF32[((ptr)>>2)] = value; break; - case 'double': HEAPF64[((ptr)>>3)] = value; break; - default: abort('invalid type for setValue: ' + type); - } -} - -/** @param {number} ptr - @param {string} type - @param {number|boolean=} noSafe */ -function getValue(ptr, type, noSafe) { - type = type || 'i8'; - if (type.charAt(type.length-1) === '*') type = 'i32'; - switch (type) { - case 'i1': return HEAP8[((ptr)>>0)]; - case 'i8': return HEAP8[((ptr)>>0)]; - case 'i16': return HEAP16[((ptr)>>1)]; - case 'i32': return HEAP32[((ptr)>>2)]; - case 'i64': return HEAP32[((ptr)>>2)]; - case 'float': return HEAPF32[((ptr)>>2)]; - case 'double': return Number(HEAPF64[((ptr)>>3)]); - default: abort('invalid type for getValue: ' + type); - } - return null; -} - -// end include: runtime_safe_heap.js -// Wasm globals - -var wasmMemory; - -//======================================== -// Runtime essentials -//======================================== - -// whether we are quitting the application. no code should run after this. -// set in exit() and abort() -var ABORT = false; - -// set by exit() and abort(). Passed to 'onExit' handler. -// NOTE: This is also used as the process return code code in shell environments -// but only when noExitRuntime is false. -var EXITSTATUS; - -/** @type {function(*, string=)} */ -function assert(condition, text) { - if (!condition) { - abort('Assertion failed: ' + text); - } -} - -// Returns the C function with a specified identifier (for C++, you need to do manual name mangling) -function getCFunc(ident) { - var func = Module['_' + ident]; // closure exported function - assert(func, 'Cannot call unknown function ' + ident + ', make sure it is exported'); - return func; -} - -// C calling interface. -/** @param {string|null=} returnType - @param {Array=} argTypes - @param {Arguments|Array=} args - @param {Object=} opts */ -function ccall(ident, returnType, argTypes, args, opts) { - // For fast lookup of conversion functions - var toC = { - 'string': function(str) { - var ret = 0; - if (str !== null && str !== undefined && str !== 0) { // null string - // at most 4 bytes per UTF-8 code point, +1 for the trailing '\0' - var len = (str.length << 2) + 1; - ret = stackAlloc(len); - stringToUTF8(str, ret, len); - } - return ret; - }, - 'array': function(arr) { - var ret = stackAlloc(arr.length); - writeArrayToMemory(arr, ret); - return ret; - } - }; - - function convertReturnValue(ret) { - if (returnType === 'string') return UTF8ToString(ret); - if (returnType === 'boolean') return Boolean(ret); - return ret; - } - - var func = getCFunc(ident); - var cArgs = []; - var stack = 0; - if (args) { - for (var i = 0; i < args.length; i++) { - var converter = toC[argTypes[i]]; - if (converter) { - if (stack === 0) stack = stackSave(); - cArgs[i] = converter(args[i]); - } else { - cArgs[i] = args[i]; - } - } - } - var ret = func.apply(null, cArgs); - function onDone(ret) { - runtimeKeepalivePop(); - if (stack !== 0) stackRestore(stack); - return convertReturnValue(ret); - } - runtimeKeepalivePush(); - var asyncMode = opts && opts.async; - // Check if we started an async operation just now. - if (Asyncify.currData) { - // If so, the WASM function ran asynchronous and unwound its stack. - // We need to return a Promise that resolves the return value - // once the stack is rewound and execution finishes. - return Asyncify.whenDone().then(onDone); - } - - ret = onDone(ret); - // If this is an async ccall, ensure we return a promise - if (asyncMode) return Promise.resolve(ret); - return ret; -} - -/** @param {string=} returnType - @param {Array=} argTypes - @param {Object=} opts */ -function cwrap(ident, returnType, argTypes, opts) { - argTypes = argTypes || []; - // When the function takes numbers and returns a number, we can just return - // the original function - var numericArgs = argTypes.every(function(type){ return type === 'number'}); - var numericRet = returnType !== 'string'; - if (numericRet && numericArgs && !opts) { - return getCFunc(ident); - } - return function() { - return ccall(ident, returnType, argTypes, arguments, opts); - } -} - -var ALLOC_NORMAL = 0; // Tries to use _malloc() -var ALLOC_STACK = 1; // Lives for the duration of the current function call - -// allocate(): This is for internal use. You can use it yourself as well, but the interface -// is a little tricky (see docs right below). The reason is that it is optimized -// for multiple syntaxes to save space in generated code. So you should -// normally not use allocate(), and instead allocate memory using _malloc(), -// initialize it with setValue(), and so forth. -// @slab: An array of data. -// @allocator: How to allocate memory, see ALLOC_* -/** @type {function((Uint8Array|Array), number)} */ -function allocate(slab, allocator) { - var ret; - - if (allocator == ALLOC_STACK) { - ret = stackAlloc(slab.length); - } else { - ret = _malloc(slab.length); - } - - if (slab.subarray || slab.slice) { - HEAPU8.set(/** @type {!Uint8Array} */(slab), ret); - } else { - HEAPU8.set(new Uint8Array(slab), ret); - } - return ret; -} - -// include: runtime_strings.js - - -// runtime_strings.js: Strings related runtime functions that are part of both MINIMAL_RUNTIME and regular runtime. - -// Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the given array that contains uint8 values, returns -// a copy of that string as a Javascript String object. - -var UTF8Decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf8') : undefined; - -/** - * @param {number} idx - * @param {number=} maxBytesToRead - * @return {string} - */ -function UTF8ArrayToString(heap, idx, maxBytesToRead) { - var endIdx = idx + maxBytesToRead; - var endPtr = idx; - // TextDecoder needs to know the byte length in advance, it doesn't stop on null terminator by itself. - // Also, use the length info to avoid running tiny strings through TextDecoder, since .subarray() allocates garbage. - // (As a tiny code save trick, compare endPtr against endIdx using a negation, so that undefined means Infinity) - while (heap[endPtr] && !(endPtr >= endIdx)) ++endPtr; - - if (endPtr - idx > 16 && heap.subarray && UTF8Decoder) { - return UTF8Decoder.decode(heap.subarray(idx, endPtr)); - } else { - var str = ''; - // If building with TextDecoder, we have already computed the string length above, so test loop end condition against that - while (idx < endPtr) { - // For UTF8 byte structure, see: - // http://en.wikipedia.org/wiki/UTF-8#Description - // https://www.ietf.org/rfc/rfc2279.txt - // https://tools.ietf.org/html/rfc3629 - var u0 = heap[idx++]; - if (!(u0 & 0x80)) { str += String.fromCharCode(u0); continue; } - var u1 = heap[idx++] & 63; - if ((u0 & 0xE0) == 0xC0) { str += String.fromCharCode(((u0 & 31) << 6) | u1); continue; } - var u2 = heap[idx++] & 63; - if ((u0 & 0xF0) == 0xE0) { - u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; - } else { - u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heap[idx++] & 63); - } - - if (u0 < 0x10000) { - str += String.fromCharCode(u0); - } else { - var ch = u0 - 0x10000; - str += String.fromCharCode(0xD800 | (ch >> 10), 0xDC00 | (ch & 0x3FF)); - } - } - } - return str; -} - -// Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the emscripten HEAP, returns a -// copy of that string as a Javascript String object. -// maxBytesToRead: an optional length that specifies the maximum number of bytes to read. You can omit -// this parameter to scan the string until the first \0 byte. If maxBytesToRead is -// passed, and the string at [ptr, ptr+maxBytesToReadr[ contains a null byte in the -// middle, then the string will cut short at that byte index (i.e. maxBytesToRead will -// not produce a string of exact length [ptr, ptr+maxBytesToRead[) -// N.B. mixing frequent uses of UTF8ToString() with and without maxBytesToRead may -// throw JS JIT optimizations off, so it is worth to consider consistently using one -// style or the other. -/** - * @param {number} ptr - * @param {number=} maxBytesToRead - * @return {string} - */ -function UTF8ToString(ptr, maxBytesToRead) { - ; - return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : ''; -} - -// Copies the given Javascript String object 'str' to the given byte array at address 'outIdx', -// encoded in UTF8 form and null-terminated. The copy will require at most str.length*4+1 bytes of space in the HEAP. -// Use the function lengthBytesUTF8 to compute the exact number of bytes (excluding null terminator) that this function will write. -// Parameters: -// str: the Javascript string to copy. -// heap: the array to copy to. Each index in this array is assumed to be one 8-byte element. -// outIdx: The starting offset in the array to begin the copying. -// maxBytesToWrite: The maximum number of bytes this function can write to the array. -// This count should include the null terminator, -// i.e. if maxBytesToWrite=1, only the null terminator will be written and nothing else. -// maxBytesToWrite=0 does not write any bytes to the output, not even the null terminator. -// Returns the number of bytes written, EXCLUDING the null terminator. - -function stringToUTF8Array(str, heap, outIdx, maxBytesToWrite) { - if (!(maxBytesToWrite > 0)) // Parameter maxBytesToWrite is not optional. Negative values, 0, null, undefined and false each don't write out any bytes. - return 0; - - var startIdx = outIdx; - var endIdx = outIdx + maxBytesToWrite - 1; // -1 for string null terminator. - for (var i = 0; i < str.length; ++i) { - // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! So decode UTF16->UTF32->UTF8. - // See http://unicode.org/faq/utf_bom.html#utf16-3 - // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description and https://www.ietf.org/rfc/rfc2279.txt and https://tools.ietf.org/html/rfc3629 - var u = str.charCodeAt(i); // possibly a lead surrogate - if (u >= 0xD800 && u <= 0xDFFF) { - var u1 = str.charCodeAt(++i); - u = 0x10000 + ((u & 0x3FF) << 10) | (u1 & 0x3FF); - } - if (u <= 0x7F) { - if (outIdx >= endIdx) break; - heap[outIdx++] = u; - } else if (u <= 0x7FF) { - if (outIdx + 1 >= endIdx) break; - heap[outIdx++] = 0xC0 | (u >> 6); - heap[outIdx++] = 0x80 | (u & 63); - } else if (u <= 0xFFFF) { - if (outIdx + 2 >= endIdx) break; - heap[outIdx++] = 0xE0 | (u >> 12); - heap[outIdx++] = 0x80 | ((u >> 6) & 63); - heap[outIdx++] = 0x80 | (u & 63); - } else { - if (outIdx + 3 >= endIdx) break; - heap[outIdx++] = 0xF0 | (u >> 18); - heap[outIdx++] = 0x80 | ((u >> 12) & 63); - heap[outIdx++] = 0x80 | ((u >> 6) & 63); - heap[outIdx++] = 0x80 | (u & 63); - } - } - // Null-terminate the pointer to the buffer. - heap[outIdx] = 0; - return outIdx - startIdx; -} - -// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr', -// null-terminated and encoded in UTF8 form. The copy will require at most str.length*4+1 bytes of space in the HEAP. -// Use the function lengthBytesUTF8 to compute the exact number of bytes (excluding null terminator) that this function will write. -// Returns the number of bytes written, EXCLUDING the null terminator. - -function stringToUTF8(str, outPtr, maxBytesToWrite) { - return stringToUTF8Array(str, HEAPU8,outPtr, maxBytesToWrite); -} - -// Returns the number of bytes the given Javascript string takes if encoded as a UTF8 byte array, EXCLUDING the null terminator byte. -function lengthBytesUTF8(str) { - var len = 0; - for (var i = 0; i < str.length; ++i) { - // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! So decode UTF16->UTF32->UTF8. - // See http://unicode.org/faq/utf_bom.html#utf16-3 - var u = str.charCodeAt(i); // possibly a lead surrogate - if (u >= 0xD800 && u <= 0xDFFF) u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF); - if (u <= 0x7F) ++len; - else if (u <= 0x7FF) len += 2; - else if (u <= 0xFFFF) len += 3; - else len += 4; - } - return len; -} - -// end include: runtime_strings.js -// include: runtime_strings_extra.js - - -// runtime_strings_extra.js: Strings related runtime functions that are available only in regular runtime. - -// Given a pointer 'ptr' to a null-terminated ASCII-encoded string in the emscripten HEAP, returns -// a copy of that string as a Javascript String object. - -function AsciiToString(ptr) { - var str = ''; - while (1) { - var ch = HEAPU8[((ptr++)>>0)]; - if (!ch) return str; - str += String.fromCharCode(ch); - } -} - -// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr', -// null-terminated and encoded in ASCII form. The copy will require at most str.length+1 bytes of space in the HEAP. - -function stringToAscii(str, outPtr) { - return writeAsciiToMemory(str, outPtr, false); -} - -// Given a pointer 'ptr' to a null-terminated UTF16LE-encoded string in the emscripten HEAP, returns -// a copy of that string as a Javascript String object. - -var UTF16Decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-16le') : undefined; - -function UTF16ToString(ptr, maxBytesToRead) { - var endPtr = ptr; - // TextDecoder needs to know the byte length in advance, it doesn't stop on null terminator by itself. - // Also, use the length info to avoid running tiny strings through TextDecoder, since .subarray() allocates garbage. - var idx = endPtr >> 1; - var maxIdx = idx + maxBytesToRead / 2; - // If maxBytesToRead is not passed explicitly, it will be undefined, and this - // will always evaluate to true. This saves on code size. - while (!(idx >= maxIdx) && HEAPU16[idx]) ++idx; - endPtr = idx << 1; - - if (endPtr - ptr > 32 && UTF16Decoder) { - return UTF16Decoder.decode(HEAPU8.subarray(ptr, endPtr)); - } else { - var str = ''; - - // If maxBytesToRead is not passed explicitly, it will be undefined, and the for-loop's condition - // will always evaluate to true. The loop is then terminated on the first null char. - for (var i = 0; !(i >= maxBytesToRead / 2); ++i) { - var codeUnit = HEAP16[(((ptr)+(i*2))>>1)]; - if (codeUnit == 0) break; - // fromCharCode constructs a character from a UTF-16 code unit, so we can pass the UTF16 string right through. - str += String.fromCharCode(codeUnit); - } - - return str; - } -} - -// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr', -// null-terminated and encoded in UTF16 form. The copy will require at most str.length*4+2 bytes of space in the HEAP. -// Use the function lengthBytesUTF16() to compute the exact number of bytes (excluding null terminator) that this function will write. -// Parameters: -// str: the Javascript string to copy. -// outPtr: Byte address in Emscripten HEAP where to write the string to. -// maxBytesToWrite: The maximum number of bytes this function can write to the array. This count should include the null -// terminator, i.e. if maxBytesToWrite=2, only the null terminator will be written and nothing else. -// maxBytesToWrite<2 does not write any bytes to the output, not even the null terminator. -// Returns the number of bytes written, EXCLUDING the null terminator. - -function stringToUTF16(str, outPtr, maxBytesToWrite) { - // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed. - if (maxBytesToWrite === undefined) { - maxBytesToWrite = 0x7FFFFFFF; - } - if (maxBytesToWrite < 2) return 0; - maxBytesToWrite -= 2; // Null terminator. - var startPtr = outPtr; - var numCharsToWrite = (maxBytesToWrite < str.length*2) ? (maxBytesToWrite / 2) : str.length; - for (var i = 0; i < numCharsToWrite; ++i) { - // charCodeAt returns a UTF-16 encoded code unit, so it can be directly written to the HEAP. - var codeUnit = str.charCodeAt(i); // possibly a lead surrogate - HEAP16[((outPtr)>>1)] = codeUnit; - outPtr += 2; - } - // Null-terminate the pointer to the HEAP. - HEAP16[((outPtr)>>1)] = 0; - return outPtr - startPtr; -} - -// Returns the number of bytes the given Javascript string takes if encoded as a UTF16 byte array, EXCLUDING the null terminator byte. - -function lengthBytesUTF16(str) { - return str.length*2; -} - -function UTF32ToString(ptr, maxBytesToRead) { - var i = 0; - - var str = ''; - // If maxBytesToRead is not passed explicitly, it will be undefined, and this - // will always evaluate to true. This saves on code size. - while (!(i >= maxBytesToRead / 4)) { - var utf32 = HEAP32[(((ptr)+(i*4))>>2)]; - if (utf32 == 0) break; - ++i; - // Gotcha: fromCharCode constructs a character from a UTF-16 encoded code (pair), not from a Unicode code point! So encode the code point to UTF-16 for constructing. - // See http://unicode.org/faq/utf_bom.html#utf16-3 - if (utf32 >= 0x10000) { - var ch = utf32 - 0x10000; - str += String.fromCharCode(0xD800 | (ch >> 10), 0xDC00 | (ch & 0x3FF)); - } else { - str += String.fromCharCode(utf32); - } - } - return str; -} - -// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr', -// null-terminated and encoded in UTF32 form. The copy will require at most str.length*4+4 bytes of space in the HEAP. -// Use the function lengthBytesUTF32() to compute the exact number of bytes (excluding null terminator) that this function will write. -// Parameters: -// str: the Javascript string to copy. -// outPtr: Byte address in Emscripten HEAP where to write the string to. -// maxBytesToWrite: The maximum number of bytes this function can write to the array. This count should include the null -// terminator, i.e. if maxBytesToWrite=4, only the null terminator will be written and nothing else. -// maxBytesToWrite<4 does not write any bytes to the output, not even the null terminator. -// Returns the number of bytes written, EXCLUDING the null terminator. - -function stringToUTF32(str, outPtr, maxBytesToWrite) { - // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed. - if (maxBytesToWrite === undefined) { - maxBytesToWrite = 0x7FFFFFFF; - } - if (maxBytesToWrite < 4) return 0; - var startPtr = outPtr; - var endPtr = startPtr + maxBytesToWrite - 4; - for (var i = 0; i < str.length; ++i) { - // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! We must decode the string to UTF-32 to the heap. - // See http://unicode.org/faq/utf_bom.html#utf16-3 - var codeUnit = str.charCodeAt(i); // possibly a lead surrogate - if (codeUnit >= 0xD800 && codeUnit <= 0xDFFF) { - var trailSurrogate = str.charCodeAt(++i); - codeUnit = 0x10000 + ((codeUnit & 0x3FF) << 10) | (trailSurrogate & 0x3FF); - } - HEAP32[((outPtr)>>2)] = codeUnit; - outPtr += 4; - if (outPtr + 4 > endPtr) break; - } - // Null-terminate the pointer to the HEAP. - HEAP32[((outPtr)>>2)] = 0; - return outPtr - startPtr; -} - -// Returns the number of bytes the given Javascript string takes if encoded as a UTF16 byte array, EXCLUDING the null terminator byte. - -function lengthBytesUTF32(str) { - var len = 0; - for (var i = 0; i < str.length; ++i) { - // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! We must decode the string to UTF-32 to the heap. - // See http://unicode.org/faq/utf_bom.html#utf16-3 - var codeUnit = str.charCodeAt(i); - if (codeUnit >= 0xD800 && codeUnit <= 0xDFFF) ++i; // possibly a lead surrogate, so skip over the tail surrogate. - len += 4; - } - - return len; -} - -// Allocate heap space for a JS string, and write it there. -// It is the responsibility of the caller to free() that memory. -function allocateUTF8(str) { - var size = lengthBytesUTF8(str) + 1; - var ret = _malloc(size); - if (ret) stringToUTF8Array(str, HEAP8, ret, size); - return ret; -} - -// Allocate stack space for a JS string, and write it there. -function allocateUTF8OnStack(str) { - var size = lengthBytesUTF8(str) + 1; - var ret = stackAlloc(size); - stringToUTF8Array(str, HEAP8, ret, size); - return ret; -} - -// Deprecated: This function should not be called because it is unsafe and does not provide -// a maximum length limit of how many bytes it is allowed to write. Prefer calling the -// function stringToUTF8Array() instead, which takes in a maximum length that can be used -// to be secure from out of bounds writes. -/** @deprecated - @param {boolean=} dontAddNull */ -function writeStringToMemory(string, buffer, dontAddNull) { - warnOnce('writeStringToMemory is deprecated and should not be called! Use stringToUTF8() instead!'); - - var /** @type {number} */ lastChar, /** @type {number} */ end; - if (dontAddNull) { - // stringToUTF8Array always appends null. If we don't want to do that, remember the - // character that existed at the location where the null will be placed, and restore - // that after the write (below). - end = buffer + lengthBytesUTF8(string); - lastChar = HEAP8[end]; - } - stringToUTF8(string, buffer, Infinity); - if (dontAddNull) HEAP8[end] = lastChar; // Restore the value under the null character. -} - -function writeArrayToMemory(array, buffer) { - HEAP8.set(array, buffer); -} - -/** @param {boolean=} dontAddNull */ -function writeAsciiToMemory(str, buffer, dontAddNull) { - for (var i = 0; i < str.length; ++i) { - HEAP8[((buffer++)>>0)] = str.charCodeAt(i); - } - // Null-terminate the pointer to the HEAP. - if (!dontAddNull) HEAP8[((buffer)>>0)] = 0; -} - -// end include: runtime_strings_extra.js -// Memory management - -function alignUp(x, multiple) { - if (x % multiple > 0) { - x += multiple - (x % multiple); - } - return x; -} - -var HEAP, -/** @type {ArrayBuffer} */ - buffer, -/** @type {Int8Array} */ - HEAP8, -/** @type {Uint8Array} */ - HEAPU8, -/** @type {Int16Array} */ - HEAP16, -/** @type {Uint16Array} */ - HEAPU16, -/** @type {Int32Array} */ - HEAP32, -/** @type {Uint32Array} */ - HEAPU32, -/** @type {Float32Array} */ - HEAPF32, -/** @type {Float64Array} */ - HEAPF64; - -function updateGlobalBufferAndViews(buf) { - buffer = buf; - Module['HEAP8'] = HEAP8 = new Int8Array(buf); - Module['HEAP16'] = HEAP16 = new Int16Array(buf); - Module['HEAP32'] = HEAP32 = new Int32Array(buf); - Module['HEAPU8'] = HEAPU8 = new Uint8Array(buf); - Module['HEAPU16'] = HEAPU16 = new Uint16Array(buf); - Module['HEAPU32'] = HEAPU32 = new Uint32Array(buf); - Module['HEAPF32'] = HEAPF32 = new Float32Array(buf); - Module['HEAPF64'] = HEAPF64 = new Float64Array(buf); -} - -var TOTAL_STACK = 5242880; - -var INITIAL_MEMORY = Module['INITIAL_MEMORY'] || 33554432; - -// include: runtime_init_table.js -// In regular non-RELOCATABLE mode the table is exported -// from the wasm module and this will be assigned once -// the exports are available. -var wasmTable; - -// end include: runtime_init_table.js -// include: runtime_stack_check.js - - -// end include: runtime_stack_check.js -// include: runtime_assertions.js - - -// end include: runtime_assertions.js -var __ATPRERUN__ = []; // functions called before the runtime is initialized -var __ATINIT__ = []; // functions called during startup -var __ATEXIT__ = []; // functions called during shutdown -var __ATPOSTRUN__ = []; // functions called after the main() is called - -var runtimeInitialized = false; -var runtimeExited = false; -var runtimeKeepaliveCounter = 0; - -function keepRuntimeAlive() { - return noExitRuntime || runtimeKeepaliveCounter > 0; -} - -function preRun() { - - if (Module['preRun']) { - if (typeof Module['preRun'] == 'function') Module['preRun'] = [Module['preRun']]; - while (Module['preRun'].length) { - addOnPreRun(Module['preRun'].shift()); - } - } - - callRuntimeCallbacks(__ATPRERUN__); -} - -function initRuntime() { - runtimeInitialized = true; - - - callRuntimeCallbacks(__ATINIT__); -} - -function exitRuntime() { - runtimeExited = true; -} - -function postRun() { - - if (Module['postRun']) { - if (typeof Module['postRun'] == 'function') Module['postRun'] = [Module['postRun']]; - while (Module['postRun'].length) { - addOnPostRun(Module['postRun'].shift()); - } - } - - callRuntimeCallbacks(__ATPOSTRUN__); -} - -function addOnPreRun(cb) { - __ATPRERUN__.unshift(cb); -} - -function addOnInit(cb) { - __ATINIT__.unshift(cb); -} - -function addOnExit(cb) { -} - -function addOnPostRun(cb) { - __ATPOSTRUN__.unshift(cb); -} - -// include: runtime_math.js - - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/imul -// || MIN_NODE_VERSION < 0.12 -// check for imul support, and also for correctness ( https://bugs.webkit.org/show_bug.cgi?id=126345 ) -if (!Math.imul || Math.imul(0xffffffff, 5) !== -5) Math.imul = function imul(a, b) { - var ah = a >>> 16; - var al = a & 0xffff; - var bh = b >>> 16; - var bl = b & 0xffff; - return (al*bl + ((ah*bl + al*bh) << 16))|0; -}; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/fround - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc - -// end include: runtime_math.js -// A counter of dependencies for calling run(). If we need to -// do asynchronous work before running, increment this and -// decrement it. Incrementing must happen in a place like -// Module.preRun (used by emcc to add file preloading). -// Note that you can add dependencies in preRun, even though -// it happens right before run - run will be postponed until -// the dependencies are met. -var runDependencies = 0; -var runDependencyWatcher = null; -var dependenciesFulfilled = null; // overridden to take different actions when all run dependencies are fulfilled - -function getUniqueRunDependency(id) { - return id; -} - -function addRunDependency(id) { - runDependencies++; - - if (Module['monitorRunDependencies']) { - Module['monitorRunDependencies'](runDependencies); - } - -} - -function removeRunDependency(id) { - runDependencies--; - - if (Module['monitorRunDependencies']) { - Module['monitorRunDependencies'](runDependencies); - } - - if (runDependencies == 0) { - if (runDependencyWatcher !== null) { - clearInterval(runDependencyWatcher); - runDependencyWatcher = null; - } - if (dependenciesFulfilled) { - var callback = dependenciesFulfilled; - dependenciesFulfilled = null; - callback(); // can add another dependenciesFulfilled - } - } -} - -Module["preloadedImages"] = {}; // maps url to image data -Module["preloadedAudios"] = {}; // maps url to audio data - -/** @param {string|number=} what */ -function abort(what) { - { - if (Module['onAbort']) { - Module['onAbort'](what); - } - } - - what = 'Aborted(' + what + ')'; - // TODO(sbc): Should we remove printing and leave it up to whoever - // catches the exception? - err(what); - - ABORT = true; - EXITSTATUS = 1; - - what += '. Build with -s ASSERTIONS=1 for more info.'; - - // Use a wasm runtime error, because a JS error might be seen as a foreign - // exception, which means we'd run destructors on it. We need the error to - // simply make the program stop. - var e = new WebAssembly.RuntimeError(what); - - // Throw the error whether or not MODULARIZE is set because abort is used - // in code paths apart from instantiation where an exception is expected - // to be thrown when abort is called. - throw e; -} - -// {{MEM_INITIALIZER}} - -// include: memoryprofiler.js - - -// end include: memoryprofiler.js -// include: URIUtils.js - - -// Prefix of data URIs emitted by SINGLE_FILE and related options. -var dataURIPrefix = 'data:application/octet-stream;base64,'; - -// Indicates whether filename is a base64 data URI. -function isDataURI(filename) { - // Prefix of data URIs emitted by SINGLE_FILE and related options. - return filename.startsWith(dataURIPrefix); -} - -// Indicates whether filename is delivered via file protocol (as opposed to http/https) -function isFileURI(filename) { - return filename.startsWith('file://'); -} - -// end include: URIUtils.js -var wasmBinaryFile = VTS_WASM_URL; - -function getBinary(file) { - try { - if (file == wasmBinaryFile && wasmBinary) { - return new Uint8Array(wasmBinary); - } - if (readBinary) { - return readBinary(file); - } else { - throw "both async and sync fetching of the wasm failed"; - } - } - catch (err) { - abort(err); - } -} - -function getBinaryPromise() { - // If we don't have the binary yet, try to to load it asynchronously. - // Fetch has some additional restrictions over XHR, like it can't be used on a file:// url. - // See https://github.com/github/fetch/pull/92#issuecomment-140665932 - // Cordova or Electron apps are typically loaded from a file:// url. - // So use fetch if it is available and the url is not a file, otherwise fall back to XHR. - if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) { - if (typeof fetch === 'function' - ) { - return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function(response) { - if (!response['ok']) { - throw "failed to load wasm binary file at '" + wasmBinaryFile + "'"; - } - return response['arrayBuffer'](); - }).catch(function () { - return getBinary(wasmBinaryFile); - }); - } - } - - // Otherwise, getBinary should be able to get it synchronously - return Promise.resolve().then(function() { return getBinary(wasmBinaryFile); }); -} - -// Create the wasm instance. -// Receives the wasm imports, returns the exports. -function createWasm() { - // prepare imports - var info = { - 'env': asmLibraryArg, - 'wasi_snapshot_preview1': asmLibraryArg, - }; - // Load the wasm module and create an instance of using native support in the JS engine. - // handle a generated wasm instance, receiving its exports and - // performing other necessary setup - /** @param {WebAssembly.Module=} module*/ - function receiveInstance(instance, module) { - var exports = instance.exports; - - exports = Asyncify.instrumentWasmExports(exports); - - Module['asm'] = exports; - - wasmMemory = Module['asm']['memory']; - updateGlobalBufferAndViews(wasmMemory.buffer); - - wasmTable = Module['asm']['__indirect_function_table']; - - addOnInit(Module['asm']['__wasm_call_ctors']); - - removeRunDependency('wasm-instantiate'); - } - // we can't run yet (except in a pthread, where we have a custom sync instantiator) - addRunDependency('wasm-instantiate'); - - // Prefer streaming instantiation if available. - function receiveInstantiationResult(result) { - // 'result' is a ResultObject object which has both the module and instance. - // receiveInstance() will swap in the exports (to Module.asm) so they can be called - // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line. - // When the regression is fixed, can restore the above USE_PTHREADS-enabled path. - receiveInstance(result['instance']); - } - - function instantiateArrayBuffer(receiver) { - return getBinaryPromise().then(function(binary) { - return WebAssembly.instantiate(binary, info); - }).then(function (instance) { - return instance; - }).then(receiver, function(reason) { - err('failed to asynchronously prepare wasm: ' + reason); - - abort(reason); - }); - } - - function instantiateAsync() { - if (!wasmBinary && - typeof WebAssembly.instantiateStreaming === 'function' && - !isDataURI(wasmBinaryFile) && - typeof fetch === 'function') { - return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function (response) { - var result = WebAssembly.instantiateStreaming(response, info); - - return result.then( - receiveInstantiationResult, - function(reason) { - // We expect the most common failure cause to be a bad MIME type for the binary, - // in which case falling back to ArrayBuffer instantiation should work. - err('wasm streaming compile failed: ' + reason); - err('falling back to ArrayBuffer instantiation'); - return instantiateArrayBuffer(receiveInstantiationResult); - }); - }); - } else { - return instantiateArrayBuffer(receiveInstantiationResult); - } - } - - // User shell pages can write their own Module.instantiateWasm = function(imports, successCallback) callback - // to manually instantiate the Wasm module themselves. This allows pages to run the instantiation parallel - // to any other async startup actions they are performing. - if (Module['instantiateWasm']) { - try { - var exports = Module['instantiateWasm'](info, receiveInstance); - exports = Asyncify.instrumentWasmExports(exports); - return exports; - } catch(e) { - err('Module.instantiateWasm callback failed with error: ' + e); - return false; - } - } - - instantiateAsync(); - return {}; // no exports yet; we'll fill them in later -} - -// Globals used by JS i64 conversions (see makeSetValue) -var tempDouble; -var tempI64; - -// === Body === - -var ASM_CONSTS = { - 434420: function($0, $1, $2) {wasm_ffmpeg_error_report($0, $1, $2);}, - 434460: function($0, $1) {wasm_isaac_generate($0, $1);}, - 434491: function($0, $1, $2) {return wasm_ffmpeg_fwrite($0, $1, $2);}, - 434532: function($0, $1) {wasm_ffmpeg_fsize($0, $1);}, - 434561: function($0, $1, $2, $3, $4) {wasm_ffmpeg_fseek($0, $1, $2, $3, $4);}, - 434602: function($0, $1) {wasm_ffmpeg_fclose($0, $1);} -}; -function __asyncjs__wasm_ffmpeg_fopen_sync(filename,filelen,acc){ return Asyncify.handleAsync(async () => { const ret = await wasm_ffmpeg_fopen(filename, filelen, acc); return ret; }); } -function __asyncjs__wasm_ffmpeg_fread_sync(fd,buf,size,ffindex){ return Asyncify.handleAsync(async () => { const ret = await wasm_ffmpeg_fread(fd, buf, size, ffindex); return ret; }); } - - - - - - function callRuntimeCallbacks(callbacks) { - while (callbacks.length > 0) { - var callback = callbacks.shift(); - if (typeof callback == 'function') { - callback(Module); // Pass the module as the first argument. - continue; - } - var func = callback.func; - if (typeof func === 'number') { - if (callback.arg === undefined) { - (function() { dynCall_v.call(null, func); })(); - } else { - (function(a1) { dynCall_vi.apply(null, [func, a1]); })(callback.arg); - } - } else { - func(callback.arg === undefined ? null : callback.arg); - } - } - } - - function withStackSave(f) { - var stack = stackSave(); - var ret = f(); - stackRestore(stack); - return ret; - } - function demangle(func) { - return func; - } - - function demangleAll(text) { - var regex = - /\b_Z[\w\d_]+/g; - return text.replace(regex, - function(x) { - var y = demangle(x); - return x === y ? x : (y + ' [' + x + ']'); - }); - } - - var wasmTableMirror = []; - function getWasmTableEntry(funcPtr) { - var func = wasmTableMirror[funcPtr]; - if (!func) { - if (funcPtr >= wasmTableMirror.length) wasmTableMirror.length = funcPtr + 1; - wasmTableMirror[funcPtr] = func = wasmTable.get(funcPtr); - } - return func; - } - - function handleException(e) { - // Certain exception types we do not treat as errors since they are used for - // internal control flow. - // 1. ExitStatus, which is thrown by exit() - // 2. "unwind", which is thrown by emscripten_unwind_to_js_event_loop() and others - // that wish to return to JS event loop. - if (e instanceof ExitStatus || e == 'unwind') { - return EXITSTATUS; - } - quit_(1, e); - } - - function jsStackTrace() { - var error = new Error(); - if (!error.stack) { - // IE10+ special cases: It does have callstack info, but it is only populated if an Error object is thrown, - // so try that as a special-case. - try { - throw new Error(); - } catch(e) { - error = e; - } - if (!error.stack) { - return '(no stack trace available)'; - } - } - return error.stack.toString(); - } - - function setWasmTableEntry(idx, func) { - wasmTable.set(idx, func); - wasmTableMirror[idx] = func; - } - - function stackTrace() { - var js = jsStackTrace(); - if (Module['extraStackTrace']) js += '\n' + Module['extraStackTrace'](); - return demangleAll(js); - } - - function ___cxa_allocate_exception(size) { - // Thrown object is prepended by exception metadata block - return _malloc(size + 16) + 16; - } - - function _atexit(func, arg) { - } - function ___cxa_atexit(a0,a1 - ) { - return _atexit(a0,a1); - } - - function ExceptionInfo(excPtr) { - this.excPtr = excPtr; - this.ptr = excPtr - 16; - - this.set_type = function(type) { - HEAP32[(((this.ptr)+(4))>>2)] = type; - }; - - this.get_type = function() { - return HEAP32[(((this.ptr)+(4))>>2)]; - }; - - this.set_destructor = function(destructor) { - HEAP32[(((this.ptr)+(8))>>2)] = destructor; - }; - - this.get_destructor = function() { - return HEAP32[(((this.ptr)+(8))>>2)]; - }; - - this.set_refcount = function(refcount) { - HEAP32[((this.ptr)>>2)] = refcount; - }; - - this.set_caught = function (caught) { - caught = caught ? 1 : 0; - HEAP8[(((this.ptr)+(12))>>0)] = caught; - }; - - this.get_caught = function () { - return HEAP8[(((this.ptr)+(12))>>0)] != 0; - }; - - this.set_rethrown = function (rethrown) { - rethrown = rethrown ? 1 : 0; - HEAP8[(((this.ptr)+(13))>>0)] = rethrown; - }; - - this.get_rethrown = function () { - return HEAP8[(((this.ptr)+(13))>>0)] != 0; - }; - - // Initialize native structure fields. Should be called once after allocated. - this.init = function(type, destructor) { - this.set_type(type); - this.set_destructor(destructor); - this.set_refcount(0); - this.set_caught(false); - this.set_rethrown(false); - } - - this.add_ref = function() { - var value = HEAP32[((this.ptr)>>2)]; - HEAP32[((this.ptr)>>2)] = value + 1; - }; - - // Returns true if last reference released. - this.release_ref = function() { - var prev = HEAP32[((this.ptr)>>2)]; - HEAP32[((this.ptr)>>2)] = prev - 1; - return prev === 1; - }; - } - - var exceptionLast = 0; - - var uncaughtExceptionCount = 0; - function ___cxa_throw(ptr, type, destructor) { - var info = new ExceptionInfo(ptr); - // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception. - info.init(type, destructor); - exceptionLast = ptr; - uncaughtExceptionCount++; - throw ptr; - } - - function _gmtime_r(time, tmPtr) { - var date = new Date(HEAP32[((time)>>2)]*1000); - HEAP32[((tmPtr)>>2)] = date.getUTCSeconds(); - HEAP32[(((tmPtr)+(4))>>2)] = date.getUTCMinutes(); - HEAP32[(((tmPtr)+(8))>>2)] = date.getUTCHours(); - HEAP32[(((tmPtr)+(12))>>2)] = date.getUTCDate(); - HEAP32[(((tmPtr)+(16))>>2)] = date.getUTCMonth(); - HEAP32[(((tmPtr)+(20))>>2)] = date.getUTCFullYear()-1900; - HEAP32[(((tmPtr)+(24))>>2)] = date.getUTCDay(); - HEAP32[(((tmPtr)+(36))>>2)] = 0; - HEAP32[(((tmPtr)+(32))>>2)] = 0; - var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0); - var yday = ((date.getTime() - start) / (1000 * 60 * 60 * 24))|0; - HEAP32[(((tmPtr)+(28))>>2)] = yday; - // Allocate a string "GMT" for us to point to. - if (!_gmtime_r.GMTString) _gmtime_r.GMTString = allocateUTF8("GMT"); - HEAP32[(((tmPtr)+(40))>>2)] = _gmtime_r.GMTString; - return tmPtr; - } - function ___gmtime_r(a0,a1 - ) { - return _gmtime_r(a0,a1); - } - - function _tzset_impl() { - var currentYear = new Date().getFullYear(); - var winter = new Date(currentYear, 0, 1); - var summer = new Date(currentYear, 6, 1); - var winterOffset = winter.getTimezoneOffset(); - var summerOffset = summer.getTimezoneOffset(); - - // Local standard timezone offset. Local standard time is not adjusted for daylight savings. - // This code uses the fact that getTimezoneOffset returns a greater value during Standard Time versus Daylight Saving Time (DST). - // Thus it determines the expected output during Standard Time, and it compares whether the output of the given date the same (Standard) or less (DST). - var stdTimezoneOffset = Math.max(winterOffset, summerOffset); - - // timezone is specified as seconds west of UTC ("The external variable - // `timezone` shall be set to the difference, in seconds, between - // Coordinated Universal Time (UTC) and local standard time."), the same - // as returned by stdTimezoneOffset. - // See http://pubs.opengroup.org/onlinepubs/009695399/functions/tzset.html - HEAP32[((__get_timezone())>>2)] = stdTimezoneOffset * 60; - - HEAP32[((__get_daylight())>>2)] = Number(winterOffset != summerOffset); - - function extractZone(date) { - var match = date.toTimeString().match(/\(([A-Za-z ]+)\)$/); - return match ? match[1] : "GMT"; - }; - var winterName = extractZone(winter); - var summerName = extractZone(summer); - var winterNamePtr = allocateUTF8(winterName); - var summerNamePtr = allocateUTF8(summerName); - if (summerOffset < winterOffset) { - // Northern hemisphere - HEAP32[((__get_tzname())>>2)] = winterNamePtr; - HEAP32[(((__get_tzname())+(4))>>2)] = summerNamePtr; - } else { - HEAP32[((__get_tzname())>>2)] = summerNamePtr; - HEAP32[(((__get_tzname())+(4))>>2)] = winterNamePtr; - } - } - function _tzset() { - // TODO: Use (malleable) environment variables instead of system settings. - if (_tzset.called) return; - _tzset.called = true; - _tzset_impl(); - } - function _localtime_r(time, tmPtr) { - _tzset(); - var date = new Date(HEAP32[((time)>>2)]*1000); - HEAP32[((tmPtr)>>2)] = date.getSeconds(); - HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes(); - HEAP32[(((tmPtr)+(8))>>2)] = date.getHours(); - HEAP32[(((tmPtr)+(12))>>2)] = date.getDate(); - HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth(); - HEAP32[(((tmPtr)+(20))>>2)] = date.getFullYear()-1900; - HEAP32[(((tmPtr)+(24))>>2)] = date.getDay(); - - var start = new Date(date.getFullYear(), 0, 1); - var yday = ((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))|0; - HEAP32[(((tmPtr)+(28))>>2)] = yday; - HEAP32[(((tmPtr)+(36))>>2)] = -(date.getTimezoneOffset() * 60); - - // Attention: DST is in December in South, and some regions don't have DST at all. - var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); - var winterOffset = start.getTimezoneOffset(); - var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset))|0; - HEAP32[(((tmPtr)+(32))>>2)] = dst; - - var zonePtr = HEAP32[(((__get_tzname())+(dst ? 4 : 0))>>2)]; - HEAP32[(((tmPtr)+(40))>>2)] = zonePtr; - - return tmPtr; - } - function ___localtime_r(a0,a1 - ) { - return _localtime_r(a0,a1); - } - - var SYSCALLS = {mappings:{},buffers:[null,[],[]],printChar:function(stream, curr) { - var buffer = SYSCALLS.buffers[stream]; - if (curr === 0 || curr === 10) { - (stream === 1 ? out : err)(UTF8ArrayToString(buffer, 0)); - buffer.length = 0; - } else { - buffer.push(curr); - } - },varargs:undefined,get:function() { - SYSCALLS.varargs += 4; - var ret = HEAP32[(((SYSCALLS.varargs)-(4))>>2)]; - return ret; - },getStr:function(ptr) { - var ret = UTF8ToString(ptr); - return ret; - },get64:function(low, high) { - return low; - }}; - function ___syscall__newselect(nfds, readfds, writefds, exceptfds, timeout) { - } - - function setErrNo(value) { - HEAP32[((___errno_location())>>2)] = value; - return value; - } - function ___syscall_fcntl64(fd, cmd, varargs) {SYSCALLS.varargs = varargs; - - return 0; - } - - function ___syscall_ioctl(fd, op, varargs) {SYSCALLS.varargs = varargs; - - return 0; - } - - function ___syscall_mkdir(path, mode) { - path = SYSCALLS.getStr(path); - return SYSCALLS.doMkdir(path, mode); - } - - function ___syscall_open(path, flags, varargs) {SYSCALLS.varargs = varargs; - - } - - function ___syscall_rmdir(path) { - } - - function ___syscall_unlink(path) { - } - - var structRegistrations = {}; - - function runDestructors(destructors) { - while (destructors.length) { - var ptr = destructors.pop(); - var del = destructors.pop(); - del(ptr); - } - } - - function simpleReadValueFromPointer(pointer) { - return this['fromWireType'](HEAPU32[pointer >> 2]); - } - - var awaitingDependencies = {}; - - var registeredTypes = {}; - - var typeDependencies = {}; - - var char_0 = 48; - - var char_9 = 57; - function makeLegalFunctionName(name) { - if (undefined === name) { - return '_unknown'; - } - name = name.replace(/[^a-zA-Z0-9_]/g, '$'); - var f = name.charCodeAt(0); - if (f >= char_0 && f <= char_9) { - return '_' + name; - } else { - return name; - } - } - function createNamedFunction(name, body) { - name = makeLegalFunctionName(name); - /*jshint evil:true*/ - return new Function( - "body", - "return function " + name + "() {\n" + - " \"use strict\";" + - " return body.apply(this, arguments);\n" + - "};\n" - )(body); - } - function extendError(baseErrorType, errorName) { - var errorClass = createNamedFunction(errorName, function(message) { - this.name = errorName; - this.message = message; - - var stack = (new Error(message)).stack; - if (stack !== undefined) { - this.stack = this.toString() + '\n' + - stack.replace(/^Error(:[^\n]*)?\n/, ''); - } - }); - errorClass.prototype = Object.create(baseErrorType.prototype); - errorClass.prototype.constructor = errorClass; - errorClass.prototype.toString = function() { - if (this.message === undefined) { - return this.name; - } else { - return this.name + ': ' + this.message; - } - }; - - return errorClass; - } - var InternalError = undefined; - function throwInternalError(message) { - throw new InternalError(message); - } - function whenDependentTypesAreResolved(myTypes, dependentTypes, getTypeConverters) { - myTypes.forEach(function(type) { - typeDependencies[type] = dependentTypes; - }); - - function onComplete(typeConverters) { - var myTypeConverters = getTypeConverters(typeConverters); - if (myTypeConverters.length !== myTypes.length) { - throwInternalError('Mismatched type converter count'); - } - for (var i = 0; i < myTypes.length; ++i) { - registerType(myTypes[i], myTypeConverters[i]); - } - } - - var typeConverters = new Array(dependentTypes.length); - var unregisteredTypes = []; - var registered = 0; - dependentTypes.forEach(function(dt, i) { - if (registeredTypes.hasOwnProperty(dt)) { - typeConverters[i] = registeredTypes[dt]; - } else { - unregisteredTypes.push(dt); - if (!awaitingDependencies.hasOwnProperty(dt)) { - awaitingDependencies[dt] = []; - } - awaitingDependencies[dt].push(function() { - typeConverters[i] = registeredTypes[dt]; - ++registered; - if (registered === unregisteredTypes.length) { - onComplete(typeConverters); - } - }); - } - }); - if (0 === unregisteredTypes.length) { - onComplete(typeConverters); - } - } - function __embind_finalize_value_object(structType) { - var reg = structRegistrations[structType]; - delete structRegistrations[structType]; - - var rawConstructor = reg.rawConstructor; - var rawDestructor = reg.rawDestructor; - var fieldRecords = reg.fields; - var fieldTypes = fieldRecords.map(function(field) { return field.getterReturnType; }). - concat(fieldRecords.map(function(field) { return field.setterArgumentType; })); - whenDependentTypesAreResolved([structType], fieldTypes, function(fieldTypes) { - var fields = {}; - fieldRecords.forEach(function(field, i) { - var fieldName = field.fieldName; - var getterReturnType = fieldTypes[i]; - var getter = field.getter; - var getterContext = field.getterContext; - var setterArgumentType = fieldTypes[i + fieldRecords.length]; - var setter = field.setter; - var setterContext = field.setterContext; - fields[fieldName] = { - read: function(ptr) { - return getterReturnType['fromWireType']( - getter(getterContext, ptr)); - }, - write: function(ptr, o) { - var destructors = []; - setter(setterContext, ptr, setterArgumentType['toWireType'](destructors, o)); - runDestructors(destructors); - } - }; - }); - - return [{ - name: reg.name, - 'fromWireType': function(ptr) { - var rv = {}; - for (var i in fields) { - rv[i] = fields[i].read(ptr); - } - rawDestructor(ptr); - return rv; - }, - 'toWireType': function(destructors, o) { - // todo: Here we have an opportunity for -O3 level "unsafe" optimizations: - // assume all fields are present without checking. - for (var fieldName in fields) { - if (!(fieldName in o)) { - throw new TypeError('Missing field: "' + fieldName + '"'); - } - } - var ptr = rawConstructor(); - for (fieldName in fields) { - fields[fieldName].write(ptr, o[fieldName]); - } - if (destructors !== null) { - destructors.push(rawDestructor, ptr); - } - return ptr; - }, - 'argPackAdvance': 8, - 'readValueFromPointer': simpleReadValueFromPointer, - destructorFunction: rawDestructor, - }]; - }); - } - - function __embind_register_bigint(primitiveType, name, size, minRange, maxRange) {} - - function getShiftFromSize(size) { - - switch (size) { - case 1: return 0; - case 2: return 1; - case 4: return 2; - case 8: return 3; - default: - throw new TypeError('Unknown type size: ' + size); - } - } - - function embind_init_charCodes() { - var codes = new Array(256); - for (var i = 0; i < 256; ++i) { - codes[i] = String.fromCharCode(i); - } - embind_charCodes = codes; - } - var embind_charCodes = undefined; - function readLatin1String(ptr) { - var ret = ""; - var c = ptr; - while (HEAPU8[c]) { - ret += embind_charCodes[HEAPU8[c++]]; - } - return ret; - } - - var BindingError = undefined; - function throwBindingError(message) { - throw new BindingError(message); - } - /** @param {Object=} options */ - function registerType(rawType, registeredInstance, options) { - options = options || {}; - - if (!('argPackAdvance' in registeredInstance)) { - throw new TypeError('registerType registeredInstance requires argPackAdvance'); - } - - var name = registeredInstance.name; - if (!rawType) { - throwBindingError('type "' + name + '" must have a positive integer typeid pointer'); - } - if (registeredTypes.hasOwnProperty(rawType)) { - if (options.ignoreDuplicateRegistrations) { - return; - } else { - throwBindingError("Cannot register type '" + name + "' twice"); - } - } - - registeredTypes[rawType] = registeredInstance; - delete typeDependencies[rawType]; - - if (awaitingDependencies.hasOwnProperty(rawType)) { - var callbacks = awaitingDependencies[rawType]; - delete awaitingDependencies[rawType]; - callbacks.forEach(function(cb) { - cb(); - }); - } - } - function __embind_register_bool(rawType, name, size, trueValue, falseValue) { - var shift = getShiftFromSize(size); - - name = readLatin1String(name); - registerType(rawType, { - name: name, - 'fromWireType': function(wt) { - // ambiguous emscripten ABI: sometimes return values are - // true or false, and sometimes integers (0 or 1) - return !!wt; - }, - 'toWireType': function(destructors, o) { - return o ? trueValue : falseValue; - }, - 'argPackAdvance': 8, - 'readValueFromPointer': function(pointer) { - // TODO: if heap is fixed (like in asm.js) this could be executed outside - var heap; - if (size === 1) { - heap = HEAP8; - } else if (size === 2) { - heap = HEAP16; - } else if (size === 4) { - heap = HEAP32; - } else { - throw new TypeError("Unknown boolean type size: " + name); - } - return this['fromWireType'](heap[pointer >> shift]); - }, - destructorFunction: null, // This type does not need a destructor - }); - } - - function ClassHandle_isAliasOf(other) { - if (!(this instanceof ClassHandle)) { - return false; - } - if (!(other instanceof ClassHandle)) { - return false; - } - - var leftClass = this.$$.ptrType.registeredClass; - var left = this.$$.ptr; - var rightClass = other.$$.ptrType.registeredClass; - var right = other.$$.ptr; - - while (leftClass.baseClass) { - left = leftClass.upcast(left); - leftClass = leftClass.baseClass; - } - - while (rightClass.baseClass) { - right = rightClass.upcast(right); - rightClass = rightClass.baseClass; - } - - return leftClass === rightClass && left === right; - } - - function shallowCopyInternalPointer(o) { - return { - count: o.count, - deleteScheduled: o.deleteScheduled, - preservePointerOnDelete: o.preservePointerOnDelete, - ptr: o.ptr, - ptrType: o.ptrType, - smartPtr: o.smartPtr, - smartPtrType: o.smartPtrType, - }; - } - - function throwInstanceAlreadyDeleted(obj) { - function getInstanceTypeName(handle) { - return handle.$$.ptrType.registeredClass.name; - } - throwBindingError(getInstanceTypeName(obj) + ' instance already deleted'); - } - - var finalizationGroup = false; - - function detachFinalizer(handle) {} - - function runDestructor($$) { - if ($$.smartPtr) { - $$.smartPtrType.rawDestructor($$.smartPtr); - } else { - $$.ptrType.registeredClass.rawDestructor($$.ptr); - } - } - function releaseClassHandle($$) { - $$.count.value -= 1; - var toDelete = 0 === $$.count.value; - if (toDelete) { - runDestructor($$); - } - } - function attachFinalizer(handle) { - if ('undefined' === typeof FinalizationGroup) { - attachFinalizer = function (handle) { return handle; }; - return handle; - } - // If the running environment has a FinalizationGroup (see - // https://github.com/tc39/proposal-weakrefs), then attach finalizers - // for class handles. We check for the presence of FinalizationGroup - // at run-time, not build-time. - finalizationGroup = new FinalizationGroup(function (iter) { - for (var result = iter.next(); !result.done; result = iter.next()) { - var $$ = result.value; - if (!$$.ptr) { - console.warn('object already deleted: ' + $$.ptr); - } else { - releaseClassHandle($$); - } - } - }); - attachFinalizer = function(handle) { - finalizationGroup.register(handle, handle.$$, handle.$$); - return handle; - }; - detachFinalizer = function(handle) { - finalizationGroup.unregister(handle.$$); - }; - return attachFinalizer(handle); - } - function ClassHandle_clone() { - if (!this.$$.ptr) { - throwInstanceAlreadyDeleted(this); - } - - if (this.$$.preservePointerOnDelete) { - this.$$.count.value += 1; - return this; - } else { - var clone = attachFinalizer(Object.create(Object.getPrototypeOf(this), { - $$: { - value: shallowCopyInternalPointer(this.$$), - } - })); - - clone.$$.count.value += 1; - clone.$$.deleteScheduled = false; - return clone; - } - } - - function ClassHandle_delete() { - if (!this.$$.ptr) { - throwInstanceAlreadyDeleted(this); - } - - if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) { - throwBindingError('Object already scheduled for deletion'); - } - - detachFinalizer(this); - releaseClassHandle(this.$$); - - if (!this.$$.preservePointerOnDelete) { - this.$$.smartPtr = undefined; - this.$$.ptr = undefined; - } - } - - function ClassHandle_isDeleted() { - return !this.$$.ptr; - } - - var delayFunction = undefined; - - var deletionQueue = []; - - function flushPendingDeletes() { - while (deletionQueue.length) { - var obj = deletionQueue.pop(); - obj.$$.deleteScheduled = false; - obj['delete'](); - } - } - function ClassHandle_deleteLater() { - if (!this.$$.ptr) { - throwInstanceAlreadyDeleted(this); - } - if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) { - throwBindingError('Object already scheduled for deletion'); - } - deletionQueue.push(this); - if (deletionQueue.length === 1 && delayFunction) { - delayFunction(flushPendingDeletes); - } - this.$$.deleteScheduled = true; - return this; - } - function init_ClassHandle() { - ClassHandle.prototype['isAliasOf'] = ClassHandle_isAliasOf; - ClassHandle.prototype['clone'] = ClassHandle_clone; - ClassHandle.prototype['delete'] = ClassHandle_delete; - ClassHandle.prototype['isDeleted'] = ClassHandle_isDeleted; - ClassHandle.prototype['deleteLater'] = ClassHandle_deleteLater; - } - function ClassHandle() { - } - - var registeredPointers = {}; - - function ensureOverloadTable(proto, methodName, humanName) { - if (undefined === proto[methodName].overloadTable) { - var prevFunc = proto[methodName]; - // Inject an overload resolver function that routes to the appropriate overload based on the number of arguments. - proto[methodName] = function() { - // TODO This check can be removed in -O3 level "unsafe" optimizations. - if (!proto[methodName].overloadTable.hasOwnProperty(arguments.length)) { - throwBindingError("Function '" + humanName + "' called with an invalid number of arguments (" + arguments.length + ") - expects one of (" + proto[methodName].overloadTable + ")!"); - } - return proto[methodName].overloadTable[arguments.length].apply(this, arguments); - }; - // Move the previous function into the overload table. - proto[methodName].overloadTable = []; - proto[methodName].overloadTable[prevFunc.argCount] = prevFunc; - } - } - /** @param {number=} numArguments */ - function exposePublicSymbol(name, value, numArguments) { - if (Module.hasOwnProperty(name)) { - if (undefined === numArguments || (undefined !== Module[name].overloadTable && undefined !== Module[name].overloadTable[numArguments])) { - throwBindingError("Cannot register public name '" + name + "' twice"); - } - - // We are exposing a function with the same name as an existing function. Create an overload table and a function selector - // that routes between the two. - ensureOverloadTable(Module, name, name); - if (Module.hasOwnProperty(numArguments)) { - throwBindingError("Cannot register multiple overloads of a function with the same number of arguments (" + numArguments + ")!"); - } - // Add the new function into the overload table. - Module[name].overloadTable[numArguments] = value; - } - else { - Module[name] = value; - if (undefined !== numArguments) { - Module[name].numArguments = numArguments; - } - } - } - - /** @constructor */ - function RegisteredClass( - name, - constructor, - instancePrototype, - rawDestructor, - baseClass, - getActualType, - upcast, - downcast - ) { - this.name = name; - this.constructor = constructor; - this.instancePrototype = instancePrototype; - this.rawDestructor = rawDestructor; - this.baseClass = baseClass; - this.getActualType = getActualType; - this.upcast = upcast; - this.downcast = downcast; - this.pureVirtualFunctions = []; - } - - function upcastPointer(ptr, ptrClass, desiredClass) { - while (ptrClass !== desiredClass) { - if (!ptrClass.upcast) { - throwBindingError("Expected null or instance of " + desiredClass.name + ", got an instance of " + ptrClass.name); - } - ptr = ptrClass.upcast(ptr); - ptrClass = ptrClass.baseClass; - } - return ptr; - } - function constNoSmartPtrRawPointerToWireType(destructors, handle) { - if (handle === null) { - if (this.isReference) { - throwBindingError('null is not a valid ' + this.name); - } - return 0; - } - - if (!handle.$$) { - throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name); - } - if (!handle.$$.ptr) { - throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name); - } - var handleClass = handle.$$.ptrType.registeredClass; - var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); - return ptr; - } - - function genericPointerToWireType(destructors, handle) { - var ptr; - if (handle === null) { - if (this.isReference) { - throwBindingError('null is not a valid ' + this.name); - } - - if (this.isSmartPointer) { - ptr = this.rawConstructor(); - if (destructors !== null) { - destructors.push(this.rawDestructor, ptr); - } - return ptr; - } else { - return 0; - } - } - - if (!handle.$$) { - throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name); - } - if (!handle.$$.ptr) { - throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name); - } - if (!this.isConst && handle.$$.ptrType.isConst) { - throwBindingError('Cannot convert argument of type ' + (handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name) + ' to parameter type ' + this.name); - } - var handleClass = handle.$$.ptrType.registeredClass; - ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); - - if (this.isSmartPointer) { - // TODO: this is not strictly true - // We could support BY_EMVAL conversions from raw pointers to smart pointers - // because the smart pointer can hold a reference to the handle - if (undefined === handle.$$.smartPtr) { - throwBindingError('Passing raw pointer to smart pointer is illegal'); - } - - switch (this.sharingPolicy) { - case 0: // NONE - // no upcasting - if (handle.$$.smartPtrType === this) { - ptr = handle.$$.smartPtr; - } else { - throwBindingError('Cannot convert argument of type ' + (handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name) + ' to parameter type ' + this.name); - } - break; - - case 1: // INTRUSIVE - ptr = handle.$$.smartPtr; - break; - - case 2: // BY_EMVAL - if (handle.$$.smartPtrType === this) { - ptr = handle.$$.smartPtr; - } else { - var clonedHandle = handle['clone'](); - ptr = this.rawShare( - ptr, - Emval.toHandle(function() { - clonedHandle['delete'](); - }) - ); - if (destructors !== null) { - destructors.push(this.rawDestructor, ptr); - } - } - break; - - default: - throwBindingError('Unsupporting sharing policy'); - } - } - return ptr; - } - - function nonConstNoSmartPtrRawPointerToWireType(destructors, handle) { - if (handle === null) { - if (this.isReference) { - throwBindingError('null is not a valid ' + this.name); - } - return 0; - } - - if (!handle.$$) { - throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name); - } - if (!handle.$$.ptr) { - throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name); - } - if (handle.$$.ptrType.isConst) { - throwBindingError('Cannot convert argument of type ' + handle.$$.ptrType.name + ' to parameter type ' + this.name); - } - var handleClass = handle.$$.ptrType.registeredClass; - var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass); - return ptr; - } - - function RegisteredPointer_getPointee(ptr) { - if (this.rawGetPointee) { - ptr = this.rawGetPointee(ptr); - } - return ptr; - } - - function RegisteredPointer_destructor(ptr) { - if (this.rawDestructor) { - this.rawDestructor(ptr); - } - } - - function RegisteredPointer_deleteObject(handle) { - if (handle !== null) { - handle['delete'](); - } - } - - function downcastPointer(ptr, ptrClass, desiredClass) { - if (ptrClass === desiredClass) { - return ptr; - } - if (undefined === desiredClass.baseClass) { - return null; // no conversion - } - - var rv = downcastPointer(ptr, ptrClass, desiredClass.baseClass); - if (rv === null) { - return null; - } - return desiredClass.downcast(rv); - } - - function getInheritedInstanceCount() { - return Object.keys(registeredInstances).length; - } - - function getLiveInheritedInstances() { - var rv = []; - for (var k in registeredInstances) { - if (registeredInstances.hasOwnProperty(k)) { - rv.push(registeredInstances[k]); - } - } - return rv; - } - - function setDelayFunction(fn) { - delayFunction = fn; - if (deletionQueue.length && delayFunction) { - delayFunction(flushPendingDeletes); - } - } - function init_embind() { - Module['getInheritedInstanceCount'] = getInheritedInstanceCount; - Module['getLiveInheritedInstances'] = getLiveInheritedInstances; - Module['flushPendingDeletes'] = flushPendingDeletes; - Module['setDelayFunction'] = setDelayFunction; - } - var registeredInstances = {}; - - function getBasestPointer(class_, ptr) { - if (ptr === undefined) { - throwBindingError('ptr should not be undefined'); - } - while (class_.baseClass) { - ptr = class_.upcast(ptr); - class_ = class_.baseClass; - } - return ptr; - } - function getInheritedInstance(class_, ptr) { - ptr = getBasestPointer(class_, ptr); - return registeredInstances[ptr]; - } - - function makeClassHandle(prototype, record) { - if (!record.ptrType || !record.ptr) { - throwInternalError('makeClassHandle requires ptr and ptrType'); - } - var hasSmartPtrType = !!record.smartPtrType; - var hasSmartPtr = !!record.smartPtr; - if (hasSmartPtrType !== hasSmartPtr) { - throwInternalError('Both smartPtrType and smartPtr must be specified'); - } - record.count = { value: 1 }; - return attachFinalizer(Object.create(prototype, { - $$: { - value: record, - }, - })); - } - function RegisteredPointer_fromWireType(ptr) { - // ptr is a raw pointer (or a raw smartpointer) - - // rawPointer is a maybe-null raw pointer - var rawPointer = this.getPointee(ptr); - if (!rawPointer) { - this.destructor(ptr); - return null; - } - - var registeredInstance = getInheritedInstance(this.registeredClass, rawPointer); - if (undefined !== registeredInstance) { - // JS object has been neutered, time to repopulate it - if (0 === registeredInstance.$$.count.value) { - registeredInstance.$$.ptr = rawPointer; - registeredInstance.$$.smartPtr = ptr; - return registeredInstance['clone'](); - } else { - // else, just increment reference count on existing object - // it already has a reference to the smart pointer - var rv = registeredInstance['clone'](); - this.destructor(ptr); - return rv; - } - } - - function makeDefaultHandle() { - if (this.isSmartPointer) { - return makeClassHandle(this.registeredClass.instancePrototype, { - ptrType: this.pointeeType, - ptr: rawPointer, - smartPtrType: this, - smartPtr: ptr, - }); - } else { - return makeClassHandle(this.registeredClass.instancePrototype, { - ptrType: this, - ptr: ptr, - }); - } - } - - var actualType = this.registeredClass.getActualType(rawPointer); - var registeredPointerRecord = registeredPointers[actualType]; - if (!registeredPointerRecord) { - return makeDefaultHandle.call(this); - } - - var toType; - if (this.isConst) { - toType = registeredPointerRecord.constPointerType; - } else { - toType = registeredPointerRecord.pointerType; - } - var dp = downcastPointer( - rawPointer, - this.registeredClass, - toType.registeredClass); - if (dp === null) { - return makeDefaultHandle.call(this); - } - if (this.isSmartPointer) { - return makeClassHandle(toType.registeredClass.instancePrototype, { - ptrType: toType, - ptr: dp, - smartPtrType: this, - smartPtr: ptr, - }); - } else { - return makeClassHandle(toType.registeredClass.instancePrototype, { - ptrType: toType, - ptr: dp, - }); - } - } - function init_RegisteredPointer() { - RegisteredPointer.prototype.getPointee = RegisteredPointer_getPointee; - RegisteredPointer.prototype.destructor = RegisteredPointer_destructor; - RegisteredPointer.prototype['argPackAdvance'] = 8; - RegisteredPointer.prototype['readValueFromPointer'] = simpleReadValueFromPointer; - RegisteredPointer.prototype['deleteObject'] = RegisteredPointer_deleteObject; - RegisteredPointer.prototype['fromWireType'] = RegisteredPointer_fromWireType; - } - /** @constructor - @param {*=} pointeeType, - @param {*=} sharingPolicy, - @param {*=} rawGetPointee, - @param {*=} rawConstructor, - @param {*=} rawShare, - @param {*=} rawDestructor, - */ - function RegisteredPointer( - name, - registeredClass, - isReference, - isConst, - - // smart pointer properties - isSmartPointer, - pointeeType, - sharingPolicy, - rawGetPointee, - rawConstructor, - rawShare, - rawDestructor - ) { - this.name = name; - this.registeredClass = registeredClass; - this.isReference = isReference; - this.isConst = isConst; - - // smart pointer properties - this.isSmartPointer = isSmartPointer; - this.pointeeType = pointeeType; - this.sharingPolicy = sharingPolicy; - this.rawGetPointee = rawGetPointee; - this.rawConstructor = rawConstructor; - this.rawShare = rawShare; - this.rawDestructor = rawDestructor; - - if (!isSmartPointer && registeredClass.baseClass === undefined) { - if (isConst) { - this['toWireType'] = constNoSmartPtrRawPointerToWireType; - this.destructorFunction = null; - } else { - this['toWireType'] = nonConstNoSmartPtrRawPointerToWireType; - this.destructorFunction = null; - } - } else { - this['toWireType'] = genericPointerToWireType; - // Here we must leave this.destructorFunction undefined, since whether genericPointerToWireType returns - // a pointer that needs to be freed up is runtime-dependent, and cannot be evaluated at registration time. - // TODO: Create an alternative mechanism that allows removing the use of var destructors = []; array in - // craftInvokerFunction altogether. - } - } - - /** @param {number=} numArguments */ - function replacePublicSymbol(name, value, numArguments) { - if (!Module.hasOwnProperty(name)) { - throwInternalError('Replacing nonexistant public symbol'); - } - // If there's an overload table for this symbol, replace the symbol in the overload table instead. - if (undefined !== Module[name].overloadTable && undefined !== numArguments) { - Module[name].overloadTable[numArguments] = value; - } - else { - Module[name] = value; - Module[name].argCount = numArguments; - } - } - - function dynCallLegacy(sig, ptr, args) { - var f = Module["dynCall_" + sig]; - return args && args.length ? f.apply(null, [ptr].concat(args)) : f.call(null, ptr); - } - function dynCall(sig, ptr, args) { - return dynCallLegacy(sig, ptr, args); - } - function getDynCaller(sig, ptr) { - var argCache = []; - return function() { - argCache.length = arguments.length; - for (var i = 0; i < arguments.length; i++) { - argCache[i] = arguments[i]; - } - return dynCall(sig, ptr, argCache); - }; - } - function embind__requireFunction(signature, rawFunction) { - signature = readLatin1String(signature); - - function makeDynCaller() { - return getDynCaller(signature, rawFunction); - } - - var fp = makeDynCaller(); - if (typeof fp !== "function") { - throwBindingError("unknown function pointer with signature " + signature + ": " + rawFunction); - } - return fp; - } - - var UnboundTypeError = undefined; - - function getTypeName(type) { - var ptr = ___getTypeName(type); - var rv = readLatin1String(ptr); - _free(ptr); - return rv; - } - function throwUnboundTypeError(message, types) { - var unboundTypes = []; - var seen = {}; - function visit(type) { - if (seen[type]) { - return; - } - if (registeredTypes[type]) { - return; - } - if (typeDependencies[type]) { - typeDependencies[type].forEach(visit); - return; - } - unboundTypes.push(type); - seen[type] = true; - } - types.forEach(visit); - - throw new UnboundTypeError(message + ': ' + unboundTypes.map(getTypeName).join([', '])); - } - function __embind_register_class( - rawType, - rawPointerType, - rawConstPointerType, - baseClassRawType, - getActualTypeSignature, - getActualType, - upcastSignature, - upcast, - downcastSignature, - downcast, - name, - destructorSignature, - rawDestructor - ) { - name = readLatin1String(name); - getActualType = embind__requireFunction(getActualTypeSignature, getActualType); - if (upcast) { - upcast = embind__requireFunction(upcastSignature, upcast); - } - if (downcast) { - downcast = embind__requireFunction(downcastSignature, downcast); - } - rawDestructor = embind__requireFunction(destructorSignature, rawDestructor); - var legalFunctionName = makeLegalFunctionName(name); - - exposePublicSymbol(legalFunctionName, function() { - // this code cannot run if baseClassRawType is zero - throwUnboundTypeError('Cannot construct ' + name + ' due to unbound types', [baseClassRawType]); - }); - - whenDependentTypesAreResolved( - [rawType, rawPointerType, rawConstPointerType], - baseClassRawType ? [baseClassRawType] : [], - function(base) { - base = base[0]; - - var baseClass; - var basePrototype; - if (baseClassRawType) { - baseClass = base.registeredClass; - basePrototype = baseClass.instancePrototype; - } else { - basePrototype = ClassHandle.prototype; - } - - var constructor = createNamedFunction(legalFunctionName, function() { - if (Object.getPrototypeOf(this) !== instancePrototype) { - throw new BindingError("Use 'new' to construct " + name); - } - if (undefined === registeredClass.constructor_body) { - throw new BindingError(name + " has no accessible constructor"); - } - var body = registeredClass.constructor_body[arguments.length]; - if (undefined === body) { - throw new BindingError("Tried to invoke ctor of " + name + " with invalid number of parameters (" + arguments.length + ") - expected (" + Object.keys(registeredClass.constructor_body).toString() + ") parameters instead!"); - } - return body.apply(this, arguments); - }); - - var instancePrototype = Object.create(basePrototype, { - constructor: { value: constructor }, - }); - - constructor.prototype = instancePrototype; - - var registeredClass = new RegisteredClass( - name, - constructor, - instancePrototype, - rawDestructor, - baseClass, - getActualType, - upcast, - downcast); - - var referenceConverter = new RegisteredPointer( - name, - registeredClass, - true, - false, - false); - - var pointerConverter = new RegisteredPointer( - name + '*', - registeredClass, - false, - false, - false); - - var constPointerConverter = new RegisteredPointer( - name + ' const*', - registeredClass, - false, - true, - false); - - registeredPointers[rawType] = { - pointerType: pointerConverter, - constPointerType: constPointerConverter - }; - - replacePublicSymbol(legalFunctionName, constructor); - - return [referenceConverter, pointerConverter, constPointerConverter]; - } - ); - } - - function heap32VectorToArray(count, firstElement) { - - var array = []; - for (var i = 0; i < count; i++) { - array.push(HEAP32[(firstElement >> 2) + i]); - } - return array; - } - function __embind_register_class_constructor( - rawClassType, - argCount, - rawArgTypesAddr, - invokerSignature, - invoker, - rawConstructor - ) { - assert(argCount > 0); - var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr); - invoker = embind__requireFunction(invokerSignature, invoker); - var args = [rawConstructor]; - var destructors = []; - - whenDependentTypesAreResolved([], [rawClassType], function(classType) { - classType = classType[0]; - var humanName = 'constructor ' + classType.name; - - if (undefined === classType.registeredClass.constructor_body) { - classType.registeredClass.constructor_body = []; - } - if (undefined !== classType.registeredClass.constructor_body[argCount - 1]) { - throw new BindingError("Cannot register multiple constructors with identical number of parameters (" + (argCount-1) + ") for class '" + classType.name + "'! Overload resolution is currently only performed using the parameter count, not actual type info!"); - } - classType.registeredClass.constructor_body[argCount - 1] = function unboundTypeHandler() { - throwUnboundTypeError('Cannot construct ' + classType.name + ' due to unbound types', rawArgTypes); - }; - - whenDependentTypesAreResolved([], rawArgTypes, function(argTypes) { - // Insert empty slot for context type (argTypes[1]). - argTypes.splice(1, 0, null); - classType.registeredClass.constructor_body[argCount - 1] = craftInvokerFunction(humanName, argTypes, null, invoker, rawConstructor); - return []; - }); - return []; - }); - } - - function new_(constructor, argumentList) { - if (!(constructor instanceof Function)) { - throw new TypeError('new_ called with constructor type ' + typeof(constructor) + " which is not a function"); - } - - /* - * Previously, the following line was just: - - function dummy() {}; - - * Unfortunately, Chrome was preserving 'dummy' as the object's name, even though at creation, the 'dummy' has the - * correct constructor name. Thus, objects created with IMVU.new would show up in the debugger as 'dummy', which - * isn't very helpful. Using IMVU.createNamedFunction addresses the issue. Doublely-unfortunately, there's no way - * to write a test for this behavior. -NRD 2013.02.22 - */ - var dummy = createNamedFunction(constructor.name || 'unknownFunctionName', function(){}); - dummy.prototype = constructor.prototype; - var obj = new dummy; - - var r = constructor.apply(obj, argumentList); - return (r instanceof Object) ? r : obj; - } - - function runAndAbortIfError(func) { - try { - return func(); - } catch (e) { - abort(e); - } - } - - function callUserCallback(func, synchronous) { - if (runtimeExited || ABORT) { - return; - } - // For synchronous calls, let any exceptions propagate, and don't let the runtime exit. - if (synchronous) { - func(); - return; - } - try { - func(); - } catch (e) { - handleException(e); - } - } - - function runtimeKeepalivePush() { - runtimeKeepaliveCounter += 1; - } - - function runtimeKeepalivePop() { - runtimeKeepaliveCounter -= 1; - } - var Asyncify = {State:{Normal:0,Unwinding:1,Rewinding:2,Disabled:3},state:0,StackSize:65536,currData:null,handleSleepReturnValue:0,exportCallStack:[],callStackNameToId:{},callStackIdToName:{},callStackId:0,asyncPromiseHandlers:null,sleepCallbacks:[],getCallStackId:function(funcName) { - var id = Asyncify.callStackNameToId[funcName]; - if (id === undefined) { - id = Asyncify.callStackId++; - Asyncify.callStackNameToId[funcName] = id; - Asyncify.callStackIdToName[id] = funcName; - } - return id; - },instrumentWasmExports:function(exports) { - var ret = {}; - for (var x in exports) { - (function(x) { - var original = exports[x]; - if (typeof original === 'function') { - ret[x] = function() { - Asyncify.exportCallStack.push(x); - try { - return original.apply(null, arguments); - } finally { - if (!ABORT) { - var y = Asyncify.exportCallStack.pop(); - assert(y === x); - Asyncify.maybeStopUnwind(); - } - } - }; - } else { - ret[x] = original; - } - })(x); - } - return ret; - },maybeStopUnwind:function() { - if (Asyncify.currData && - Asyncify.state === Asyncify.State.Unwinding && - Asyncify.exportCallStack.length === 0) { - // We just finished unwinding. - - Asyncify.state = Asyncify.State.Normal; - // Keep the runtime alive so that a re-wind can be done later. - runAndAbortIfError(Module['_asyncify_stop_unwind']); - if (typeof Fibers !== 'undefined') { - Fibers.trampoline(); - } - } - },whenDone:function() { - return new Promise(function(resolve, reject) { - Asyncify.asyncPromiseHandlers = { - resolve: resolve, - reject: reject - }; - }); - },allocateData:function() { - // An asyncify data structure has three fields: - // 0 current stack pos - // 4 max stack pos - // 8 id of function at bottom of the call stack (callStackIdToName[id] == name of js function) - // - // The Asyncify ABI only interprets the first two fields, the rest is for the runtime. - // We also embed a stack in the same memory region here, right next to the structure. - // This struct is also defined as asyncify_data_t in emscripten/fiber.h - var ptr = _malloc(12 + Asyncify.StackSize); - Asyncify.setDataHeader(ptr, ptr + 12, Asyncify.StackSize); - Asyncify.setDataRewindFunc(ptr); - return ptr; - },setDataHeader:function(ptr, stack, stackSize) { - HEAP32[((ptr)>>2)] = stack; - HEAP32[(((ptr)+(4))>>2)] = stack + stackSize; - },setDataRewindFunc:function(ptr) { - var bottomOfCallStack = Asyncify.exportCallStack[0]; - var rewindId = Asyncify.getCallStackId(bottomOfCallStack); - HEAP32[(((ptr)+(8))>>2)] = rewindId; - },getDataRewindFunc:function(ptr) { - var id = HEAP32[(((ptr)+(8))>>2)]; - var name = Asyncify.callStackIdToName[id]; - var func = Module['asm'][name]; - return func; - },doRewind:function(ptr) { - var start = Asyncify.getDataRewindFunc(ptr); - // Once we have rewound and the stack we no longer need to artificially keep - // the runtime alive. - - return start(); - },handleSleep:function(startAsync) { - if (ABORT) return; - if (Asyncify.state === Asyncify.State.Normal) { - // Prepare to sleep. Call startAsync, and see what happens: - // if the code decided to call our callback synchronously, - // then no async operation was in fact begun, and we don't - // need to do anything. - var reachedCallback = false; - var reachedAfterCallback = false; - startAsync(function(handleSleepReturnValue) { - if (ABORT) return; - Asyncify.handleSleepReturnValue = handleSleepReturnValue || 0; - reachedCallback = true; - if (!reachedAfterCallback) { - // We are happening synchronously, so no need for async. - return; - } - Asyncify.state = Asyncify.State.Rewinding; - runAndAbortIfError(function() { Module['_asyncify_start_rewind'](Asyncify.currData) }); - if (typeof Browser !== 'undefined' && Browser.mainLoop.func) { - Browser.mainLoop.resume(); - } - var asyncWasmReturnValue, isError = false; - try { - asyncWasmReturnValue = Asyncify.doRewind(Asyncify.currData); - } catch (err) { - asyncWasmReturnValue = err; - isError = true; - } - // Track whether the return value was handled by any promise handlers. - var handled = false; - if (!Asyncify.currData) { - // All asynchronous execution has finished. - // `asyncWasmReturnValue` now contains the final - // return value of the exported async WASM function. - // - // Note: `asyncWasmReturnValue` is distinct from - // `Asyncify.handleSleepReturnValue`. - // `Asyncify.handleSleepReturnValue` contains the return - // value of the last C function to have executed - // `Asyncify.handleSleep()`, where as `asyncWasmReturnValue` - // contains the return value of the exported WASM function - // that may have called C functions that - // call `Asyncify.handleSleep()`. - var asyncPromiseHandlers = Asyncify.asyncPromiseHandlers; - if (asyncPromiseHandlers) { - Asyncify.asyncPromiseHandlers = null; - (isError ? asyncPromiseHandlers.reject : asyncPromiseHandlers.resolve)(asyncWasmReturnValue); - handled = true; - } - } - if (isError && !handled) { - // If there was an error and it was not handled by now, we have no choice but to - // rethrow that error into the global scope where it can be caught only by - // `onerror` or `onunhandledpromiserejection`. - throw asyncWasmReturnValue; - } - }); - reachedAfterCallback = true; - if (!reachedCallback) { - // A true async operation was begun; start a sleep. - Asyncify.state = Asyncify.State.Unwinding; - // TODO: reuse, don't alloc/free every sleep - Asyncify.currData = Asyncify.allocateData(); - runAndAbortIfError(function() { Module['_asyncify_start_unwind'](Asyncify.currData) }); - if (typeof Browser !== 'undefined' && Browser.mainLoop.func) { - Browser.mainLoop.pause(); - } - } - } else if (Asyncify.state === Asyncify.State.Rewinding) { - // Stop a resume. - Asyncify.state = Asyncify.State.Normal; - runAndAbortIfError(Module['_asyncify_stop_rewind']); - _free(Asyncify.currData); - Asyncify.currData = null; - // Call all sleep callbacks now that the sleep-resume is all done. - Asyncify.sleepCallbacks.forEach(function(func) { - callUserCallback(func); - }); - } else { - abort('invalid state: ' + Asyncify.state); - } - return Asyncify.handleSleepReturnValue; - },handleAsync:function(startAsync) { - return Asyncify.handleSleep(function(wakeUp) { - // TODO: add error handling as a second param when handleSleep implements it. - startAsync().then(wakeUp); - }); - }}; - function craftInvokerFunction(humanName, argTypes, classType, cppInvokerFunc, cppTargetFunc) { - // humanName: a human-readable string name for the function to be generated. - // argTypes: An array that contains the embind type objects for all types in the function signature. - // argTypes[0] is the type object for the function return value. - // argTypes[1] is the type object for function this object/class type, or null if not crafting an invoker for a class method. - // argTypes[2...] are the actual function parameters. - // classType: The embind type object for the class to be bound, or null if this is not a method of a class. - // cppInvokerFunc: JS Function object to the C++-side function that interops into C++ code. - // cppTargetFunc: Function pointer (an integer to FUNCTION_TABLE) to the target C++ function the cppInvokerFunc will end up calling. - var argCount = argTypes.length; - - if (argCount < 2) { - throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!"); - } - - var isClassMethodFunc = (argTypes[1] !== null && classType !== null); - - // Free functions with signature "void function()" do not need an invoker that marshalls between wire types. - // TODO: This omits argument count check - enable only at -O3 or similar. - // if (ENABLE_UNSAFE_OPTS && argCount == 2 && argTypes[0].name == "void" && !isClassMethodFunc) { - // return FUNCTION_TABLE[fn]; - // } - - // Determine if we need to use a dynamic stack to store the destructors for the function parameters. - // TODO: Remove this completely once all function invokers are being dynamically generated. - var needsDestructorStack = false; - - for (var i = 1; i < argTypes.length; ++i) { // Skip return value at index 0 - it's not deleted here. - if (argTypes[i] !== null && argTypes[i].destructorFunction === undefined) { // The type does not define a destructor function - must use dynamic stack - needsDestructorStack = true; - break; - } - } - - var returns = (argTypes[0].name !== "void"); - - var argsList = ""; - var argsListWired = ""; - for (var i = 0; i < argCount - 2; ++i) { - argsList += (i!==0?", ":"")+"arg"+i; - argsListWired += (i!==0?", ":"")+"arg"+i+"Wired"; - } - - var invokerFnBody = - "return function "+makeLegalFunctionName(humanName)+"("+argsList+") {\n" + - "if (arguments.length !== "+(argCount - 2)+") {\n" + - "throwBindingError('function "+humanName+" called with ' + arguments.length + ' arguments, expected "+(argCount - 2)+" args!');\n" + - "}\n"; - - if (needsDestructorStack) { - invokerFnBody += - "var destructors = [];\n"; - } - - var dtorStack = needsDestructorStack ? "destructors" : "null"; - var args1 = ["throwBindingError", "invoker", "fn", "runDestructors", "retType", "classParam"]; - var args2 = [throwBindingError, cppInvokerFunc, cppTargetFunc, runDestructors, argTypes[0], argTypes[1]]; - - if (isClassMethodFunc) { - invokerFnBody += "var thisWired = classParam.toWireType("+dtorStack+", this);\n"; - } - - for (var i = 0; i < argCount - 2; ++i) { - invokerFnBody += "var arg"+i+"Wired = argType"+i+".toWireType("+dtorStack+", arg"+i+"); // "+argTypes[i+2].name+"\n"; - args1.push("argType"+i); - args2.push(argTypes[i+2]); - } - - if (isClassMethodFunc) { - argsListWired = "thisWired" + (argsListWired.length > 0 ? ", " : "") + argsListWired; - } - - invokerFnBody += - (returns?"var rv = ":"") + "invoker(fn"+(argsListWired.length>0?", ":"")+argsListWired+");\n"; - - args1.push("Asyncify"); - args2.push(Asyncify); - invokerFnBody += "function onDone(" + (returns ? "rv" : "") + ") {\n"; - - if (needsDestructorStack) { - invokerFnBody += "runDestructors(destructors);\n"; - } else { - for (var i = isClassMethodFunc?1:2; i < argTypes.length; ++i) { // Skip return value at index 0 - it's not deleted here. Also skip class type if not a method. - var paramName = (i === 1 ? "thisWired" : ("arg"+(i - 2)+"Wired")); - if (argTypes[i].destructorFunction !== null) { - invokerFnBody += paramName+"_dtor("+paramName+"); // "+argTypes[i].name+"\n"; - args1.push(paramName+"_dtor"); - args2.push(argTypes[i].destructorFunction); - } - } - } - - if (returns) { - invokerFnBody += "var ret = retType.fromWireType(rv);\n" + - "return ret;\n"; - } else { - } - - invokerFnBody += "}\n"; - invokerFnBody += "return Asyncify.currData ? Asyncify.whenDone().then(onDone) : onDone(" + (returns ? "rv" : "") +");\n" - - invokerFnBody += "}\n"; - - args1.push(invokerFnBody); - - var invokerFunction = new_(Function, args1).apply(null, args2); - return invokerFunction; - } - function __embind_register_class_function( - rawClassType, - methodName, - argCount, - rawArgTypesAddr, // [ReturnType, ThisType, Args...] - invokerSignature, - rawInvoker, - context, - isPureVirtual - ) { - var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr); - methodName = readLatin1String(methodName); - rawInvoker = embind__requireFunction(invokerSignature, rawInvoker); - - whenDependentTypesAreResolved([], [rawClassType], function(classType) { - classType = classType[0]; - var humanName = classType.name + '.' + methodName; - - if (methodName.startsWith("@@")) { - methodName = Symbol[methodName.substring(2)]; - } - - if (isPureVirtual) { - classType.registeredClass.pureVirtualFunctions.push(methodName); - } - - function unboundTypesHandler() { - throwUnboundTypeError('Cannot call ' + humanName + ' due to unbound types', rawArgTypes); - } - - var proto = classType.registeredClass.instancePrototype; - var method = proto[methodName]; - if (undefined === method || (undefined === method.overloadTable && method.className !== classType.name && method.argCount === argCount - 2)) { - // This is the first overload to be registered, OR we are replacing a function in the base class with a function in the derived class. - unboundTypesHandler.argCount = argCount - 2; - unboundTypesHandler.className = classType.name; - proto[methodName] = unboundTypesHandler; - } else { - // There was an existing function with the same name registered. Set up a function overload routing table. - ensureOverloadTable(proto, methodName, humanName); - proto[methodName].overloadTable[argCount - 2] = unboundTypesHandler; - } - - whenDependentTypesAreResolved([], rawArgTypes, function(argTypes) { - - var memberFunction = craftInvokerFunction(humanName, argTypes, classType, rawInvoker, context); - - // Replace the initial unbound-handler-stub function with the appropriate member function, now that all types - // are resolved. If multiple overloads are registered for this function, the function goes into an overload table. - if (undefined === proto[methodName].overloadTable) { - // Set argCount in case an overload is registered later - memberFunction.argCount = argCount - 2; - proto[methodName] = memberFunction; - } else { - proto[methodName].overloadTable[argCount - 2] = memberFunction; - } - - return []; - }); - return []; - }); - } - - var emval_free_list = []; - - var emval_handle_array = [{},{value:undefined},{value:null},{value:true},{value:false}]; - function __emval_decref(handle) { - if (handle > 4 && 0 === --emval_handle_array[handle].refcount) { - emval_handle_array[handle] = undefined; - emval_free_list.push(handle); - } - } - - function count_emval_handles() { - var count = 0; - for (var i = 5; i < emval_handle_array.length; ++i) { - if (emval_handle_array[i] !== undefined) { - ++count; - } - } - return count; - } - - function get_first_emval() { - for (var i = 5; i < emval_handle_array.length; ++i) { - if (emval_handle_array[i] !== undefined) { - return emval_handle_array[i]; - } - } - return null; - } - function init_emval() { - Module['count_emval_handles'] = count_emval_handles; - Module['get_first_emval'] = get_first_emval; - } - var Emval = {toValue:function(handle) { - if (!handle) { - throwBindingError('Cannot use deleted val. handle = ' + handle); - } - return emval_handle_array[handle].value; - },toHandle:function(value) { - switch (value) { - case undefined :{ return 1; } - case null :{ return 2; } - case true :{ return 3; } - case false :{ return 4; } - default:{ - var handle = emval_free_list.length ? - emval_free_list.pop() : - emval_handle_array.length; - - emval_handle_array[handle] = {refcount: 1, value: value}; - return handle; - } - } - }}; - function __embind_register_emval(rawType, name) { - name = readLatin1String(name); - registerType(rawType, { - name: name, - 'fromWireType': function(handle) { - var rv = Emval.toValue(handle); - __emval_decref(handle); - return rv; - }, - 'toWireType': function(destructors, value) { - return Emval.toHandle(value); - }, - 'argPackAdvance': 8, - 'readValueFromPointer': simpleReadValueFromPointer, - destructorFunction: null, // This type does not need a destructor - - // TODO: do we need a deleteObject here? write a test where - // emval is passed into JS via an interface - }); - } - - function _embind_repr(v) { - if (v === null) { - return 'null'; - } - var t = typeof v; - if (t === 'object' || t === 'array' || t === 'function') { - return v.toString(); - } else { - return '' + v; - } - } - - function floatReadValueFromPointer(name, shift) { - switch (shift) { - case 2: return function(pointer) { - return this['fromWireType'](HEAPF32[pointer >> 2]); - }; - case 3: return function(pointer) { - return this['fromWireType'](HEAPF64[pointer >> 3]); - }; - default: - throw new TypeError("Unknown float type: " + name); - } - } - function __embind_register_float(rawType, name, size) { - var shift = getShiftFromSize(size); - name = readLatin1String(name); - registerType(rawType, { - name: name, - 'fromWireType': function(value) { - return value; - }, - 'toWireType': function(destructors, value) { - // The VM will perform JS to Wasm value conversion, according to the spec: - // https://www.w3.org/TR/wasm-js-api-1/#towebassemblyvalue - return value; - }, - 'argPackAdvance': 8, - 'readValueFromPointer': floatReadValueFromPointer(name, shift), - destructorFunction: null, // This type does not need a destructor - }); - } - - function __embind_register_function(name, argCount, rawArgTypesAddr, signature, rawInvoker, fn) { - var argTypes = heap32VectorToArray(argCount, rawArgTypesAddr); - name = readLatin1String(name); - - rawInvoker = embind__requireFunction(signature, rawInvoker); - - exposePublicSymbol(name, function() { - throwUnboundTypeError('Cannot call ' + name + ' due to unbound types', argTypes); - }, argCount - 1); - - whenDependentTypesAreResolved([], argTypes, function(argTypes) { - var invokerArgsArray = [argTypes[0] /* return value */, null /* no class 'this'*/].concat(argTypes.slice(1) /* actual params */); - replacePublicSymbol(name, craftInvokerFunction(name, invokerArgsArray, null /* no class 'this'*/, rawInvoker, fn), argCount - 1); - return []; - }); - } - - function integerReadValueFromPointer(name, shift, signed) { - // integers are quite common, so generate very specialized functions - switch (shift) { - case 0: return signed ? - function readS8FromPointer(pointer) { return HEAP8[pointer]; } : - function readU8FromPointer(pointer) { return HEAPU8[pointer]; }; - case 1: return signed ? - function readS16FromPointer(pointer) { return HEAP16[pointer >> 1]; } : - function readU16FromPointer(pointer) { return HEAPU16[pointer >> 1]; }; - case 2: return signed ? - function readS32FromPointer(pointer) { return HEAP32[pointer >> 2]; } : - function readU32FromPointer(pointer) { return HEAPU32[pointer >> 2]; }; - default: - throw new TypeError("Unknown integer type: " + name); - } - } - function __embind_register_integer(primitiveType, name, size, minRange, maxRange) { - name = readLatin1String(name); - if (maxRange === -1) { // LLVM doesn't have signed and unsigned 32-bit types, so u32 literals come out as 'i32 -1'. Always treat those as max u32. - maxRange = 4294967295; - } - - var shift = getShiftFromSize(size); - - var fromWireType = function(value) { - return value; - }; - - if (minRange === 0) { - var bitshift = 32 - 8*size; - fromWireType = function(value) { - return (value << bitshift) >>> bitshift; - }; - } - - var isUnsignedType = (name.includes('unsigned')); - - registerType(primitiveType, { - name: name, - 'fromWireType': fromWireType, - 'toWireType': function(destructors, value) { - // todo: Here we have an opportunity for -O3 level "unsafe" optimizations: we could - // avoid the following two if()s and assume value is of proper type. - if (typeof value !== "number" && typeof value !== "boolean") { - throw new TypeError('Cannot convert "' + _embind_repr(value) + '" to ' + this.name); - } - if (value < minRange || value > maxRange) { - throw new TypeError('Passing a number "' + _embind_repr(value) + '" from JS side to C/C++ side to an argument of type "' + name + '", which is outside the valid range [' + minRange + ', ' + maxRange + ']!'); - } - return isUnsignedType ? (value >>> 0) : (value | 0); - }, - 'argPackAdvance': 8, - 'readValueFromPointer': integerReadValueFromPointer(name, shift, minRange !== 0), - destructorFunction: null, // This type does not need a destructor - }); - } - - function __embind_register_memory_view(rawType, dataTypeIndex, name) { - var typeMapping = [ - Int8Array, - Uint8Array, - Int16Array, - Uint16Array, - Int32Array, - Uint32Array, - Float32Array, - Float64Array, - ]; - - var TA = typeMapping[dataTypeIndex]; - - function decodeMemoryView(handle) { - handle = handle >> 2; - var heap = HEAPU32; - var size = heap[handle]; // in elements - var data = heap[handle + 1]; // byte offset into emscripten heap - return new TA(buffer, data, size); - } - - name = readLatin1String(name); - registerType(rawType, { - name: name, - 'fromWireType': decodeMemoryView, - 'argPackAdvance': 8, - 'readValueFromPointer': decodeMemoryView, - }, { - ignoreDuplicateRegistrations: true, - }); - } - - function __embind_register_std_string(rawType, name) { - name = readLatin1String(name); - var stdStringIsUTF8 - //process only std::string bindings with UTF8 support, in contrast to e.g. std::basic_string - = (name === "std::string"); - - registerType(rawType, { - name: name, - 'fromWireType': function(value) { - var length = HEAPU32[value >> 2]; - - var str; - if (stdStringIsUTF8) { - var decodeStartPtr = value + 4; - // Looping here to support possible embedded '0' bytes - for (var i = 0; i <= length; ++i) { - var currentBytePtr = value + 4 + i; - if (i == length || HEAPU8[currentBytePtr] == 0) { - var maxRead = currentBytePtr - decodeStartPtr; - var stringSegment = UTF8ToString(decodeStartPtr, maxRead); - if (str === undefined) { - str = stringSegment; - } else { - str += String.fromCharCode(0); - str += stringSegment; - } - decodeStartPtr = currentBytePtr + 1; - } - } - } else { - var a = new Array(length); - for (var i = 0; i < length; ++i) { - a[i] = String.fromCharCode(HEAPU8[value + 4 + i]); - } - str = a.join(''); - } - - _free(value); - - return str; - }, - 'toWireType': function(destructors, value) { - if (value instanceof ArrayBuffer) { - value = new Uint8Array(value); - } - - var getLength; - var valueIsOfTypeString = (typeof value === 'string'); - - if (!(valueIsOfTypeString || value instanceof Uint8Array || value instanceof Uint8ClampedArray || value instanceof Int8Array)) { - throwBindingError('Cannot pass non-string to std::string'); - } - if (stdStringIsUTF8 && valueIsOfTypeString) { - getLength = function() {return lengthBytesUTF8(value);}; - } else { - getLength = function() {return value.length;}; - } - - // assumes 4-byte alignment - var length = getLength(); - var ptr = _malloc(4 + length + 1); - HEAPU32[ptr >> 2] = length; - if (stdStringIsUTF8 && valueIsOfTypeString) { - stringToUTF8(value, ptr + 4, length + 1); - } else { - if (valueIsOfTypeString) { - for (var i = 0; i < length; ++i) { - var charCode = value.charCodeAt(i); - if (charCode > 255) { - _free(ptr); - throwBindingError('String has UTF-16 code units that do not fit in 8 bits'); - } - HEAPU8[ptr + 4 + i] = charCode; - } - } else { - for (var i = 0; i < length; ++i) { - HEAPU8[ptr + 4 + i] = value[i]; - } - } - } - - if (destructors !== null) { - destructors.push(_free, ptr); - } - return ptr; - }, - 'argPackAdvance': 8, - 'readValueFromPointer': simpleReadValueFromPointer, - destructorFunction: function(ptr) { _free(ptr); }, - }); - } - - function __embind_register_std_wstring(rawType, charSize, name) { - name = readLatin1String(name); - var decodeString, encodeString, getHeap, lengthBytesUTF, shift; - if (charSize === 2) { - decodeString = UTF16ToString; - encodeString = stringToUTF16; - lengthBytesUTF = lengthBytesUTF16; - getHeap = function() { return HEAPU16; }; - shift = 1; - } else if (charSize === 4) { - decodeString = UTF32ToString; - encodeString = stringToUTF32; - lengthBytesUTF = lengthBytesUTF32; - getHeap = function() { return HEAPU32; }; - shift = 2; - } - registerType(rawType, { - name: name, - 'fromWireType': function(value) { - // Code mostly taken from _embind_register_std_string fromWireType - var length = HEAPU32[value >> 2]; - var HEAP = getHeap(); - var str; - - var decodeStartPtr = value + 4; - // Looping here to support possible embedded '0' bytes - for (var i = 0; i <= length; ++i) { - var currentBytePtr = value + 4 + i * charSize; - if (i == length || HEAP[currentBytePtr >> shift] == 0) { - var maxReadBytes = currentBytePtr - decodeStartPtr; - var stringSegment = decodeString(decodeStartPtr, maxReadBytes); - if (str === undefined) { - str = stringSegment; - } else { - str += String.fromCharCode(0); - str += stringSegment; - } - decodeStartPtr = currentBytePtr + charSize; - } - } - - _free(value); - - return str; - }, - 'toWireType': function(destructors, value) { - if (!(typeof value === 'string')) { - throwBindingError('Cannot pass non-string to C++ string type ' + name); - } - - // assumes 4-byte alignment - var length = lengthBytesUTF(value); - var ptr = _malloc(4 + length + charSize); - HEAPU32[ptr >> 2] = length >> shift; - - encodeString(value, ptr + 4, length + charSize); - - if (destructors !== null) { - destructors.push(_free, ptr); - } - return ptr; - }, - 'argPackAdvance': 8, - 'readValueFromPointer': simpleReadValueFromPointer, - destructorFunction: function(ptr) { _free(ptr); }, - }); - } - - function __embind_register_value_object( - rawType, - name, - constructorSignature, - rawConstructor, - destructorSignature, - rawDestructor - ) { - structRegistrations[rawType] = { - name: readLatin1String(name), - rawConstructor: embind__requireFunction(constructorSignature, rawConstructor), - rawDestructor: embind__requireFunction(destructorSignature, rawDestructor), - fields: [], - }; - } - - function __embind_register_value_object_field( - structType, - fieldName, - getterReturnType, - getterSignature, - getter, - getterContext, - setterArgumentType, - setterSignature, - setter, - setterContext - ) { - structRegistrations[structType].fields.push({ - fieldName: readLatin1String(fieldName), - getterReturnType: getterReturnType, - getter: embind__requireFunction(getterSignature, getter), - getterContext: getterContext, - setterArgumentType: setterArgumentType, - setter: embind__requireFunction(setterSignature, setter), - setterContext: setterContext, - }); - } - - function __embind_register_void(rawType, name) { - name = readLatin1String(name); - registerType(rawType, { - isVoid: true, // void return values can be optimized out sometimes - name: name, - 'argPackAdvance': 0, - 'fromWireType': function() { - return undefined; - }, - 'toWireType': function(destructors, o) { - // TODO: assert if anything else is given? - return undefined; - }, - }); - } - - function _abort() { - abort(''); - } - - function _clock() { - if (_clock.start === undefined) _clock.start = Date.now(); - return ((Date.now() - _clock.start) * (1000000 / 1000))|0; - } - - var _emscripten_get_now;if (typeof performance !== 'undefined' && performance.now) { - _emscripten_get_now = function() { return performance.now(); } - } else { - _emscripten_get_now = Date.now; - } - - var _emscripten_get_now_is_monotonic = - ((typeof performance === 'object' && performance && typeof performance['now'] === 'function') - );; - function _clock_gettime(clk_id, tp) { - // int clock_gettime(clockid_t clk_id, struct timespec *tp); - var now; - if (clk_id === 0) { - now = Date.now(); - } else if ((clk_id === 1 || clk_id === 4) && _emscripten_get_now_is_monotonic) { - now = _emscripten_get_now(); - } else { - setErrNo(28); - return -1; - } - HEAP32[((tp)>>2)] = (now/1000)|0; // seconds - HEAP32[(((tp)+(4))>>2)] = ((now % 1000)*1000*1000)|0; // nanoseconds - return 0; - } - - var readAsmConstArgsArray = []; - function readAsmConstArgs(sigPtr, buf) { - ; - readAsmConstArgsArray.length = 0; - var ch; - // Most arguments are i32s, so shift the buffer pointer so it is a plain - // index into HEAP32. - buf >>= 2; - while (ch = HEAPU8[sigPtr++]) { - // A double takes two 32-bit slots, and must also be aligned - the backend - // will emit padding to avoid that. - var readAsmConstArgsDouble = ch < 105; - if (readAsmConstArgsDouble && (buf & 1)) buf++; - readAsmConstArgsArray.push(readAsmConstArgsDouble ? HEAPF64[buf++ >> 1] : HEAP32[buf]); - ++buf; - } - return readAsmConstArgsArray; - } - function _emscripten_asm_const_int(code, sigPtr, argbuf) { - var args = readAsmConstArgs(sigPtr, argbuf); - return ASM_CONSTS[code].apply(null, args); - } - - - var _emscripten_memcpy_big = Uint8Array.prototype.copyWithin - ? function(dest, src, num) { HEAPU8.copyWithin(dest, src, src + num); } - : function(dest, src, num) { HEAPU8.set(HEAPU8.subarray(src, src+num), dest); } - ; - - function emscripten_realloc_buffer(size) { - try { - // round size grow request up to wasm page size (fixed 64KB per spec) - wasmMemory.grow((size - buffer.byteLength + 65535) >>> 16); // .grow() takes a delta compared to the previous size - updateGlobalBufferAndViews(wasmMemory.buffer); - return 1 /*success*/; - } catch(e) { - } - // implicit 0 return to save code size (caller will cast "undefined" into 0 - // anyhow) - } - function _emscripten_resize_heap(requestedSize) { - var oldSize = HEAPU8.length; - requestedSize = requestedSize >>> 0; - // With pthreads, races can happen (another thread might increase the size in between), so return a failure, and let the caller retry. - - // Memory resize rules: - // 1. Always increase heap size to at least the requested size, rounded up to next page multiple. - // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap geometrically: increase the heap size according to - // MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%), - // At most overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB). - // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap linearly: increase the heap size by at least MEMORY_GROWTH_LINEAR_STEP bytes. - // 3. Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest - // 4. If we were unable to allocate as much memory, it may be due to over-eager decision to excessively reserve due to (3) above. - // Hence if an allocation fails, cut down on the amount of excess growth, in an attempt to succeed to perform a smaller allocation. - - // A limit is set for how much we can grow. We should not exceed that - // (the wasm binary specifies it, so if we tried, we'd fail anyhow). - // In CAN_ADDRESS_2GB mode, stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate full 4GB Wasm memories, the size will wrap - // back to 0 bytes in Wasm side for any code that deals with heap sizes, which would require special casing all heap size related code to treat - // 0 specially. - var maxHeapSize = MAX_HEAP_SIZE; - if (requestedSize > maxHeapSize) { - return false; - } - - // Loop through potential heap size increases. If we attempt a too eager reservation that fails, cut down on the - // attempted size and reserve a smaller bump instead. (max 3 times, chosen somewhat arbitrarily) - for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { - var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); // ensure geometric growth - // but limit overreserving (default to capping at +96MB overgrowth at most) - overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296 ); - - var newSize = Math.min(maxHeapSize, alignUp(Math.max(requestedSize, overGrownHeapSize), 65536)); - - var replacement = emscripten_realloc_buffer(newSize); - if (replacement) { - - return true; - } - } - return false; - } - - var ENV = {}; - - function getExecutableName() { - return thisProgram || './this.program'; - } - function getEnvStrings() { - if (!getEnvStrings.strings) { - // Default values. - // Browser language detection #8751 - var lang = ((typeof navigator === 'object' && navigator.languages && navigator.languages[0]) || 'C').replace('-', '_') + '.UTF-8'; - var env = { - 'USER': 'web_user', - 'LOGNAME': 'web_user', - 'PATH': '/', - 'PWD': '/', - 'HOME': '/home/web_user', - 'LANG': lang, - '_': getExecutableName() - }; - // Apply the user-provided values, if any. - for (var x in ENV) { - // x is a key in ENV; if ENV[x] is undefined, that means it was - // explicitly set to be so. We allow user code to do that to - // force variables with default values to remain unset. - if (ENV[x] === undefined) delete env[x]; - else env[x] = ENV[x]; - } - var strings = []; - for (var x in env) { - strings.push(x + '=' + env[x]); - } - getEnvStrings.strings = strings; - } - return getEnvStrings.strings; - } - function _environ_get(__environ, environ_buf) { - var bufSize = 0; - getEnvStrings().forEach(function(string, i) { - var ptr = environ_buf + bufSize; - HEAP32[(((__environ)+(i * 4))>>2)] = ptr; - writeAsciiToMemory(string, ptr); - bufSize += string.length + 1; - }); - return 0; - } - - function _environ_sizes_get(penviron_count, penviron_buf_size) { - var strings = getEnvStrings(); - HEAP32[((penviron_count)>>2)] = strings.length; - var bufSize = 0; - strings.forEach(function(string) { - bufSize += string.length + 1; - }); - HEAP32[((penviron_buf_size)>>2)] = bufSize; - return 0; - } - - function _exit(status) { - // void _exit(int status); - // http://pubs.opengroup.org/onlinepubs/000095399/functions/exit.html - exit(status); - } - - function _fd_close(fd) { - return 0; - } - - function _fd_fdstat_get(fd, pbuf) { - // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 - var type = fd == 1 || fd == 2 ? 2 : abort(); - HEAP8[((pbuf)>>0)] = type; - // TODO HEAP16[(((pbuf)+(2))>>1)] = ?; - // TODO (tempI64 = [?>>>0,(tempDouble=?,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[(((pbuf)+(8))>>2)] = tempI64[0],HEAP32[(((pbuf)+(12))>>2)] = tempI64[1]); - // TODO (tempI64 = [?>>>0,(tempDouble=?,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[(((pbuf)+(16))>>2)] = tempI64[0],HEAP32[(((pbuf)+(20))>>2)] = tempI64[1]); - return 0; - } - - function _fd_read(fd, iov, iovcnt, pnum) { - var stream = SYSCALLS.getStreamFromFD(fd); - var num = SYSCALLS.doReadv(stream, iov, iovcnt); - HEAP32[((pnum)>>2)] = num; - return 0; - } - - function _fd_seek(fd, offset_low, offset_high, whence, newOffset) { - } - - function flush_NO_FILESYSTEM() { - // flush anything remaining in the buffers during shutdown - if (typeof _fflush !== 'undefined') _fflush(0); - var buffers = SYSCALLS.buffers; - if (buffers[1].length) SYSCALLS.printChar(1, 10); - if (buffers[2].length) SYSCALLS.printChar(2, 10); - } - function _fd_write(fd, iov, iovcnt, pnum) { - ; - // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 - var num = 0; - for (var i = 0; i < iovcnt; i++) { - var ptr = HEAP32[((iov)>>2)]; - var len = HEAP32[(((iov)+(4))>>2)]; - iov += 8; - for (var j = 0; j < len; j++) { - SYSCALLS.printChar(fd, HEAPU8[ptr+j]); - } - num += len; - } - HEAP32[((pnum)>>2)] = num; - return 0; - } - - function _gettimeofday(ptr) { - var now = Date.now(); - HEAP32[((ptr)>>2)] = (now/1000)|0; // seconds - HEAP32[(((ptr)+(4))>>2)] = ((now % 1000)*1000)|0; // microseconds - return 0; - } - - - - function _mktime(tmPtr) { - _tzset(); - var date = new Date(HEAP32[(((tmPtr)+(20))>>2)] + 1900, - HEAP32[(((tmPtr)+(16))>>2)], - HEAP32[(((tmPtr)+(12))>>2)], - HEAP32[(((tmPtr)+(8))>>2)], - HEAP32[(((tmPtr)+(4))>>2)], - HEAP32[((tmPtr)>>2)], - 0); - - // There's an ambiguous hour when the time goes back; the tm_isdst field is - // used to disambiguate it. Date() basically guesses, so we fix it up if it - // guessed wrong, or fill in tm_isdst with the guess if it's -1. - var dst = HEAP32[(((tmPtr)+(32))>>2)]; - var guessedOffset = date.getTimezoneOffset(); - var start = new Date(date.getFullYear(), 0, 1); - var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); - var winterOffset = start.getTimezoneOffset(); - var dstOffset = Math.min(winterOffset, summerOffset); // DST is in December in South - if (dst < 0) { - // Attention: some regions don't have DST at all. - HEAP32[(((tmPtr)+(32))>>2)] = Number(summerOffset != winterOffset && dstOffset == guessedOffset); - } else if ((dst > 0) != (dstOffset == guessedOffset)) { - var nonDstOffset = Math.max(winterOffset, summerOffset); - var trueOffset = dst > 0 ? dstOffset : nonDstOffset; - // Don't try setMinutes(date.getMinutes() + ...) -- it's messed up. - date.setTime(date.getTime() + (trueOffset - guessedOffset)*60000); - } - - HEAP32[(((tmPtr)+(24))>>2)] = date.getDay(); - var yday = ((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))|0; - HEAP32[(((tmPtr)+(28))>>2)] = yday; - // To match expected behavior, update fields from date - HEAP32[((tmPtr)>>2)] = date.getSeconds(); - HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes(); - HEAP32[(((tmPtr)+(8))>>2)] = date.getHours(); - HEAP32[(((tmPtr)+(12))>>2)] = date.getDate(); - HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth(); - - return (date.getTime() / 1000)|0; - } - - function _setTempRet0(val) { - setTempRet0(val); - } - - function __isLeapYear(year) { - return year%4 === 0 && (year%100 !== 0 || year%400 === 0); - } - - function __arraySum(array, index) { - var sum = 0; - for (var i = 0; i <= index; sum += array[i++]) { - // no-op - } - return sum; - } - - var __MONTH_DAYS_LEAP = [31,29,31,30,31,30,31,31,30,31,30,31]; - - var __MONTH_DAYS_REGULAR = [31,28,31,30,31,30,31,31,30,31,30,31]; - function __addDays(date, days) { - var newDate = new Date(date.getTime()); - while (days > 0) { - var leap = __isLeapYear(newDate.getFullYear()); - var currentMonth = newDate.getMonth(); - var daysInCurrentMonth = (leap ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR)[currentMonth]; - - if (days > daysInCurrentMonth-newDate.getDate()) { - // we spill over to next month - days -= (daysInCurrentMonth-newDate.getDate()+1); - newDate.setDate(1); - if (currentMonth < 11) { - newDate.setMonth(currentMonth+1) - } else { - newDate.setMonth(0); - newDate.setFullYear(newDate.getFullYear()+1); - } - } else { - // we stay in current month - newDate.setDate(newDate.getDate()+days); - return newDate; - } - } - - return newDate; - } - function _strftime(s, maxsize, format, tm) { - // size_t strftime(char *restrict s, size_t maxsize, const char *restrict format, const struct tm *restrict timeptr); - // http://pubs.opengroup.org/onlinepubs/009695399/functions/strftime.html - - var tm_zone = HEAP32[(((tm)+(40))>>2)]; - - var date = { - tm_sec: HEAP32[((tm)>>2)], - tm_min: HEAP32[(((tm)+(4))>>2)], - tm_hour: HEAP32[(((tm)+(8))>>2)], - tm_mday: HEAP32[(((tm)+(12))>>2)], - tm_mon: HEAP32[(((tm)+(16))>>2)], - tm_year: HEAP32[(((tm)+(20))>>2)], - tm_wday: HEAP32[(((tm)+(24))>>2)], - tm_yday: HEAP32[(((tm)+(28))>>2)], - tm_isdst: HEAP32[(((tm)+(32))>>2)], - tm_gmtoff: HEAP32[(((tm)+(36))>>2)], - tm_zone: tm_zone ? UTF8ToString(tm_zone) : '' - }; - - var pattern = UTF8ToString(format); - - // expand format - var EXPANSION_RULES_1 = { - '%c': '%a %b %d %H:%M:%S %Y', // Replaced by the locale's appropriate date and time representation - e.g., Mon Aug 3 14:02:01 2013 - '%D': '%m/%d/%y', // Equivalent to %m / %d / %y - '%F': '%Y-%m-%d', // Equivalent to %Y - %m - %d - '%h': '%b', // Equivalent to %b - '%r': '%I:%M:%S %p', // Replaced by the time in a.m. and p.m. notation - '%R': '%H:%M', // Replaced by the time in 24-hour notation - '%T': '%H:%M:%S', // Replaced by the time - '%x': '%m/%d/%y', // Replaced by the locale's appropriate date representation - '%X': '%H:%M:%S', // Replaced by the locale's appropriate time representation - // Modified Conversion Specifiers - '%Ec': '%c', // Replaced by the locale's alternative appropriate date and time representation. - '%EC': '%C', // Replaced by the name of the base year (period) in the locale's alternative representation. - '%Ex': '%m/%d/%y', // Replaced by the locale's alternative date representation. - '%EX': '%H:%M:%S', // Replaced by the locale's alternative time representation. - '%Ey': '%y', // Replaced by the offset from %EC (year only) in the locale's alternative representation. - '%EY': '%Y', // Replaced by the full alternative year representation. - '%Od': '%d', // Replaced by the day of the month, using the locale's alternative numeric symbols, filled as needed with leading zeros if there is any alternative symbol for zero; otherwise, with leading characters. - '%Oe': '%e', // Replaced by the day of the month, using the locale's alternative numeric symbols, filled as needed with leading characters. - '%OH': '%H', // Replaced by the hour (24-hour clock) using the locale's alternative numeric symbols. - '%OI': '%I', // Replaced by the hour (12-hour clock) using the locale's alternative numeric symbols. - '%Om': '%m', // Replaced by the month using the locale's alternative numeric symbols. - '%OM': '%M', // Replaced by the minutes using the locale's alternative numeric symbols. - '%OS': '%S', // Replaced by the seconds using the locale's alternative numeric symbols. - '%Ou': '%u', // Replaced by the weekday as a number in the locale's alternative representation (Monday=1). - '%OU': '%U', // Replaced by the week number of the year (Sunday as the first day of the week, rules corresponding to %U ) using the locale's alternative numeric symbols. - '%OV': '%V', // Replaced by the week number of the year (Monday as the first day of the week, rules corresponding to %V ) using the locale's alternative numeric symbols. - '%Ow': '%w', // Replaced by the number of the weekday (Sunday=0) using the locale's alternative numeric symbols. - '%OW': '%W', // Replaced by the week number of the year (Monday as the first day of the week) using the locale's alternative numeric symbols. - '%Oy': '%y', // Replaced by the year (offset from %C ) using the locale's alternative numeric symbols. - }; - for (var rule in EXPANSION_RULES_1) { - pattern = pattern.replace(new RegExp(rule, 'g'), EXPANSION_RULES_1[rule]); - } - - var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - var MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - - function leadingSomething(value, digits, character) { - var str = typeof value === 'number' ? value.toString() : (value || ''); - while (str.length < digits) { - str = character[0]+str; - } - return str; - } - - function leadingNulls(value, digits) { - return leadingSomething(value, digits, '0'); - } - - function compareByDay(date1, date2) { - function sgn(value) { - return value < 0 ? -1 : (value > 0 ? 1 : 0); - } - - var compare; - if ((compare = sgn(date1.getFullYear()-date2.getFullYear())) === 0) { - if ((compare = sgn(date1.getMonth()-date2.getMonth())) === 0) { - compare = sgn(date1.getDate()-date2.getDate()); - } - } - return compare; - } - - function getFirstWeekStartDate(janFourth) { - switch (janFourth.getDay()) { - case 0: // Sunday - return new Date(janFourth.getFullYear()-1, 11, 29); - case 1: // Monday - return janFourth; - case 2: // Tuesday - return new Date(janFourth.getFullYear(), 0, 3); - case 3: // Wednesday - return new Date(janFourth.getFullYear(), 0, 2); - case 4: // Thursday - return new Date(janFourth.getFullYear(), 0, 1); - case 5: // Friday - return new Date(janFourth.getFullYear()-1, 11, 31); - case 6: // Saturday - return new Date(janFourth.getFullYear()-1, 11, 30); - } - } - - function getWeekBasedYear(date) { - var thisDate = __addDays(new Date(date.tm_year+1900, 0, 1), date.tm_yday); - - var janFourthThisYear = new Date(thisDate.getFullYear(), 0, 4); - var janFourthNextYear = new Date(thisDate.getFullYear()+1, 0, 4); - - var firstWeekStartThisYear = getFirstWeekStartDate(janFourthThisYear); - var firstWeekStartNextYear = getFirstWeekStartDate(janFourthNextYear); - - if (compareByDay(firstWeekStartThisYear, thisDate) <= 0) { - // this date is after the start of the first week of this year - if (compareByDay(firstWeekStartNextYear, thisDate) <= 0) { - return thisDate.getFullYear()+1; - } else { - return thisDate.getFullYear(); - } - } else { - return thisDate.getFullYear()-1; - } - } - - var EXPANSION_RULES_2 = { - '%a': function(date) { - return WEEKDAYS[date.tm_wday].substring(0,3); - }, - '%A': function(date) { - return WEEKDAYS[date.tm_wday]; - }, - '%b': function(date) { - return MONTHS[date.tm_mon].substring(0,3); - }, - '%B': function(date) { - return MONTHS[date.tm_mon]; - }, - '%C': function(date) { - var year = date.tm_year+1900; - return leadingNulls((year/100)|0,2); - }, - '%d': function(date) { - return leadingNulls(date.tm_mday, 2); - }, - '%e': function(date) { - return leadingSomething(date.tm_mday, 2, ' '); - }, - '%g': function(date) { - // %g, %G, and %V give values according to the ISO 8601:2000 standard week-based year. - // In this system, weeks begin on a Monday and week 1 of the year is the week that includes - // January 4th, which is also the week that includes the first Thursday of the year, and - // is also the first week that contains at least four days in the year. - // If the first Monday of January is the 2nd, 3rd, or 4th, the preceding days are part of - // the last week of the preceding year; thus, for Saturday 2nd January 1999, - // %G is replaced by 1998 and %V is replaced by 53. If December 29th, 30th, - // or 31st is a Monday, it and any following days are part of week 1 of the following year. - // Thus, for Tuesday 30th December 1997, %G is replaced by 1998 and %V is replaced by 01. - - return getWeekBasedYear(date).toString().substring(2); - }, - '%G': function(date) { - return getWeekBasedYear(date); - }, - '%H': function(date) { - return leadingNulls(date.tm_hour, 2); - }, - '%I': function(date) { - var twelveHour = date.tm_hour; - if (twelveHour == 0) twelveHour = 12; - else if (twelveHour > 12) twelveHour -= 12; - return leadingNulls(twelveHour, 2); - }, - '%j': function(date) { - // Day of the year (001-366) - return leadingNulls(date.tm_mday+__arraySum(__isLeapYear(date.tm_year+1900) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, date.tm_mon-1), 3); - }, - '%m': function(date) { - return leadingNulls(date.tm_mon+1, 2); - }, - '%M': function(date) { - return leadingNulls(date.tm_min, 2); - }, - '%n': function() { - return '\n'; - }, - '%p': function(date) { - if (date.tm_hour >= 0 && date.tm_hour < 12) { - return 'AM'; - } else { - return 'PM'; - } - }, - '%S': function(date) { - return leadingNulls(date.tm_sec, 2); - }, - '%t': function() { - return '\t'; - }, - '%u': function(date) { - return date.tm_wday || 7; - }, - '%U': function(date) { - // Replaced by the week number of the year as a decimal number [00,53]. - // The first Sunday of January is the first day of week 1; - // days in the new year before this are in week 0. [ tm_year, tm_wday, tm_yday] - var janFirst = new Date(date.tm_year+1900, 0, 1); - var firstSunday = janFirst.getDay() === 0 ? janFirst : __addDays(janFirst, 7-janFirst.getDay()); - var endDate = new Date(date.tm_year+1900, date.tm_mon, date.tm_mday); - - // is target date after the first Sunday? - if (compareByDay(firstSunday, endDate) < 0) { - // calculate difference in days between first Sunday and endDate - var februaryFirstUntilEndMonth = __arraySum(__isLeapYear(endDate.getFullYear()) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, endDate.getMonth()-1)-31; - var firstSundayUntilEndJanuary = 31-firstSunday.getDate(); - var days = firstSundayUntilEndJanuary+februaryFirstUntilEndMonth+endDate.getDate(); - return leadingNulls(Math.ceil(days/7), 2); - } - - return compareByDay(firstSunday, janFirst) === 0 ? '01': '00'; - }, - '%V': function(date) { - // Replaced by the week number of the year (Monday as the first day of the week) - // as a decimal number [01,53]. If the week containing 1 January has four - // or more days in the new year, then it is considered week 1. - // Otherwise, it is the last week of the previous year, and the next week is week 1. - // Both January 4th and the first Thursday of January are always in week 1. [ tm_year, tm_wday, tm_yday] - var janFourthThisYear = new Date(date.tm_year+1900, 0, 4); - var janFourthNextYear = new Date(date.tm_year+1901, 0, 4); - - var firstWeekStartThisYear = getFirstWeekStartDate(janFourthThisYear); - var firstWeekStartNextYear = getFirstWeekStartDate(janFourthNextYear); - - var endDate = __addDays(new Date(date.tm_year+1900, 0, 1), date.tm_yday); - - if (compareByDay(endDate, firstWeekStartThisYear) < 0) { - // if given date is before this years first week, then it belongs to the 53rd week of last year - return '53'; - } - - if (compareByDay(firstWeekStartNextYear, endDate) <= 0) { - // if given date is after next years first week, then it belongs to the 01th week of next year - return '01'; - } - - // given date is in between CW 01..53 of this calendar year - var daysDifference; - if (firstWeekStartThisYear.getFullYear() < date.tm_year+1900) { - // first CW of this year starts last year - daysDifference = date.tm_yday+32-firstWeekStartThisYear.getDate() - } else { - // first CW of this year starts this year - daysDifference = date.tm_yday+1-firstWeekStartThisYear.getDate(); - } - return leadingNulls(Math.ceil(daysDifference/7), 2); - }, - '%w': function(date) { - return date.tm_wday; - }, - '%W': function(date) { - // Replaced by the week number of the year as a decimal number [00,53]. - // The first Monday of January is the first day of week 1; - // days in the new year before this are in week 0. [ tm_year, tm_wday, tm_yday] - var janFirst = new Date(date.tm_year, 0, 1); - var firstMonday = janFirst.getDay() === 1 ? janFirst : __addDays(janFirst, janFirst.getDay() === 0 ? 1 : 7-janFirst.getDay()+1); - var endDate = new Date(date.tm_year+1900, date.tm_mon, date.tm_mday); - - // is target date after the first Monday? - if (compareByDay(firstMonday, endDate) < 0) { - var februaryFirstUntilEndMonth = __arraySum(__isLeapYear(endDate.getFullYear()) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, endDate.getMonth()-1)-31; - var firstMondayUntilEndJanuary = 31-firstMonday.getDate(); - var days = firstMondayUntilEndJanuary+februaryFirstUntilEndMonth+endDate.getDate(); - return leadingNulls(Math.ceil(days/7), 2); - } - return compareByDay(firstMonday, janFirst) === 0 ? '01': '00'; - }, - '%y': function(date) { - // Replaced by the last two digits of the year as a decimal number [00,99]. [ tm_year] - return (date.tm_year+1900).toString().substring(2); - }, - '%Y': function(date) { - // Replaced by the year as a decimal number (for example, 1997). [ tm_year] - return date.tm_year+1900; - }, - '%z': function(date) { - // Replaced by the offset from UTC in the ISO 8601:2000 standard format ( +hhmm or -hhmm ). - // For example, "-0430" means 4 hours 30 minutes behind UTC (west of Greenwich). - var off = date.tm_gmtoff; - var ahead = off >= 0; - off = Math.abs(off) / 60; - // convert from minutes into hhmm format (which means 60 minutes = 100 units) - off = (off / 60)*100 + (off % 60); - return (ahead ? '+' : '-') + String("0000" + off).slice(-4); - }, - '%Z': function(date) { - return date.tm_zone; - }, - '%%': function() { - return '%'; - } - }; - for (var rule in EXPANSION_RULES_2) { - if (pattern.includes(rule)) { - pattern = pattern.replace(new RegExp(rule, 'g'), EXPANSION_RULES_2[rule](date)); - } - } - - var bytes = intArrayFromString(pattern, false); - if (bytes.length > maxsize) { - return 0; - } - - writeArrayToMemory(bytes, s); - return bytes.length-1; - } - - function _strftime_l(s, maxsize, format, tm) { - return _strftime(s, maxsize, format, tm); // no locale support yet - } - - function _time(ptr) { - ; - var ret = (Date.now()/1000)|0; - if (ptr) { - HEAP32[((ptr)>>2)] = ret; - } - return ret; - } - -InternalError = Module['InternalError'] = extendError(Error, 'InternalError');; -embind_init_charCodes(); -BindingError = Module['BindingError'] = extendError(Error, 'BindingError');; -init_ClassHandle(); -init_RegisteredPointer(); -init_embind();; -UnboundTypeError = Module['UnboundTypeError'] = extendError(Error, 'UnboundTypeError');; -init_emval();; -var ASSERTIONS = false; - - - -/** @type {function(string, boolean=, number=)} */ -function intArrayFromString(stringy, dontAddNull, length) { - var len = length > 0 ? length : lengthBytesUTF8(stringy)+1; - var u8array = new Array(len); - var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); - if (dontAddNull) u8array.length = numBytesWritten; - return u8array; -} - -function intArrayToString(array) { - var ret = []; - for (var i = 0; i < array.length; i++) { - var chr = array[i]; - if (chr > 0xFF) { - if (ASSERTIONS) { - assert(false, 'Character code ' + chr + ' (' + String.fromCharCode(chr) + ') at offset ' + i + ' not in 0x00-0xFF.'); - } - chr &= 0xFF; - } - ret.push(String.fromCharCode(chr)); - } - return ret.join(''); -} - - -var asmLibraryArg = { - "__asyncjs__wasm_ffmpeg_fopen_sync": __asyncjs__wasm_ffmpeg_fopen_sync, - "__asyncjs__wasm_ffmpeg_fread_sync": __asyncjs__wasm_ffmpeg_fread_sync, - "__cxa_allocate_exception": ___cxa_allocate_exception, - "__cxa_atexit": ___cxa_atexit, - "__cxa_throw": ___cxa_throw, - "__gmtime_r": ___gmtime_r, - "__localtime_r": ___localtime_r, - "__syscall__newselect": ___syscall__newselect, - "__syscall_fcntl64": ___syscall_fcntl64, - "__syscall_ioctl": ___syscall_ioctl, - "__syscall_mkdir": ___syscall_mkdir, - "__syscall_open": ___syscall_open, - "__syscall_rmdir": ___syscall_rmdir, - "__syscall_unlink": ___syscall_unlink, - "_embind_finalize_value_object": __embind_finalize_value_object, - "_embind_register_bigint": __embind_register_bigint, - "_embind_register_bool": __embind_register_bool, - "_embind_register_class": __embind_register_class, - "_embind_register_class_constructor": __embind_register_class_constructor, - "_embind_register_class_function": __embind_register_class_function, - "_embind_register_emval": __embind_register_emval, - "_embind_register_float": __embind_register_float, - "_embind_register_function": __embind_register_function, - "_embind_register_integer": __embind_register_integer, - "_embind_register_memory_view": __embind_register_memory_view, - "_embind_register_std_string": __embind_register_std_string, - "_embind_register_std_wstring": __embind_register_std_wstring, - "_embind_register_value_object": __embind_register_value_object, - "_embind_register_value_object_field": __embind_register_value_object_field, - "_embind_register_void": __embind_register_void, - "abort": _abort, - "clock": _clock, - "clock_gettime": _clock_gettime, - "emscripten_asm_const_int": _emscripten_asm_const_int, - "emscripten_get_now": _emscripten_get_now, - "emscripten_memcpy_big": _emscripten_memcpy_big, - "emscripten_resize_heap": _emscripten_resize_heap, - "environ_get": _environ_get, - "environ_sizes_get": _environ_sizes_get, - "exit": _exit, - "fd_close": _fd_close, - "fd_fdstat_get": _fd_fdstat_get, - "fd_read": _fd_read, - "fd_seek": _fd_seek, - "fd_write": _fd_write, - "gettimeofday": _gettimeofday, - "gmtime_r": _gmtime_r, - "localtime_r": _localtime_r, - "mktime": _mktime, - "setTempRet0": _setTempRet0, - "strftime": _strftime, - "strftime_l": _strftime_l, - "time": _time -}; -var asm = createWasm(); -/** @type {function(...*):?} */ -var ___wasm_call_ctors = Module["___wasm_call_ctors"] = function() { - return (___wasm_call_ctors = Module["___wasm_call_ctors"] = Module["asm"]["__wasm_call_ctors"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var ___getTypeName = Module["___getTypeName"] = function() { - return (___getTypeName = Module["___getTypeName"] = Module["asm"]["__getTypeName"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var ___embind_register_native_and_builtin_types = Module["___embind_register_native_and_builtin_types"] = function() { - return (___embind_register_native_and_builtin_types = Module["___embind_register_native_and_builtin_types"] = Module["asm"]["__embind_register_native_and_builtin_types"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _free = Module["_free"] = function() { - return (_free = Module["_free"] = Module["asm"]["free"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _malloc = Module["_malloc"] = function() { - return (_malloc = Module["_malloc"] = Module["asm"]["malloc"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var ___errno_location = Module["___errno_location"] = function() { - return (___errno_location = Module["___errno_location"] = Module["asm"]["__errno_location"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var __get_tzname = Module["__get_tzname"] = function() { - return (__get_tzname = Module["__get_tzname"] = Module["asm"]["_get_tzname"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var __get_daylight = Module["__get_daylight"] = function() { - return (__get_daylight = Module["__get_daylight"] = Module["asm"]["_get_daylight"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var __get_timezone = Module["__get_timezone"] = function() { - return (__get_timezone = Module["__get_timezone"] = Module["asm"]["_get_timezone"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var stackSave = Module["stackSave"] = function() { - return (stackSave = Module["stackSave"] = Module["asm"]["stackSave"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var stackRestore = Module["stackRestore"] = function() { - return (stackRestore = Module["stackRestore"] = Module["asm"]["stackRestore"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var stackAlloc = Module["stackAlloc"] = function() { - return (stackAlloc = Module["stackAlloc"] = Module["asm"]["stackAlloc"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _emscripten_stack_set_limits = Module["_emscripten_stack_set_limits"] = function() { - return (_emscripten_stack_set_limits = Module["_emscripten_stack_set_limits"] = Module["asm"]["emscripten_stack_set_limits"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _emscripten_stack_get_base = Module["_emscripten_stack_get_base"] = function() { - return (_emscripten_stack_get_base = Module["_emscripten_stack_get_base"] = Module["asm"]["emscripten_stack_get_base"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _emscripten_stack_get_end = Module["_emscripten_stack_get_end"] = function() { - return (_emscripten_stack_get_end = Module["_emscripten_stack_get_end"] = Module["asm"]["emscripten_stack_get_end"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _memalign = Module["_memalign"] = function() { - return (_memalign = Module["_memalign"] = Module["asm"]["memalign"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_vi = Module["dynCall_vi"] = function() { - return (dynCall_vi = Module["dynCall_vi"] = Module["asm"]["dynCall_vi"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_ii = Module["dynCall_ii"] = function() { - return (dynCall_ii = Module["dynCall_ii"] = Module["asm"]["dynCall_ii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viiii = Module["dynCall_viiii"] = function() { - return (dynCall_viiii = Module["dynCall_viiii"] = Module["asm"]["dynCall_viiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiii = Module["dynCall_iiii"] = function() { - return (dynCall_iiii = Module["dynCall_iiii"] = Module["asm"]["dynCall_iiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iii = Module["dynCall_iii"] = function() { - return (dynCall_iii = Module["dynCall_iii"] = Module["asm"]["dynCall_iii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_vii = Module["dynCall_vii"] = function() { - return (dynCall_vii = Module["dynCall_vii"] = Module["asm"]["dynCall_vii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viiiiifiii = Module["dynCall_viiiiifiii"] = function() { - return (dynCall_viiiiifiii = Module["dynCall_viiiiifiii"] = Module["asm"]["dynCall_viiiiifiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_i = Module["dynCall_i"] = function() { - return (dynCall_i = Module["dynCall_i"] = Module["asm"]["dynCall_i"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viii = Module["dynCall_viii"] = function() { - return (dynCall_viii = Module["dynCall_viii"] = Module["asm"]["dynCall_viii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiiifiii = Module["dynCall_iiiiiifiii"] = function() { - return (dynCall_iiiiiifiii = Module["dynCall_iiiiiifiii"] = Module["asm"]["dynCall_iiiiiifiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_v = Module["dynCall_v"] = function() { - return (dynCall_v = Module["dynCall_v"] = Module["asm"]["dynCall_v"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiii = Module["dynCall_iiiiii"] = function() { - return (dynCall_iiiiii = Module["dynCall_iiiiii"] = Module["asm"]["dynCall_iiiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiiii = Module["dynCall_iiiiiii"] = function() { - return (dynCall_iiiiiii = Module["dynCall_iiiiiii"] = Module["asm"]["dynCall_iiiiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_ijiii = Module["dynCall_ijiii"] = function() { - return (dynCall_ijiii = Module["dynCall_ijiii"] = Module["asm"]["dynCall_ijiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_jiji = Module["dynCall_jiji"] = function() { - return (dynCall_jiji = Module["dynCall_jiji"] = Module["asm"]["dynCall_jiji"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiji = Module["dynCall_iiiji"] = function() { - return (dynCall_iiiji = Module["dynCall_iiiji"] = Module["asm"]["dynCall_iiiji"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viiiiii = Module["dynCall_viiiiii"] = function() { - return (dynCall_viiiiii = Module["dynCall_viiiiii"] = Module["asm"]["dynCall_viiiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiii = Module["dynCall_iiiii"] = function() { - return (dynCall_iiiii = Module["dynCall_iiiii"] = Module["asm"]["dynCall_iiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_dd = Module["dynCall_dd"] = function() { - return (dynCall_dd = Module["dynCall_dd"] = Module["asm"]["dynCall_dd"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iidiiii = Module["dynCall_iidiiii"] = function() { - return (dynCall_iidiiii = Module["dynCall_iidiiii"] = Module["asm"]["dynCall_iidiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viijii = Module["dynCall_viijii"] = function() { - return (dynCall_viijii = Module["dynCall_viijii"] = Module["asm"]["dynCall_viijii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiiiiii = Module["dynCall_iiiiiiiii"] = function() { - return (dynCall_iiiiiiiii = Module["dynCall_iiiiiiiii"] = Module["asm"]["dynCall_iiiiiiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiij = Module["dynCall_iiiiij"] = function() { - return (dynCall_iiiiij = Module["dynCall_iiiiij"] = Module["asm"]["dynCall_iiiiij"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiid = Module["dynCall_iiiiid"] = function() { - return (dynCall_iiiiid = Module["dynCall_iiiiid"] = Module["asm"]["dynCall_iiiiid"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiijj = Module["dynCall_iiiiijj"] = function() { - return (dynCall_iiiiijj = Module["dynCall_iiiiijj"] = Module["asm"]["dynCall_iiiiijj"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiiiii = Module["dynCall_iiiiiiii"] = function() { - return (dynCall_iiiiiiii = Module["dynCall_iiiiiiii"] = Module["asm"]["dynCall_iiiiiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = function() { - return (dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = Module["asm"]["dynCall_iiiiiijj"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var dynCall_viiiii = Module["dynCall_viiiii"] = function() { - return (dynCall_viiiii = Module["dynCall_viiiii"] = Module["asm"]["dynCall_viiiii"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _asyncify_start_unwind = Module["_asyncify_start_unwind"] = function() { - return (_asyncify_start_unwind = Module["_asyncify_start_unwind"] = Module["asm"]["asyncify_start_unwind"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _asyncify_stop_unwind = Module["_asyncify_stop_unwind"] = function() { - return (_asyncify_stop_unwind = Module["_asyncify_stop_unwind"] = Module["asm"]["asyncify_stop_unwind"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _asyncify_start_rewind = Module["_asyncify_start_rewind"] = function() { - return (_asyncify_start_rewind = Module["_asyncify_start_rewind"] = Module["asm"]["asyncify_start_rewind"]).apply(null, arguments); -}; - -/** @type {function(...*):?} */ -var _asyncify_stop_rewind = Module["_asyncify_stop_rewind"] = function() { - return (_asyncify_stop_rewind = Module["_asyncify_stop_rewind"] = Module["asm"]["asyncify_stop_rewind"]).apply(null, arguments); -}; - - - - - -// === Auto-generated postamble setup entry stuff === - -Module["ccall"] = ccall; -Module["cwrap"] = cwrap; - -var calledRun; - -/** - * @constructor - * @this {ExitStatus} - */ -function ExitStatus(status) { - this.name = "ExitStatus"; - this.message = "Program terminated with exit(" + status + ")"; - this.status = status; -} - -var calledMain = false; - -dependenciesFulfilled = function runCaller() { - // If run has never been called, and we should call run (INVOKE_RUN is true, and Module.noInitialRun is not false) - if (!calledRun) run(); - if (!calledRun) dependenciesFulfilled = runCaller; // try this again later, after new deps are fulfilled -}; - -/** @type {function(Array=)} */ -function run(args) { - args = args || arguments_; - - if (runDependencies > 0) { - return; - } - - preRun(); - - // a preRun added a dependency, run will be called later - if (runDependencies > 0) { - return; - } - - function doRun() { - // run may have just been called through dependencies being fulfilled just in this very frame, - // or while the async setStatus time below was happening - if (calledRun) return; - calledRun = true; - Module['calledRun'] = true; - - if (ABORT) return; - - initRuntime(); - - if (Module['onRuntimeInitialized']) Module['onRuntimeInitialized'](); - - postRun(); - } - - if (Module['setStatus']) { - Module['setStatus']('Running...'); - setTimeout(function() { - setTimeout(function() { - Module['setStatus'](''); - }, 1); - doRun(); - }, 1); - } else - { - doRun(); - } -} -Module['run'] = run; - -/** @param {boolean|number=} implicit */ -function exit(status, implicit) { - EXITSTATUS = status; - - if (keepRuntimeAlive()) { - } else { - exitRuntime(); - } - - procExit(status); -} - -function procExit(code) { - EXITSTATUS = code; - if (!keepRuntimeAlive()) { - if (Module['onExit']) Module['onExit'](code); - ABORT = true; - } - quit_(code, new ExitStatus(code)); -} - -if (Module['preInit']) { - if (typeof Module['preInit'] == 'function') Module['preInit'] = [Module['preInit']]; - while (Module['preInit'].length > 0) { - Module['preInit'].pop()(); - } -} - -run(); - - - - - diff --git a/electron/assets/wasm/wasm_video_decode.wasm b/electron/assets/wasm/wasm_video_decode.wasm deleted file mode 100644 index 839c4d2..0000000 Binary files a/electron/assets/wasm/wasm_video_decode.wasm and /dev/null differ diff --git a/electron/dualReportWorker.ts b/electron/dualReportWorker.ts deleted file mode 100644 index 67e36e8..0000000 --- a/electron/dualReportWorker.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { parentPort, workerData } from 'worker_threads' -import { wcdbService } from './services/wcdbService' -import { dualReportService } from './services/dualReportService' - -interface WorkerConfig { - year: number - friendUsername: string - dbPath: string - decryptKey: string - myWxid: string - resourcesPath?: string - userDataPath?: string - logEnabled?: boolean - excludeWords?: string[] -} - -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 dualReportService.generateReportWithConfig({ - year: config.year, - friendUsername: config.friendUsername, - dbPath: config.dbPath, - decryptKey: config.decryptKey, - wxid: config.myWxid, - excludeWords: config.excludeWords, - onProgress: (status: string, progress: number) => { - parentPort?.postMessage({ - type: 'dualReport:progress', - data: { status, progress } - }) - } - }) - - parentPort?.postMessage({ type: 'dualReport:result', data: result }) -} - -run().catch((err) => { - parentPort?.postMessage({ type: 'dualReport:error', error: String(err) }) -}) diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist deleted file mode 100644 index 02af842..0000000 --- a/electron/entitlements.mac.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.debugger - - com.apple.security.get-task-allow - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.disable-library-validation - - - diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts deleted file mode 100644 index dd55157..0000000 --- a/electron/exportWorker.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { parentPort, workerData } from 'worker_threads' - -interface ExportWorkerConfig { - mode?: 'sessions' | 'single' | 'contacts' - sessionIds?: string[] - sessionId?: string - outputDir?: string - outputPath?: string - options?: any - taskId?: string - dbPath?: string - decryptKey?: string - myWxid?: string - imageXorKey?: unknown - imageAesKey?: string - resourcesPath?: string - userDataPath?: string - logEnabled?: boolean - isPackaged?: boolean -} - -const config = workerData as ExportWorkerConfig -const controlState = { - pauseRequested: false, - stopRequested: false -} - -const CREATED_PATH_FLUSH_INTERVAL_MS = 200 -const CREATED_PATH_BATCH_LIMIT = 256 -const PROGRESS_POST_INTERVAL_MS = 180 -let queuedCreatedFiles: string[] = [] -let queuedCreatedDirs: string[] = [] -let createdPathFlushTimer: ReturnType | null = null -let pendingProgress: any = null -let progressPostTimer: ReturnType | null = null -let lastProgressPostedAt = 0 - -function flushCreatedPaths() { - if (createdPathFlushTimer) { - clearTimeout(createdPathFlushTimer) - createdPathFlushTimer = null - } - const filePaths = queuedCreatedFiles - const dirPaths = queuedCreatedDirs - queuedCreatedFiles = [] - queuedCreatedDirs = [] - if (!parentPort) return - if (filePaths.length > 0) { - parentPort.postMessage({ type: 'export:createdFiles', filePaths }) - } - if (dirPaths.length > 0) { - parentPort.postMessage({ type: 'export:createdDirs', dirPaths }) - } -} - -function scheduleCreatedPathFlush() { - if (createdPathFlushTimer) return - createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS) -} - -function queueCreatedFile(filePath: string) { - const normalized = String(filePath || '').trim() - if (!normalized) return - queuedCreatedFiles.push(normalized) - if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) { - flushCreatedPaths() - } else { - scheduleCreatedPathFlush() - } -} - -function queueCreatedDir(dirPath: string) { - const normalized = String(dirPath || '').trim() - if (!normalized) return - queuedCreatedDirs.push(normalized) - if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) { - flushCreatedPaths() - } else { - scheduleCreatedPathFlush() - } -} - -function flushProgress() { - if (!pendingProgress) return - if (progressPostTimer) { - clearTimeout(progressPostTimer) - progressPostTimer = null - } - parentPort?.postMessage({ - type: 'export:progress', - data: pendingProgress - }) - pendingProgress = null - lastProgressPostedAt = Date.now() -} - -function queueProgress(progress: any) { - pendingProgress = progress - if (progress?.phase === 'complete') { - flushProgress() - return - } - - const now = Date.now() - const elapsed = now - lastProgressPostedAt - if (elapsed >= PROGRESS_POST_INTERVAL_MS) { - flushProgress() - return - } - - if (progressPostTimer) return - progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed) -} - -parentPort?.on('message', (message: any) => { - if (!message || typeof message.type !== 'string') return - if (message.type === 'export:pause') { - controlState.pauseRequested = true - return - } - if (message.type === 'export:resume') { - controlState.pauseRequested = false - return - } - if (message.type === 'export:cancel') { - controlState.stopRequested = true - controlState.pauseRequested = false - } -}) - -process.env.WEFLOW_WORKER = '1' -if (config.resourcesPath) { - process.env.WCDB_RESOURCES_PATH = config.resourcesPath -} -if (config.userDataPath) { - process.env.WEFLOW_USER_DATA_PATH = config.userDataPath - process.env.WEFLOW_CONFIG_CWD = config.userDataPath -} -process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow' - -async function run() { - const [{ wcdbService }, { exportService }] = await Promise.all([ - import('./services/wcdbService'), - import('./services/exportService') - ]) - - wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') - wcdbService.setLogEnabled(config.logEnabled === true) - exportService.setRuntimeConfig({ - dbPath: config.dbPath, - decryptKey: config.decryptKey, - myWxid: config.myWxid, - imageXorKey: config.imageXorKey, - imageAesKey: config.imageAesKey, - resourcesPath: config.resourcesPath, - appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, - isPackaged: config.isPackaged - }) - - const onProgress = (progress: any) => queueProgress(progress) - - const taskControl = config.taskId - ? { - shouldPause: () => controlState.pauseRequested, - shouldStop: () => controlState.stopRequested, - recordCreatedFile: queueCreatedFile, - recordCreatedDir: queueCreatedDir - } - : undefined - - let result: any - if (config.mode === 'contacts') { - const [{ contactExportService }, { chatService }] = await Promise.all([ - import('./services/contactExportService'), - import('./services/chatService') - ]) - chatService.setRuntimeConfig({ - dbPath: config.dbPath, - decryptKey: config.decryptKey, - myWxid: config.myWxid, - resourcesPath: config.resourcesPath, - appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, - isPackaged: config.isPackaged - }) - result = await contactExportService.exportContacts( - String(config.outputDir || ''), - config.options || {} - ) - } else if (config.mode === 'single') { - result = await exportService.exportSessionToChatLab( - String(config.sessionId || '').trim(), - String(config.outputPath || '').trim(), - config.options || { format: 'chatlab' }, - onProgress, - taskControl - ) - } else { - result = await exportService.exportSessions( - Array.isArray(config.sessionIds) ? config.sessionIds : [], - String(config.outputDir || ''), - config.options || { format: 'json' }, - onProgress, - taskControl - ) - } - - flushProgress() - flushCreatedPaths() - - parentPort?.postMessage({ - type: 'export:result', - data: result - }) -} - -run().catch((error) => { - flushProgress() - flushCreatedPaths() - parentPort?.postMessage({ - type: 'export:error', - error: String(error) - }) -}) diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts deleted file mode 100644 index 6107dd2..0000000 --- a/electron/imageSearchWorker.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 } - -const payload = workerData as WorkerPayload - -function looksLikeMd5(value: string): boolean { - return /^[a-fA-F0-9]{16,32}$/.test(value) -} - -function stripDatVariantSuffix(base: string): string { - const lower = base.toLowerCase() - const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c'] - for (const suffix of suffixes) { - if (lower.endsWith(suffix)) { - return lower.slice(0, -suffix.length) - } - } - if (/[._][a-z]$/.test(lower)) { - return lower.slice(0, -2) - } - return lower -} - -function hasXVariant(baseLower: string): boolean { - return stripDatVariantSuffix(baseLower) !== baseLower -} - -function hasImageVariantSuffix(baseLower: string): boolean { - return stripDatVariantSuffix(baseLower) !== baseLower -} - -function normalizeDatBase(name: string): string { - let base = name.toLowerCase() - if (base.endsWith('.dat') || base.endsWith('.jpg')) { - base = base.slice(0, -4) - } - while (true) { - const stripped = stripDatVariantSuffix(base) - if (stripped === base) { - return base - } - base = stripped - } -} - -function isLikelyImageDatBase(baseLower: string): boolean { - return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower)) -} - -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 - return lower.endsWith('.dat') && lower.includes(normalizedTarget) -} - -function scoreDatName(fileName: string): number { - const lower = fileName.toLowerCase() - const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 - if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550 - if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520 - if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510 - if (!hasXVariant(baseLower)) return 500 - if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 - if (isThumbnailDat(lower)) return 100 - return 350 -} - -function isThumbnailDat(fileName: string): boolean { - const lower = fileName.toLowerCase() - return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.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 (!matchesDatName(lower, datName)) continue - matchedBases.add(baseLower) - const isThumb = isThumbnailDat(lower) - if (!allowThumbnail && isThumb) continue - if (thumbOnly && !isThumb) continue - candidates.push({ - score: scoreDatName(lower), - path: entryPath, - isThumb - }) - } - } - if (!candidates.length) { - return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) } - } - - const nonThumb = candidates.filter((item) => !item.isThumb) - const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates) - - 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 deleted file mode 100644 index 9c932ba..0000000 --- a/electron/main.ts +++ /dev/null @@ -1,4486 +0,0 @@ -import './preload-env' -import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' -import { Worker } from 'worker_threads' -import { randomUUID } from 'crypto' -import { join, dirname } from 'path' -import { autoUpdater } from 'electron-updater' -import { readFile, writeFile, mkdir, rm, readdir, copyFile } 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, ExportProgress } from './services/exportService' -import { exportTaskControlService } from './services/exportTaskControlService' -import { KeyService } from './services/keyService' -import { KeyServiceLinux } from './services/keyServiceLinux' -import { KeyServiceMac } from './services/keyServiceMac' -import { voiceTranscribeService } from './services/voiceTranscribeService' -import { videoService } from './services/videoService' -import { snsService, isVideoUrl } from './services/snsService' -import { windowsHelloService } from './services/windowsHelloService' -import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' -import { cloudControlService } from './services/cloudControlService' - -import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow' -import { httpService } from './services/httpService' -import { messagePushService } from './services/messagePushService' -import { insightService } from './services/insightService' -import { insightRecordService } from './services/insightRecordService' -import { insightProfileService } from './services/insightProfileService' -import { groupSummaryService } from './services/groupSummaryService' -import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' -import { bizService } from './services/bizService' -import { backupService } from './services/backupService' -import { imageDownloadService } from './services/imageDownloadService' - -// 配置自动更新 -autoUpdater.autoDownload = false -autoUpdater.autoInstallOnAppQuit = true -autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 -// 更新通道策略: -// - 稳定版(如 4.3.0)默认走 latest -// - 预览版(如 0.26.2)默认走 preview(0.年.当年发布序号) -// - 开发版(如 26.4.5)默认走 dev(年.月.日) -// - 用户可在设置页切换稳定/预览/开发,切换后即时生效 -// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。 -const appVersion = app.getVersion() -const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => { - const normalized = String(version || '').trim().replace(/^v/i, '') - if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview' - if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev' - // 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4) - if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview' - if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev' - // 兼容 alpha/beta/rc 预发布 - if (/(alpha|beta|rc)/i.test(normalized)) return 'dev' - return 'stable' -} - -const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => { - const inferred = inferUpdateTrackFromVersion(appVersion) - if (inferred === 'preview' || inferred === 'dev') return inferred - return 'stable' -})() -let configService: ConfigService | null = null -const activeExportWorkers = new Map() -const activeExportTasks = new Set() - -const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim() - -const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => { - const worker = activeExportWorkers.get(taskId) - if (!worker) return - try { - worker.postMessage({ type: `export:${action}` }) - } catch (error) { - console.warn(`[export-task-control] failed to post ${action} to worker:`, error) - } -} - -const finalizeExportTaskControlResult = async (taskId: string, result: any) => { - if (!taskId) return result - if (result?.stopped) { - const cleanup = await exportTaskControlService.cleanupTask(taskId) - if (!cleanup.success) { - return { - ...result, - success: false, - error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}` - } - } - return { - ...result, - cleanup - } - } - if (!result?.paused) { - exportTaskControlService.releaseTask(taskId) - } - return result -} - -const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => { - if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw - return null -} - -const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => { - const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel')) - return configuredTrack || defaultUpdateTrack -} - -const isRemoteVersionNewer = (latestVersion: string, currentVersion: string): boolean => { - const latest = String(latestVersion || '').trim() - const current = String(currentVersion || '').trim() - if (!latest || !current) return false - - const parseVersion = (version: string) => { - const normalized = version.replace(/^v/i, '') - const [main, pre = ''] = normalized.split('-', 2) - const core = main.split('.').map((segment) => Number.parseInt(segment, 10) || 0) - const prerelease = pre ? pre.split('.').map((segment) => /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : segment) : [] - return { core, prerelease } - } - - const compareParsedVersion = (a: ReturnType, b: ReturnType): number => { - const maxLen = Math.max(a.core.length, b.core.length) - for (let i = 0; i < maxLen; i += 1) { - const left = a.core[i] || 0 - const right = b.core[i] || 0 - if (left > right) return 1 - if (left < right) return -1 - } - - const aPre = a.prerelease - const bPre = b.prerelease - if (aPre.length === 0 && bPre.length === 0) return 0 - if (aPre.length === 0) return 1 - if (bPre.length === 0) return -1 - - const preMaxLen = Math.max(aPre.length, bPre.length) - for (let i = 0; i < preMaxLen; i += 1) { - const left = aPre[i] - const right = bPre[i] - if (left === undefined) return -1 - if (right === undefined) return 1 - if (left === right) continue - - const leftNum = typeof left === 'number' - const rightNum = typeof right === 'number' - if (leftNum && rightNum) return left > right ? 1 : -1 - if (leftNum) return -1 - if (rightNum) return 1 - return String(left) > String(right) ? 1 : -1 - } - - return 0 - } - - try { - return autoUpdater.currentVersion.compare(latest) < 0 - } catch { - return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0 - } -} - -const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => { - if (isRemoteVersionNewer(latestVersion, currentVersion)) return true - const effectiveTrack = getEffectiveUpdateTrack() - const currentTrack = inferUpdateTrackFromVersion(currentVersion) - // 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级) - if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true - return false -} - -let lastAppliedUpdaterChannel: string | null = null -let lastAppliedUpdaterFeedUrl: string | null = null -const resetUpdaterProviderCache = () => { - const updater = autoUpdater as any - // electron-updater 会缓存 provider;切换 channel 后需清理缓存,避免仍请求旧通道 - for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) { - if (Object.prototype.hasOwnProperty.call(updater, key)) { - updater[key] = null - } - } -} - -const getUpdaterFeedUrlByTrack = (track: 'stable' | 'preview' | 'dev'): string => { - const repoBase = 'https://github.com/hicccc77/WeFlow/releases' - if (track === 'stable') return `${repoBase}/latest/download` - if (track === 'preview') return `${repoBase}/download/nightly-preview` - return `${repoBase}/download/nightly-dev` -} - -const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { - const track = getEffectiveUpdateTrack() - const currentTrack = inferUpdateTrackFromVersion(appVersion) - const baseUpdateChannel = track === 'stable' ? 'latest' : track - const nextFeedUrl = getUpdaterFeedUrlByTrack(track) - const nextUpdaterChannel = - process.platform === 'win32' && process.arch === 'arm64' - ? `${baseUpdateChannel}-arm64` - : baseUpdateChannel - if ( - (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) || - (lastAppliedUpdaterFeedUrl && lastAppliedUpdaterFeedUrl !== nextFeedUrl) - ) { - resetUpdaterProviderCache() - } - autoUpdater.allowPrerelease = track !== 'stable' - // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级) - autoUpdater.allowDowngrade = track !== currentTrack - // 统一走 generic feed,确保 preview/dev 命中各自固定发布页,不受 GitHub provider 的 prerelease 选择影响。 - autoUpdater.setFeedURL({ - provider: 'generic', - url: nextFeedUrl, - channel: nextUpdaterChannel - }) - autoUpdater.channel = nextUpdaterChannel - lastAppliedUpdaterChannel = nextUpdaterChannel - lastAppliedUpdaterFeedUrl = nextFeedUrl -} - -applyAutoUpdateChannel('startup') -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) - -const getLaunchAtStartupUnsupportedReason = (): string | null => { - if (process.platform !== 'win32' && process.platform !== 'darwin') { - return '当前平台暂不支持开机自启动' - } - if (!app.isPackaged) { - return '仅安装后的 Windows / macOS 版本支持开机自启动' - } - return null -} - -const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null - -const getStoredLaunchAtStartupPreference = (): boolean | undefined => { - const value = configService?.get('launchAtStartup') - return typeof value === 'boolean' ? value : undefined -} - -const getSystemLaunchAtStartup = (): boolean => { - if (!isLaunchAtStartupSupported()) return false - try { - return app.getLoginItemSettings().openAtLogin === true - } catch (error) { - console.error('[WeFlow] 读取开机自启动状态失败:', error) - return false - } -} - -const buildLaunchAtStartupSettings = (enabled: boolean): Parameters[0] => - process.platform === 'win32' - ? { openAtLogin: enabled, path: process.execPath } - : { openAtLogin: enabled } - -const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => { - try { - app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled)) - const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true - if (effectiveEnabled !== enabled) { - return { - success: false, - enabled: effectiveEnabled, - error: '系统未接受该开机自启动设置' - } - } - return { success: true, enabled: effectiveEnabled } - } catch (error) { - return { - success: false, - enabled: getSystemLaunchAtStartup(), - error: `设置开机自启动失败: ${String((error as Error)?.message || error)}` - } - } -} - -const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => { - const unsupportedReason = getLaunchAtStartupUnsupportedReason() - if (unsupportedReason) { - return { - enabled: getStoredLaunchAtStartupPreference() === true, - supported: false, - reason: unsupportedReason - } - } - return { - enabled: getSystemLaunchAtStartup(), - supported: true - } -} - -const applyLaunchAtStartupPreference = ( - enabled: boolean -): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => { - const unsupportedReason = getLaunchAtStartupUnsupportedReason() - if (unsupportedReason) { - return { - success: false, - enabled: getStoredLaunchAtStartupPreference() === true, - supported: false, - reason: unsupportedReason - } - } - - const result = setSystemLaunchAtStartup(enabled) - configService?.set('launchAtStartup', result.enabled) - return { - ...result, - supported: true - } -} - -const syncLaunchAtStartupPreference = () => { - if (!configService) return - - const unsupportedReason = getLaunchAtStartupUnsupportedReason() - if (unsupportedReason) return - - const storedPreference = getStoredLaunchAtStartupPreference() - const systemEnabled = getSystemLaunchAtStartup() - - if (typeof storedPreference !== 'boolean') { - configService.set('launchAtStartup', systemEnabled) - return - } - - if (storedPreference === systemEnabled) return - - const result = setSystemLaunchAtStartup(storedPreference) - configService.set('launchAtStartup', result.enabled) - if (!result.success && result.error) { - console.error('[WeFlow] 同步开机自启动设置失败:', result.error) - } -} - -// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。 -// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。 -function sanitizePathEnv() { - // 开发模式不做裁剪,避免影响本地工具链 - if (process.env.VITE_DEV_SERVER_URL) return - - const rawPath = process.env.PATH || process.env.Path - if (!rawPath) return - - const sep = process.platform === 'win32' ? ';' : ':' - const parts = rawPath.split(sep).filter(Boolean) - - const systemRoot = process.env.SystemRoot || process.env.WINDIR || '' - const safePrefixes = [ - systemRoot, - systemRoot ? join(systemRoot, 'System32') : '', - systemRoot ? join(systemRoot, 'SysWOW64') : '', - dirname(process.execPath), - process.resourcesPath, - join(process.resourcesPath || '', 'resources') - ].filter(Boolean) - - const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase() - const isSafe = (p: string) => { - const np = normalize(p) - return safePrefixes.some((prefix) => np.startsWith(normalize(prefix))) - } - - const filtered = parts.filter(isSafe) - if (filtered.length !== parts.length) { - const removed = parts.filter((p) => !isSafe(p)) - console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed) - const nextPath = filtered.join(sep) - process.env.PATH = nextPath - process.env.Path = nextPath - } -} - -// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境 -sanitizePathEnv() - -// 单例服务 - -// 协议窗口实例 -let agreementWindow: BrowserWindow | null = null -let onboardingWindow: BrowserWindow | null = null -// Splash 启动窗口 -let splashWindow: BrowserWindow | null = null -const sessionChatWindows = new Map() -const sessionChatWindowSources = new Map() - -let keyService: any -if (process.platform === 'darwin') { - keyService = new KeyServiceMac() -} else if (process.platform === 'linux') { - keyService = new KeyServiceLinux() -} else { - keyService = new KeyService() -} - -let mainWindowReady = false -let shouldShowMain = true -let isAppQuitting = false -let shutdownPromise: Promise | null = null -let tray: Tray | null = null -let isClosePromptVisible = false - -interface ChatHistoryPayloadEntry { - sessionId: string - title?: string - recordList: any[] - createdAt: number - lastAccessedAt: number -} - -const chatHistoryPayloadStore = new Map() -const chatHistoryPayloadTtlMs = 10 * 60 * 1000 -const chatHistoryPayloadMaxEntries = 20 - -const pruneChatHistoryPayloadStore = (): void => { - const now = Date.now() - - for (const [payloadId, payload] of chatHistoryPayloadStore.entries()) { - if (now - payload.createdAt > chatHistoryPayloadTtlMs) { - chatHistoryPayloadStore.delete(payloadId) - } - } - - while (chatHistoryPayloadStore.size > chatHistoryPayloadMaxEntries) { - const oldestPayloadId = chatHistoryPayloadStore.keys().next().value as string | undefined - if (!oldestPayloadId) break - chatHistoryPayloadStore.delete(oldestPayloadId) - } -} - -type WindowCloseBehavior = 'ask' | 'tray' | 'quit' -type CloseRestoreMethod = 'tray' | 'dock' - -// 更新下载状态管理(Issue #294 修复) -let isDownloadInProgress = false -let downloadProgressHandler: ((progress: any) => void) | null = null -let downloadedHandler: (() => void) | null = null - -const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => { - const merged = (() => { - if (typeof rawReleaseNotes === 'string') { - return rawReleaseNotes - } - if (Array.isArray(rawReleaseNotes)) { - return rawReleaseNotes - .map((item) => { - if (!item || typeof item !== 'object') return '' - const note = (item as { note?: unknown }).note - return typeof note === 'string' ? note : '' - }) - .filter(Boolean) - .join('\n\n') - } - return '' - })() - - if (!merged.trim()) return '' - - const normalizeHeadingText = (raw: string): string => { - return raw - .replace(/<[^>]*>/g, ' ') - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/'/gi, '\'') - .replace(/'/gi, '\'') - .toLowerCase() - .replace(/[::]/g, '') - .replace(/\s+/g, '') - .trim() - } - - const shouldStripReleaseSection = (headingRaw: string): boolean => { - const heading = normalizeHeadingText(headingRaw) - if (!heading) return false - if (heading.startsWith('下载') || heading.startsWith('download')) return true - - if ((heading.includes('macos') || heading.startsWith('mac')) && heading.includes('安装提示')) return true - return false - } - - // 兼容 electron-updater 直接返回 HTML 的场景(含 dir/anchor 等标签嵌套) - const removeDownloadSectionFromHtml = (input: string): string => { - const headingPattern = /]*>([\s\S]*?)<\/h\1>/gi - const headings: Array<{ start: number; end: number; headingText: string }> = [] - let match: RegExpExecArray | null - - while ((match = headingPattern.exec(input)) !== null) { - const full = match[0] - headings.push({ - start: match.index, - end: match.index + full.length, - headingText: match[2] || '' - }) - } - - if (headings.length === 0) return input - - const rangesToRemove: Array<{ start: number; end: number }> = [] - for (let i = 0; i < headings.length; i += 1) { - const current = headings[i] - if (!shouldStripReleaseSection(current.headingText)) continue - - const nextStart = i + 1 < headings.length ? headings[i + 1].start : input.length - rangesToRemove.push({ start: current.start, end: nextStart }) - } - - if (rangesToRemove.length === 0) return input - - let output = '' - let cursor = 0 - for (const range of rangesToRemove) { - output += input.slice(cursor, range.start) - cursor = range.end - } - output += input.slice(cursor) - return output - } - - // 兼容 Markdown 场景(Action 最终 release note 模板) - const removeDownloadSectionFromMarkdown = (input: string): string => { - const lines = input.split(/\r?\n/) - const output: string[] = [] - let skipSection = false - - for (const line of lines) { - const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/) - if (headingMatch) { - if (shouldStripReleaseSection(headingMatch[1])) { - skipSection = true - continue - } - if (skipSection) { - skipSection = false - } - } - if (!skipSection) { - output.push(line) - } - } - - return output.join('\n') - } - - const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged)) - // 兜底:即使没有匹配到标题,也不在弹窗展示 macOS 隔离标记清理命令 - .replace(/^[ \t>*-]*`?\s*xattr\s+-[a-z]*d[a-z]*\s+com\.apple\.quarantine[^\n]*`?\s*$/gim, '') - .replace(/\n{3,}/g, '\n\n') - .trim() - - return cleaned -} - -const getDialogReleaseNotes = (rawReleaseNotes: unknown): string => { - const track = getEffectiveUpdateTrack() - if (track !== 'stable') { - return '修复了一些已知问题' - } - return normalizeReleaseNotes(rawReleaseNotes) -} - -type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' -type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' - -interface AnnualReportYearsProgressPayload { - years?: number[] - done: boolean - error?: string - canceled?: boolean - strategy?: AnnualReportYearsLoadStrategy - phase?: AnnualReportYearsLoadPhase - statusText?: string - nativeElapsedMs?: number - scanElapsedMs?: number - totalElapsedMs?: number - switched?: boolean - nativeTimedOut?: boolean -} - -interface AnnualReportYearsTaskState { - cacheKey: string - canceled: boolean - done: boolean - snapshot: AnnualReportYearsProgressPayload - updatedAt: number -} - -interface OpenSessionChatWindowOptions { - source?: 'chat' | 'export' - initialDisplayName?: string - initialAvatarUrl?: string - initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' -} - -const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { - return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' -} - -const normalizeSessionChatWindowOptionString = (value: unknown): string => { - return String(value || '').trim() -} - -const loadSessionChatWindowContent = ( - win: BrowserWindow, - sessionId: string, - source: 'chat' | 'export', - options?: OpenSessionChatWindowOptions -) => { - const queryParams = new URLSearchParams({ - sessionId, - source - }) - const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName) - const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl) - const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType) - if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName) - if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl) - if (initialContactType) queryParams.set('initialContactType', initialContactType) - const query = queryParams.toString() - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) - return - } - win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-window?${query}` - }) -} - -const annualReportYearsLoadTasks = new Map() -const annualReportYearsTaskByCacheKey = new Map() -const annualReportYearsSnapshotCache = new Map() -const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000 - -const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => { - const years = Array.isArray(snapshot.years) ? [...snapshot.years] : [] - return { ...snapshot, years } -} - -const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => { - return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}` -} - -const pruneAnnualReportYearsSnapshotCache = (): void => { - const now = Date.now() - for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) { - if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) { - annualReportYearsSnapshotCache.delete(cacheKey) - } - } -} - -const persistAnnualReportYearsSnapshot = ( - cacheKey: string, - taskId: string, - snapshot: AnnualReportYearsProgressPayload -): void => { - annualReportYearsSnapshotCache.set(cacheKey, { - taskId, - snapshot: normalizeAnnualReportYearsSnapshot(snapshot), - updatedAt: Date.now() - }) - pruneAnnualReportYearsSnapshotCache() -} - -const getAnnualReportYearsSnapshot = ( - cacheKey: string -): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => { - pruneAnnualReportYearsSnapshotCache() - const entry = annualReportYearsSnapshotCache.get(cacheKey) - if (!entry) return null - return { - taskId: entry.taskId, - snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot) - } -} - -const broadcastAnnualReportYearsProgress = ( - taskId: string, - payload: AnnualReportYearsProgressPayload -): void => { - for (const win of BrowserWindow.getAllWindows()) { - if (win.isDestroyed()) continue - win.webContents.send('annualReport:availableYearsProgress', { - taskId, - ...payload - }) - } -} - -const isYearsLoadCanceled = (taskId: string): boolean => { - const task = annualReportYearsLoadTasks.get(taskId) - return task?.canceled === true -} - -const setupCustomTitleBarWindow = (win: BrowserWindow): void => { - if (process.platform === 'darwin') { - win.setWindowButtonVisibility(false) - } - - const emitMaximizeState = () => { - if (win.isDestroyed()) return - win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen()) - } - - win.on('maximize', emitMaximizeState) - win.on('unmaximize', emitMaximizeState) - win.on('enter-full-screen', emitMaximizeState) - win.on('leave-full-screen', emitMaximizeState) - win.webContents.on('did-finish-load', emitMaximizeState) -} - -let notificationNavigateHandlerRegistered = false -const focusMainWindowAndNavigate = (sessionId: string): void => { - const targetWindow = mainWindow - if (!targetWindow || targetWindow.isDestroyed()) return - if (targetWindow.isMinimized()) targetWindow.restore() - targetWindow.show() - targetWindow.focus() - targetWindow.webContents.send('navigate-to-session', sessionId) -} - -const focusMainWindowAndNavigateRoute = (route: string): void => { - const targetWindow = mainWindow - if (!targetWindow || targetWindow.isDestroyed()) return - if (targetWindow.isMinimized()) targetWindow.restore() - targetWindow.show() - targetWindow.focus() - targetWindow.webContents.send('navigate-to-route', route) -} - -const handleNotificationClickNavigation = (payload: unknown): void => { - if (payload && typeof payload === 'object') { - const data = payload as { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string } - const targetRoute = String(data.targetRoute || '').trim() - if (targetRoute.startsWith('/')) { - focusMainWindowAndNavigateRoute(targetRoute) - return - } - if (data.channel === 'ai-insight' && data.insightRecordId) { - focusMainWindowAndNavigateRoute(`/insight-inbox?recordId=${encodeURIComponent(String(data.insightRecordId))}`) - return - } - focusMainWindowAndNavigate(String(data.sessionId || '')) - return - } - focusMainWindowAndNavigate(String(payload || '')) -} - -const ensureNotificationNavigateHandlerRegistered = (): void => { - if (notificationNavigateHandlerRegistered) return - notificationNavigateHandlerRegistered = true - ipcMain.on('notification-clicked', (_event, payload) => { - handleNotificationClickNavigation(payload) - }) - setNotificationNavigateHandler((payload: unknown) => { - handleNotificationClickNavigation(payload) - }) -} - -let wechatRequestHeaderInterceptorRegistered = false -const ensureWeChatRequestHeaderInterceptor = (): void => { - if (wechatRequestHeaderInterceptorRegistered) return - wechatRequestHeaderInterceptorRegistered = true - - session.defaultSession.webRequest.onBeforeSendHeaders( - { - urls: [ - '*://*.qpic.cn/*', - '*://*.qlogo.cn/*', - '*://*.wechat.com/*', - '*://*.weixin.qq.com/*', - '*://*.wx.qq.com/*' - ] - }, - (details, callback) => { - details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351" - details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" - details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br" - details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9" - details.requestHeaders['Connection'] = "keep-alive" - details.requestHeaders['Range'] = "bytes=0-" - - let host = '' - try { - host = new URL(details.url).hostname.toLowerCase() - } catch {} - const isWxQQ = host === 'wx.qq.com' || host.endsWith('.wx.qq.com') - details.requestHeaders['Referer'] = isWxQQ ? 'https://wx.qq.com/' : 'https://servicewechat.com/' - - callback({ cancel: false, requestHeaders: details.requestHeaders }) - } - ) -} - -const getWindowCloseBehavior = (): WindowCloseBehavior => { - const behavior = configService?.get('windowCloseBehavior') - return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' -} - -const isSilentStartupEnabled = (): boolean => { - return configService?.get('silentStartup') === true -} - -const getCloseRestoreMethod = (): CloseRestoreMethod | null => { - if (tray) return 'tray' - if (process.platform === 'darwin') return 'dock' - return null -} - -const canKeepMainWindowInBackground = (): boolean => { - return getCloseRestoreMethod() !== null -} - -const getPlatformIconName = (): string => { - if (process.platform === 'linux') return 'icon.png' - if (process.platform === 'darwin') return 'icon.icns' - return 'icon.ico' -} - -const resolveAppIconPath = (): string => { - const iconName = getPlatformIconName() - if (!process.env.VITE_DEV_SERVER_URL) { - return join(process.resourcesPath, iconName) - } - if (process.platform === 'darwin') { - return join(__dirname, '../resources/icons/macos/icon.icns') - } - return join(__dirname, `../public/${iconName}`) -} - -const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { - if (isClosePromptVisible) return - isClosePromptVisible = true - const restoreMethod = getCloseRestoreMethod() - win.webContents.send('window:confirmCloseRequested', { - canMinimizeToTray: restoreMethod !== null, - restoreMethod: restoreMethod ?? undefined - }) -} - -function createWindow(options: { autoShow?: boolean } = {}) { - // 获取图标路径 - 打包后在 resources 目录 - const { autoShow = true } = options - const iconPath = resolveAppIconPath() - - const win = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 1000, - minHeight: 700, - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - webSecurity: false // Allow loading local files (video playback) - }, - titleBarStyle: 'hidden', - titleBarOverlay: false, - show: false - }) - setupCustomTitleBarWindow(win) - - // 窗口准备好后显示 - // Splash 模式下不在这里 show,由启动流程统一控制 - win.once('ready-to-show', () => { - mainWindowReady = true - if (autoShow && !splashWindow) { - 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')) - } - - // 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确) - win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => { - const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com'] - try { - const host = new URL(url).hostname - if (trusted.some(d => host.endsWith(d))) { - event.preventDefault() - callback(true) - return - } - } catch {} - callback(false) - }) - - win.on('close', (e) => { - if (isAppQuitting || win !== mainWindow) return - e.preventDefault() - const closeBehavior = getWindowCloseBehavior() - - if (closeBehavior === 'quit') { - isAppQuitting = true - app.quit() - return - } - - if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) { - win.hide() - return - } - - requestMainWindowCloseConfirmation(win) - }) - - win.on('closed', () => { - if (mainWindow !== win) return - - mainWindow = null - mainWindowReady = false - isClosePromptVisible = false - - if (process.platform !== 'darwin' && !isAppQuitting) { - destroyNotificationWindow() - if (BrowserWindow.getAllWindows().length === 0) { - app.quit() - } - } - }) - - 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') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : 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 -} - -/** - * 创建 Splash 启动窗口 - * 使用纯 HTML 页面,不依赖 React,确保极速显示 - */ -function createSplashWindow(): BrowserWindow { - const isDev = !!process.env.VITE_DEV_SERVER_URL - const splashThemeId = configService?.get('themeId') || 'cloud-dancer' - const splashThemeMode = configService?.get('theme') || 'system' - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - splashWindow = new BrowserWindow({ - width: 680, - height: 460, - resizable: false, - frame: false, - transparent: true, - backgroundColor: '#00000000', - hasShadow: false, - center: true, - skipTaskbar: false, - icon: iconPath, - webPreferences: { - contextIsolation: true, - nodeIntegration: false - // 不需要 preload —— 通过 executeJavaScript 单向推送进度 - }, - show: false - }) - - if (isDev) { - const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL) - splashUrl.searchParams.set('themeId', splashThemeId) - splashUrl.searchParams.set('themeMode', splashThemeMode) - splashWindow.loadURL(splashUrl.toString()) - } else { - splashWindow.loadFile(join(__dirname, '../dist/splash.html'), { - query: { - themeId: splashThemeId, - themeMode: splashThemeMode - } - }) - } - - splashWindow.once('ready-to-show', () => { - splashWindow?.show() - }) - - splashWindow.on('closed', () => { - splashWindow = null - }) - - return splashWindow -} - -/** - * 向 Splash 窗口发送进度更新 - */ -function updateSplashProgress(percent: number, text: string, indeterminate = false) { - if (splashWindow && !splashWindow.isDestroyed()) { - splashWindow.webContents - .executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`) - .catch(() => {}) - } -} - -/** - * 关闭 Splash 窗口 - */ -function closeSplash() { - if (splashWindow && !splashWindow.isDestroyed()) { - splashWindow.close() - splashWindow = null - } -} - -/** - * 创建首次引导窗口 - */ -function createOnboardingWindow(mode: 'default' | 'add-account' = 'default') { - const onboardingHash = mode === 'add-account' - ? '/onboarding-window?mode=add-account' - : '/onboarding-window' - - if (onboardingWindow && !onboardingWindow.isDestroyed()) { - if (process.env.VITE_DEV_SERVER_URL) { - onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`) - } else { - onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) - } - onboardingWindow.focus() - return onboardingWindow - } - - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - onboardingWindow = new BrowserWindow({ - width: 960, - height: 680, - minWidth: 900, - minHeight: 620, - resizable: false, - 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}#${onboardingHash}`) - } else { - onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) - } - - onboardingWindow.on('closed', () => { - onboardingWindow = null - }) - - return onboardingWindow -} - -/** - * 创建独立的视频播放窗口 - * 窗口大小会根据视频比例自动调整 - */ -function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) { - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - // 获取屏幕尺寸 - const { screen } = require('electron') - const primaryDisplay = screen.getPrimaryDisplay() - const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize - - // 计算窗口尺寸,只有标题栏 40px,控制栏悬浮 - let winWidth = 854 - let winHeight = 520 - const titleBarHeight = 40 - - if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) { - const aspectRatio = videoWidth / videoHeight - - const maxWidth = Math.floor(screenWidth * 0.85) - const maxHeight = Math.floor(screenHeight * 0.85) - - if (aspectRatio >= 1) { - // 横向视频 - winWidth = Math.min(videoWidth, maxWidth) - winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight - - if (winHeight > maxHeight) { - winHeight = maxHeight - winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) - } - } else { - // 竖向视频 - const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) - winHeight = videoDisplayHeight + titleBarHeight - winWidth = Math.floor(videoDisplayHeight * aspectRatio) - - if (winWidth < 300) { - winWidth = 300 - winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight - } - } - - winWidth = Math.max(winWidth, 360) - winHeight = Math.max(winHeight, 280) - } - - const win = new BrowserWindow({ - width: winWidth, - height: winHeight, - minWidth: 360, - minHeight: 280, - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - webSecurity: false - }, - titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#1a1a1a', - symbolColor: '#ffffff', - height: 40 - }, - show: false, - backgroundColor: '#000000', - autoHideMenuBar: true - }) - - win.once('ready-to-show', () => { - win.show() - }) - - const videoParam = `videoPath=${encodeURIComponent(videoPath)}` - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`) - - 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'), { - hash: `/video-player-window?${videoParam}` - }) - } -} - -/** - * 创建独立的图片查看窗口 - */ -function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - const win = new BrowserWindow({ - width: 900, - height: 700, - minWidth: 400, - minHeight: 300, - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - webSecurity: false // 允许加载本地文件 - }, - frame: false, - show: false, - backgroundColor: '#000000', - autoHideMenuBar: true - }) - - setupCustomTitleBarWindow(win) - - win.once('ready-to-show', () => { - win.show() - }) - - let imageParam = `imagePath=${encodeURIComponent(imagePath)}` - if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}` - - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`) - - 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'), { - hash: `/image-viewer-window?${imageParam}` - }) - } - - return win -} - -/** - * 创建独立的聊天记录窗口 - */ -function createChatHistoryWindow(sessionId: string, messageId: number) { - return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`) -} - -function createChatHistoryPayloadWindow(payloadId: string) { - const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) - win.on('closed', () => { - chatHistoryPayloadStore.delete(payloadId) - }) - return win -} - -function createChatHistoryRouteWindow(route: string) { - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - const win = new BrowserWindow({ - width: 600, - height: 800, - minWidth: 400, - minHeight: 500, - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false - }, - titleBarStyle: 'hidden', - titleBarOverlay: false, - show: false, - backgroundColor: '#FFFFFF', - autoHideMenuBar: true - }) - setupCustomTitleBarWindow(win) - - let hasShown = false - let isReadyToShow = false - let hasLoadedRoute = false - const showChatHistoryWindow = () => { - if (hasShown || !isReadyToShow || !hasLoadedRoute || win.isDestroyed()) return - hasShown = true - win.show() - } - - win.webContents.once('did-finish-load', () => { - hasLoadedRoute = true - setTimeout(showChatHistoryWindow, 30) - }) - win.webContents.once('did-fail-load', () => { - hasLoadedRoute = true - showChatHistoryWindow() - }) - win.once('ready-to-show', () => { - isReadyToShow = true - showChatHistoryWindow() - }) - - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`) - - 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'), { - hash: route - }) - } - - return win -} - -/** - * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) - */ -function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return null - const normalizedSource = normalizeSessionChatWindowSource(options?.source) - - const existing = sessionChatWindows.get(normalizedSessionId) - if (existing && !existing.isDestroyed()) { - const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' - if (trackedSource !== normalizedSource) { - loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options) - sessionChatWindowSources.set(normalizedSessionId, normalizedSource) - } - if (existing.isMinimized()) { - existing.restore() - } - existing.focus() - return existing - } - - const isDev = !!process.env.VITE_DEV_SERVER_URL - const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : (process.platform === 'darwin' - ? join(process.resourcesPath, 'icon.icns') - : join(process.resourcesPath, 'icon.ico')) - - const isDark = nativeTheme.shouldUseDarkColors - - const win = new BrowserWindow({ - width: 600, - height: 820, - minWidth: 420, - minHeight: 560, - icon: iconPath, - webPreferences: { - preload: join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false - }, - titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: isDark ? '#ffffff' : '#1a1a1a', - height: 40 - }, - show: false, - backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', - autoHideMenuBar: true - }) - - loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options) - - if (process.env.VITE_DEV_SERVER_URL) { - 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() - } - }) - } - - win.once('ready-to-show', () => { - win.show() - win.focus() - }) - - win.on('closed', () => { - const tracked = sessionChatWindows.get(normalizedSessionId) - if (tracked === win) { - sessionChatWindows.delete(normalizedSessionId) - sessionChatWindowSources.delete(normalizedSessionId) - } - }) - - sessionChatWindows.set(normalizedSessionId, win) - sessionChatWindowSources.set(normalizedSessionId, normalizedSource) - return win -} - -function showMainWindow() { - shouldShowMain = true - if (mainWindowReady) { - mainWindow?.show() - } -} - -const normalizeAccountId = (value: string): string => { - const trimmed = String(value || '').trim() - if (!trimmed) return '' - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - return match?.[1] || trimmed - } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - return suffixMatch ? suffixMatch[1] : trimmed -} - -const buildAccountNameMatcher = (wxidCandidates: string[]) => { - const loweredCandidates = wxidCandidates - .map((item) => String(item || '').trim().toLowerCase()) - .filter(Boolean) - return (name: string): boolean => { - const loweredName = String(name || '').trim().toLowerCase() - if (!loweredName) return false - return loweredCandidates.some((candidate) => ( - loweredName === candidate || - loweredName.startsWith(`${candidate}_`) || - loweredName.includes(candidate) - )) - } -} - -const removePathIfExists = async ( - targetPath: string, - removedPaths: string[], - warnings: string[] -): Promise => { - if (!targetPath || !existsSync(targetPath)) return - try { - await rm(targetPath, { recursive: true, force: true }) - removedPaths.push(targetPath) - } catch (error) { - warnings.push(`${targetPath}: ${String(error)}`) - } -} - -const removeMatchedEntriesInDir = async ( - rootDir: string, - shouldRemove: (name: string) => boolean, - removedPaths: string[], - warnings: string[] -): Promise => { - if (!rootDir || !existsSync(rootDir)) return - try { - const entries = await readdir(rootDir, { withFileTypes: true }) - for (const entry of entries) { - if (!shouldRemove(entry.name)) continue - const targetPath = join(rootDir, entry.name) - await removePathIfExists(targetPath, removedPaths, warnings) - } - } catch (error) { - warnings.push(`${rootDir}: ${String(error)}`) - } -} - -const normalizeFsPathForCompare = (value: string): string => { - const normalized = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '') - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -type SnsCacheMigrationCandidate = { - label: string - sourceDir: string - targetDir: string - fileCount: number -} - -type SnsCacheMigrationPlan = { - legacyBaseDir: string - currentBaseDir: string - candidates: SnsCacheMigrationCandidate[] - totalFiles: number -} - -type SnsCacheMigrationProgressPayload = { - status: 'running' | 'done' | 'error' - phase: 'copying' | 'cleanup' | 'done' | 'error' - current: number - total: number - copied: number - skipped: number - remaining: number - message?: string - currentItemLabel?: string -} - -let snsCacheMigrationInProgress = false - -const countFilesInDir = async (dirPath: string): Promise => { - if (!dirPath || !existsSync(dirPath)) return 0 - try { - const entries = await readdir(dirPath, { withFileTypes: true }) - let count = 0 - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - count += await countFilesInDir(fullPath) - continue - } - if (entry.isFile()) count += 1 - } - return count - } catch { - return 0 - } -} - -const migrateDirectoryPreserveNewFiles = async ( - sourceDir: string, - targetDir: string, - onFileProcessed?: (payload: { copied: boolean }) => void -): Promise<{ copied: number; skipped: number; processed: number }> => { - let copied = 0 - let skipped = 0 - let processed = 0 - - if (!existsSync(sourceDir)) return { copied, skipped, processed } - await mkdir(targetDir, { recursive: true }) - - const entries = await readdir(sourceDir, { withFileTypes: true }) - for (const entry of entries) { - const sourcePath = join(sourceDir, entry.name) - const targetPath = join(targetDir, entry.name) - - if (entry.isDirectory()) { - const nested = await migrateDirectoryPreserveNewFiles(sourcePath, targetPath, onFileProcessed) - copied += nested.copied - skipped += nested.skipped - processed += nested.processed - continue - } - - if (!entry.isFile()) continue - - if (existsSync(targetPath)) { - skipped += 1 - processed += 1 - onFileProcessed?.({ copied: false }) - continue - } - - await mkdir(dirname(targetPath), { recursive: true }) - await copyFile(sourcePath, targetPath) - copied += 1 - processed += 1 - onFileProcessed?.({ copied: true }) - } - - return { copied, skipped, processed } -} - -const collectLegacySnsCacheMigrationPlan = async (): Promise => { - if (!configService) return null - - const legacyBaseDir = configService.getCacheBasePath() - const configuredCachePath = String(configService.get('cachePath') || '').trim() - const currentBaseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') - - if (!legacyBaseDir || !currentBaseDir) return null - - const candidates = [ - { - label: '朋友圈媒体缓存', - sourceDir: join(legacyBaseDir, 'sns_cache'), - targetDir: join(currentBaseDir, 'sns_cache') - }, - { - label: '朋友圈表情缓存(合并到 Emojis)', - sourceDir: join(legacyBaseDir, 'sns_emoji_cache'), - targetDir: join(currentBaseDir, 'Emojis') - }, - { - label: '朋友圈表情缓存(当前目录残留)', - sourceDir: join(currentBaseDir, 'sns_emoji_cache'), - targetDir: join(currentBaseDir, 'Emojis') - } - ] - - const pendingKeys = new Set() - const pending: SnsCacheMigrationCandidate[] = [] - for (const item of candidates) { - const sourceKey = normalizeFsPathForCompare(item.sourceDir) - const targetKey = normalizeFsPathForCompare(item.targetDir) - if (!sourceKey || sourceKey === targetKey) continue - const dedupeKey = `${sourceKey}=>${targetKey}` - if (pendingKeys.has(dedupeKey)) continue - const fileCount = await countFilesInDir(item.sourceDir) - if (fileCount <= 0) continue - pendingKeys.add(dedupeKey) - pending.push({ ...item, fileCount }) - } - if (pending.length === 0) return null - - const totalFiles = pending.reduce((sum, item) => sum + item.fileCount, 0) - return { - legacyBaseDir, - currentBaseDir, - candidates: pending, - totalFiles - } -} - -const runLegacySnsCacheMigration = async ( - plan: SnsCacheMigrationPlan, - onProgress: (payload: SnsCacheMigrationProgressPayload) => void -): Promise<{ copied: number; skipped: number; totalFiles: number }> => { - let processed = 0 - let copied = 0 - let skipped = 0 - const total = plan.totalFiles - - const emitProgress = (patch?: Partial) => { - onProgress({ - status: 'running', - phase: 'copying', - current: processed, - total, - copied, - skipped, - remaining: Math.max(0, total - processed), - ...patch - }) - } - - emitProgress({ message: '准备迁移缓存...' }) - - for (const item of plan.candidates) { - emitProgress({ currentItemLabel: item.label, message: `正在迁移:${item.label}` }) - const result = await migrateDirectoryPreserveNewFiles(item.sourceDir, item.targetDir, ({ copied: copiedThisFile }) => { - processed += 1 - if (copiedThisFile) copied += 1 - else skipped += 1 - emitProgress({ currentItemLabel: item.label }) - }) - // 兜底对齐计数,防止回调未触发造成偏差 - const expectedProcessed = copied + skipped - if (processed !== expectedProcessed) { - processed = expectedProcessed - copied = Math.max(copied, result.copied) - skipped = Math.max(skipped, result.skipped) - emitProgress({ currentItemLabel: item.label }) - } - } - - emitProgress({ phase: 'cleanup', message: '正在清理旧目录...' }) - for (const item of plan.candidates) { - await rm(item.sourceDir, { recursive: true, force: true }) - } - - if (existsSync(plan.legacyBaseDir)) { - try { - const remaining = await readdir(plan.legacyBaseDir) - if (remaining.length === 0) { - await rm(plan.legacyBaseDir, { recursive: true, force: true }) - } - } catch { - // 忽略旧目录清理失败,不影响迁移结果 - } - } - - onProgress({ - status: 'done', - phase: 'done', - current: processed, - total, - copied, - skipped, - remaining: Math.max(0, total - processed), - message: '迁移完成' - }) - - return { copied, skipped, totalFiles: total } -} - -// 注册 IPC 处理器 -function registerIpcHandlers() { - registerNotificationHandlers() - ensureNotificationNavigateHandlerRegistered() - bizService.registerHandlers() - // 配置相关 - ipcMain.handle('config:get', async (_, key: string) => { - return configService?.get(key as any) - }) - - ipcMain.handle('config:set', async (_, key: string, value: any) => { - let result: unknown - if (key === 'launchAtStartup') { - result = applyLaunchAtStartupPreference(value === true) - } else { - result = configService?.set(key as any, value) - } - if (key === 'updateChannel') { - applyAutoUpdateChannel('settings') - } - void messagePushService.handleConfigChanged(key) - void insightService.handleConfigChanged(key) - void groupSummaryService.handleConfigChanged(key) - return result - }) - - // AI 见解 - ipcMain.handle('insight:testConnection', async () => { - return insightService.testConnection() - }) - - ipcMain.handle('insight:getTodayStats', async () => { - return insightService.getTodayStats() - }) - - ipcMain.handle('insight:listRecords', async (_, filters?: { - keyword?: string - sessionId?: string - startTime?: number - endTime?: number - sourceType?: 'insight' | 'message_analysis' | 'all' - limit?: number - offset?: number - }) => { - return insightRecordService.listRecords(filters || {}) - }) - - ipcMain.handle('insight:getRecord', async (_, id: string) => { - return insightRecordService.getRecord(id) - }) - - ipcMain.handle('insight:markRecordRead', async (_, id: string) => { - return insightRecordService.markRecordRead(id) - }) - - ipcMain.handle('insight:clearRecords', async (_, filters?: { - sessionId?: string - startTime?: number - endTime?: number - }) => { - return insightRecordService.clearRecords(filters || {}) - }) - - ipcMain.handle('insight:triggerTest', async () => { - return insightService.triggerTest() - }) - - ipcMain.handle('insight:triggerSessionInsight', async (_, payload: { - sessionId: string - displayName?: string - avatarUrl?: string - }) => { - return insightService.triggerSessionInsight(payload) - }) - - ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => { - return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : []) - }) - - ipcMain.handle('insight:generateProfile', async (_, payload: { - sessionId: string - displayName?: string - avatarUrl?: string - }) => { - return insightProfileService.generateProfile(payload) - }) - - ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => { - return insightProfileService.cancelProfile(sessionId) - }) - - ipcMain.handle('insight:generateFootprintInsight', async (_, payload: { - rangeLabel: string - summary: { - private_inbound_people?: number - private_replied_people?: number - private_outbound_people?: number - private_reply_rate?: number - mention_count?: number - mention_group_count?: number - } - privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> - mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> - }) => { - return insightService.generateFootprintInsight(payload) - }) - - ipcMain.handle('insight:generateMessageInsight', async (_, payload: { - sessionId: string - displayName?: string - avatarUrl?: string - targetLocalId?: number - targetCreateTime?: number - targetMessageKey?: string - targetText: string - targetSenderName?: string - contextCount?: number - forceRefresh?: boolean - }) => { - return insightService.generateMessageInsight(payload) - }) - - ipcMain.handle('groupSummary:listRecords', async (_, filters?: { - sessionId?: string - startTime?: number - endTime?: number - limit?: number - offset?: number - }) => { - return groupSummaryService.listRecords(filters || {}) - }) - - ipcMain.handle('groupSummary:getRecord', async (_, id: string) => { - return groupSummaryService.getRecord(id) - }) - - ipcMain.handle('groupSummary:triggerManual', async (_, payload: { - sessionId: string - displayName?: string - avatarUrl?: string - startTime: number - endTime: number - }) => { - return groupSummaryService.triggerManual(payload) - }) - - ipcMain.handle('groupSummary:triggerDay', async (_, payload: { - sessionId: string - displayName?: string - avatarUrl?: string - date: string - }) => { - return groupSummaryService.triggerDay(payload) - }) - - ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { - try { - if (!configService) { - return { success: false, error: 'Config service is not initialized' } - } - const normalized = normalizeWeiboCookieInput(rawInput) - configService.set('aiInsightWeiboCookie' as any, normalized as any) - weiboService.clearCache() - return { success: true, normalized, hasCookie: Boolean(normalized) } - } catch (error) { - return { success: false, error: (error as Error).message || 'Failed to save Weibo cookie' } - } - }) - - ipcMain.handle('social:validateWeiboUid', async (_, uid: string) => { - try { - if (!configService) { - return { success: false, error: 'Config service is not initialized' } - } - const cookie = String(configService.get('aiInsightWeiboCookie' as any) || '') - return await weiboService.validateUid(uid, cookie) - } catch (error) { - return { success: false, error: (error as Error).message || 'Failed to validate Weibo UID' } - } - }) - - ipcMain.handle('config:clear', async () => { - if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { - const result = setSystemLaunchAtStartup(false) - if (!result.success && result.error) { - console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error) - } - } - configService?.clear() - messagePushService.handleConfigCleared() - insightService.handleConfigCleared() - groupSummaryService.handleConfigCleared() - 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('app:getLaunchAtStartupStatus', async () => { - return getLaunchAtStartupStatus() - }) - - ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => { - return applyLaunchAtStartupPreference(enabled === true) - }) - - 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('log:clear', async () => { - try { - const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log') - await mkdir(dirname(logPath), { recursive: true }) - await writeFile(logPath, '', 'utf8') - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - }) - - ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { - return exportCardDiagnosticsService.snapshot(options?.limit) - }) - - ipcMain.handle('diagnostics:clearExportCardLogs', async () => { - exportCardDiagnosticsService.clear() - return { success: true } - }) - - ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: { - filePath?: string - frontendLogs?: unknown[] - }) => { - const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : '' - if (!filePath) { - return { success: false, error: '导出路径不能为空' } - } - return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || []) - }) - - // 数据收集服务 - ipcMain.handle('cloud:init', async () => { - await cloudControlService.init() - }) - - ipcMain.handle('cloud:recordPage', (_, pageName: string) => { - cloudControlService.recordPage(pageName) - }) - - ipcMain.handle('cloud:getLogs', async () => { - return cloudControlService.getLogs() - }) - - ipcMain.handle('app:checkForUpdates', async () => { - if (!AUTO_UPDATE_ENABLED) { - return { hasUpdate: false } - } - // 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。 - applyAutoUpdateChannel('settings') - try { - const result = await autoUpdater.checkForUpdates() - if (result && result.updateInfo) { - const currentVersion = app.getVersion() - const latestVersion = result.updateInfo.version - if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) { - return { - hasUpdate: true, - version: latestVersion, - releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes), - minimumVersion: (result.updateInfo as any).minimumVersion - } - } - } - return { hasUpdate: false } - } catch (error) { - console.error('检查更新失败:', error) - return { hasUpdate: false } - } - }) - - ipcMain.handle('app:downloadAndInstall', async (event) => { - if (!AUTO_UPDATE_ENABLED) { - throw new Error('自动更新已暂时禁用') - } - - // 防止重复下载(Issue #294 修复) - if (isDownloadInProgress) { - throw new Error('更新正在下载中,请稍候') - } - - isDownloadInProgress = true - const win = BrowserWindow.fromWebContents(event.sender) - - // 清理旧的监听器(Issue #294 修复:防止监听器泄漏) - if (downloadProgressHandler) { - autoUpdater.removeListener('download-progress', downloadProgressHandler) - downloadProgressHandler = null - } - if (downloadedHandler) { - autoUpdater.removeListener('update-downloaded', downloadedHandler) - downloadedHandler = null - } - - // 创建新的监听器并保存引用 - downloadProgressHandler = (progress) => { - if (win && !win.isDestroyed()) { - win.webContents.send('app:downloadProgress', progress) - } - } - - downloadedHandler = () => { - console.log('[Update] 更新下载完成,准备安装') - if (downloadProgressHandler) { - autoUpdater.removeListener('download-progress', downloadProgressHandler) - downloadProgressHandler = null - } - downloadedHandler = null - isDownloadInProgress = false - autoUpdater.quitAndInstall(false, true) - } - - autoUpdater.on('download-progress', downloadProgressHandler) - autoUpdater.once('update-downloaded', downloadedHandler) - - try { - console.log('[Update] 开始下载更新...') - await autoUpdater.downloadUpdate() - } catch (error: any) { - console.error('[Update] 下载更新失败:', error) - // 失败时清理状态和监听器 - isDownloadInProgress = false - if (downloadProgressHandler) { - autoUpdater.removeListener('download-progress', downloadProgressHandler) - downloadProgressHandler = null - } - if (downloadedHandler) { - autoUpdater.removeListener('update-downloaded', downloadedHandler) - downloadedHandler = null - } - - const errorCode = typeof error?.code === 'string' ? error.code : '' - const rawErrorMessage = - typeof error?.message === 'string' - ? error.message - : (typeof error === 'string' ? error : JSON.stringify(error)) - - if (errorCode === 'ERR_UPDATER_ZIP_FILE_NOT_FOUND' || /ZIP file not provided/i.test(rawErrorMessage)) { - throw new Error('当前发布版本缺少 macOS 自动更新所需的 ZIP 包,请联系开发者重新发布该版本') - } - - throw new Error(rawErrorMessage || '下载更新失败,请稍后重试') - } - }) - - ipcMain.handle('app:ignoreUpdate', async (_, version: string) => { - configService?.set('ignoredUpdateVersion', version) - return { success: true } - }) - - // 窗口控制 - 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.handle('window:isMaximized', (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - return Boolean(win?.isMaximized() || win?.isFullScreen()) - }) - - ipcMain.on('window:close', (event) => { - BrowserWindow.fromWebContents(event.sender)?.close() - }) - - ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => { - if (!mainWindow || mainWindow.isDestroyed()) { - isClosePromptVisible = false - return false - } - - try { - if (action === 'tray') { - if (canKeepMainWindowInBackground()) { - mainWindow.hide() - return true - } - return false - } - - if (action === 'quit') { - isAppQuitting = true - app.quit() - return true - } - - return true - } finally { - isClosePromptVisible = false - } - }) - - // 更新窗口控件主题色 - 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('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => { - createVideoPlayerWindow(videoPath, videoWidth, videoHeight) - }) - - // 打开聊天记录窗口 - ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => { - createChatHistoryWindow(sessionId, messageId) - return true - }) - - ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => { - const payloadId = randomUUID() - pruneChatHistoryPayloadStore() - const now = Date.now() - chatHistoryPayloadStore.set(payloadId, { - sessionId: String(payload?.sessionId || '').trim(), - title: String(payload?.title || '').trim() || '聊天记录', - recordList: Array.isArray(payload?.recordList) ? payload.recordList : [], - createdAt: now, - lastAccessedAt: now - }) - pruneChatHistoryPayloadStore() - createChatHistoryPayloadWindow(payloadId) - return true - }) - - ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => { - pruneChatHistoryPayloadStore() - const normalizedPayloadId = String(payloadId || '').trim() - const payload = chatHistoryPayloadStore.get(normalizedPayloadId) - if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } - const nextPayload: ChatHistoryPayloadEntry = { - ...payload, - lastAccessedAt: Date.now() - } - chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload) - return { - success: true, - payload: { - sessionId: nextPayload.sessionId, - title: nextPayload.title, - recordList: nextPayload.recordList - } - } - }) - - // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) - ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { - const win = createSessionChatWindow(sessionId, options) - return Boolean(win) - }) - - // 根据视频尺寸调整窗口大小 - ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (!win || !videoWidth || !videoHeight) return - - const { screen } = require('electron') - const primaryDisplay = screen.getPrimaryDisplay() - const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize - - // 只有标题栏 40px,控制栏悬浮在视频上 - const titleBarHeight = 40 - const aspectRatio = videoWidth / videoHeight - - const maxWidth = Math.floor(screenWidth * 0.85) - const maxHeight = Math.floor(screenHeight * 0.85) - - let winWidth: number - let winHeight: number - - if (aspectRatio >= 1) { - // 横向视频 - 以宽度为基准 - winWidth = Math.min(videoWidth, maxWidth) - winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight - - if (winHeight > maxHeight) { - winHeight = maxHeight - winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) - } - } else { - // 竖向视频 - 以高度为基准 - const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) - winHeight = videoDisplayHeight + titleBarHeight - winWidth = Math.floor(videoDisplayHeight * aspectRatio) - - // 确保宽度不会太窄 - if (winWidth < 300) { - winWidth = 300 - winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight - } - } - - winWidth = Math.max(winWidth, 360) - winHeight = Math.max(winHeight, 280) - - // 调整窗口大小并居中 - win.setSize(winWidth, winHeight) - win.center() - }) - - // 视频相关 - ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => { - try { - const result = await videoService.getVideoInfo(videoMd5, options) - return { success: true, ...result } - } catch (e) { - return { success: false, error: String(e), exists: false } - } - }) - - ipcMain.handle('video:parseVideoMd5', async (_, content: string) => { - try { - const md5 = videoService.parseVideoMd5(content) - return { success: true, md5 } - } catch (e) { - return { success: false, error: String(e) } - } - }) - - // 数据库路径相关 - ipcMain.handle('dbpath:autoDetect', async () => { - return dbPathService.autoDetect() - }) - - ipcMain.handle('dbpath:scanWxids', async (_, rootPath: string) => { - return dbPathService.scanWxids(rootPath) - }) - - ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => { - return dbPathService.scanWxidCandidates(rootPath) - }) - - ipcMain.handle('dbpath:getDefault', async () => { - return dbPathService.getDefaultPath() - }) - - // WCDB 数据库相关 - ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => { - const cfg = configService || new ConfigService() - const accountDir = cfg.getAccountDir(dbPath, wxid) - if (!accountDir) { - return { success: false, error: '未找到账号目录' } - } - return wcdbService.testConnection(accountDir, hexKey) - }) - - ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => { - const cfg = configService || new ConfigService() - const accountDir = cfg.getAccountDir(dbPath, wxid) - if (!accountDir) { - return false - } - return wcdbService.open(accountDir, hexKey) - }) - - ipcMain.handle('wcdb:close', async () => { - wcdbService.close() - return true - }) - - ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => { - return backupService.createBackup(payload.outputPath, payload.options) - }) - - ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => { - return backupService.inspectBackup(payload.archivePath) - }) - - ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => { - return backupService.restoreBackup(payload.archivePath) - }) - - - - // 聊天相关 - ipcMain.handle('chat:connect', async () => { - return chatService.connect() - }) - - ipcMain.handle('chat:getSessions', async () => { - return chatService.getSessions() - }) - - ipcMain.handle('chat:markAllSessionsRead', async () => { - return chatService.markAllSessionsRead() - }) - - ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => { - return chatService.getSessionStatuses(usernames) - }) - - ipcMain.handle('chat:getExportTabCounts', async () => { - return chatService.getExportTabCounts() - }) - - ipcMain.handle('chat:getContactTypeCounts', async () => { - return chatService.getContactTypeCounts() - }) - - ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => { - return chatService.getSessionMessageCounts(sessionIds, options) - }) - - ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { - skipDisplayName?: boolean - onlyMissingAvatar?: boolean - }) => { - return chatService.enrichSessionsContactInfo(usernames, options) - }) - - ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { - return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending) - }) - - ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { - return chatService.getLatestMessages(sessionId, limit) - }) - - ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => { - return chatService.getNewMessages(sessionId, minTime, limit) - }) - - ipcMain.handle('chat:getAntiRevokeSessions', async () => { - return chatService.getAntiRevokeSessions() - }) - - ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => { - return chatService.updateMessage(sessionId, localId, createTime, newContent) - }) - - ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => { - return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint) - }) - - ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => { - return chatService.checkAntiRevokeTriggers(sessionIds) - }) - - ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => { - return chatService.installAntiRevokeTriggers(sessionIds) - }) - - ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => { - return chatService.uninstallAntiRevokeTriggers(sessionIds) - }) - - ipcMain.handle('chat:getContact', async (_, username: string) => { - return await chatService.getContact(username) - }) - - - ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { - return await chatService.getContactAvatar(username) - }) - - ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => { - return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername) - }) - - ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => { - return await chatService.getContacts(options) - }) - - ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { - return chatService.getCachedSessionMessages(sessionId) - }) - - 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:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => { - const cfg = configService - if (!cfg) return { success: false, error: '配置服务未初始化' } - - const clearCache = options?.clearCache === true - const clearExports = options?.clearExports === true - if (!clearCache && !clearExports) { - return { success: false, error: '请至少选择一项清理范围' } - } - - const rawWxid = String(cfg.getMyWxidCleaned() || '').trim() - if (!rawWxid) { - return { success: false, error: '当前账号未登录或未识别,无法清理' } - } - const normalizedWxid = normalizeAccountId(rawWxid) - const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean))) - const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates) - const removedPaths: string[] = [] - const warnings: string[] = [] - - try { - wcdbService.close() - chatService.close() - } catch (error) { - warnings.push(`关闭数据库连接失败: ${String(error)}`) - } - - if (clearCache) { - const [analyticsResult, imageResult] = await Promise.all([ - analyticsService.clearCache(), - imageDecryptService.clearCache() - ]) - const chatResult = chatService.clearCaches() - const cleanupResults = [analyticsResult, imageResult, chatResult] - for (const result of cleanupResults) { - if (!result.success && result.error) warnings.push(result.error) - } - - const configuredCachePath = String(cfg.get('cachePath') || '').trim() - const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') - const userDataCacheDir = join(app.getPath('userData'), 'cache') - const cacheRootCandidates = [ - configuredCachePath, - join(documentsWeFlowDir, 'Images'), - join(documentsWeFlowDir, 'Voices'), - join(documentsWeFlowDir, 'Emojis'), - userDataCacheDir - ].filter(Boolean) - - for (const wxid of wxidCandidates) { - if (configuredCachePath) { - await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings) - await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings) - await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings) - await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings) - } - await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings) - await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings) - await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings) - await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings) - } - - for (const cacheRoot of cacheRootCandidates) { - await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings) - } - } - - if (clearExports) { - const configuredExportPath = String(cfg.get('exportPath') || '').trim() - const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') - const exportRootCandidates = [ - configuredExportPath, - join(documentsWeFlowDir, 'exports'), - join(documentsWeFlowDir, 'Exports') - ].filter(Boolean) - - for (const exportRoot of exportRootCandidates) { - await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings) - } - - const resetConfigKeys = [ - 'exportSessionRecordMap', - 'exportLastSessionRunMap', - 'exportLastContentRunMap', - 'exportSessionMessageCountCacheMap', - 'exportSessionContentMetricCacheMap', - 'exportSnsStatsCacheMap', - 'snsPageCacheMap', - 'contactsListCacheMap', - 'contactsAvatarCacheMap', - 'lastSession' - ] - for (const key of resetConfigKeys) { - const defaultValue = key === 'lastSession' ? '' : {} - cfg.set(key as any, defaultValue as any) - } - - try { - const dbPath = String(cfg.get('dbPath') || '').trim() - const automationMapRaw = cfg.get('exportAutomationTaskMap') as Record | undefined - if (automationMapRaw && typeof automationMapRaw === 'object') { - const nextAutomationMap: Record = { ...automationMapRaw } - let changed = false - for (const scopeKey of Object.keys(automationMapRaw)) { - const normalizedScopeKey = String(scopeKey || '').trim() - if (!normalizedScopeKey) continue - const separatorIndex = normalizedScopeKey.lastIndexOf('::') - const scopedDbPath = separatorIndex >= 0 - ? normalizedScopeKey.slice(0, separatorIndex) - : '' - const scopedWxidRaw = separatorIndex >= 0 - ? normalizedScopeKey.slice(separatorIndex + 2) - : normalizedScopeKey - const scopedWxid = normalizeAccountId(scopedWxidRaw) - const wxidMatched = wxidCandidates.includes(scopedWxidRaw) || scopedWxid === normalizedWxid - const dbPathMatched = !dbPath || !scopedDbPath || scopedDbPath === dbPath - if (!wxidMatched || !dbPathMatched) continue - delete nextAutomationMap[scopeKey] - changed = true - } - if (changed) { - cfg.set('exportAutomationTaskMap' as any, nextAutomationMap as any) - } else if (!Object.keys(automationMapRaw).length) { - cfg.set('exportAutomationTaskMap' as any, {} as any) - } - } - } catch (error) { - warnings.push(`清理自动化导出任务失败: ${String(error)}`) - } - } - - if (clearCache) { - try { - const wxidConfigsRaw = cfg.get('wxidConfigs') as Record | undefined - if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') { - const nextConfigs: Record = { ...wxidConfigsRaw } - for (const key of Object.keys(nextConfigs)) { - if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) { - delete nextConfigs[key] - } - } - cfg.set('wxidConfigs' as any, nextConfigs as any) - } - cfg.set('myWxid' as any, '') - cfg.set('decryptKey' as any, '') - cfg.set('imageXorKey' as any, 0) - cfg.set('imageAesKey' as any, '') - cfg.set('dbPath' as any, '') - cfg.set('lastOpenedDb' as any, '') - cfg.set('onboardingDone' as any, false) - cfg.set('lastSession' as any, '') - } catch (error) { - warnings.push(`清理账号配置失败: ${String(error)}`) - } - } - - return { - success: true, - removedPaths, - warning: warnings.length > 0 ? warnings.join('; ') : undefined - } - }) - - ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { - return chatService.getSessionDetail(sessionId) - }) - - ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { - return chatService.getSessionDetailFast(sessionId) - }) - - ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { - return chatService.getSessionDetailExtra(sessionId) - }) - - ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: { - includeRelations?: boolean - forceRefresh?: boolean - allowStaleCache?: boolean - preferAccurateSpecialTypes?: boolean - cacheOnly?: boolean - beginTimestamp?: number - endTimestamp?: number - }) => { - return chatService.getExportSessionStats(sessionIds, options) - }) - - ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => { - return chatService.getGroupMyMessageCountHint(chatroomId) - }) - - ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { - return chatService.getImageData(sessionId, msgId) - }) - - ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => { - return chatService.getVoiceData(sessionId, msgId, createTime, serverId) - }) - ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { - return chatService.getAllVoiceMessages(sessionId) - }) - ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => { - return chatService.getAllImageMessages(sessionId) - }) - ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { - return chatService.getMessageDates(sessionId) - }) - ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { - return chatService.getMessageDateCounts(sessionId) - }) - - ipcMain.handle('chat:getResourceMessages', async (_, options?: { - sessionId?: string - types?: Array<'image' | 'video' | 'voice' | 'file'> - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }) => { - return chatService.getResourceMessages(options) - }) - - ipcMain.handle('chat:getMediaStream', async (_, options?: { - sessionId?: string - mediaType?: 'image' | 'video' | 'all' - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }) => { - return wcdbService.getMediaStream(options) - }) - ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { - return chatService.resolveVoiceCache(sessionId, msgId) - }) - - ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { - return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { - event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text }) - }) - }) - - ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => { - return chatService.getMessageById(sessionId, localId) - }) - - ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => { - return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) - }) - - ipcMain.handle('chat:getMyFootprintStats', async (_, beginTimestamp: number, endTimestamp: number, options?: { - myWxid?: string - privateSessionIds?: string[] - groupSessionIds?: string[] - mentionLimit?: number - privateLimit?: number - mentionMode?: 'text_at_me' | string - }) => { - return chatService.getMyFootprintStats(beginTimestamp, endTimestamp, options) - }) - - ipcMain.handle('chat:exportMyFootprint', async (_, beginTimestamp: number, endTimestamp: number, format: 'csv' | 'json', filePath: string) => { - return chatService.exportMyFootprint(beginTimestamp, endTimestamp, format, filePath) - }) - - ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { - return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) - }) - - ipcMain.handle('sns:getSnsUsernames', async () => { - return snsService.getSnsUsernames() - }) - - ipcMain.handle('sns:getUserPostCounts', async () => { - return snsService.getUserPostCounts() - }) - - ipcMain.handle('sns:getExportStats', async () => { - return snsService.getExportStats() - }) - - ipcMain.handle('sns:getExportStatsFast', async () => { - return snsService.getExportStatsFast() - }) - - ipcMain.handle('sns:getUserPostStats', async (_, username: string) => { - return snsService.getUserPostStats(username) - }) - - ipcMain.handle('sns:debugResource', async (_, url: string) => { - return snsService.debugResource(url) - }) - - ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => { - const url = typeof payload === 'string' ? payload : payload?.url - const key = typeof payload === 'string' ? undefined : payload?.key - return snsService.proxyImage(url, key) - }) - - ipcMain.handle('sns:downloadImage', async (_, payload: { url: string; key?: string | number }) => { - try { - const { url, key } = payload - const result = await snsService.downloadImage(url, key) - - if (!result.success || !result.data) { - return { success: false, error: result.error || '下载图片失败' } - } - - const { dialog } = await import('electron') - const ext = (result.contentType || '').split('/')[1] || 'jpg' - const defaultPath = `SNS_${Date.now()}.${ext}` - - - const filters = isVideoUrl(url) - ? [{ name: 'Videos', extensions: ['mp4', 'mov', 'avi', 'mkv'] }] - : [{ name: 'Images', extensions: [ext, 'jpg', 'jpeg', 'png', 'webp', 'gif'] }] - - const { filePath, canceled } = await dialog.showSaveDialog({ - defaultPath, - filters - }) - - if (canceled || !filePath) { - return { success: false, error: '用户已取消' } - } - - const fs = await import('fs/promises') - await fs.writeFile(filePath, result.data) - - return { success: true, filePath } - } catch (e) { - return { success: false, error: String(e) } - } - }) - - ipcMain.handle('sns:exportTimeline', async (event, options: any) => { - const exportOptions = { ...(options || {}) } - const taskId = normalizeExportTaskId(exportOptions.taskId) - delete exportOptions.taskId - const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined - if (taskId) activeExportTasks.add(taskId) - - try { - const result = await snsService.exportTimeline( - exportOptions, - (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) - } - }, - taskControl - ) - return finalizeExportTaskControlResult(taskId, result) - } finally { - if (taskId) activeExportTasks.delete(taskId) - } - }) - - ipcMain.handle('sns:selectExportDir', async () => { - const { dialog } = await import('electron') - const result = await dialog.showOpenDialog({ - properties: ['openDirectory', 'createDirectory'], - title: '选择导出目录' - }) - if (result.canceled || !result.filePaths?.[0]) { - return { canceled: true } - } - return { canceled: false, filePath: result.filePaths[0] } - }) - - ipcMain.handle('sns:installBlockDeleteTrigger', async () => { - return snsService.installSnsBlockDeleteTrigger() - }) - - ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => { - return snsService.uninstallSnsBlockDeleteTrigger() - }) - - ipcMain.handle('sns:checkBlockDeleteTrigger', async () => { - return snsService.checkSnsBlockDeleteTrigger() - }) - - ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => { - return snsService.deleteSnsPost(postId) - }) - - ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => { - return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey) - }) - - ipcMain.handle('sns:getCacheMigrationStatus', async () => { - try { - const plan = await collectLegacySnsCacheMigrationPlan() - if (!plan) { - return { - success: true, - needed: false, - inProgress: snsCacheMigrationInProgress, - totalFiles: 0, - items: [] - } - } - return { - success: true, - needed: true, - inProgress: snsCacheMigrationInProgress, - totalFiles: plan.totalFiles, - legacyBaseDir: plan.legacyBaseDir, - currentBaseDir: plan.currentBaseDir, - items: plan.candidates - } - } catch (error) { - return { success: false, needed: false, error: String((error as Error)?.message || error || '') } - } - }) - - ipcMain.handle('sns:startCacheMigration', async (event) => { - if (snsCacheMigrationInProgress) { - return { success: false, error: '迁移任务正在进行中' } - } - - const sender = event.sender - let lastProgress: SnsCacheMigrationProgressPayload = { - status: 'running', - phase: 'copying', - current: 0, - total: 0, - copied: 0, - skipped: 0, - remaining: 0 - } - const emitProgress = (payload: SnsCacheMigrationProgressPayload) => { - lastProgress = payload - if (!sender.isDestroyed()) { - sender.send('sns:cacheMigrationProgress', payload) - } - } - - try { - const plan = await collectLegacySnsCacheMigrationPlan() - if (!plan) { - emitProgress({ - status: 'done', - phase: 'done', - current: 0, - total: 0, - copied: 0, - skipped: 0, - remaining: 0, - message: '无需迁移' - }) - return { success: true, copied: 0, skipped: 0, totalFiles: 0, message: '无需迁移' } - } - - snsCacheMigrationInProgress = true - const result = await runLegacySnsCacheMigration(plan, emitProgress) - return { success: true, ...result } - } catch (error) { - const message = String((error as Error)?.message || error || '') - emitProgress({ - ...lastProgress, - status: 'error', - phase: 'error', - message - }) - return { success: false, error: message } - } finally { - snsCacheMigrationInProgress = false - } - }) - - // 私聊克隆 - - - ipcMain.handle('image:decrypt', async (_, payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - force?: boolean - preferFilePath?: boolean - hardlinkOnly?: boolean - disableUpdateCheck?: boolean - allowCacheIndex?: boolean - suppressEvents?: boolean - }) => { - return imageDecryptService.decryptImage(payload) - }) - ipcMain.handle('image:resolveCache', async (_, payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - hardlinkOnly?: boolean - disableUpdateCheck?: boolean - allowCacheIndex?: boolean - suppressEvents?: boolean - }) => { - return imageDecryptService.resolveCachedImage(payload) - }) - ipcMain.handle( - 'image:resolveCacheBatch', - async ( - _, - payloads: Array<{ - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - hardlinkOnly?: boolean - suppressEvents?: boolean - }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean } - ) => { - const list = Array.isArray(payloads) ? payloads : [] - if (list.length === 0) return { success: true, rows: [] } - - const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10) - const maxConcurrent = Number.isFinite(maxConcurrentRaw) - ? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48)) - : 10 - const workerCount = Math.min(maxConcurrent, list.length) - - const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length) - let cursor = 0 - const dedupe = new Map>() - - const makeDedupeKey = (payload: typeof list[number]): string => { - const sessionId = String(payload.sessionId || '').trim().toLowerCase() - const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase() - const imageDatName = String(payload.imageDatName || '').trim().toLowerCase() - const createTime = Number(payload.createTime || 0) || 0 - const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true - const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true - const allowCacheIndex = options?.allowCacheIndex !== false - const disableUpdateCheck = options?.disableUpdateCheck === true - const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true - return [ - sessionId, - imageMd5, - imageDatName, - String(createTime), - preferFilePath ? 'pf1' : 'pf0', - hardlinkOnly ? 'hl1' : 'hl0', - allowCacheIndex ? 'ci1' : 'ci0', - disableUpdateCheck ? 'du1' : 'du0', - suppressEvents ? 'se1' : 'se0' - ].join('|') - } - - const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({ - ...payload, - preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true, - hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true, - disableUpdateCheck: options?.disableUpdateCheck === true, - allowCacheIndex: options?.allowCacheIndex !== false, - suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true - }) - - const worker = async () => { - while (true) { - const index = cursor - cursor += 1 - if (index >= list.length) return - const payload = list[index] - const key = makeDedupeKey(payload) - const existing = dedupe.get(key) - if (existing) { - rows[index] = await existing - continue - } - const task = resolveOne(payload).catch((error) => ({ - success: false, - error: String(error) - })) - dedupe.set(key, task) - rows[index] = await task - } - } - - await Promise.all(Array.from({ length: workerCount }, () => worker())) - return { success: true, rows } - } - ) - ipcMain.handle( - 'image:preload', - async ( - _, - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, - options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } - ) => { - imagePreloadService.enqueue(payloads || [], options) - return true - }) - ipcMain.handle( - 'image:preloadHardlinkMd5s', - async (_, md5List?: string[]) => { - await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : []) - return true - } - ) - - // Windows Hello - ipcMain.handle('auth:hello', async (event, message?: string) => { - // 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致 - // 如果主窗口不存在(极其罕见),则回退到调用者窗口 - const targetWin = (mainWindow && !mainWindow.isDestroyed()) - ? mainWindow - : (BrowserWindow.fromWebContents(event.sender) || undefined) - - const result = await windowsHelloService.verify(message, targetWin) - - // Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥 - if (result && configService) { - const secret = configService.getHelloSecret() - if (secret && configService.isLockMode()) { - configService.unlock(secret) - } - } - - return result - }) - - // 验证应用锁状态(检测 lock: 前缀,防篡改) - ipcMain.handle('auth:verifyEnabled', async () => { - return configService?.verifyAuthEnabled() ?? false - }) - - // 密码解锁(验证 + 解密密钥到内存) - ipcMain.handle('auth:unlock', async (_event, password: string) => { - if (!configService) return { success: false, error: '配置服务未初始化' } - return configService.unlock(password) - }) - - // 开启应用锁 - ipcMain.handle('auth:enableLock', async (_event, password: string) => { - if (!configService) return { success: false, error: '配置服务未初始化' } - return configService.enableLock(password) - }) - - // 关闭应用锁 - ipcMain.handle('auth:disableLock', async (_event, password: string) => { - if (!configService) return { success: false, error: '配置服务未初始化' } - return configService.disableLock(password) - }) - - // 修改密码 - ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => { - if (!configService) return { success: false, error: '配置服务未初始化' } - return configService.changePassword(oldPassword, newPassword) - }) - - // 设置 Hello Secret - ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => { - if (!configService) return { success: false } - configService.setHelloSecret(password) - return { success: true } - }) - - // 清除 Hello Secret - ipcMain.handle('auth:clearHelloSecret', async () => { - if (!configService) return { success: false } - configService.clearHelloSecret() - return { success: true } - }) - - // 检查是否处于 lock: 模式 - ipcMain.handle('auth:isLockMode', async () => { - return configService?.isLockMode() ?? false - }) - - // 导出相关 - ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { - return exportService.getExportStats(sessionIds, options) - }) - - ipcMain.handle('export:pauseTask', async (_, taskId: string) => { - const normalizedTaskId = normalizeExportTaskId(taskId) - if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } - const success = exportTaskControlService.pauseTask(normalizedTaskId) - if (success) postExportWorkerControl(normalizedTaskId, 'pause') - return { success } - }) - - ipcMain.handle('export:resumeTask', async (_, taskId: string) => { - const normalizedTaskId = normalizeExportTaskId(taskId) - if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } - const success = exportTaskControlService.resumeTask(normalizedTaskId) - if (success) postExportWorkerControl(normalizedTaskId, 'resume') - return { success } - }) - - ipcMain.handle('export:cancelTask', async (_, taskId: string) => { - const normalizedTaskId = normalizeExportTaskId(taskId) - if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' } - const success = exportTaskControlService.cancelTask(normalizedTaskId) - if (success) postExportWorkerControl(normalizedTaskId, 'cancel') - if (success && !activeExportTasks.has(normalizedTaskId)) { - const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId) - return cleanup.success - ? { success: true, cleanup } - : { success: false, error: cleanup.error || '清理已导出文件失败' } - } - return { success } - }) - - ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => { - const taskId = normalizeExportTaskId(controlOptions?.taskId) - if (taskId) exportTaskControlService.createControl(taskId, outputDir) - if (taskId) activeExportTasks.add(taskId) - const PROGRESS_FORWARD_INTERVAL_MS = 180 - let pendingProgress: ExportProgress | null = null - let progressTimer: NodeJS.Timeout | null = null - let lastProgressSentAt = 0 - - const flushProgress = () => { - if (!pendingProgress) return - if (progressTimer) { - clearTimeout(progressTimer) - progressTimer = null - } - if (!event.sender.isDestroyed()) { - event.sender.send('export:progress', pendingProgress) - } - pendingProgress = null - lastProgressSentAt = Date.now() - } - - const queueProgress = (progress: ExportProgress) => { - pendingProgress = progress - const force = progress.phase === 'complete' - if (force) { - flushProgress() - return - } - - const now = Date.now() - const elapsed = now - lastProgressSentAt - if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) { - flushProgress() - return - } - - if (progressTimer) return - progressTimer = setTimeout(() => { - flushProgress() - }, PROGRESS_FORWARD_INTERVAL_MS - elapsed) - } - - const onProgress = (progress: ExportProgress) => { - queueProgress(progress) - } - - const cfg = configService || new ConfigService() - configService = cfg - const logEnabled = cfg.get('logEnabled') - const dbPath = String(cfg.get('dbPath') || '').trim() - const decryptKey = String(cfg.get('decryptKey') || '').trim() - const myWxid = String(cfg.getMyWxidCleaned() || '').trim() - const imageKeys = cfg.getImageKeysForCurrentWxid() - const resourcesPath = app.isPackaged - ? join(process.resourcesPath, 'resources') - : join(app.getAppPath(), 'resources') - const userDataPath = app.getPath('userData') - const workerPath = join(__dirname, 'exportWorker.js') - - const runWorker = async () => { - return await new Promise((resolve, reject) => { - const worker = new Worker(workerPath, { - workerData: { - sessionIds, - outputDir, - options, - taskId, - dbPath, - decryptKey, - myWxid, - imageXorKey: imageKeys.xorKey, - imageAesKey: imageKeys.aesKey, - resourcesPath, - userDataPath, - logEnabled, - isPackaged: app.isPackaged - } - }) - - let settled = false - if (taskId) { - activeExportWorkers.set(taskId, worker) - } - const finalizeResolve = (value: any) => { - if (settled) return - settled = true - if (taskId && activeExportWorkers.get(taskId) === worker) { - activeExportWorkers.delete(taskId) - } - worker.removeAllListeners() - void worker.terminate() - resolve(value) - } - const finalizeReject = (error: Error) => { - if (settled) return - settled = true - if (taskId && activeExportWorkers.get(taskId) === worker) { - activeExportWorkers.delete(taskId) - } - worker.removeAllListeners() - void worker.terminate() - reject(error) - } - - worker.on('message', (msg: any) => { - if (msg && msg.type === 'export:progress') { - onProgress(msg.data as ExportProgress) - return - } - if (msg && msg.type === 'export:createdFiles' && taskId) { - const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : [] - for (const filePath of filePaths) { - exportTaskControlService.recordCreatedFile(taskId, String(filePath || '')) - } - return - } - if (msg && msg.type === 'export:createdDirs' && taskId) { - const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : [] - for (const dirPath of dirPaths) { - exportTaskControlService.recordCreatedDir(taskId, String(dirPath || '')) - } - return - } - if (msg && msg.type === 'export:createdFile' && taskId) { - exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || '')) - return - } - if (msg && msg.type === 'export:createdDir' && taskId) { - exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || '')) - return - } - if (msg && msg.type === 'export:result') { - finalizeResolve(msg.data) - return - } - if (msg && msg.type === 'export:error') { - finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败'))) - } - }) - - worker.on('error', (error) => { - finalizeReject(error instanceof Error ? error : new Error(String(error))) - }) - - worker.on('exit', (code) => { - if (settled) return - if (code === 0) { - finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' }) - } else { - finalizeReject(new Error(`导出 Worker 异常退出: ${code}`)) - } - }) - }) - } - - try { - const result = await runWorker() - return await finalizeExportTaskControlResult(taskId, result) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`[export-worker] ${errorMessage}`) - const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : [] - const failedSessionErrors: Record = {} - for (const sessionId of normalizedSessionIds) { - failedSessionErrors[sessionId] = errorMessage - } - const result = { - success: false, - successCount: 0, - failCount: normalizedSessionIds.length, - failedSessionIds: normalizedSessionIds, - failedSessionErrors, - error: `导出 Worker 执行失败: ${errorMessage}` - } - return await finalizeExportTaskControlResult(taskId, result) - } finally { - if (taskId) activeExportTasks.delete(taskId) - flushProgress() - if (progressTimer) { - clearTimeout(progressTimer) - progressTimer = null - } - } - }) - - ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => { - const cfg = configService || new ConfigService() - configService = cfg - const imageKeys = cfg.getImageKeysForCurrentWxid() - const workerPath = join(__dirname, 'exportWorker.js') - - try { - return await new Promise((resolve) => { - const worker = new Worker(workerPath, { - workerData: { - mode: 'single', - sessionId, - outputPath, - options, - dbPath: String(cfg.get('dbPath') || '').trim(), - decryptKey: String(cfg.get('decryptKey') || '').trim(), - myWxid: String(cfg.getMyWxidCleaned() || '').trim(), - imageXorKey: imageKeys.xorKey, - imageAesKey: imageKeys.aesKey, - resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), - userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled'), - isPackaged: app.isPackaged - } - }) - - let settled = false - const finalize = (value: any) => { - if (settled) return - settled = true - worker.removeAllListeners() - void worker.terminate() - resolve(value) - } - const fail = (error: unknown) => { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`[export-worker-single] ${errorMessage}`) - finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` }) - } - - worker.on('message', (msg: any) => { - if (msg && msg.type === 'export:progress') { - if (!event.sender.isDestroyed()) { - event.sender.send('export:progress', msg.data) - } - return - } - if (msg && msg.type === 'export:result') { - finalize(msg.data) - return - } - if (msg && msg.type === 'export:error') { - fail(String(msg.error || '导出 Worker 执行失败')) - } - }) - worker.on('error', fail) - worker.on('exit', (code) => { - if (settled) return - if (code === 0) { - finalize({ success: false, error: '导出 Worker 未返回结果' }) - } else { - fail(`导出 Worker 异常退出: ${code}`) - } - }) - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`[export-worker-single] ${errorMessage}`) - return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` } - } - }) - - ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => { - const cfg = configService || new ConfigService() - configService = cfg - const workerPath = join(__dirname, 'exportWorker.js') - - try { - return await new Promise((resolve) => { - const worker = new Worker(workerPath, { - workerData: { - mode: 'contacts', - outputDir, - options, - dbPath: String(cfg.get('dbPath') || '').trim(), - decryptKey: String(cfg.get('decryptKey') || '').trim(), - myWxid: String(cfg.getMyWxidCleaned() || '').trim(), - resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), - userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled'), - isPackaged: app.isPackaged - } - }) - - let settled = false - const finalize = (value: any) => { - if (settled) return - settled = true - worker.removeAllListeners() - void worker.terminate() - resolve(value) - } - const fail = (error: unknown) => { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`[export-worker-contacts] ${errorMessage}`) - finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` }) - } - - worker.on('message', (msg: any) => { - if (msg && msg.type === 'export:result') { - finalize(msg.data) - return - } - if (msg && msg.type === 'export:error') { - fail(String(msg.error || '导出 Worker 执行失败')) - } - }) - worker.on('error', fail) - worker.on('exit', (code) => { - if (settled) return - if (code === 0) { - finalize({ success: false, error: '导出 Worker 未返回结果' }) - } else { - fail(`导出 Worker 异常退出: ${code}`) - } - }) - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`[export-worker-contacts] ${errorMessage}`) - return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` } - } - }) - - // 数据分析相关 - ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => { - return analyticsService.getOverallStatistics(force) - }) - - ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => { - return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp) - }) - - ipcMain.handle('analytics:getTimeDistribution', async () => { - return analyticsService.getTimeDistribution() - }) - - ipcMain.handle('analytics:getSelfSentDailyDistribution', async (_, beginTimestamp?: number, endTimestamp?: number, force?: boolean) => { - return analyticsService.getSelfSentDailyDistribution(beginTimestamp, endTimestamp, force) - }) - - ipcMain.handle('analytics:getExcludedUsernames', async () => { - return analyticsService.getExcludedUsernames() - }) - - ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => { - return analyticsService.setExcludedUsernames(usernames) - }) - - ipcMain.handle('analytics:getExcludeCandidates', async () => { - return analyticsService.getExcludeCandidates() - }) - - // 缓存管理 - ipcMain.handle('cache:clearAnalytics', async () => { - return analyticsService.clearCache() - }) - - ipcMain.handle('cache:clearImages', async () => { - const imageResult = await imageDecryptService.clearCache() - const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true }) - snsService.clearMemoryCache() - const errors = [imageResult, emojiResult] - .filter((result) => !result.success) - .map((result) => result.error) - .filter(Boolean) as string[] - if (errors.length > 0) { - return { success: false, error: errors.join('; ') } - } - return { success: true } - }) - - ipcMain.handle('cache:clearAll', async () => { - const [analyticsResult, imageResult] = await Promise.all([ - analyticsService.clearCache(), - imageDecryptService.clearCache() - ]) - const chatResult = chatService.clearCaches() - snsService.clearMemoryCache() - const errors = [analyticsResult, imageResult, chatResult] - .filter((result) => !result.success) - .map((result) => result.error) - .filter(Boolean) as string[] - if (errors.length > 0) { - return { success: false, error: errors.join('; ') } - } - return { success: true } - }) - - ipcMain.handle('whisper:downloadModel', async (event) => { - return voiceTranscribeService.downloadModel((progress) => { - event.sender.send('whisper:downloadProgress', progress) - }) - }) - - ipcMain.handle('whisper:getModelStatus', async () => { - return voiceTranscribeService.getModelStatus() - }) - - // 群聊分析相关 - ipcMain.handle('groupAnalytics:getGroupChats', async () => { - return groupAnalyticsService.getGroupChats() - }) - - ipcMain.handle('groupAnalytics:getGroupMembers', async (_, chatroomId: string) => { - return groupAnalyticsService.getGroupMembers(chatroomId) - }) - - ipcMain.handle( - 'groupAnalytics:getGroupMembersPanelData', - async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => { - const normalizedOptions = typeof options === 'boolean' - ? { forceRefresh: options } - : options - return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions) - } - ) - - 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( - 'groupAnalytics:getGroupMemberAnalytics', - async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => { - return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime) - } - ) - - ipcMain.handle( - 'groupAnalytics:getGroupMemberMessages', - async ( - _, - chatroomId: string, - memberUsername: string, - options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } - ) => { - return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options) - } - ) - - ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { - return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) - }) - - ipcMain.handle( - 'groupAnalytics:exportGroupMemberMessages', - async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => { - return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime) - } - ) - - // 打开协议窗口 - ipcMain.handle('window:openAgreementWindow', async () => { - createAgreementWindow() - return true - }) - - // 打开图片查看窗口 - ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => { - // 如果是 dataUrl,写入临时文件 - if (imagePath.startsWith('data:')) { - const commaIdx = imagePath.indexOf(',') - const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64" - const ext = meta.split('/')[1]?.split(';')[0] || 'jpg' - const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`) - await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64')) - createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath) - } else { - createImageViewerWindow(imagePath, liveVideoPath) - } - }) - - // 完成引导,关闭引导窗口并显示主窗口 - 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 (_, options?: { mode?: 'add-account' }) => { - shouldShowMain = false - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.hide() - } - const mode = options?.mode === 'add-account' ? 'add-account' : 'default' - createOnboardingWindow(mode) - 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.getMyWxidCleaned() - }) - }) - - ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => { - const cfg = configService || new ConfigService() - configService = cfg - - const dbPath = cfg.get('dbPath') - const decryptKey = cfg.get('decryptKey') - const wxid = cfg.get('myWxid') - const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid) - - const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey) - if (runningTaskId) { - const runningTask = annualReportYearsLoadTasks.get(runningTaskId) - if (runningTask && !runningTask.done) { - return { - success: true, - taskId: runningTaskId, - reused: true, - snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot) - } - } - annualReportYearsTaskByCacheKey.delete(cacheKey) - } - - const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey) - if (cachedSnapshot && cachedSnapshot.snapshot.done) { - return { - success: true, - taskId: cachedSnapshot.taskId, - reused: true, - snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot) - } - } - - const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done - ? { - ...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot), - done: false, - canceled: false, - error: undefined - } - : { - years: [], - done: false, - strategy: 'native', - phase: 'native', - statusText: '准备使用原生快速模式加载年份...', - nativeElapsedMs: 0, - scanElapsedMs: 0, - totalElapsedMs: 0, - switched: false, - nativeTimedOut: false - } - - const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => { - const task = annualReportYearsLoadTasks.get(taskId) - if (!task) return null - - const hasPayloadYears = Array.isArray(payload.years) - const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0)) - ? [...(payload.years || [])] - : Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : [] - - const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({ - ...task.snapshot, - ...payload, - years: nextYears - }) - task.snapshot = nextSnapshot - task.done = nextSnapshot.done === true - task.updatedAt = Date.now() - annualReportYearsLoadTasks.set(taskId, task) - persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot) - return nextSnapshot - } - - annualReportYearsLoadTasks.set(taskId, { - cacheKey, - canceled: false, - done: false, - snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot), - updatedAt: Date.now() - }) - annualReportYearsTaskByCacheKey.set(cacheKey, taskId) - persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot) - - void (async () => { - try { - const result = await annualReportService.getAvailableYears({ - dbPath, - decryptKey, - wxid, - onProgress: (progress) => { - if (isYearsLoadCanceled(taskId)) return - const snapshot = updateTaskSnapshot({ - ...progress, - done: false - }) - if (!snapshot) return - broadcastAnnualReportYearsProgress(taskId, snapshot) - }, - shouldCancel: () => isYearsLoadCanceled(taskId) - }) - - const canceled = isYearsLoadCanceled(taskId) - if (canceled) { - const snapshot = updateTaskSnapshot({ - done: true, - canceled: true, - phase: 'done', - statusText: '已取消年份加载' - }) - if (snapshot) { - broadcastAnnualReportYearsProgress(taskId, snapshot) - } - return - } - - const completionPayload: AnnualReportYearsProgressPayload = result.success - ? { - years: result.data || [], - done: true, - strategy: result.meta?.strategy, - phase: 'done', - statusText: result.meta?.statusText || '年份数据加载完成', - nativeElapsedMs: result.meta?.nativeElapsedMs, - scanElapsedMs: result.meta?.scanElapsedMs, - totalElapsedMs: result.meta?.totalElapsedMs, - switched: result.meta?.switched, - nativeTimedOut: result.meta?.nativeTimedOut - } - : { - years: result.data || [], - done: true, - error: result.error || '加载年度数据失败', - strategy: result.meta?.strategy, - phase: 'done', - statusText: result.meta?.statusText || '年份数据加载失败', - nativeElapsedMs: result.meta?.nativeElapsedMs, - scanElapsedMs: result.meta?.scanElapsedMs, - totalElapsedMs: result.meta?.totalElapsedMs, - switched: result.meta?.switched, - nativeTimedOut: result.meta?.nativeTimedOut - } - - const snapshot = updateTaskSnapshot(completionPayload) - if (snapshot) { - broadcastAnnualReportYearsProgress(taskId, snapshot) - } - } catch (e) { - const snapshot = updateTaskSnapshot({ - done: true, - error: String(e), - phase: 'done', - statusText: '年份数据加载失败', - strategy: 'hybrid' - }) - if (snapshot) { - broadcastAnnualReportYearsProgress(taskId, snapshot) - } - } finally { - const task = annualReportYearsLoadTasks.get(taskId) - if (task) { - annualReportYearsTaskByCacheKey.delete(task.cacheKey) - } - annualReportYearsLoadTasks.delete(taskId) - } - })() - - return { - success: true, - taskId, - reused: false, - snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot) - } - }) - - ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => { - const key = String(taskId || '').trim() - if (!key) return { success: false, error: '任务ID不能为空' } - const task = annualReportYearsLoadTasks.get(key) - if (!task) return { success: true } - task.canceled = true - annualReportYearsLoadTasks.set(key, task) - return { success: true } - }) - - 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.getMyWxidCleaned() - 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('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => { - const cfg = configService || new ConfigService() - configService = cfg - - const dbPath = cfg.get('dbPath') - const decryptKey = cfg.get('decryptKey') - const wxid = cfg.getMyWxidCleaned() - const logEnabled = cfg.get('logEnabled') - const friendUsername = payload?.friendUsername - const year = payload?.year ?? 0 - const excludeWords = cfg.get('wordCloudExcludeWords') || [] - - if (!friendUsername) { - return { success: false, error: '缺少好友用户名' } - } - - const resourcesPath = app.isPackaged - ? join(process.resourcesPath, 'resources') - : join(app.getAppPath(), 'resources') - const userDataPath = app.getPath('userData') - - const workerPath = join(__dirname, 'dualReportWorker.js') - - return await new Promise((resolve) => { - const worker = new Worker(workerPath, { - workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled, excludeWords } - }) - - const cleanup = () => { - worker.removeAllListeners() - } - - worker.on('message', (msg: any) => { - if (msg && msg.type === 'dualReport:progress') { - for (const win of BrowserWindow.getAllWindows()) { - if (!win.isDestroyed()) { - win.webContents.send('dualReport:progress', msg.data) - } - } - return - } - if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) { - cleanup() - void worker.terminate() - resolve(msg.data ?? msg.result) - return - } - if (msg && (msg.type === 'dualReport: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('annualReport:captureCurrentWindow', async (event) => { - try { - const win = BrowserWindow.fromWebContents(event.sender) - if (!win || win.isDestroyed()) { - return { success: false, error: '窗口不可用' } - } - - const image = await win.webContents.capturePage() - return { - success: true, - dataUrl: image.toDataURL(), - size: image.getSize() - } - } catch (e) { - return { success: false, error: String(e) } - } - }) - - // 密钥获取 - ipcMain.handle('key:autoGetDbKey', async (event) => { - return keyService.autoGetDbKey(180_000, (message: string, level: number) => { - event.sender.send('key:dbKeyStatus', { message, level }) - }) - }) - - ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => { - return keyService.autoGetImageKey(manualDir, (message: string) => { - event.sender.send('key:imageKeyStatus', { message }) - }, wxid) - }) - - ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => { - return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => { - event.sender.send('key:imageKeyStatus', { message }) - }) - }) - - // HTTP API 服务 - ipcMain.handle('http:start', async (_, port?: number, host?: string) => { - const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1' - return httpService.start(port || 5031, bindHost) - }) - - ipcMain.handle('http:stop', async () => { - await httpService.stop() - return { success: true } - }) - - ipcMain.handle('http:status', async () => { - return { - running: httpService.isRunning(), - port: httpService.getPort(), - mediaExportPath: httpService.getDefaultMediaExportPath() - } - }) - - // 自动下载原图 - ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => { - return await imageDownloadService.startAutoDownload(whitelist || []) - }) - - ipcMain.handle('image:stopAutoDownload', async () => { - await imageDownloadService.stopAutoDownload() - return { success: true } - }) - - ipcMain.handle('image:getAutoDownloadStatus', async () => { - return await imageDownloadService.getStatus() - }) -} - -// 主窗口引用 -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 (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) { - // 检查该版本是否被用户忽略 - const ignoredVersion = configService?.get('ignoredUpdateVersion') - if (ignoredVersion === latestVersion) { - - return - } - - // 通知渲染进程有新版本 - mainWindow.webContents.send('app:updateAvailable', { - version: latestVersion, - releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes), - minimumVersion: (result.updateInfo as any).minimumVersion - }) - } - } - } catch (error) { - console.error('启动时检查更新失败:', error) - } - }, 3000) -} - -app.whenReady().then(async () => { - // 先初始化配置,以便在启动早期判定是否需要静默启动 - configService = new ConfigService() - applyAutoUpdateChannel('startup') - syncLaunchAtStartupPreference() - const onboardingDone = configService.get('onboardingDone') === true - const startInBackground = onboardingDone && isSilentStartupEnabled() - shouldShowMain = onboardingDone - - if (!startInBackground) { - // 非静默模式下显示 Splash,提供启动反馈 - createSplashWindow() - - // 等待 Splash 页面加载完成后再推送进度 - if (splashWindow) { - await new Promise((resolve) => { - if (splashWindow!.webContents.isLoading()) { - splashWindow!.webContents.once('did-finish-load', () => resolve()) - } else { - resolve() - } - }) - splashWindow.webContents - .executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`) - .catch(() => {}) - } - } - - const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const withTimeout = (task: () => Promise, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => { - return new Promise((resolve) => { - let settled = false - const timer = setTimeout(() => { - if (settled) return - settled = true - resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` }) - }, timeoutMs) - - task() - .then((value) => { - if (settled) return - settled = true - clearTimeout(timer) - resolve({ timedOut: false, value }) - }) - .catch((error) => { - if (settled) return - settled = true - clearTimeout(timer) - resolve({ timedOut: false, error: String(error) }) - }) - }) - } - - updateSplashProgress(5, '正在加载配置...') - - // 将用户主题配置推送给 Splash 窗口 - if (splashWindow && !splashWindow.isDestroyed()) { - const themeId = configService.get('themeId') || 'cloud-dancer' - const themeMode = configService.get('theme') || 'system' - splashWindow.webContents - .executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`) - .catch(() => {}) - } - await delay(200) - - // 设置资源路径 - updateSplashProgress(12, '正在初始化...') - const candidateResources = app.isPackaged - ? join(process.resourcesPath, 'resources') - : join(app.getAppPath(), 'resources') - const fallbackResources = join(process.cwd(), 'resources') - const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources - const userDataPath = app.getPath('userData') - await delay(200) - - // 初始化数据库服务 - updateSplashProgress(20, '正在初始化...') - wcdbService.setPaths(resourcesPath, userDataPath) - wcdbService.setLogEnabled(configService.get('logEnabled') === true) - await delay(200) - - // 注册 IPC 处理器 - updateSplashProgress(28, '正在初始化...') - registerIpcHandlers() - if (configService.get('autoDownloadHighRes')) { - const whitelistArr = configService.get('autoDownloadWhitelist') || [] - const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0) - ? (whitelistArr.join('\0') + '\0\0') - : '' - imageDownloadService.startAutoDownload(whitelistStr) - } - chatService.addDbMonitorListener((type, json) => { - messagePushService.handleDbMonitorChange(type, json) - insightService.handleDbMonitorChange(type, json) - }) - messagePushService.start() - insightService.start() - groupSummaryService.start() - await delay(200) - - // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等) - if (onboardingDone) { - updateSplashProgress(34, '正在连接数据库...') - const connectWarmup = await withTimeout(() => chatService.connect(), 12000) - const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true - - if (!connected) { - const reason = connectWarmup.timedOut - ? connectWarmup.error - : (connectWarmup.value?.error || connectWarmup.error || 'unknown') - console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason) - updateSplashProgress(68, '数据库预热已跳过') - } else { - const preloadUsernames = new Set() - - updateSplashProgress(44, '正在预加载会话...') - const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000) - if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) { - for (const session of sessionsWarmup.value.sessions) { - const username = String((session as any)?.username || '').trim() - if (username) preloadUsernames.add(username) - } - } - - updateSplashProgress(56, '正在预加载联系人...') - const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000) - if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) { - for (const contact of contactsWarmup.value.contacts) { - const username = String((contact as any)?.username || '').trim() - if (username) preloadUsernames.add(username) - } - } - - updateSplashProgress(63, '正在缓存联系人头像...') - const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000) - if (avatarWarmupUsernames.length > 0) { - await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000) - } - - updateSplashProgress(68, '正在初始化消息库索引...') - await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000) - } - } else { - updateSplashProgress(68, '首次启动准备中...') - } - - // 创建主窗口(不显示,由启动流程统一控制) - updateSplashProgress(70, '正在准备主窗口...') - ensureWeChatRequestHeaderInterceptor() - mainWindow = createWindow({ autoShow: false }) - - const resolvedTrayIcon = resolveAppIconPath() - - - try { - tray = new Tray(resolvedTrayIcon) - tray.setToolTip('WeFlow') - const contextMenu = Menu.buildFromTemplate([ - { - label: '显示主窗口', - click: () => { - if (mainWindow) { - mainWindow.show() - mainWindow.focus() - } - } - }, - { type: 'separator' }, - { - label: '退出', - click: () => { - isAppQuitting = true - app.quit() - } - } - ]) - tray.setContextMenu(contextMenu) - tray.on('click', () => { - if (mainWindow) { - if (mainWindow.isVisible()) { - mainWindow.focus() - } else { - mainWindow.show() - mainWindow.focus() - } - } - }) - tray.on('double-click', () => { - if (mainWindow) { - mainWindow.show() - mainWindow.focus() - } - }) - } catch (e) { - console.warn('[Tray] Failed to create tray icon:', e) - } - - // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) - updateSplashProgress(70, '正在准备主窗口...', true) - await new Promise((resolve) => { - if (mainWindowReady) { - resolve() - } else { - mainWindow!.once('ready-to-show', () => { - mainWindowReady = true - resolve() - }) - } - }) - - // 加载完成,收尾 - updateSplashProgress(100, '启动完成') - await new Promise((resolve) => setTimeout(resolve, 250)) - closeSplash() - - if (!onboardingDone) { - createOnboardingWindow() - } else if (startInBackground && tray) { - mainWindow?.hide() - } else { - mainWindow?.show() - } - - // 启动时检测更新(不阻塞启动) - checkForUpdatesOnStartup() - - await httpService.autoStart() - - app.on('activate', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible()) { - mainWindow.show() - } - mainWindow.focus() - return - } - - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow() - } - }) -}) - -const shutdownAppServices = async (): Promise => { - if (shutdownPromise) return shutdownPromise - shutdownPromise = (async () => { - isAppQuitting = true - // 销毁 tray 图标 - if (tray) { try { tray.destroy() } catch {} tray = null } - // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 - destroyNotificationWindow() - messagePushService.stop() - insightService.stop() - groupSummaryService.stop() - // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 - const forceExitTimer = setTimeout(() => { - console.warn('[App] Force exit after timeout') - app.exit(0) - }, 5000) - forceExitTimer.unref() - try { await cloudControlService.stop() } catch {} - // 停止自动下载服务 - try { await imageDownloadService.stopAutoDownload() } catch {} - // 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调 - try { chatService.close() } catch {} - // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 - try { await httpService.stop() } catch {} - // 终止 wcdb Worker 线程,避免线程阻止进程退出 - try { await wcdbService.shutdown() } catch {} - })() - return shutdownPromise -} - -app.on('before-quit', () => { - void shutdownAppServices() -}) - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) diff --git a/electron/nodert.d.ts b/electron/nodert.d.ts deleted file mode 100644 index 0c20623..0000000 --- a/electron/nodert.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare module '@nodert-win10-rs4/windows.security.credentials.ui' { - export enum UserConsentVerificationResult { - Verified = 0, - DeviceNotPresent = 1, - NotConfiguredForUser = 2, - DisabledByPolicy = 3, - DeviceBusy = 4, - RetriesExhausted = 5, - Canceled = 6 - } - - export enum UserConsentVerifierAvailability { - Available = 0, - DeviceNotPresent = 1, - NotConfiguredForUser = 2, - DisabledByPolicy = 3, - DeviceBusy = 4 - } - - export class UserConsentVerifier { - static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void; - static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void; - } -} diff --git a/electron/preload-env.ts b/electron/preload-env.ts deleted file mode 100644 index 514b5e6..0000000 --- a/electron/preload-env.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { join, dirname } from 'path' - -/** - * 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL - * 解决系统中存在冲突版本的数据服务导致的应用崩溃问题 - */ -function enforceLocalDllPriority() { - const isDev = !!process.env.VITE_DEV_SERVER_URL - const sep = process.platform === 'win32' ? ';' : ':' - - let possiblePaths: string[] = [] - - if (isDev) { - // 开发环境 - possiblePaths.push(join(process.cwd(), 'resources')) - } else { - // 生产环境 - possiblePaths.push(dirname(process.execPath)) - if (process.resourcesPath) { - possiblePaths.push(process.resourcesPath) - } - } - - const dllPaths = possiblePaths.join(sep) - - if (process.env.PATH) { - process.env.PATH = dllPaths + sep + process.env.PATH - } else { - process.env.PATH = dllPaths - } - - -} - -try { - enforceLocalDllPriority() -} catch (e) { - console.error('[WeFlow] Failed to enforce local service priority:', e) -} diff --git a/electron/preload.ts b/electron/preload.ts deleted file mode 100644 index 98e97f3..0000000 --- a/electron/preload.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron' - -type CloseConfirmPayload = { - canMinimizeToTray: boolean - restoreMethod?: 'tray' | 'dock' -} - -// 暴露给渲染进程的 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') - }, - - // 通知 - notification: { - show: (data: any) => ipcRenderer.invoke('notification:show', data), - close: () => ipcRenderer.invoke('notification:close'), - click: (payload: any) => ipcRenderer.send('notification-clicked', payload), - ready: () => ipcRenderer.send('notification:ready'), - resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }), - onShow: (callback: (event: any, data: any) => void) => { - ipcRenderer.on('notification:show', callback) - return () => ipcRenderer.removeAllListeners('notification:show') - }, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话 - onNavigateToSession: (callback: (sessionId: string) => void) => { - const listener = (_: any, sessionId: string) => callback(sessionId) - ipcRenderer.on('navigate-to-session', listener) - return () => ipcRenderer.removeListener('navigate-to-session', listener) - }, - onNavigateToRoute: (callback: (route: string) => void) => { - const listener = (_: any, route: string) => callback(route) - ipcRenderer.on('navigate-to-route', listener) - return () => ipcRenderer.removeListener('navigate-to-route', listener) - } - }, - - // 认证 - auth: { - hello: (message?: string) => ipcRenderer.invoke('auth:hello', message), - verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'), - unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password), - enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password), - disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password), - changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword), - setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password), - clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'), - isLockMode: () => ipcRenderer.invoke('auth:isLockMode') - }, - - - // 对话框 - 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'), - getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'), - setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled), - checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), - downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), - ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version), - onDownloadProgress: (callback: (progress: any) => 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'), - clear: () => ipcRenderer.invoke('log:clear'), - debug: (data: any) => ipcRenderer.send('log:debug', data) - }, - - diagnostics: { - getExportCardLogs: (options?: { limit?: number }) => - ipcRenderer.invoke('diagnostics:getExportCardLogs', options), - clearExportCardLogs: () => - ipcRenderer.invoke('diagnostics:clearExportCardLogs'), - exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) => - ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload) - }, - - // 窗口控制 - window: { - minimize: () => ipcRenderer.send('window:minimize'), - maximize: () => ipcRenderer.send('window:maximize'), - isMaximized: () => ipcRenderer.invoke('window:isMaximized'), - onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => { - const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized) - ipcRenderer.on('window:maximizeStateChanged', listener) - return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) - }, - close: () => ipcRenderer.send('window:close'), - onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => { - const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload) - ipcRenderer.on('window:confirmCloseRequested', listener) - return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener) - }, - respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => - ipcRenderer.invoke('window:respondCloseConfirm', action), - openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), - completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), - openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options), - setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), - openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => - ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), - resizeToFitVideo: (videoWidth: number, videoHeight: number) => - ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), - openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => - ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), - openChatHistoryWindow: (sessionId: string, messageId: number) => - ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), - openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) => - ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload), - getChatHistoryPayload: (payloadId: string) => - ipcRenderer.invoke('window:getChatHistoryPayload', payloadId), - openSessionChatWindow: ( - sessionId: string, - options?: { - source?: 'chat' | 'export' - initialDisplayName?: string - initialAvatarUrl?: string - initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' - } - ) => - ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) - }, - - // 数据库路径 - dbPath: { - autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'), - scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath), - scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', 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'), - - }, - - backup: { - create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload), - inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload), - restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload), - onProgress: (callback: (progress: any) => void) => { - const listener = (_: unknown, progress: any) => callback(progress) - ipcRenderer.on('backup:progress', listener) - return () => ipcRenderer.removeListener('backup:progress', listener) - } - }, - - // 密钥获取 - key: { - autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), - autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid), - scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir), - 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'), - markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'), - getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'), - getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), - getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), - getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), - getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options), - enrichSessionsContactInfo: ( - usernames: string[], - options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } - ) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options), - getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => - ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), - getLatestMessages: (sessionId: string, limit?: number) => - ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), - getNewMessages: (sessionId: string, minTime: number, limit?: number) => - ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit), - getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), - getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), - updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => - ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent), - deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => - ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint), - checkAntiRevokeTriggers: (sessionIds: string[]) => - ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds), - installAntiRevokeTriggers: (sessionIds: string[]) => - ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds), - uninstallAntiRevokeTriggers: (sessionIds: string[]) => - ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds), - resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => - ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), - getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), - downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), - getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), - clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => - ipcRenderer.invoke('chat:clearCurrentAccountData', options), - close: () => ipcRenderer.invoke('chat:close'), - getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), - getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), - getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), - getExportSessionStats: ( - sessionIds: string[], - options?: { - includeRelations?: boolean - forceRefresh?: boolean - allowStaleCache?: boolean - preferAccurateSpecialTypes?: boolean - cacheOnly?: boolean - beginTimestamp?: number - endTimestamp?: number - } - ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), - getGroupMyMessageCountHint: (chatroomId: string) => - ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), - getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), - getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => - ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), - getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), - getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), - getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), - getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), - getResourceMessages: (options?: { - sessionId?: string - types?: Array<'image' | 'video' | 'voice' | 'file'> - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }) => ipcRenderer.invoke('chat:getResourceMessages', options), - getMediaStream: (options?: { - sessionId?: string - mediaType?: 'image' | 'video' | 'all' - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }) => ipcRenderer.invoke('chat:getMediaStream', options), - resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), - getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), - onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { - const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload) - ipcRenderer.on('chat:voiceTranscriptPartial', listener) - return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) - }, - getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options), - getMessage: (sessionId: string, localId: number) => - ipcRenderer.invoke('chat:getMessage', sessionId, localId), - searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => - ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp), - getMyFootprintStats: ( - beginTimestamp: number, - endTimestamp: number, - options?: { - myWxid?: string - privateSessionIds?: string[] - groupSessionIds?: string[] - mentionLimit?: number - privateLimit?: number - mentionMode?: 'text_at_me' | string - } - ) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options), - exportMyFootprint: ( - beginTimestamp: number, - endTimestamp: number, - format: 'csv' | 'json', - filePath: string - ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath), - onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { - ipcRenderer.on('wcdb-change', callback) - return () => ipcRenderer.removeListener('wcdb-change', callback) - } - }, - - - - // 图片解密 - image: { - decrypt: (payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - force?: boolean - preferFilePath?: boolean - hardlinkOnly?: boolean - disableUpdateCheck?: boolean - allowCacheIndex?: boolean - suppressEvents?: boolean - }) => - ipcRenderer.invoke('image:decrypt', payload), - resolveCache: (payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - hardlinkOnly?: boolean - disableUpdateCheck?: boolean - allowCacheIndex?: boolean - suppressEvents?: boolean - }) => - ipcRenderer.invoke('image:resolveCache', payload), - resolveCacheBatch: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean } - ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), - preload: ( - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, - options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } - ) => ipcRenderer.invoke('image:preload', payloads, options), - preloadHardlinkMd5s: (md5List: string[]) => - ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List), - onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { - const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) - ipcRenderer.on('image:updateAvailable', listener) - return () => ipcRenderer.removeListener('image:updateAvailable', listener) - }, - onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => { - const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) - ipcRenderer.on('image:cacheResolved', listener) - return () => ipcRenderer.removeListener('image:cacheResolved', listener) - }, - onDecryptProgress: (callback: (payload: { - cacheKey: string - imageMd5?: string - imageDatName?: string - stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' - progress: number - status: 'running' | 'done' | 'error' - message?: string - }) => void) => { - const listener = (_: unknown, payload: { - cacheKey: string - imageMd5?: string - imageDatName?: string - stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' - progress: number - status: 'running' | 'done' | 'error' - message?: string - }) => callback(payload) - ipcRenderer.on('image:decryptProgress', listener) - return () => ipcRenderer.removeListener('image:decryptProgress', listener) - }, - startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist), - stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'), - getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus') - }, - - // 视频 - video: { - getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options), - parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) - }, - - process: { - platform: process.platform, - arch: process.arch - }, - - // 数据分析 - analytics: { - getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), - getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => - ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp), - getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), - getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => - ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force), - getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'), - setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames), - getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'), - onProgress: (callback: (payload: { status: string; progress: number }) => void) => { - ipcRenderer.on('analytics:progress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('analytics:progress') - } - }, - - // 缓存管理 - cache: { - clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'), - clearImages: () => ipcRenderer.invoke('cache:clearImages'), - clearAll: () => ipcRenderer.invoke('cache:clearAll') - }, - - // 群聊分析 - groupAnalytics: { - getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), - getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), - getGroupMembersPanelData: ( - chatroomId: string, - options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } - ) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options), - 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), - getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime), - getGroupMemberMessages: ( - chatroomId: string, - memberUsername: string, - options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } - ) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options), - exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath), - exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime) - }, - - // 年度报告 - annualReport: { - getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'), - startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'), - cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId), - 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), - captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'), - onAvailableYearsProgress: (callback: (payload: { - taskId: string - years?: number[] - done: boolean - error?: string - canceled?: boolean - strategy?: 'cache' | 'native' | 'hybrid' - phase?: 'cache' | 'native' | 'scan' | 'done' - statusText?: string - nativeElapsedMs?: number - scanElapsedMs?: number - totalElapsedMs?: number - switched?: boolean - nativeTimedOut?: boolean - }) => void) => { - ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress') - }, - onProgress: (callback: (payload: { status: string; progress: number }) => void) => { - ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('annualReport:progress') - } - }, - dualReport: { - generateReport: (payload: { friendUsername: string; year: number }) => - ipcRenderer.invoke('dualReport:generateReport', payload), - onProgress: (callback: (payload: { status: string; progress: number }) => void) => { - ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('dualReport:progress') - } - }, - - // 导出 - export: { - getExportStats: (sessionIds: string[], options: any) => - ipcRenderer.invoke('export:getExportStats', sessionIds, options), - exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) => - ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions), - pauseTask: (taskId: string) => - ipcRenderer.invoke('export:pauseTask', taskId), - resumeTask: (taskId: string) => - ipcRenderer.invoke('export:resumeTask', taskId), - cancelTask: (taskId: string) => - ipcRenderer.invoke('export:cancelTask', taskId), - exportSession: (sessionId: string, outputPath: string, options: any) => - ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), - exportContacts: (outputDir: string, options: any) => - ipcRenderer.invoke('export:exportContacts', outputDir, options), - onProgress: (callback: (payload: { - current: number - total: number - currentSession: string - currentSessionId?: string - phase: string - phaseProgress?: number - phaseTotal?: number - phaseLabel?: string - collectedMessages?: number - exportedMessages?: number - estimatedTotalMessages?: number - writtenFiles?: number - }) => void) => { - ipcRenderer.on('export:progress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('export:progress') - } - }, - - whisper: { - downloadModel: () => - ipcRenderer.invoke('whisper:downloadModel'), - getModelStatus: () => - ipcRenderer.invoke('whisper:getModelStatus'), - onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => { - ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('whisper:downloadProgress') - } - }, - - // 朋友圈 - sns: { - getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), - getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), - getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), - getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), - getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), - getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), - debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), - proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), - downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), - exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options), - onExportProgress: (callback: (payload: any) => void) => { - ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('sns:exportProgress') - }, - selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'), - installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'), - uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'), - checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'), - deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId), - downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params), - getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'), - startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'), - onCacheMigrationProgress: (callback: (payload: any) => void) => { - const listener = (_event: unknown, payload: any) => callback(payload) - ipcRenderer.on('sns:cacheMigrationProgress', listener) - return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener) - } - }, - - biz: { - listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account), - listMessages: (username: string, account?: string, limit?: number, offset?: number) => - ipcRenderer.invoke('biz:listMessages', username, account, limit, offset), - listPayRecords: (account?: string, limit?: number, offset?: number) => - ipcRenderer.invoke('biz:listPayRecords', account, limit, offset) - }, - - - // 数据收集 - cloud: { - init: () => ipcRenderer.invoke('cloud:init'), - recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName), - getLogs: () => ipcRenderer.invoke('cloud:getLogs') - }, - - // HTTP API 服务 - http: { - start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host), - stop: () => ipcRenderer.invoke('http:stop'), - status: () => ipcRenderer.invoke('http:status') - }, - - // AI 见解 - insight: { - testConnection: () => ipcRenderer.invoke('insight:testConnection'), - getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'), - listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters), - getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id), - markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id), - clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters), - triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), - triggerSessionInsight: (payload: { - sessionId: string - displayName?: string - avatarUrl?: string - }) => ipcRenderer.invoke('insight:triggerSessionInsight', payload), - listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds), - generateProfile: (payload: { - sessionId: string - displayName?: string - avatarUrl?: string - }) => ipcRenderer.invoke('insight:generateProfile', payload), - cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId), - generateFootprintInsight: (payload: { - rangeLabel: string - summary: { - private_inbound_people?: number - private_replied_people?: number - private_outbound_people?: number - private_reply_rate?: number - mention_count?: number - mention_group_count?: number - } - privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> - mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> - }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload), - generateMessageInsight: (payload: { - sessionId: string - displayName?: string - avatarUrl?: string - targetLocalId?: number - targetCreateTime?: number - targetMessageKey?: string - targetText: string - targetSenderName?: string - contextCount?: number - forceRefresh?: boolean - }) => ipcRenderer.invoke('insight:generateMessageInsight', payload) - }, - - groupSummary: { - listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters), - getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id), - triggerManual: (payload: { - sessionId: string - displayName?: string - avatarUrl?: string - startTime: number - endTime: number - }) => ipcRenderer.invoke('groupSummary:triggerManual', payload), - triggerDay: (payload: { - sessionId: string - displayName?: string - avatarUrl?: string - date: string - }) => ipcRenderer.invoke('groupSummary:triggerDay', payload) - }, - - social: { - saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput), - validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid) - } -}) diff --git a/electron/services/accountDirResolver.ts b/electron/services/accountDirResolver.ts deleted file mode 100644 index 7c440fb..0000000 --- a/electron/services/accountDirResolver.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * 账号目录解析器(Worker 线程 / 主进程通用) - * - * 职责:在 dbPath 根目录下,根据传入的 wxid,找出微信"实际写入数据" - * 的那个账号子目录,例如: - * dbPath = <微信数据根目录> - * wxid = customwxid_abcd 或 customwxid - * 期望返回 <微信数据根目录>/customwxid_abcd(带后缀、有 session.db 的那个) - * - * 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件 - * 也会在 Worker 线程中被加载,无法依赖 electron-store。 - */ -import { existsSync, readdirSync, statSync } from 'fs' -import { join } from 'path' - -// 解析结果缓存(进程内,避免重复 IO)。key = `${dbPath}|${cleanedWxid}` -const accountDirCache = new Map() - -/** - * 把 wxid 字符串"标准化"为目录前缀。 - * - wxid_xxx_yyyy → wxid_xxx (wxid_ 后只取第一段) - * - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid) - * - 其他形式 → 原样返回 - * - * 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。 - * 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。 - */ -const 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 -} - -const isDirectory = (path: string): boolean => { - try { - return statSync(path).isDirectory() - } catch { - return false - } -} - -/** - * 解析账号目录的真实绝对路径。 - * - * ## 修复 #996(错误码 -3001:未找到数据库目录) - * - * ### 旧实现存在的两处严重缺陷 - * 1. **对 wxid_ 开头的目录强制要求"带后缀"**: - * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), - * 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。 - * - * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**: - * 旧实现写法是 - * ``` - * if (!lowerWxid.startsWith('wxid_')) { - * const direct = join(root, cleanedWxid) - * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage - * } - * ``` - * 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成 - * `<自定义号>`,于是无论用户存的是哪个 wxid,都会命中旧的、无后缀的 - * 空目录(它真实存在但里面没有 db_storage),最终触发 -3001。 - * - * ### 修复后的统一匹配流程 - * 1. 扫描 dbPath 下所有子目录; - * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 - * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; - * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项 - * (没有 db_storage 也没有 FileStorage/Image[2]); - * 4. 在剩余候选中按以下优先级排序,取最优: - * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; - * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; - * - **修改时间更新** > 更旧:兜底。 - */ -export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => { - if (!dbPath || !wxid) return null - - const cleanedWxid = cleanAccountDirName(wxid) - const normalized = dbPath.replace(/[\\/]+$/, '') - const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` - - // 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除 - const cached = accountDirCache.get(cacheKey) - if (cached && existsSync(cached)) return cached - if (cached && !existsSync(cached)) { - accountDirCache.delete(cacheKey) - } - - const lowerWxid = cleanedWxid.toLowerCase() - - try { - const entries = readdirSync(normalized) - type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } - const candidates: Candidate[] = [] - - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!isDirectory(entryPath)) continue - - const lowerEntry = entry.toLowerCase() - const isExactMatch = lowerEntry === lowerWxid - const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) - // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 - if (!isExactMatch && !isSuffixMatch) continue - - // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 - // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 - // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 - if (!accountDirLooksValid(entryPath)) continue - - let mtime = 0 - try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } - candidates.push({ - entryPath, - isExact: isExactMatch, - hasSession: accountDirHasSessionDb(entryPath), - mtime, - }) - } - - if (candidates.length > 0) { - candidates.sort((a, b) => { - // 1) 优先选有 session.db 的(真实写入数据的目录) - if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 - // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) - if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 - // 3) 最后按修改时间倒序(最新的优先) - return b.mtime - a.mtime - }) - const best = candidates[0].entryPath - accountDirCache.set(cacheKey, best) - return best - } - } catch { /* 扫描目录失败时直接 fallthrough 返回 null */ } - - return null -} - -/** - * 浅层判定一个目录"看起来像不像账号目录": - * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 - * - * 用于在候选阶段剔除"同名但实际无数据"的残留空目录 - *(例如自定义微信号后遗留下来的旧 wxid 主目录)。 - */ -const accountDirLooksValid = (entryPath: string): boolean => { - return ( - existsSync(join(entryPath, 'db_storage')) || - existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) - ) -} - -/** - * 检测账号目录下是否存在 session.db。 - * - * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, - * 同时兼容微信 4.x 两种已知布局: - * - db_storage/session/session.db (新版本嵌套布局) - * - db_storage/session.db (部分版本扁平布局) - */ -const accountDirHasSessionDb = (entryPath: string): boolean => { - const candidates = [ - join(entryPath, 'db_storage', 'session', 'session.db'), - join(entryPath, 'db_storage', 'session.db'), - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return true - } - return false -} diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts deleted file mode 100644 index 9cffe56..0000000 --- a/electron/services/analyticsService.ts +++ /dev/null @@ -1,833 +0,0 @@ -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { join } from 'path' -import { readFile, writeFile, rm } from 'fs/promises' -import { app } from 'electron' -import { createHash } from 'crypto' - -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 SelfSentDailyDistribution { - unit: 'day' - dailyDistribution: Record - totalMessages: number - firstMessageTime: number | null - lastMessageTime: number | null - beginTimestamp: number - endTimestamp: number -} - -export interface ContactRanking { - username: string - displayName: string - avatarUrl?: string - wechatId?: 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 selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; 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 normalizeUsername(username: string): string { - return username.trim().toLowerCase() - } - - private normalizeExcludedUsernames(value: unknown): string[] { - if (!Array.isArray(value)) return [] - const normalized = value - .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '') - .filter((item) => item.length > 0) - return Array.from(new Set(normalized)) - } - - private getExcludedUsernamesList(): string[] { - return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames')) - } - - private getExcludedUsernamesSet(): Set { - return new Set(this.getExcludedUsernamesList()) - } - - private async getAliasMap(usernames: string[]): Promise> { - const map: Record = {} - if (usernames.length === 0) return map - - const result = await wcdbService.getContactAliasMap(usernames) - if (!result.success || !result.map) return map - for (const [username, alias] of Object.entries(result.map)) { - if (username && alias) map[username] = alias - } - - return map - } - - 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 - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - 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 - - if (username.toLowerCase() === 'weixin') return false - - const excludeList = [ - '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 accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: '未找到账号目录' } - - const ok = await wcdbService.open(accountDir, decryptKey) - if (!ok) return { success: false, error: 'WCDB 打开失败' } - - const cleanedWxid = this.cleanAccountDirName(wxid) - - return { success: true, cleanedWxid } - } - - private async getPrivateSessions( - cleanedWxid: string, - excludedUsernames?: Set - ): 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 excluded = excludedUsernames ?? this.getExcludedUsernamesSet() - - 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) => { - if (!this.isPrivateSession(s.username, cleanedWxid)) return false - if (excluded.size === 0) return true - return !excluded.has(this.normalizeUsername(s.username)) - }) - 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, - lite = false - ): Promise { - const cursorResult = lite - ? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp) - : 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 getRowCreateTime(row: Record): number { - const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0' - const parsed = parseInt(String(raw), 10) - if (!Number.isFinite(parsed) || parsed <= 0) return 0 - return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed - } - - private isRowSentByMe(row: Record, cleanedWxid: string): boolean { - const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend - const normalized = String(isSendRaw).trim().toLowerCase() - let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true' - - if (isSendRaw === undefined || isSendRaw === null) { - const senderUsername = row.sender_username || row.senderUsername || row.sender - if (senderUsername && cleanedWxid) { - const senderLower = String(senderUsername).toLowerCase() - const myWxidLower = cleanedWxid.toLowerCase() - isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`) - } - } - - return isSend - } - - private formatDayKey(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') - return `${year}-${month}-${day}` - } - - private sortDailyDistribution(daily: Record): Record { - const sorted: Record = {} - for (const key of Object.keys(daily).sort()) { - sorted[key] = daily[key] - } - return sorted - } - - private completeDailyDistribution( - daily: Record, - firstTimestamp: number, - lastTimestamp: number - ): Record { - if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) { - return this.sortDailyDistribution(daily) - } - - const start = new Date(firstTimestamp * 1000) - const end = new Date(lastTimestamp * 1000) - start.setHours(0, 0, 0, 0) - end.setHours(0, 0, 0, 0) - - const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 - if (roughDays <= 0 || roughDays > 5000) { - return this.sortDailyDistribution(daily) - } - - const completed: Record = {} - const cursor = new Date(start) - while (cursor.getTime() <= end.getTime()) { - const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}` - completed[key] = daily[key] || 0 - cursor.setDate(cursor.getDate() + 1) - } - - return completed - } - - 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 { - if (sessionIds.length === 0) { - return `${beginTimestamp}-${endTimestamp}-0-empty` - } - const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort() - const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12) - return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}` - } - - private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise { - const cleanedWxid = this.configService.getMyWxidCleaned() || '' - - const aggregate = { - total: 0, - sent: 0, - received: 0, - firstTime: 0, - lastTime: 0, - typeCounts: {} as Record, - hourly: {} as Record, - weekday: {} as Record, - daily: {} as Record, - sentDaily: {} 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 = this.getRowCreateTime(row) - 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 isSend = this.isRowSentByMe(row, cleanedWxid) - - 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 - if (isSend) { - aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1 - } - }, beginTimestamp, endTimestamp) - - if (sessionStat.total > 0) { - aggregate.sessions[sessionId] = sessionStat - } - } - - return aggregate - } - - private async computeSelfSentDailyDistribution( - sessionIds: string[], - cleanedWxid: string, - beginTimestamp = 0, - endTimestamp = 0 - ): Promise { - const dailyDistribution: Record = {} - let totalMessages = 0 - let firstMessageTime = 0 - let lastMessageTime = 0 - - for (const sessionId of sessionIds) { - await this.iterateSessionMessages(sessionId, (row) => { - const createTime = this.getRowCreateTime(row) - if (!createTime) return - if (beginTimestamp > 0 && createTime < beginTimestamp) return - if (endTimestamp > 0 && createTime > endTimestamp) return - if (!this.isRowSentByMe(row, cleanedWxid)) return - - const dayKey = this.formatDayKey(createTime) - dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1 - totalMessages += 1 - - if (firstMessageTime === 0 || createTime < firstMessageTime) { - firstMessageTime = createTime - } - if (createTime > lastMessageTime) { - lastMessageTime = createTime - } - }, beginTimestamp, endTimestamp, true) - } - - return { - unit: 'day', - dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime), - totalMessages, - firstMessageTime: firstMessageTime || null, - lastMessageTime: lastMessageTime || null, - beginTimestamp, - endTimestamp - } - } - - private async getAggregateWithFallback( - sessionIds: string[], - beginTimestamp = 0, - endTimestamp = 0, - window?: any, - force = false - ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> { - const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp) - - if (force) { - if (this.aggregateCache) this.aggregateCache = null - if (this.fallbackAggregateCache) this.fallbackAggregateCache = null - } - - if (!force && 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 (!force) { - const fileCache = await this.loadCacheFromFile() - if (fileCache && fileCache.key === cacheKey) { - this.aggregateCache = fileCache - return { success: true, data: fileCache.data, source: 'file-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 { - const result = await promise - // 如果计算成功,同时写入此文件缓存 - if (result.success && result.data && result.source !== 'cache') { - this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() }) - } - return result - } finally { - if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { - this.aggregatePromise = null - } - } - } - - private getCacheFilePath(): string { - return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json') - } - - private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> { - try { - const raw = await readFile(this.getCacheFilePath(), 'utf-8') - return JSON.parse(raw) - } catch { return null } - } - - private async saveCacheToFile(data: any) { - try { - await writeFile(this.getCacheFilePath(), JSON.stringify(data)) - } catch (e) { - console.error('保存统计缓存失败:', e) - } - } - - 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 getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> { - try { - return { success: true, data: this.getExcludedUsernamesList() } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> { - try { - const normalized = this.normalizeExcludedUsernames(usernames) - this.configService.set('analyticsExcludedUsernames', normalized) - await this.clearCache() - return { success: true, data: normalized } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> { - try { - const conn = await this.ensureConnected() - if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } - - const excluded = this.getExcludedUsernamesSet() - const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set()) - - const usernames = new Set(sessionInfo.usernames) - for (const name of excluded) usernames.add(name) - - if (usernames.size === 0) { - return { success: true, data: [] } - } - - const usernameList = Array.from(usernames) - const [displayNames, avatarUrls, aliasMap] = await Promise.all([ - wcdbService.getDisplayNames(usernameList), - wcdbService.getAvatarUrls(usernameList), - this.getAliasMap(usernameList) - ]) - - const entries = usernameList.map((username) => { - const displayName = displayNames.success && displayNames.map - ? (displayNames.map[username] || username) - : username - const avatarUrl = avatarUrls.success && avatarUrls.map - ? avatarUrls.map[username] - : undefined - const alias = aliasMap[username] - const wechatId = alias || (!username.startsWith('wxid_') ? username : '') - return { username, displayName, avatarUrl, wechatId } - }) - - return { success: true, data: entries } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getOverallStatistics(force = false): 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, force) - - 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, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): 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, beginTimestamp, endTimestamp) - 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, aliasMap] = await Promise.all([ - wcdbService.getDisplayNames(usernames), - wcdbService.getAvatarUrls(usernames), - this.getAliasMap(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 - const alias = aliasMap[username] || '' - const wechatId = alias || (!username.startsWith('wxid_') ? username : '') - return { - username, - displayName, - avatarUrl, - wechatId, - 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=周日, 1=周一...6=周六 - // 前端期望 1=周一...7=周日 - 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) } - } - } - - async getSelfSentDailyDistribution( - beginTimestamp: number = 0, - endTimestamp: number = 0, - force = false - ): Promise<{ success: boolean; data?: SelfSentDailyDistribution; 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 cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}` - if (force) this.selfSentDailyCache = null - - if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) { - if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) { - return { success: true, data: this.selfSentDailyCache.data } - } - } - - const data = await this.computeSelfSentDailyDistribution( - sessionInfo.usernames, - conn.cleanedWxid, - beginTimestamp, - endTimestamp - ) - this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() } - - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async clearCache(): Promise<{ success: boolean; error?: string }> { - this.aggregateCache = null - this.fallbackAggregateCache = null - this.selfSentDailyCache = null - this.aggregatePromise = null - try { - await rm(this.getCacheFilePath(), { force: true }) - return { success: true } - } 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 deleted file mode 100644 index 04faedf..0000000 --- a/electron/services/annualReportService.ts +++ /dev/null @@ -1,1606 +0,0 @@ -import { parentPort } from 'worker_threads' -import { wcdbService } from './wcdbService' -import { resolveAccountDir } from './accountDirResolver' - -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 - topInitiatedFriend?: string - topInitiatedCount?: number - } | null - responseSpeed: { - avgResponseTime: number - fastestFriend: string - fastestTime: number - } | null - topPhrases: { - phrase: string - count: number - }[] - snsStats?: { - totalPosts: number - typeCounts?: Record - topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] - topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] - } - lostFriend: { - username: string - displayName: string - avatarUrl?: string - earlyCount: number - lateCount: number - periodDesc: string - } | null -} - -export interface AvailableYearsLoadProgress { - years: number[] - strategy: 'cache' | 'native' | 'hybrid' - phase: 'cache' | 'native' | 'scan' - statusText: string - nativeElapsedMs: number - scanElapsedMs: number - totalElapsedMs: number - switched?: boolean - nativeTimedOut?: boolean -} - -interface AvailableYearsLoadMeta { - strategy: 'cache' | 'native' | 'hybrid' - nativeElapsedMs: number - scanElapsedMs: number - totalElapsedMs: number - switched: boolean - nativeTimedOut: boolean - statusText: string -} - -class AnnualReportService { - private readonly availableYearsCacheTtlMs = 10 * 60 * 1000 - private readonly availableYearsScanConcurrency = 4 - private readonly availableYearsColumnCache = new Map() - private readonly availableYearsCache = new Map() - - 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})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - 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 accountDir = resolveAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: '未找到账号目录' } - - const ok = await wcdbService.open(accountDir, decryptKey) - if (!ok) return { success: false, error: 'WCDB 打开失败' } - - const cleanedWxid = this.cleanAccountDirName(wxid) - 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 = [ - '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 - if (username.toLowerCase() === 'weixin') 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 quoteSqlIdentifier(identifier: string): string { - return `"${String(identifier || '').replace(/"/g, '""')}"` - } - - private toUnixTimestamp(value: any): number { - const n = Number(value) - if (!Number.isFinite(n) || n <= 0) return 0 - // 兼容毫秒级时间戳 - const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) - return seconds > 0 ? seconds : 0 - } - - private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean { - let changed = false - const currentYear = new Date().getFullYear() - const minTs = firstTs > 0 ? firstTs : lastTs - const maxTs = lastTs > 0 ? lastTs : firstTs - if (minTs <= 0 || maxTs <= 0) return changed - - const minYear = new Date(minTs * 1000).getFullYear() - const maxYear = new Date(maxTs * 1000).getFullYear() - for (let y = minYear; y <= maxYear; y++) { - if (y >= 2010 && y <= currentYear && !years.has(y)) { - years.add(y) - changed = true - } - } - return changed - } - - private normalizeAvailableYears(years: Iterable): number[] { - return Array.from(new Set(Array.from(years))) - .filter((y) => Number.isFinite(y)) - .map((y) => Math.floor(y)) - .sort((a, b) => b - a) - } - - private async forEachWithConcurrency( - items: T[], - concurrency: number, - handler: (item: T, index: number) => Promise, - shouldStop?: () => boolean - ): Promise { - if (!items.length) return - const workerCount = Math.max(1, Math.min(concurrency, items.length)) - let nextIndex = 0 - const workers: Promise[] = [] - - for (let i = 0; i < workerCount; i++) { - workers.push((async () => { - while (true) { - if (shouldStop?.()) break - const current = nextIndex - nextIndex += 1 - if (current >= items.length) break - await handler(items[current], current) - } - })()) - } - - await Promise.all(workers) - } - - private async detectTimeColumn(dbPath: string, tableName: string): Promise { - const cacheKey = `${dbPath}\u0001${tableName}` - if (this.availableYearsColumnCache.has(cacheKey)) { - const cached = this.availableYearsColumnCache.get(cacheKey) || '' - return cached || null - } - - const result = await wcdbService.getMessageTableColumns(dbPath, tableName) - if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) { - this.availableYearsColumnCache.set(cacheKey, '') - return null - } - - const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] - const columns = new Set() - for (const columnName of result.columns) { - const name = String(columnName || '').trim().toLowerCase() - if (name) columns.add(name) - } - - for (const candidate of candidates) { - if (columns.has(candidate)) { - this.availableYearsColumnCache.set(cacheKey, candidate) - return candidate - } - } - - this.availableYearsColumnCache.set(cacheKey, '') - return null - } - - private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> { - const cacheKey = `${dbPath}\u0001${tableName}` - const cachedColumn = this.availableYearsColumnCache.get(cacheKey) - const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time' - const tried = new Set() - - const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { - const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName) - if (!result.success || !result.data) return null - const row = result.data as Record - const actualColumn = String(row.column || '').trim().toLowerCase() - if (column && actualColumn && column.toLowerCase() !== actualColumn) return null - const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) - const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) - return { first, last } - } - - tried.add(initialColumn) - const quick = await queryByColumn(initialColumn) - if (quick) { - if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn) - return quick - } - - const detectedColumn = await this.detectTimeColumn(dbPath, tableName) - if (!detectedColumn || tried.has(detectedColumn)) { - return null - } - - return queryByColumn(detectedColumn) - } - - private async getAvailableYearsByTableScan( - sessionIds: string[], - options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } - ): Promise { - const years = new Set() - let lastEmittedSize = 0 - - const emitIfChanged = (force = false) => { - if (!options?.onProgress) return - const next = this.normalizeAvailableYears(years) - if (!force && next.length === lastEmittedSize) return - options.onProgress(next) - lastEmittedSize = next.length - } - - const shouldCancel = () => options?.shouldCancel?.() === true - - await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => { - if (shouldCancel()) return - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) { - return - } - - for (const table of tableStats.tables as Record[]) { - if (shouldCancel()) return - const tableName = String(table.table_name || table.name || '').trim() - const dbPath = String(table.db_path || table.dbPath || '').trim() - if (!tableName || !dbPath) continue - - const range = await this.getTableTimeRange(dbPath, tableName) - if (!range) continue - const changed = this.addYearsFromRange(years, range.first, range.last) - if (changed) emitIfChanged() - } - }, shouldCancel) - - emitIfChanged(true) - return this.normalizeAvailableYears(years) - } - - private async getAvailableYearsByEdgeScan( - sessionIds: string[], - options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } - ): Promise { - const years = new Set() - let lastEmittedSize = 0 - const shouldCancel = () => options?.shouldCancel?.() === true - - const emitIfChanged = (force = false) => { - if (!options?.onProgress) return - const next = this.normalizeAvailableYears(years) - if (!force && next.length === lastEmittedSize) return - options.onProgress(next) - lastEmittedSize = next.length - } - - for (const sessionId of sessionIds) { - if (shouldCancel()) break - const first = await this.getEdgeMessageTime(sessionId, true) - const last = await this.getEdgeMessageTime(sessionId, false) - const changed = this.addYearsFromRange(years, first || 0, last || 0) - if (changed) emitIfChanged() - } - emitIfChanged(true) - return this.normalizeAvailableYears(years) - } - - private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string { - return `${dbPath}\u0001${cleanedWxid}` - } - - private getCachedAvailableYears(cacheKey: string): number[] | null { - const cached = this.availableYearsCache.get(cacheKey) - if (!cached) return null - if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) { - this.availableYearsCache.delete(cacheKey) - return null - } - return [...cached.years] - } - - private setCachedAvailableYears(cacheKey: string, years: number[]): void { - const normalized = this.normalizeAvailableYears(years) - - this.availableYearsCache.set(cacheKey, { - years: normalized, - updatedAt: Date.now() - }) - - if (this.availableYearsCache.size > 8) { - let oldestKey = '' - let oldestTime = Number.POSITIVE_INFINITY - for (const [key, val] of this.availableYearsCache) { - if (val.updatedAt < oldestTime) { - oldestTime = val.updatedAt - oldestKey = key - } - } - if (oldestKey) this.availableYearsCache.delete(oldestKey) - } - } - - 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 '' - // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 - // 短字符串(如 "123456" 等纯数字)容易被误判为 hex - if (raw.length > 16 && this.looksLikeHex(raw)) { - const bytes = Buffer.from(raw, 'hex') - if (bytes.length > 0) return this.decodeBinaryContent(bytes) - } - // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 - // 短字符串(如 "test", "home" 等)容易被误判为 base64 - if (raw.length > 16 && 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 - onProgress?: (payload: AvailableYearsLoadProgress) => void - shouldCancel?: () => boolean - nativeTimeoutMs?: number - }): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> { - try { - const isCancelled = () => params.shouldCancel?.() === true - const totalStartedAt = Date.now() - let nativeElapsedMs = 0 - let scanElapsedMs = 0 - let switched = false - let nativeTimedOut = false - let latestYears: number[] = [] - - const emitProgress = (payload: { - years?: number[] - strategy: 'cache' | 'native' | 'hybrid' - phase: 'cache' | 'native' | 'scan' - statusText: string - switched?: boolean - nativeTimedOut?: boolean - }) => { - if (!params.onProgress) return - if (Array.isArray(payload.years)) latestYears = payload.years - params.onProgress({ - years: latestYears, - strategy: payload.strategy, - phase: payload.phase, - statusText: payload.statusText, - nativeElapsedMs, - scanElapsedMs, - totalElapsedMs: Date.now() - totalStartedAt, - switched: payload.switched ?? switched, - nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut - }) - } - - const buildMeta = ( - strategy: 'cache' | 'native' | 'hybrid', - statusText: string - ): AvailableYearsLoadMeta => ({ - strategy, - nativeElapsedMs, - scanElapsedMs, - totalElapsedMs: Date.now() - totalStartedAt, - switched, - nativeTimedOut, - statusText - }) - - const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) - if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') } - if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } - const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) - const cached = this.getCachedAvailableYears(cacheKey) - if (cached) { - latestYears = cached - emitProgress({ - years: cached, - strategy: 'cache', - phase: 'cache', - statusText: '命中缓存,已快速加载年份数据' - }) - return { - success: true, - data: cached, - meta: buildMeta('cache', '命中缓存,已快速加载年份数据') - } - } - - const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) - if (sessionIds.length === 0) { - return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') } - } - if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } - - const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000)) - const nativeStartedAt = Date.now() - let nativeTicker: ReturnType | null = null - - emitProgress({ - strategy: 'native', - phase: 'native', - statusText: '正在使用原生快速模式加载年份...' - }) - nativeTicker = setInterval(() => { - nativeElapsedMs = Date.now() - nativeStartedAt - emitProgress({ - strategy: 'native', - phase: 'native', - statusText: '正在使用原生快速模式加载年份...' - }) - }, 120) - - const nativeRace = await Promise.race([ - wcdbService.getAvailableYears(sessionIds) - .then((result) => ({ kind: 'result' as const, result })) - .catch((error) => ({ kind: 'error' as const, error: String(error) })), - new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs)) - ]) - - if (nativeTicker) { - clearInterval(nativeTicker) - nativeTicker = null - } - nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt) - - if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } - - if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) { - const years = this.normalizeAvailableYears(nativeRace.result.data) - latestYears = years - this.setCachedAvailableYears(cacheKey, years) - emitProgress({ - years, - strategy: 'native', - phase: 'native', - statusText: '原生快速模式加载完成' - }) - return { - success: true, - data: years, - meta: buildMeta('native', '原生快速模式加载完成') - } - } - - switched = true - nativeTimedOut = nativeRace.kind === 'timeout' - emitProgress({ - strategy: 'hybrid', - phase: 'native', - statusText: nativeTimedOut - ? '原生快速模式超时,已自动切换到扫表兼容模式...' - : '原生快速模式不可用,已自动切换到扫表兼容模式...', - switched: true, - nativeTimedOut - }) - - const scanStartedAt = Date.now() - let scanTicker: ReturnType | null = null - scanTicker = setInterval(() => { - scanElapsedMs = Date.now() - scanStartedAt - emitProgress({ - strategy: 'hybrid', - phase: 'scan', - statusText: nativeTimedOut - ? '原生已超时,正在使用扫表兼容模式加载年份...' - : '正在使用扫表兼容模式加载年份...', - switched: true, - nativeTimedOut - }) - }, 120) - - let years = await this.getAvailableYearsByTableScan(sessionIds, { - onProgress: (items) => { - latestYears = items - scanElapsedMs = Date.now() - scanStartedAt - emitProgress({ - years: items, - strategy: 'hybrid', - phase: 'scan', - statusText: nativeTimedOut - ? '原生已超时,正在使用扫表兼容模式加载年份...' - : '正在使用扫表兼容模式加载年份...', - switched: true, - nativeTimedOut - }) - }, - shouldCancel: params.shouldCancel - }) - - if (isCancelled()) { - if (scanTicker) clearInterval(scanTicker) - return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } - } - if (years.length === 0) { - years = await this.getAvailableYearsByEdgeScan(sessionIds, { - onProgress: (items) => { - latestYears = items - scanElapsedMs = Date.now() - scanStartedAt - emitProgress({ - years: items, - strategy: 'hybrid', - phase: 'scan', - statusText: '扫表结果为空,正在执行游标兜底扫描...', - switched: true, - nativeTimedOut - }) - }, - shouldCancel: params.shouldCancel - }) - } - if (scanTicker) { - clearInterval(scanTicker) - scanTicker = null - } - scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt) - - if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } - - this.setCachedAvailableYears(cacheKey, years) - latestYears = years - emitProgress({ - years, - strategy: 'hybrid', - phase: 'scan', - statusText: '扫表兼容模式加载完成', - switched: true, - nativeTimedOut - }) - return { - success: true, - data: years, - meta: buildMeta('hybrid', '扫表兼容模式加载完成') - } - } catch (e) { - return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } } - } - } - - 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 isAllTime = year <= 0 - const reportYear = isAllTime ? 0 : year - const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000) - const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) - - const now = new Date() - // 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd) - const actualStartTime = startTime - const actualEndTime = endTime - - 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, actualStartTime, actualEndTime) - 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, actualStartTime, actualEndTime, 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, actualStartTime, actualEndTime) - 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' - let isSent = parseInt(isSendRaw, 10) === 1 - const localType = parseInt(row.local_type || row.type || '1', 10) - - // 兼容逻辑 - if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') { - const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase() - if (sender) { - const rawLower = rawWxid.toLowerCase() - const cleanedLower = cleanedWxid.toLowerCase() - if (sender === rawLower || sender === cleanedLower || - rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) { - isSent = true - } - } - } - - // 响应速度 & 对话发起 - 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: number - 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, actualStartTime, actualEndTime, onProgress, 45, 75) - if (streakResult.days > longestStreakDays) { - longestStreakDays = streakResult.days - longestStreakSessionId = streakResult.sessionId - longestStreakStart = streakResult.start - longestStreakEnd = streakResult.end - } - } - - // 获取朋友圈统计 - this.reportProgress('分析朋友圈数据...', 75, onProgress) - let snsStatsResult: { - totalPosts: number - typeCounts?: Record - topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] - topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] - } | undefined - - const snsBeginTime = isAllTime ? 0 : actualStartTime - const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime - const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime) - - if (snsStats.success && snsStats.data) { - const d = snsStats.data - const usersToFetch = new Set() - d.topLikers?.forEach((u: any) => usersToFetch.add(u.username)) - d.topLiked?.forEach((u: any) => usersToFetch.add(u.username)) - - const snsUserIds = Array.from(usersToFetch) - const [snsDisplayNames, snsAvatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(snsUserIds), - wcdbService.getAvatarUrls(snsUserIds) - ]) - - const getSnsUserInfo = (username: string) => ({ - displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username, - avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined - }) - - snsStatsResult = { - totalPosts: d.totalPosts || 0, - typeCounts: d.typeCounts, - topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })), - topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })) - } - } - - // ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。 - if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) { - const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid) - if (snsExportStats.success && snsExportStats.data) { - const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0)) - snsStatsResult = { - totalPosts: fallbackTotalPosts, - typeCounts: snsStatsResult?.typeCounts, - topLikers: snsStatsResult?.topLikers || [], - topLiked: snsStatsResult?.topLiked || [] - } - } - } - - 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 - let topInitiatedSessionId = '' - let topInitiatedCount = 0 - for (const [sessionId, stats] of conversationStarts.entries()) { - totalInitiated += stats.initiated - totalReceived += stats.received - if (stats.initiated > topInitiatedCount) { - topInitiatedCount = stats.initiated - topInitiatedSessionId = sessionId - } - } - const totalConversations = totalInitiated + totalReceived - if (totalConversations > 0) { - const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null - socialInitiative = { - initiatedChats: totalInitiated, - receivedChats: totalReceived, - initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10, - topInitiatedFriend: topInitiatedCount > 0 - ? (topInitiatedInfo?.displayName || topInitiatedSessionId) - : undefined, - topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined - } - } - - 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 })) - - // 曾经的好朋友 (Once Best Friend / Lost Friend) - let lostFriend: AnnualReportData['lostFriend'] = null - let maxEarlyCount = 80 // 最低门槛 - let bestEarlyCount = 0 - let bestLateCount = 0 - let bestSid = '' - let bestPeriodDesc = '' - - const currentMonthIndex = new Date().getMonth() + 1 // 1-12 - - const currentYearNum = now.getFullYear() - - if (isAllTime) { - const days = Object.keys(d.daily).sort() - if (days.length >= 2) { - const firstDay = Math.floor(new Date(days[0]).getTime() / 1000) - const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000) - const midPoint = Math.floor((firstDay + lastDay) / 2) - - this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress) - const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint) - this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress) - const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0) - - if (earlyRes.success && lateRes.success && earlyRes.data) { - const earlyData = earlyRes.data.sessions || {} - const lateData = (lateRes.data?.sessions) || {} - for (const sid of sessionIds) { - const e = earlyData[sid] || { sent: 0, received: 0 } - const l = lateData[sid] || { sent: 0, received: 0 } - const early = (e.sent || 0) + (e.received || 0) - const late = (l.sent || 0) + (l.received || 0) - if (early > 100 && early > late * 5) { - // 选择前期消息量最多的 - if (early > maxEarlyCount) { - maxEarlyCount = early - bestEarlyCount = early - bestLateCount = late - bestSid = sid - bestPeriodDesc = '这段时间以来' - } - } - } - } - } - } else if (year === currentYearNum) { - // 当前年份:独立获取过去12个月的滚动数据 - this.reportProgress('分析近期好友趋势...', 86, onProgress) - // 往前数12个月的起点、中点、终点 - const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000) - const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000) - const rollingEnd = Math.floor(now.getTime() / 1000) - - const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1) - const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd) - - if (earlyRes.success && lateRes.success && earlyRes.data) { - const earlyData = earlyRes.data.sessions || {} - const lateData = lateRes.data?.sessions || {} - for (const sid of sessionIds) { - const e = earlyData[sid] || { sent: 0, received: 0 } - const l = lateData[sid] || { sent: 0, received: 0 } - const early = (e.sent || 0) + (e.received || 0) - const late = (l.sent || 0) + (l.received || 0) - if (early > 80 && early > late * 5) { - // 选择前期消息量最多的 - if (early > maxEarlyCount) { - maxEarlyCount = early - bestEarlyCount = early - bestLateCount = late - bestSid = sid - bestPeriodDesc = '去年的这个时候' - } - } - } - } - } else { - // 指定完整年份 (1-6 vs 7-12) - for (const [sid, stat] of Object.entries(d.sessions)) { - const s = stat as any - const mWeights = s.monthly || {} - let early = 0 - let late = 0 - for (let m = 1; m <= 6; m++) early += mWeights[m] || 0 - for (let m = 7; m <= 12; m++) late += mWeights[m] || 0 - - if (early > 80 && early > late * 5) { - // 选择前期消息量最多的 - if (early > maxEarlyCount) { - maxEarlyCount = early - bestEarlyCount = early - bestLateCount = late - bestSid = sid - bestPeriodDesc = `${year}年上半年` - } - } - } - } - - if (bestSid) { - let info = contactInfoMap.get(bestSid) - // 如果 contactInfoMap 中没有该联系人,则单独获取 - if (!info) { - const [displayNameRes, avatarUrlRes] = await Promise.all([ - wcdbService.getDisplayNames([bestSid]), - wcdbService.getAvatarUrls([bestSid]) - ]) - info = { - displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid, - avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined - } - } - lostFriend = { - username: bestSid, - displayName: info?.displayName || bestSid, - avatarUrl: info?.avatarUrl, - earlyCount: bestEarlyCount, - lateCount: bestLateCount, - periodDesc: bestPeriodDesc - } - } - - const reportData: AnnualReportData = { - year: reportYear, - totalMessages, - totalFriends: contactStats.size, - coreFriends, - monthlyTopFriends, - peakDay, - longestStreak, - activityHeatmap: { data: heatmapData }, - midnightKing, - selfAvatarUrl, - mutualFriend, - socialInitiative, - responseSpeed, - topPhrases, - snsStats: snsStatsResult, - lostFriend - } - - return { success: true, data: reportData } - } catch (e) { - return { success: false, error: String(e) } - } - } -} - -export const annualReportService = new AnnualReportService() diff --git a/electron/services/avatarFileCacheService.ts b/electron/services/avatarFileCacheService.ts deleted file mode 100644 index 7216154..0000000 --- a/electron/services/avatarFileCacheService.ts +++ /dev/null @@ -1,219 +0,0 @@ -import https from "https"; -import http, { IncomingMessage } from "http"; -import { promises as fs } from "fs"; -import { join } from "path"; -import { ConfigService } from "./config"; - -// 头像文件缓存服务 - 复用项目已有的缓存目录结构 -export class AvatarFileCacheService { - private static instance: AvatarFileCacheService | null = null; - - // 头像文件缓存目录 - private readonly cacheDir: string; - // 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的) - private readonly pendingDownloads: Map> = - new Map(); - // LRU 追踪:文件路径->最后访问时间 - private readonly lruOrder: string[] = []; - private readonly maxCacheFiles = 100; - - private constructor() { - const basePath = ConfigService.getInstance().getCacheBasePath(); - this.cacheDir = join(basePath, "avatar-files"); - this.ensureCacheDir(); - this.loadLruOrder(); - } - - public static getInstance(): AvatarFileCacheService { - if (!AvatarFileCacheService.instance) { - AvatarFileCacheService.instance = new AvatarFileCacheService(); - } - return AvatarFileCacheService.instance; - } - - private ensureCacheDir(): void { - // 同步确保目录存在(构造函数调用) - try { - fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {}); - } catch {} - } - - private async ensureCacheDirAsync(): Promise { - try { - await fs.mkdir(this.cacheDir, { recursive: true }); - } catch {} - } - - private getFilePath(url: string): string { - // 使用URL的hash作为文件名,避免特殊字符问题 - const hash = this.hashString(url); - return join(this.cacheDir, `avatar_${hash}.png`); - } - - 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; // 转换为32位整数 - } - return Math.abs(hash).toString(16); - } - - private async loadLruOrder(): Promise { - try { - const entries = await fs.readdir(this.cacheDir); - // 按修改时间排序(旧的在前) - const filesWithTime: { file: string; mtime: number }[] = []; - for (const entry of entries) { - if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue; - try { - const stat = await fs.stat(join(this.cacheDir, entry)); - filesWithTime.push({ file: entry, mtime: stat.mtimeMs }); - } catch {} - } - filesWithTime.sort((a, b) => a.mtime - b.mtime); - this.lruOrder.length = 0; - this.lruOrder.push(...filesWithTime.map((f) => f.file)); - } catch {} - } - - private updateLru(fileName: string): void { - const index = this.lruOrder.indexOf(fileName); - if (index > -1) { - this.lruOrder.splice(index, 1); - } - this.lruOrder.push(fileName); - } - - private async evictIfNeeded(): Promise { - while (this.lruOrder.length >= this.maxCacheFiles) { - const oldest = this.lruOrder.shift(); - if (oldest) { - try { - await fs.rm(join(this.cacheDir, oldest)); - console.log(`[AvatarFileCache] Evicted: ${oldest}`); - } catch {} - } - } - } - - private async downloadAvatar(url: string): Promise { - const localPath = this.getFilePath(url); - - // 检查文件是否已存在 - try { - await fs.access(localPath); - const fileName = localPath.split("/").pop()!; - this.updateLru(fileName); - return localPath; - } catch {} - - await this.ensureCacheDirAsync(); - await this.evictIfNeeded(); - - return new Promise((resolve) => { - const options = { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", - Referer: "https://servicewechat.com/", - Accept: - "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9", - Connection: "keep-alive", - }, - }; - - const callback = (res: IncomingMessage) => { - if (res.statusCode !== 200) { - resolve(null); - return; - } - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", async () => { - try { - const buffer = Buffer.concat(chunks); - await fs.writeFile(localPath, buffer); - const fileName = localPath.split("/").pop()!; - this.updateLru(fileName); - console.log( - `[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`, - ); - resolve(localPath); - } catch { - resolve(null); - } - }); - res.on("error", () => resolve(null)); - }; - - const req = url.startsWith("https") - ? https.get(url, options, callback) - : http.get(url, options, callback); - - req.on("error", () => resolve(null)); - req.setTimeout(10000, () => { - req.destroy(); - resolve(null); - }); - }); - } - - /** - * 获取头像本地文件路径,如果需要会下载 - * 同一URL并发调用会复用同一个下载任务 - */ - async getAvatarPath(url: string): Promise { - if (!url) return null; - - // 检查是否有正在进行的下载 - const pending = this.pendingDownloads.get(url); - if (pending) { - return pending; - } - - // 发起新下载 - const downloadPromise = this.downloadAvatar(url); - this.pendingDownloads.set(url, downloadPromise); - - try { - const result = await downloadPromise; - return result; - } finally { - this.pendingDownloads.delete(url); - } - } - - // 清理所有缓存文件(App退出时调用) - async clearCache(): Promise { - try { - const entries = await fs.readdir(this.cacheDir); - for (const entry of entries) { - if (entry.startsWith("avatar_") && entry.endsWith(".png")) { - try { - await fs.rm(join(this.cacheDir, entry)); - } catch {} - } - } - this.lruOrder.length = 0; - console.log("[AvatarFileCache] Cache cleared"); - } catch {} - } - - // 获取当前缓存的文件数量 - async getCacheCount(): Promise { - try { - const entries = await fs.readdir(this.cacheDir); - return entries.filter( - (e) => e.startsWith("avatar_") && e.endsWith(".png"), - ).length; - } catch { - return 0; - } - } -} - -export const avatarFileCache = AvatarFileCacheService.getInstance(); diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts deleted file mode 100644 index feac28e..0000000 --- a/electron/services/backupService.ts +++ /dev/null @@ -1,1088 +0,0 @@ -import { BrowserWindow, app } from 'electron' -import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs' -import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises' -import { basename, dirname, join, relative, resolve, sep } from 'path' -import { tmpdir } from 'os' -import * as tar from 'tar' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { expandHomePath } from '../utils/pathUtils' - -type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink' -type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed' -type BackupResourceKind = 'image' | 'video' | 'file' -const TEMP_MARKER = '.weflow-backup-temp' -const TEMP_TTL_MS = 24 * 60 * 60 * 1000 - -export interface BackupOptions { - includeImages?: boolean - includeVideos?: boolean - includeFiles?: boolean -} - -interface BackupDbEntry { - id: string - kind: BackupDbKind - dbPath: string - relativePath: string - tables: BackupTableEntry[] -} - -interface BackupTableEntry { - name: string - snapshotPath: string - rows: number - columns: number - schemaSql?: string -} - -interface BackupResourceEntry { - kind: BackupResourceKind - id: string - md5?: string - sessionId?: string - createTime?: number - sourceFileName?: string - archivePath: string - targetRelativePath: string - ext?: string - size?: number -} - -interface BackupManifest { - version: 1 - type: 'weflow-db-snapshots' - createdAt: string - appVersion: string - source: { - wxid: string - dbRoot: string - } - databases: BackupDbEntry[] - options?: BackupOptions - resources?: { - images?: BackupResourceEntry[] - videos?: BackupResourceEntry[] - files?: BackupResourceEntry[] - } -} - -interface BackupProgress { - phase: BackupPhase - message: string - current?: number - total?: number - detail?: string -} - -function emitBackupProgress(progress: BackupProgress): void { - for (const win of BrowserWindow.getAllWindows()) { - if (!win.isDestroyed()) { - win.webContents.send('backup:progress', progress) - } - } -} - -function safeName(value: string): string { - return encodeURIComponent(value || 'unnamed').replace(/%/g, '_') -} - -function toArchivePath(path: string): string { - return path.split(sep).join('/') -} - -async function withTimeout(task: Promise, timeoutMs: number, message: string): Promise { - let timer: NodeJS.Timeout | null = null - try { - return await Promise.race([ - task, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), timeoutMs) - }) - ]) - } finally { - if (timer) clearTimeout(timer) - } -} - -function delay(ms = 0): Promise { - return new Promise(resolveDelay => setTimeout(resolveDelay, ms)) -} - -function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void { - let lastEmitAt = 0 - return (progress: BackupProgress, force = false) => { - const now = Date.now() - if (!force && now - lastEmitAt < minIntervalMs) return - lastEmitAt = now - emitBackupProgress(progress) - } -} - -async function runWithConcurrency( - items: T[], - concurrency: number, - worker: (item: T, index: number) => Promise -): Promise { - let nextIndex = 0 - const workerCount = Math.max(1, Math.min(concurrency, items.length)) - await Promise.all(Array.from({ length: workerCount }, async () => { - while (true) { - const index = nextIndex - nextIndex += 1 - if (index >= items.length) return - await worker(items[index], index) - if (index % 50 === 0) await delay() - } - })) -} - -function hasResourceOptions(options: BackupOptions): boolean { - return options.includeImages === true || options.includeVideos === true || options.includeFiles === true -} - -function normalizeArchivePath(value: string): string { - return String(value || '').replace(/\\/g, '/') -} - -export class BackupService { - private configService = new ConfigService() - private cleanedTempDirs = false - - private cleanupStaleTempDirs(): void { - if (this.cleanedTempDirs) return - this.cleanedTempDirs = true - const root = tmpdir() - const now = Date.now() - try { - for (const entry of readdirSync(root)) { - if (!entry.startsWith('weflow-backup-')) continue - const dir = join(root, entry) - const marker = join(dir, TEMP_MARKER) - try { - const stat = statSync(dir) - if (!stat.isDirectory()) continue - if (!existsSync(marker)) continue - const age = now - stat.mtimeMs - if (age < TEMP_TTL_MS) continue - rmSync(dir, { recursive: true, force: true }) - } catch {} - } - } catch {} - } - - private async createTempDir(prefix: string): Promise { - this.cleanupStaleTempDirs() - const dir = await mkdtemp(join(tmpdir(), prefix)) - await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8') - return dir - } - - private buildWxidCandidates(wxid: string): string[] { - const wxidCandidates = Array.from(new Set([ - String(wxid || '').trim(), - this.cleanAccountDirName(wxid) - ].filter(Boolean))) - return wxidCandidates - } - - private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean { - const accountName = basename(accountDir).toLowerCase() - return wxidCandidates - .map(item => item.toLowerCase()) - .some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`)) - } - - private normalizeExistingPath(inputPath: string): string { - const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '') - if (!expanded) return expanded - try { - if (existsSync(expanded) && statSync(expanded).isFile()) { - return dirname(expanded) - } - } catch {} - return expanded - } - - private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null { - let current = normalized - for (let i = 0; i < 8; i += 1) { - if (!current) break - if (basename(current).toLowerCase() === 'db_storage') { - const accountDir = dirname(current) - if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) { - return current - } - } - const parent = dirname(current) - if (!parent || parent === current) break - current = parent - } - return null - } - - private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null { - if (!rootPath || !existsSync(rootPath)) return null - - for (const candidateWxid of wxidCandidates) { - const viaWxid = join(rootPath, candidateWxid, 'db_storage') - if (existsSync(viaWxid)) return viaWxid - } - - try { - const entries = readdirSync(rootPath) - const loweredWxids = wxidCandidates.map(item => item.toLowerCase()) - for (const entry of entries) { - const entryPath = join(rootPath, entry) - try { - if (!statSync(entryPath).isDirectory()) continue - } catch { - continue - } - const lowerEntry = entry.toLowerCase() - if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue - const candidate = join(entryPath, 'db_storage') - if (existsSync(candidate)) return candidate - } - } catch {} - - return null - } - - private resolveDbStoragePath(dbPath: string, wxid: string): string | null { - const normalized = this.normalizeExistingPath(dbPath) - if (!normalized) return null - - const wxidCandidates = this.buildWxidCandidates(wxid) - const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates) - if (ancestor) return ancestor - - const direct = join(normalized, 'db_storage') - if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct - - const roots = Array.from(new Set([ - normalized, - join(normalized, 'WeChat Files'), - join(normalized, 'xwechat_files') - ])) - for (const root of roots) { - const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates) - if (dbStorage) return dbStorage - } - - return null - } - - private resolveAccountDir(dbPath: string, wxid: string): string | null { - const dbStorage = this.resolveDbStoragePath(dbPath, wxid) - return dbStorage ? dirname(dbStorage) : null - } - - private cleanAccountDirName(wxid: string): string { - const trimmed = String(wxid || '').trim() - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - return match?.[1] || trimmed - } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - return suffixMatch ? suffixMatch[1] : trimmed - } - - private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise { - const dir = join(root, rel) - const files: string[] = [] - for (const entry of readdirSync(dir)) { - const entryRel = rel ? join(rel, entry) : entry - const entryPath = join(root, entryRel) - try { - const stat = statSync(entryPath) - if (stat.isDirectory()) { - files.push(...await this.listFilesForArchive(root, entryRel, state)) - } else if (stat.isFile()) { - files.push(toArchivePath(entryRel)) - } - state.visited += 1 - if (state.visited % 200 === 0) await delay() - } catch {} - } - return files - } - - private resolveExtractedPath(extractDir: string, archivePath: string): string | null { - const normalized = normalizeArchivePath(archivePath) - if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null - const root = resolve(extractDir) - const target = resolve(join(extractDir, normalized)) - if (target !== root && !target.startsWith(`${root}${sep}`)) return null - return target - } - - private resolveStagingPath(stagingDir: string, archivePath: string): string | null { - return this.resolveExtractedPath(stagingDir, archivePath) - } - - private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null { - const normalized = normalizeArchivePath(relativePath) - if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null - const root = resolve(accountDir) - const target = resolve(join(accountDir, normalized)) - if (target !== root && !target.startsWith(`${root}${sep}`)) return null - return target - } - - private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null { - const rel = toArchivePath(relative(accountDir, filePath)) - if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null - return rel - } - - private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise { - const files: string[] = [] - if (!existsSync(root)) return files - try { - for (const entry of readdirSync(root)) { - const fullPath = join(root, entry) - let stat - try { - stat = statSync(fullPath) - } catch { - continue - } - if (stat.isDirectory()) { - files.push(...await this.listFilesUnderDir(fullPath, state)) - } else if (stat.isFile()) { - files.push(fullPath) - } - state.visited += 1 - if (state.visited % 300 === 0) await delay() - } - } catch {} - return files - } - - private async stagePlainResource(sourcePath: string, outputPath: string): Promise { - mkdirSync(dirname(outputPath), { recursive: true }) - try { - await link(sourcePath, outputPath) - } catch { - await copyFile(sourcePath, outputPath) - } - } - - private async writeTarEntryToFile(entry: any, outputPath: string): Promise { - mkdirSync(dirname(outputPath), { recursive: true }) - await new Promise((resolvePromise, rejectPromise) => { - const out = createWriteStream(outputPath) - const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error))) - out.on('finish', resolvePromise) - out.on('error', fail) - entry.on('error', fail) - entry.pipe(out) - }) - } - - private async listChatImageDatFiles(accountDir: string): Promise { - const attachRoot = join(accountDir, 'msg', 'attach') - const result: string[] = [] - if (!existsSync(attachRoot)) return result - - const scanImgDir = async (imgDir: string): Promise => { - let entries: string[] = [] - try { - entries = readdirSync(imgDir) - } catch { - return - } - for (const entry of entries) { - const fullPath = join(imgDir, entry) - let stat - try { - stat = statSync(fullPath) - } catch { - continue - } - if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) { - result.push(fullPath) - } else if (stat.isDirectory()) { - let nestedEntries: string[] = [] - try { - nestedEntries = readdirSync(fullPath) - } catch { - continue - } - for (const nestedEntry of nestedEntries) { - const nestedPath = join(fullPath, nestedEntry) - try { - if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) { - result.push(nestedPath) - } - } catch {} - } - } - if (result.length > 0 && result.length % 500 === 0) await delay() - } - } - - const walk = async (dir: string): Promise => { - let entries: Array<{ name: string; isDirectory: () => boolean }> = [] - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const child = join(dir, entry.name) - if (entry.name.toLowerCase() === 'img') { - await scanImgDir(child) - } else { - await walk(child) - } - if (result.length > 0 && result.length % 500 === 0) await delay() - } - } - - await walk(attachRoot) - return Array.from(new Set(result)) - } - - private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> { - const configuredWxid = String(this.configService.getMyWxidCleaned() || '').trim() - const wxid = String(wxidOverride || configuredWxid || '').trim() - const dbPath = String(this.configService.get('dbPath') || '').trim() - const decryptKey = String(this.configService.get('decryptKey') || '').trim() - if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' } - if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' } - - // 使用 ConfigService 统一解析账号目录 - const accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` } - const dbStorage = join(accountDir, 'db_storage') - if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } - - const accountDirName = basename(accountDir) - const opened = await withTimeout( - wcdbService.open(accountDir, decryptKey), - 15000, - '连接目标账号数据库超时,请检查数据库路径、密钥是否正确' - ) - if (!opened) { - const detail = await wcdbService.getLastInitError().catch(() => null) - return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` } - } - - return { success: true, wxid: accountDir, dbPath, dbStorage } - } - - private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string { - if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind - return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}` - } - - private toDbRelativePath(dbStorage: string, dbPath: string): string { - const rel = toArchivePath(relative(dbStorage, dbPath)) - if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath) - return rel - } - - private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null { - const normalized = String(relativePath || '').replace(/\\/g, '/') - if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null - const root = resolve(dbStorage) - const target = resolve(join(dbStorage, normalized)) - if (target !== root && !target.startsWith(`${root}${sep}`)) return null - return target - } - - private defaultRelativeDbPath(kind: BackupDbKind): string | null { - if (kind === 'session') return 'session/session.db' - if (kind === 'contact') return 'contact/contact.db' - if (kind === 'emoticon') return 'emoticon/emoticon.db' - if (kind === 'sns') return 'sns/sns.db' - if (kind === 'hardlink') return 'hardlink/hardlink.db' - return null - } - - private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null { - const normalized = String(db.relativePath || '').replace(/\\/g, '/') - const legacyFixedPath = this.defaultRelativeDbPath(db.kind) - if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) { - return this.resolveTargetDbPath(dbStorage, legacyFixedPath) - } - return this.resolveTargetDbPath(dbStorage, db.relativePath) - } - - private findFirstExisting(paths: string[]): string { - for (const path of paths) { - try { - if (existsSync(path) && statSync(path).isFile()) return path - } catch {} - } - return '' - } - - private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string { - if (kind === 'session') { - return this.findFirstExisting([ - join(dbStorage, 'session', 'session.db'), - join(dbStorage, 'Session', 'session.db'), - join(dbStorage, 'session.db') - ]) - } - if (kind === 'contact') { - return this.findFirstExisting([ - join(dbStorage, 'Contact', 'contact.db'), - join(dbStorage, 'Contact', 'Contact.db'), - join(dbStorage, 'contact', 'contact.db'), - join(dbStorage, 'session', 'contact.db') - ]) - } - if (kind === 'emoticon') { - return this.findFirstExisting([ - join(dbStorage, 'emoticon', 'emoticon.db'), - join(dbStorage, 'emotion', 'emoticon.db') - ]) - } - if (kind === 'sns') { - return this.findFirstExisting([ - join(dbStorage, 'sns', 'sns.db'), - join(dirname(dbStorage), 'sns', 'sns.db') - ]) - } - if (kind === 'hardlink') { - return this.findFirstExisting([ - join(dbStorage, 'hardlink', 'hardlink.db'), - join(dbStorage, 'hardlink.db'), - join(dirname(dbStorage), 'hardlink.db') - ]) - } - return '' - } - - private async collectDatabases(dbStorage: string): Promise>> { - const result: Array> = [] - for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) { - const dbPath = this.resolveKnownDbPath(kind, dbStorage) - result.push({ - id: kind, - kind, - dbPath, - relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind - }) - } - - const messageDbs = await wcdbService.listMessageDbs() - if (messageDbs.success && Array.isArray(messageDbs.data)) { - messageDbs.data.forEach((dbPath, index) => { - result.push({ - id: this.buildDbId('message', index, dbPath), - kind: 'message', - dbPath, - relativePath: this.toDbRelativePath(dbStorage, dbPath) - }) - }) - } - - const mediaDbs = await wcdbService.listMediaDbs() - if (mediaDbs.success && Array.isArray(mediaDbs.data)) { - mediaDbs.data.forEach((dbPath, index) => { - result.push({ - id: this.buildDbId('media', index, dbPath), - kind: 'media', - dbPath, - relativePath: this.toDbRelativePath(dbStorage, dbPath) - }) - }) - } - - return result - } - - private async collectImageResources( - connected: { wxid: string; dbStorage: string }, - stagingDir: string, - manifest: BackupManifest - ): Promise { - const accountDir = dirname(connected.dbStorage) - const imagesDir = join(stagingDir, 'resources', 'images') - const imagePaths = await this.listChatImageDatFiles(accountDir) - if (imagePaths.length === 0) return - - mkdirSync(imagesDir, { recursive: true }) - const resources: BackupResourceEntry[] = [] - const emitImageProgress = createThrottledProgressEmitter(160) - for (let index = 0; index < imagePaths.length; index += 1) { - const sourcePath = imagePaths[index] - const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) - if (!relativeTarget) continue - emitImageProgress({ - phase: 'exporting', - message: '正在打包图片资源', - current: index + 1, - total: imagePaths.length, - detail: relativeTarget - }) - const archivePath = toArchivePath(join('resources', 'images', relativeTarget)) - const outputPath = join(stagingDir, archivePath) - await this.stagePlainResource(sourcePath, outputPath) - const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase() - const stat = statSync(sourcePath) - resources.push({ - kind: 'image', - id: relativeTarget, - md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined, - sourceFileName: basename(sourcePath), - archivePath, - targetRelativePath: relativeTarget, - size: stat.size - }) - if (index % 20 === 0) await delay() - } - - if (resources.length > 0) { - manifest.resources = { ...(manifest.resources || {}), images: resources } - } - } - - private async collectPlainResources( - connected: { dbStorage: string }, - stagingDir: string, - manifest: BackupManifest, - kind: 'video' | 'file' - ): Promise { - const accountDir = dirname(connected.dbStorage) - const roots = kind === 'video' - ? [ - join(accountDir, 'msg', 'video'), - join(accountDir, 'FileStorage', 'Video') - ] - : [ - join(accountDir, 'FileStorage', 'File'), - join(accountDir, 'msg', 'file') - ] - const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root))) - const uniqueFiles = Array.from(new Set(listed.flat())) - if (uniqueFiles.length === 0) return - - const resources: BackupResourceEntry[] = [] - const bucket = kind === 'video' ? 'videos' : 'files' - const emitResourceProgress = createThrottledProgressEmitter(180) - await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => { - emitResourceProgress({ - phase: 'exporting', - message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源', - current: index + 1, - total: uniqueFiles.length, - detail: basename(sourcePath) - }) - const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath) - if (!relativeTarget) return - const archivePath = toArchivePath(join('resources', bucket, relativeTarget)) - const outputPath = join(stagingDir, archivePath) - await this.stagePlainResource(sourcePath, outputPath) - let size = 0 - try { size = statSync(sourcePath).size } catch {} - const entry: BackupResourceEntry = { - kind, - id: relativeTarget, - sourceFileName: basename(sourcePath), - archivePath, - targetRelativePath: relativeTarget, - size - } - resources.push(entry) - }) - - if (resources.length > 0) { - manifest.resources = { - ...(manifest.resources || {}), - [bucket]: resources - } - } - } - - async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> { - let stagingDir = '' - try { - emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' }) - const connected = await this.ensureConnected() - if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) { - return { success: false, error: connected.error || '数据库未连接' } - } - - stagingDir = await this.createTempDir('weflow-backup-') - const snapshotsDir = join(stagingDir, 'snapshots') - mkdirSync(snapshotsDir, { recursive: true }) - - const dbs = await this.collectDatabases(connected.dbStorage) - const manifest: BackupManifest = { - version: 1, - type: 'weflow-db-snapshots', - createdAt: new Date().toISOString(), - appVersion: app.getVersion(), - source: { - wxid: connected.wxid, - dbRoot: connected.dbPath - }, - databases: [], - options: { - includeImages: options.includeImages === true, - includeVideos: options.includeVideos === true, - includeFiles: options.includeFiles === true - } - } - - const tableJobs: Array<{ db: Omit; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = [] - for (let index = 0; index < dbs.length; index += 1) { - const db = dbs[index] - emitBackupProgress({ - phase: 'scanning', - message: '正在扫描数据库和表', - current: index + 1, - total: dbs.length, - detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}` - }) - const tablesResult = await wcdbService.listTables(db.kind, db.dbPath) - if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue - const dbDir = join(snapshotsDir, db.id) - mkdirSync(dbDir, { recursive: true }) - const entry: BackupDbEntry = { ...db, tables: [] } - manifest.databases.push(entry) - for (const table of tablesResult.tables) { - const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table) - if (!schemaResult.success || !schemaResult.schema) continue - const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`)) - tableJobs.push({ - db, - table, - schemaSql: schemaResult.schema, - snapshotPath, - outputPath: join(stagingDir, snapshotPath) - }) - } - } - - let current = 0 - for (const job of tableJobs) { - current++ - emitBackupProgress({ - phase: 'exporting', - message: '正在导出数据库快照', - current, - total: tableJobs.length, - detail: `${job.db.kind}:${job.table}` - }) - const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath) - if (!exported.success) { - throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`) - } - const dbEntry = manifest.databases.find(item => item.id === job.db.id) - dbEntry?.tables.push({ - name: job.table, - snapshotPath: job.snapshotPath, - rows: exported.rows || 0, - columns: exported.columns || 0, - schemaSql: job.schemaSql - }) - } - - if (options.includeImages === true) { - await this.collectImageResources( - { wxid: connected.wxid, dbStorage: connected.dbStorage }, - stagingDir, - manifest - ) - } - if (options.includeVideos === true) { - await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video') - } - if (options.includeFiles === true) { - await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file') - } - - await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8') - mkdirSync(dirname(outputPath), { recursive: true }) - const archiveFiles = await this.listFilesForArchive(stagingDir) - const shouldCompress = !hasResourceOptions(options) - let packed = 0 - const emitPackingProgress = createThrottledProgressEmitter(150) - emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length }) - await tar.c({ - gzip: shouldCompress ? { level: 1 } : false, - cwd: stagingDir, - file: outputPath, - portable: true, - noMtime: true, - sync: false, - onWriteEntry: (entry: any) => { - packed += 1 - emitPackingProgress({ - phase: 'packing', - message: '正在写入备份包', - current: Math.min(packed, archiveFiles.length), - total: archiveFiles.length, - detail: String(entry?.path || entry || '') - }) - } - } as any, archiveFiles) - emitBackupProgress({ - phase: 'packing', - message: '正在写入备份包', - current: archiveFiles.length, - total: archiveFiles.length - }) - emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length }) - return { success: true, filePath: outputPath, manifest } - } catch (e) { - const error = e instanceof Error ? e.message : String(e) - emitBackupProgress({ phase: 'failed', message: error }) - return { success: false, error } - } finally { - if (stagingDir) { - try { rmSync(stagingDir, { recursive: true, force: true }) } catch {} - } - } - } - - async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> { - let extractDir = '' - try { - emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' }) - extractDir = await this.createTempDir('weflow-backup-inspect-') - await tar.x({ - file: archivePath, - cwd: extractDir, - filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json' - } as any) - const manifestPath = join(extractDir, 'manifest.json') - if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } - const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest - if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { - emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' }) - return { success: false, error: '不支持的备份包格式' } - } - emitBackupProgress({ phase: 'done', message: '备份包已读取' }) - return { success: true, manifest } - } catch (e) { - emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) }) - return { success: false, error: e instanceof Error ? e.message : String(e) } - } finally { - if (extractDir) { - try { rmSync(extractDir, { recursive: true, force: true }) } catch {} - } - } - } - - private async streamRestoreArchive( - archivePath: string, - extractDir: string, - manifest: BackupManifest, - connected: { dbStorage: string; wxid?: string }, - startCurrent: number, - total: number - ): Promise<{ current: number; skipped: number }> { - const snapshotPaths = new Set() - for (const db of manifest.databases || []) { - for (const table of db.tables || []) { - const path = normalizeArchivePath(table.snapshotPath) - if (path) snapshotPaths.add(path) - } - } - - const imageByPath = new Map() - for (const image of manifest.resources?.images || []) { - const path = normalizeArchivePath(image.archivePath) - if (path) imageByPath.set(path, image) - } - - const plainByPath = new Map() - for (const resource of [ - ...(manifest.resources?.videos || []), - ...(manifest.resources?.files || []) - ]) { - const path = normalizeArchivePath(resource.archivePath) - if (path) plainByPath.set(path, resource) - } - - const accountDir = dirname(connected.dbStorage) - let current = startCurrent - let skipped = 0 - const pending: Promise[] = [] - const emitRestoreProgress = createThrottledProgressEmitter(160) - await tar.t({ - file: archivePath, - onReadEntry: (entry: any) => { - const entryPath = normalizeArchivePath(entry.path) - if (snapshotPaths.has(entryPath)) { - const outputPath = this.resolveStagingPath(extractDir, entryPath) - if (!outputPath) { - entry.resume() - return - } - pending.push(this.writeTarEntryToFile(entry, outputPath)) - return - } - - const image = imageByPath.get(entryPath) - if (image) { - const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath) - if (!targetPath) { - skipped += 1 - entry.resume() - return - } - current += 1 - emitRestoreProgress({ - phase: 'restoring', - message: '正在写回图片资源', - current, - total, - detail: image.md5 || image.targetRelativePath - }) - if (existsSync(targetPath)) { - skipped += 1 - entry.resume() - return - } - pending.push(this.writeTarEntryToFile(entry, targetPath)) - return - } - - const resource = plainByPath.get(entryPath) - if (resource) { - const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath) - current += 1 - emitRestoreProgress({ - phase: 'restoring', - message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源', - current, - total, - detail: resource.targetRelativePath - }) - if (!targetPath || existsSync(targetPath)) { - skipped += 1 - entry.resume() - return - } - pending.push(this.writeTarEntryToFile(entry, targetPath)) - return - } - - entry.resume() - } - } as any) - - await Promise.all(pending) - return { current, skipped } - } - - async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> { - let extractDir = '' - try { - emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' }) - extractDir = await this.createTempDir('weflow-backup-restore-') - await tar.x({ - file: archivePath, - cwd: extractDir, - filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json' - } as any) - const manifestPath = join(extractDir, 'manifest.json') - if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } - const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest - if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { - return { success: false, error: '不支持的备份包格式' } - } - const targetWxid = String(manifest.source?.wxid || '').trim() - if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' } - - emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid }) - const connected = await this.ensureConnected(targetWxid) - if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' } - - const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table }))) - const imageJobs = manifest.resources?.images || [] - const plainResourceJobs = [ - ...(manifest.resources?.videos || []), - ...(manifest.resources?.files || []) - ] - const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length - let inserted = 0 - let ignored = 0 - let skipped = 0 - let current = 0 - if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) { - emitBackupProgress({ - phase: 'inspecting', - message: '正在按需读取备份包', - current: 0, - total: totalRestoreJobs, - detail: archivePath - }) - const streamed = await this.streamRestoreArchive( - archivePath, - extractDir, - manifest, - { dbStorage: connected.dbStorage, wxid: connected.wxid }, - 0, - totalRestoreJobs - ) - current = streamed.current - skipped += streamed.skipped - } - - for (const job of tableJobs) { - current++ - const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db) - if (targetDbPath === null) { - skipped++ - continue - } - if (!job.table.schemaSql) { - skipped++ - continue - } - - emitBackupProgress({ - phase: 'restoring', - message: '正在通过 WCDB 写入数据库', - current, - total: totalRestoreJobs, - detail: `${job.db.kind}:${job.table.name}` - }) - const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath) - if (!inputPath || !existsSync(inputPath)) { - skipped++ - continue - } - mkdirSync(dirname(targetDbPath), { recursive: true }) - const restored = await wcdbService.importTableSnapshotWithSchema( - job.db.kind, - targetDbPath, - job.table.name, - inputPath, - job.table.schemaSql - ) - if (!restored.success) { - skipped++ - continue - } - inserted += restored.inserted || 0 - ignored += restored.ignored || 0 - if (current % 4 === 0) await delay() - } - - emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs }) - return { success: true, inserted, ignored, skipped } - } catch (e) { - const error = e instanceof Error ? e.message : String(e) - emitBackupProgress({ phase: 'failed', message: error }) - return { success: false, error } - } finally { - if (extractDir) { - try { rmSync(extractDir, { recursive: true, force: true }) } catch {} - } - } - } -} - -export const backupService = new BackupService() diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts deleted file mode 100644 index 2ee481e..0000000 --- a/electron/services/bizService.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { join } from 'path' -import { readdirSync, existsSync } from 'fs' -import { wcdbService } from './wcdbService' -import { ConfigService } from './config' -import { chatService, Message } from './chatService' -import { ipcMain } from 'electron' -import { createHash } from 'crypto' - -export interface BizAccount { - username: string - name: string - avatar: string - type: number - last_time: number - formatted_last_time: string - unread_count?: number -} - -export interface BizMessage { - local_id: number - create_time: number - title: string - des: string - url: string - cover: string - content_list: any[] -} - -export interface BizPayRecord { - local_id: number - create_time: number - title: string - description: string - merchant_name: string - merchant_icon: string - timestamp: number - formatted_time: string -} - -export class BizService { - private configService: ConfigService - - constructor() { - this.configService = new ConfigService() - } - - 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 parseBizContentList(xmlStr: string): any[] { - if (!xmlStr) return [] - const contentList: any[] = [] - try { - const itemRegex = /([\s\S]*?)<\/item>/gi - let match: RegExpExecArray | null - while ((match = itemRegex.exec(xmlStr)) !== null) { - const itemXml = match[1] - const itemStruct = { - title: this.extractXmlValue(itemXml, 'title'), - url: this.extractXmlValue(itemXml, 'url'), - cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'), - summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest') - } - if (itemStruct.title) contentList.push(itemStruct) - } - } catch (e) { } - return contentList - } - - private parsePayXml(xmlStr: string): any { - if (!xmlStr) return null - try { - const title = this.extractXmlValue(xmlStr, 'title') - const description = this.extractXmlValue(xmlStr, 'des') - const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付' - const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url') - const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0') - if (!title && !description) return null - return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime } - } catch (e) { return null } - } - - async listAccounts(account?: string): Promise { - try { - // 1. 获取公众号联系人列表 - const contactsResult = await chatService.getContacts({ lite: true }) - if (!contactsResult.success || !contactsResult.contacts) return [] - - const officialContacts = contactsResult.contacts.filter(c => c.type === 'official') - const usernames = officialContacts.map(c => c.username) - - // 获取头像和昵称等补充信息 - const enrichment = await chatService.enrichSessionsContactInfo(usernames) - const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} - - const root = this.configService.get('dbPath') - const myWxid = this.configService.getMyWxidCleaned() - const accountWxid = account || myWxid - if (!root || !accountWxid) return [] - - const bizLatestTime: Record = {} - const bizUnreadCount: Record = {} - - try { - const sessionsRes = await chatService.getSessions() - if (sessionsRes.success && sessionsRes.sessions) { - for (const session of sessionsRes.sessions) { - const uname = session.username || session.strUsrName || session.userName || session.id - // 适配日志中发现的字段,注意转为整型数字 - const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' - const time = parseInt(timeStr.toString(), 10) - - if (usernames.includes(uname) && time > 0) { - bizLatestTime[uname] = time - } - if (usernames.includes(uname)) { - const unread = Number(session.unreadCount ?? session.unread_count ?? 0) - bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0 - } - } - } - } catch (e) { - console.error('获取 Sessions 失败:', e) - } - - // 3. 格式化时间显示 - const formatBizTime = (ts: number) => { - if (!ts) return '' - const date = new Date(ts * 1000) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) - - const yesterday = new Date(now) - yesterday.setDate(now.getDate() - 1) - if (date.toDateString() === yesterday.toDateString()) return '昨天' - - const isThisYear = date.getFullYear() === now.getFullYear() - if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}` - - return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}` - } - - // 4. 组装数据 - const result: BizAccount[] = officialContacts.map(contact => { - const uname = contact.username - const info = contactInfoMap[uname] - const lastTime = bizLatestTime[uname] || 0 - return { - username: uname, - name: info?.displayName || contact.displayName || uname, - avatar: info?.avatarUrl || '', - type: 0, - last_time: lastTime, - formatted_last_time: formatBizTime(lastTime), - unread_count: bizUnreadCount[uname] || 0 - } - }) - - // 5. 补充公众号类型 (订阅号/服务号) - const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db') - if (existsSync(contactDbPath)) { - const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') - if (bizInfoRes.success && bizInfoRes.rows) { - const typeMap: Record = {} - for (const r of bizInfoRes.rows) typeMap[r.username] = r.type - for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username] - } - } - - // 6. 排序输出 - return result - .filter(acc => !acc.name.includes('广告')) - .sort((a, b) => { - if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶 - if (b.username === 'gh_3dfda90e39d6') return 1 - return b.last_time - a.last_time // 按最新时间降序排列 - }) - } catch (e) { - console.error('获取账号列表发生错误:', e) - return [] - } - } - - async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise { - try { - // 仅保留核心路径:利用 chatService 的自动路由能力 - const res = await chatService.getMessages(username, offset, limit) - if (!res.success || !res.messages) return [] - - return res.messages.map(msg => { - const bizMsg: BizMessage = { - local_id: msg.localId, - create_time: msg.createTime, - title: msg.linkTitle || msg.parsedContent || '', - des: msg.appMsgDesc || '', - url: msg.linkUrl || '', - cover: msg.linkThumb || msg.appMsgThumbUrl || '', - content_list: [] - } - if (msg.rawContent) { - bizMsg.content_list = this.parseBizContentList(msg.rawContent) - if (bizMsg.content_list.length > 0 && !bizMsg.title) { - bizMsg.title = bizMsg.content_list[0].title - bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover - } - } - return bizMsg - }) - } catch (e) { return [] } - } - - async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise { - const username = 'gh_3dfda90e39d6' - try { - const res = await chatService.getMessages(username, offset, limit) - if (!res.success || !res.messages) return [] - - const records: BizPayRecord[] = [] - for (const msg of res.messages) { - if (!msg.rawContent) continue - const parsedData = this.parsePayXml(msg.rawContent) - if (parsedData) { - records.push({ - local_id: msg.localId, - create_time: msg.createTime, - ...parsedData, - timestamp: parsedData.timestamp || msg.createTime, - formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString() - }) - } - } - return records - } catch (e) { return [] } - } - - registerHandlers() { - ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account)) - ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset)) - ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset)) - } -} - -export const bizService = new BizService() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts deleted file mode 100644 index 6c393a0..0000000 --- a/electron/services/chatService.ts +++ /dev/null @@ -1,11897 +0,0 @@ -import { join, dirname, basename, extname } from 'path' -import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' -import { createRequire } from 'module' -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 { app, BrowserWindow, dialog } from 'electron' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { MessageCacheService } from './messageCacheService' -import { ContactCacheService, ContactCacheEntry } from './contactCacheService' -import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' -import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' -import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' -import { voiceTranscribeService } from './voiceTranscribeService' -import { ImageDecryptService } from './imageDecryptService' -import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData' -import { LRUCache } from '../utils/LRUCache.js' - -export interface ChatSession { - username: string - type: number - unreadCount: number - summary: string - sortTimestamp: number // 用于排序 - lastTimestamp: number // 用于显示时间 - lastMsgType: number - messageCountHint?: number - displayName?: string - avatarUrl?: string - lastMsgSender?: string - lastSenderDisplayName?: string - selfWxid?: string - isFolded?: boolean // 是否已折叠进"折叠的群聊" - isMuted?: boolean // 是否开启免打扰 -} - -export interface Message { - messageKey: string - localId: number - serverId: number - serverIdRaw?: string - localType: number - createTime: number - sortSeq: number - isSend: number | null - senderUsername: string | null - parsedContent: string - rawContent: string - content?: string // 原始XML内容(与rawContent相同,供前端使用) - // 表情包相关 - emojiCdnUrl?: string - emojiMd5?: string - emojiLocalPath?: string // 本地缓存 castle 路径 - emojiThumbUrl?: string - emojiEncryptUrl?: string - emojiAesKey?: string - // 引用消息相关 - quotedContent?: string - quotedSender?: string - // 图片/视频相关 - imageMd5?: string - imageDatName?: string - videoMd5?: string - aesKey?: string - encrypVer?: number - cdnThumbUrl?: string - voiceDurationSeconds?: number - // Type 49 细分字段 - linkTitle?: string // 链接/文件标题 - linkUrl?: string // 链接 URL - linkThumb?: string // 链接缩略图 - fileName?: string // 文件名 - fileSize?: number // 文件大小 - fileExt?: string // 文件扩展名 - fileMd5?: string // 文件 MD5 - xmlType?: string // XML 中的 type 字段 - appMsgKind?: string // 归一化 appmsg 类型 - appMsgDesc?: string - appMsgAppName?: string - appMsgSourceName?: string - appMsgSourceUsername?: string - appMsgThumbUrl?: string - appMsgMusicUrl?: string - appMsgDataUrl?: string - appMsgLocationLabel?: string - finderNickname?: string - finderUsername?: string - finderCoverUrl?: string - finderAvatar?: string - finderDuration?: number - // 位置消息 - locationLat?: number - locationLng?: number - locationPoiname?: string - locationLabel?: string - // 音乐消息 - musicAlbumUrl?: string - musicUrl?: string - // 礼物消息 - giftImageUrl?: string - giftWish?: string - giftPrice?: string - // 名片消息 - cardUsername?: string // 名片的微信ID - cardNickname?: string // 名片的昵称 - cardAvatarUrl?: string // 名片头像 URL - // 转账消息 - transferPayerUsername?: string // 转账付款人 - transferReceiverUsername?: string // 转账收款人 - // 聊天记录 - chatRecordTitle?: string // 聊天记录标题 - chatRecordList?: Array<{ - datatype: number - sourcename: string - sourcetime: string - sourceheadurl?: string - datadesc?: string - datatitle?: string - fileext?: string - datasize?: number - messageuuid?: string - dataurl?: string - datathumburl?: string - datacdnurl?: string - cdndatakey?: string - cdnthumbkey?: string - aeskey?: string - md5?: string - fullmd5?: string - thumbfullmd5?: string - srcMsgLocalid?: number - imgheight?: number - imgwidth?: number - duration?: number - chatRecordTitle?: string - chatRecordDesc?: string - chatRecordList?: any[] - }> - _db_path?: string // 内部字段:记录消息所属数据库路径 -} - -type ResourceMessageType = 'image' | 'video' | 'voice' | 'file' - -interface ResourceMessageItem extends Message { - sessionId: string - sessionDisplayName?: string - resourceType: ResourceMessageType -} - -export interface Contact { - username: string - alias: string - remark: string - nickName: string -} - -export interface ContactInfo { - username: string - displayName: string - remark?: string - nickname?: string - alias?: string - labels?: string[] - detailDescription?: string - region?: string - avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' -} - -interface GetContactsOptions { - lite?: boolean -} - -interface ExportSessionStats { - totalMessages: number - voiceMessages: number - imageMessages: number - videoMessages: number - emojiMessages: number - transferMessages: number - redPacketMessages: number - callMessages: number - firstTimestamp?: number - lastTimestamp?: number - privateMutualGroups?: number - groupMemberCount?: number - groupMyMessages?: number - groupActiveSpeakers?: number - groupMutualFriends?: number -} - -interface ExportSessionStatsOptions { - includeRelations?: boolean - forceRefresh?: boolean - allowStaleCache?: boolean - preferAccurateSpecialTypes?: boolean - cacheOnly?: boolean - beginTimestamp?: number - endTimestamp?: number -} - -interface ExportSessionStatsCacheMeta { - updatedAt: number - stale: boolean - includeRelations: boolean - source: 'memory' | 'disk' | 'fresh' -} - -interface ExportTabCounts { - private: number - group: number - official: number - former_friend: number -} - -interface SessionDetailFast { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number -} - -interface SessionDetailExtra { - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] -} - -type SessionDetail = SessionDetailFast & SessionDetailExtra - -interface SyntheticUnreadState { - readTimestamp: number - scannedTimestamp: number - latestTimestamp: number - unreadCount: number - summaryTimestamp?: number - summary?: string - lastMsgType?: number -} - -interface MyFootprintSummary { - private_inbound_people: number - private_replied_people: number - private_outbound_people: number - private_reply_rate: number - mention_count: number - mention_group_count: number -} - -interface MyFootprintPrivateSession { - session_id: string - incoming_count: number - outgoing_count: number - replied: boolean - first_incoming_ts: number - first_reply_ts: number - latest_ts: number - anchor_local_id: number - anchor_create_time: number - displayName?: string - avatarUrl?: string -} - -interface MyFootprintPrivateSegment { - session_id: string - segment_index: number - start_ts: number - end_ts: number - duration_sec: number - incoming_count: number - outgoing_count: number - message_count: number - replied: boolean - first_incoming_ts: number - first_reply_ts: number - latest_ts: number - anchor_local_id: number - anchor_create_time: number - displayName?: string - avatarUrl?: string -} - -interface MyFootprintMentionItem { - session_id: string - local_id: number - create_time: number - sender_username: string - message_content: string - source: string - sessionDisplayName?: string - senderDisplayName?: string - senderAvatarUrl?: string -} - -interface MyFootprintMentionGroup { - session_id: string - count: number - latest_ts: number - displayName?: string - avatarUrl?: string -} - -interface MyFootprintDiagnostics { - truncated: boolean - scanned_dbs: number - elapsed_ms: number - mention_truncated?: boolean - private_truncated?: boolean - native_ms?: number - source_filter_ms?: number - fallback_ms?: number - enrich_ms?: number - pipeline_ms?: number - fallback_used?: boolean - private_limit_effective?: number - mention_candidate_limit?: number - native_mention_candidates?: number - source_filtered_mentions?: number - private_session_count?: number - group_session_count?: number - native_passes?: number - native_group_chunks?: number -} - -interface MyFootprintData { - summary: MyFootprintSummary - private_sessions: MyFootprintPrivateSession[] - private_segments: MyFootprintPrivateSegment[] - mentions: MyFootprintMentionItem[] - mention_groups: MyFootprintMentionGroup[] - diagnostics: MyFootprintDiagnostics -} - -// 表情包缓存 -const emojiCache: Map = new Map() -const emojiDownloading: Map> = new Map() -const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) - -class ChatService { - private configService: ConfigService - private runtimeConfig?: { dbPath?: string; decryptKey?: string; myWxid?: string } - private connected = false - private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>() - private messageCursors: Map = new Map() - private messageCursorMutex: boolean = false - private readonly messageBatchDefault = 50 - private readonly messageCursorSessionLimit = 8 - private avatarCache: Map - private readonly avatarCacheTtlMs = 10 * 60 * 1000 - private readonly defaultV1AesKey = 'cfcd208495d565ef' - private readonly contactCacheService: ContactCacheService - private readonly messageCacheService: MessageCacheService - private readonly sessionStatsCacheService: SessionStatsCacheService - private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService - private readonly imageDecryptService: ImageDecryptService - private voiceWavCache: LRUCache - private voiceTranscriptCache: LRUCache - private voiceTranscriptPending = new Map>() - private transcriptCacheLoaded = false - private transcriptCacheDirty = false - private transcriptFlushTimer: ReturnType | null = null - private mediaDbsCache: string[] | null = null - private mediaDbsCacheTime = 0 - private readonly mediaDbsCacheTtl = 300000 // 5分钟 - private readonly voiceWavCacheMaxEntries = 50 - // 缓存 media.db 的表结构信息 - private mediaDbSchemaCache = new Map() - // 缓存会话表信息,避免每次查询 - private sessionTablesCache = new Map; updatedAt: number }>() - private messageTableColumnsCache = new Map; updatedAt: number }>() - private messageName2IdTableCache = new Map() - private messageSenderIdCache = new Map() - private readonly sessionTablesCacheTtl = 300000 // 5分钟 - private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 - private messageDbCountSnapshotCache: { - dbPaths: string[] - dbSignature: string - updatedAt: number - } | null = null - private readonly messageDbCountSnapshotCacheTtlMs = 8000 - private sessionMessageCountCache = new Map() - private sessionMessageCountHintCache = new Map() - private syntheticUnreadState = new Map() - private sessionMessageCountBatchCache: { - dbSignature: string - sessionIdsKey: string - counts: Record - updatedAt: number - } | null = null - private sessionMessageCountCacheScope = '' - private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 - private readonly sessionMessageCountBatchCacheTtlMs = 5 * 60 * 1000 - private sessionDetailFastCache = new Map() - private sessionDetailExtraCache = new Map() - private readonly sessionDetailFastCacheTtlMs = 60 * 1000 - private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 - private sessionStatusCache = new Map() - private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 - private sessionStatsCacheScope = '' - private sessionStatsMemoryCache = new Map() - private sessionStatsPendingBasic = new Map>() - private sessionStatsPendingFull = new Map>() - private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null - private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 - private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 - private groupMyMessageCountCacheScope = '' - private groupMyMessageCountMemoryCache = new Map() - private initFailureDialogShown = false - private readonly contactExtendedFieldCandidates = [ - 'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList', - 'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign', - 'country', 'province', 'city', 'region', - 'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo', - 'extra_buffer', 'extraBuffer' - ] - private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase())) - private contactExtendedSelectableColumns: string[] | null = null - private contactLabelNameMapCache: Map | null = null - private contactLabelNameMapCacheAt = 0 - private readonly visibilityAnomalyLogWindowMs = 30000 - private readonly visibilityAnomalyLogBurst = 3 - private visibilityAnomalyLogState = new Map() - private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000 - private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null - private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>() - private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000 - private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN') - private readonly slowGetContactsLogThresholdMs = 1200 - - constructor() { - this.configService = new ConfigService() - this.contactCacheService = new ContactCacheService(this.configService.getCacheBasePath()) - const persisted = this.contactCacheService.getAllEntries() - this.avatarCache = new Map(Object.entries(persisted)) - this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) - this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) - this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) - this.imageDecryptService = new ImageDecryptService() - // 初始化LRU缓存,限制大小防止内存泄漏 - this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) - this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 - } - - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void { - this.runtimeConfig = config - } - - /** - * 清理账号目录名 - */ - 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})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - /** - * 判断头像 URL 是否可用,过滤历史缓存里的错误 hex 数据。 - */ - private isValidAvatarUrl(avatarUrl?: string): avatarUrl is string { - const normalized = String(avatarUrl || '').trim() - if (!normalized) return false - const normalizedLower = normalized.toLowerCase() - if (normalizedLower.includes('base64,ffd8')) return false - if (normalizedLower.startsWith('ffd8')) return false - return true - } - - private extractErrorCode(message?: string | null): number | null { - const text = String(message || '').trim() - if (!text) return null - const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/) - if (!match) return null - const parsed = Number(match[1]) - return Number.isFinite(parsed) ? parsed : null - } - - private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string { - const code = this.extractErrorCode(rawMessage) ?? fallbackCode - return `错误码: ${code}` - } - - private async maybeShowInitFailureDialog(errorMessage: string): Promise { - if (!app.isPackaged) return - if (this.initFailureDialogShown) return - - const code = this.extractErrorCode(errorMessage) - if (code === null) return - const isSecurityCode = - code === -101 || - code === -102 || - code === -2299 || - code === -2301 || - code === -2302 || - code === -1006 || - (code <= -2201 && code >= -2212) - if (!isSecurityCode) return - - this.initFailureDialogShown = true - const detail = [ - `错误码: ${code}` - ].join('\n') - - try { - await dialog.showMessageBox({ - type: 'error', - title: 'WeFlow 启动失败', - message: '启动失败,请反馈错误码。', - detail, - buttons: ['确定'], - noLink: true - }) - } catch { - // 弹窗失败不阻断主流程 - } - } - - /** - * 连接数据库 - */ - async connect(): Promise<{ success: boolean; error?: string }> { - try { - const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() - const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() - const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() - if (!wxid) { - return { success: false, error: '请先在设置页面配置微信ID' } - } - if (!dbPath) { - return { success: false, error: '请先在设置页面配置数据库路径' } - } - if (!decryptKey) { - return { success: false, error: '请先在设置页面配置解密密钥' } - } - - if (this.connected && wcdbService.isReady()) { - return { success: true } - } - - // 使用 ConfigService 统一解析账号目录 - const accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) { - return { success: false, error: '未找到账号目录,请检查数据库路径和微信ID配置' } - } - - const openOk = await wcdbService.open(accountDir, decryptKey) - if (!openOk) { - const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError()) - await this.maybeShowInitFailureDialog(detailedError) - return { success: false, error: detailedError } - } - - this.connected = true - - // 设置数据库监控 - this.setupDbMonitor() - - // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) - this.warmupMediaDbsCache() - - return { success: true } - } catch (e) { - console.error('ChatService: 连接数据库失败:', e) - return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) } - } - } - - private monitorSetup = false - - addDbMonitorListener(listener: (type: string, json: string) => void): () => void { - this.dbMonitorListeners.add(listener) - return () => { - this.dbMonitorListeners.delete(listener) - } - } - - private setupDbMonitor() { - if (this.monitorSetup) return - this.monitorSetup = true - - // 使用 C++数据服务内部的文件监控 (ReadDirectoryChangesW) - // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 - wcdbService.setMonitor((type, json) => { - this.handleSessionStatsMonitorChange(type, json) - for (const listener of this.dbMonitorListeners) { - try { - listener(type, json) - } catch (error) { - console.error('[ChatService] 数据库监听回调失败:', error) - } - } - const windows = BrowserWindow.getAllWindows() - // 广播给所有渲染进程窗口 - windows.forEach((win) => { - if (!win.isDestroyed()) { - win.webContents.send('wcdb-change', { type, json }) - } - }) - }) - } - - /** - * 预热 media 数据库列表缓存(后台异步执行) - */ - private async warmupMediaDbsCache(): Promise { - try { - const result = await wcdbService.listMediaDbs() - if (result.success && result.data) { - this.mediaDbsCache = result.data as string[] - this.mediaDbsCacheTime = Date.now() - } - } catch (e) { - // 静默失败,不影响主流程 - } - } - - async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const [messageSnapshot, mediaResult] = await Promise.all([ - this.getMessageDbCountSnapshot(true), - wcdbService.listMediaDbs() - ]) - - let messageDbCount = 0 - if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) { - messageDbCount = messageSnapshot.dbPaths.length - } - - let mediaDbCount = 0 - if (mediaResult.success && Array.isArray(mediaResult.data)) { - this.mediaDbsCache = [...mediaResult.data] - this.mediaDbsCacheTime = Date.now() - mediaDbCount = mediaResult.data.length - } - - if (!messageSnapshot.success && !mediaResult.success) { - return { - success: false, - error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败' - } - } - - return { success: true, messageDbCount, mediaDbCount } - } catch (e) { - return { success: false, error: String(e) } - } - } - - private async ensureConnected(): Promise<{ success: boolean; error?: string }> { - if (this.connected && wcdbService.isReady()) { - return { success: true } - } - if (!wcdbService.isReady()) { - this.monitorSetup = false - } - const result = await this.connect() - if (!result.success) { - this.connected = false - return { success: false, error: result.error } - } - return { success: true } - } - - /** - * 关闭数据库连接 - */ - private async closeMessageCursorBySession(sessionId: string): Promise { - const state = this.messageCursors.get(sessionId) - if (!state) return - try { - await wcdbService.closeMessageCursor(state.cursor) - } catch (error) { - console.warn(`[ChatService] 关闭消息游标失败: ${sessionId}`, error) - } finally { - this.messageCursors.delete(sessionId) - } - } - - private async trimMessageCursorStates(activeSessionId: string): Promise { - if (this.messageCursors.size <= this.messageCursorSessionLimit) return - for (const [sessionId] of this.messageCursors) { - if (this.messageCursors.size <= this.messageCursorSessionLimit) break - if (sessionId === activeSessionId) continue - await this.closeMessageCursorBySession(sessionId) - } - } - - 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 - this.monitorSetup = false - } - - /** - * 修改消息内容 - */ - async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return { success: false, error: connectResult.error } - return await wcdbService.updateMessage(sessionId, localId, createTime, newContent) - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 删除消息 - */ - async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return { success: false, error: connectResult.error } - return await wcdbService.deleteMessage(sessionId, localId, createTime, dbPathHint) - } catch (e) { - return { success: false, error: String(e) } - } - } - - async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return { success: false, error: connectResult.error } - const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) - const result = validIds.length > 0 - ? await wcdbService.checkMessageAntiRevokeTriggers(validIds) - : { success: true, rows: [] } - if (!result.success) return result - return { success: true, rows: [...(result.rows || []), ...invalidRows] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async installAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return { success: false, error: connectResult.error } - const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) - const result = validIds.length > 0 - ? await wcdbService.installMessageAntiRevokeTriggers(validIds) - : { success: true, rows: [] } - if (!result.success) return result - return { success: true, rows: [...(result.rows || []), ...invalidRows] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; error?: string }> - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return { success: false, error: connectResult.error } - const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds) - const result = validIds.length > 0 - ? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds) - : { success: true, rows: [] } - if (!result.success) return result - return { success: true, rows: [...(result.rows || []), ...invalidRows] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) - */ - async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - this.refreshSessionMessageCountCacheScope() - - 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}` } - } - - const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => - String( - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - row.talker_id || - row.talkerId || - '' - ).trim() - )) - - // 转换为 ChatSession(先加载缓存,但不等待额外状态查询) - const sessions: ChatSession[] = [] - const now = Date.now() - const myWxid = this.configService.getMyWxidCleaned() - - 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 || - '' - - let sessionLocalType = this.getSessionLocalType(row) - if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) { - sessionLocalType = openimLocalTypeMap.get(username) - } - if (!this.shouldKeepSession(username, sessionLocalType)) 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) - const messageCountHintRaw = - row.message_count ?? - row.messageCount ?? - row.msg_count ?? - row.msgCount ?? - row.total_count ?? - row.totalCount ?? - row.n_msg ?? - row.nMsg ?? - row.message_num ?? - row.messageNum - const parsedMessageCountHint = Number(messageCountHintRaw) - const messageCountHint = Number.isFinite(parsedMessageCountHint) && parsedMessageCountHint >= 0 - ? Math.floor(parsedMessageCountHint) - : undefined - - // 先尝试从缓存获取联系人信息(快速路径) - let displayName = username - let avatarUrl: string | undefined = undefined - const cached = this.avatarCache.get(username) - if (cached) { - displayName = cached.displayName || username - avatarUrl = cached.avatarUrl - } - - const nextSession: ChatSession = { - 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, - messageCountHint, - displayName, - avatarUrl, - lastMsgSender: row.last_msg_sender, - lastSenderDisplayName: row.last_sender_display_name, - selfWxid: myWxid - } - - const cachedStatus = this.sessionStatusCache.get(username) - if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { - nextSession.isFolded = cachedStatus.isFolded - nextSession.isMuted = cachedStatus.isMuted - } - - sessions.push(nextSession) - - if (typeof messageCountHint === 'number') { - this.sessionMessageCountHintCache.set(username, messageCountHint) - this.sessionMessageCountCache.set(username, { - count: messageCountHint, - updatedAt: Date.now() - }) - } - } - - await this.addMissingOfficialSessions(sessions, myWxid) - await this.applySyntheticUnreadCounts(sessions) - sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0)) - - // 不等待联系人信息加载,直接返回基础会话列表 - // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 - return { success: true, sessions } - } catch (e) { - console.error('ChatService: 获取会话列表失败:', e) - return { success: false, error: String(e) } - } - } - - async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { - try { - const result = await this.getSessions() - if (!result.success || !Array.isArray(result.sessions)) { - return { success: false, error: result.error || '获取会话失败' } - } - - return { - success: true, - sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_')) - } - } catch (e) { - console.error('ChatService: 获取防撤回会话列表失败:', e) - return { success: false, error: String(e) } - } - } - - async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - const result = await wcdbService.markAllSessionsRead() - if (result.success) { - this.syntheticUnreadState.clear() - } - return result - } catch (e) { - console.error('ChatService: 一键已读失败:', e) - return { success: false, error: String(e) } - } - } - - private getSessionUsername(row: Record): string { - return String( - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - row.talker_id || - row.talkerId || - '' - ).trim() - } - - private isAntiRevokeContactRow(username: string, row: Record): boolean { - if (!username) return false - if (username.endsWith('@chatroom')) return true - if (username.startsWith('gh_')) return false - - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) - const lowered = username.toLowerCase() - if (this.isEnterpriseOpenimUsername(username)) { - return this.isAllowedEnterpriseOpenimByLocalType(username, localType) - } - if (lowered.startsWith('weixin') && lowered !== 'weixin') return true - return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username) - } - - private async loadAntiRevokeContactMap(usernames: string[]): Promise> { - const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean))) - const map = new Map() - if (targets.length === 0) return map - - try { - const contactResult = await wcdbService.getContactsCompact(targets) - if (!contactResult.success || !Array.isArray(contactResult.contacts)) return map - - for (const row of contactResult.contacts as Record[]) { - const username = String(row.username || '').trim() - if (!username || !this.isAntiRevokeContactRow(username, row)) continue - map.set(username, { - displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim() - }) - } - } catch { - return map - } - - return map - } - - private async hasAntiRevokeMessageTables(sessionId: string): Promise { - try { - const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) - if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false - return tableStatsResult.tables.some((row: Record) => { - const tableName = String(row.table_name || row.tableName || '').trim() - return tableName.length > 0 - }) - } catch { - return false - } - } - - private async buildAntiRevokeSessionsFromRows(rows: Record[]): Promise { - if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return [] - - const candidateRows: Array<{ username: string; row: Record }> = [] - const privateCandidateIds: string[] = [] - const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => this.getSessionUsername(row))) - - for (const row of rows) { - const username = this.getSessionUsername(row) - if (!username) continue - - let sessionLocalType = this.getSessionLocalType(row) - if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) { - sessionLocalType = openimLocalTypeMap.get(username) - } - if (!this.shouldKeepSession(username, sessionLocalType)) continue - - if (username.endsWith('@chatroom')) { - candidateRows.push({ username, row }) - } else { - privateCandidateIds.push(username) - candidateRows.push({ username, row }) - } - } - - const contactMap = await this.loadAntiRevokeContactMap(privateCandidateIds) - const sessions: ChatSession[] = [] - const myWxid = this.configService.getMyWxidCleaned() - const now = Date.now() - - for (const { username, row } of candidateRows) { - const isGroup = username.endsWith('@chatroom') - if (!isGroup && !contactMap.has(username)) continue - if (!await this.hasAntiRevokeMessageTables(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) - const cached = this.avatarCache.get(username) - const contact = contactMap.get(username) - - const session: ChatSession = { - 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: contact?.displayName || cached?.displayName || username, - avatarUrl: cached?.avatarUrl, - lastMsgSender: row.last_msg_sender, - lastSenderDisplayName: row.last_sender_display_name, - selfWxid: myWxid - } - - const cachedStatus = this.sessionStatusCache.get(username) - if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { - session.isFolded = cachedStatus.isFolded - session.isMuted = cachedStatus.isMuted - } - - sessions.push(session) - } - - return sessions - } - - private async filterAntiRevokeSessionIds(sessionIds: string[]): Promise<{ - validIds: string[] - invalidRows: Array<{ sessionId: string; success: false; error: string }> - }> { - const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - if (normalizedIds.length === 0) return { validIds: [], invalidRows: [] } - - const sessionsResult = await this.getAntiRevokeSessions() - const allowedIds = new Set((sessionsResult.sessions || []).map((session) => session.username)) - const validIds = normalizedIds.filter((sessionId) => allowedIds.has(sessionId)) - const invalidRows = normalizedIds - .filter((sessionId) => !allowedIds.has(sessionId)) - .map((sessionId) => ({ - sessionId, - success: false as const, - error: '该会话不是联系人或群聊,或不存在可安装防撤回的消息表' - })) - - return { validIds, invalidRows } - } - - private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise { - const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean)) - try { - const contactResult = await wcdbService.getContactsCompact() - if (!contactResult.success || !Array.isArray(contactResult.contacts)) return - - for (const row of contactResult.contacts as Record[]) { - const username = String(row.username || '').trim() - if (!username || existing.has(username)) continue - const lowered = username.toLowerCase() - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) - const isOfficial = username.startsWith('gh_') - const isSpecialWeixin = lowered.startsWith('weixin') && lowered !== 'weixin' - const isSpecialOpenim = this.isAllowedEnterpriseOpenimByLocalType(username, localType) - if (!isOfficial && !isSpecialWeixin && !isSpecialOpenim) continue - - sessions.push({ - username, - type: 0, - unreadCount: 0, - summary: isOfficial ? '查看公众号历史消息' : '暂无会话记录', - sortTimestamp: 0, - lastTimestamp: 0, - lastMsgType: 0, - displayName: row.remark || row.nick_name || row.alias || username, - avatarUrl: undefined, - selfWxid: myWxid - }) - existing.add(username) - } - } catch (error) { - console.warn('[ChatService] 补充公众号会话失败:', error) - } - } - - private shouldUseSyntheticUnread(sessionId: string): boolean { - const normalized = String(sessionId || '').trim() - return normalized.startsWith('gh_') - } - - private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> { - const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) - if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) { - return { total: 0, latestTimestamp: 0 } - } - - let total = 0 - let latestTimestamp = 0 - for (const row of tableStatsResult.tables as Record[]) { - const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0) - if (Number.isFinite(count) && count > 0) { - total += Math.floor(count) - } - - const latest = Number( - row.last_timestamp ?? - row.lastTimestamp ?? - row.last_time ?? - row.lastTime ?? - row.max_create_time ?? - row.maxCreateTime ?? - 0 - ) - if (Number.isFinite(latest) && latest > latestTimestamp) { - latestTimestamp = Math.floor(latest) - } - } - - return { total, latestTimestamp } - } - - private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise { - const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username)) - if (candidates.length === 0) return - - for (const session of candidates) { - try { - const snapshot = await this.getSessionMessageStatsSnapshot(session.username) - const latestTimestamp = Math.max( - Number(session.lastTimestamp || 0), - Number(session.sortTimestamp || 0), - snapshot.latestTimestamp - ) - if (latestTimestamp > 0) { - session.lastTimestamp = latestTimestamp - session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp) - } - if (snapshot.total > 0) { - session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total) - this.sessionMessageCountHintCache.set(session.username, session.messageCountHint) - } - - let state = this.syntheticUnreadState.get(session.username) - if (!state) { - const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp) - state = { - readTimestamp: latestTimestamp, - scannedTimestamp: latestTimestamp, - latestTimestamp, - unreadCount: initialUnread.count - } - if (initialUnread.latestMessage) { - state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage) - state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp) - state.lastMsgType = Number(initialUnread.latestMessage.localType || 0) - } - this.syntheticUnreadState.set(session.username, state) - } - - let latestMessageForSummary: Message | undefined - if (latestTimestamp > state.scannedTimestamp) { - const newMessagesResult = await this.getNewMessages( - session.username, - Math.max(0, state.scannedTimestamp), - 1000 - ) - if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) { - let nextUnread = state.unreadCount - let nextScannedTimestamp = state.scannedTimestamp - for (const message of newMessagesResult.messages) { - const createTime = Number(message.createTime || 0) - if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue - if (message.isSend === 1) continue - nextUnread += 1 - latestMessageForSummary = message - if (createTime > nextScannedTimestamp) { - nextScannedTimestamp = Math.floor(createTime) - } - } - state.unreadCount = nextUnread - state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp) - } else { - state.scannedTimestamp = latestTimestamp - } - } - - state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp) - if (latestMessageForSummary) { - const summary = this.getSessionSummaryFromMessage(latestMessageForSummary) - if (summary) { - state.summary = summary - state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp) - state.lastMsgType = Number(latestMessageForSummary.localType || 0) - } - } - if (state.summary) { - session.summary = state.summary - session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0) - } - session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount) - } catch (error) { - console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error) - } - } - } - - private getSessionSummaryFromMessage(message: Message): string { - const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim() - let summary = '' - switch (Number(message.localType || 0)) { - case 1: - summary = message.parsedContent || message.rawContent || '' - break - case 3: - summary = '[图片]' - break - case 34: - summary = '[语音]' - break - case 43: - summary = '[视频]' - break - case 47: - summary = '[表情]' - break - case 42: - summary = message.cardNickname || '[名片]' - break - case 48: - summary = '[位置]' - break - case 49: - summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]' - break - default: - summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0)) - break - } - return cleanOfficialPrefix(this.cleanString(summary)) - } - - private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{ - count: number - latestMessage?: Message - }> { - const normalizedLatest = Number(latestTimestamp || 0) - if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 } - - const nowSeconds = Math.floor(Date.now() / 1000) - if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) { - return { count: 0 } - } - - const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20) - if (!result.success || !Array.isArray(result.messages)) return { count: 0 } - const unreadMessages = result.messages.filter((message) => { - const createTime = Number(message.createTime || 0) - return Number.isFinite(createTime) && - createTime >= normalizedLatest && - message.isSend !== 1 - }) - return { - count: unreadMessages.length, - latestMessage: unreadMessages[unreadMessages.length - 1] - } - } - - private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void { - const normalized = String(sessionId || '').trim() - if (!this.shouldUseSyntheticUnread(normalized)) return - - let latestTimestamp = 0 - const state = this.syntheticUnreadState.get(normalized) - if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp) - for (const message of messages) { - const createTime = Number(message.createTime || 0) - if (Number.isFinite(createTime) && createTime > latestTimestamp) { - latestTimestamp = Math.floor(createTime) - } - } - - this.syntheticUnreadState.set(normalized, { - readTimestamp: latestTimestamp, - scannedTimestamp: latestTimestamp, - latestTimestamp, - unreadCount: 0, - summary: state?.summary, - summaryTimestamp: state?.summaryTimestamp, - lastMsgType: state?.lastMsgType - }) - } - - async getSessionStatuses(usernames: string[]): Promise<{ - success: boolean - map?: Record - error?: string - }> { - try { - if (!Array.isArray(usernames) || usernames.length === 0) { - return { success: true, map: {} } - } - - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - - const result = await wcdbService.getContactStatus(usernames) - if (!result.success || !result.map) { - return { success: false, error: result.error || '获取会话状态失败' } - } - - const now = Date.now() - for (const username of usernames) { - const state = result.map[username] || { isFolded: false, isMuted: false } - this.sessionStatusCache.set(username, { - isFolded: Boolean(state.isFolded), - isMuted: Boolean(state.isMuted), - updatedAt: now - }) - } - - return { - success: true, - map: result.map as Record - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 异步补充会话列表的联系人信息(公开方法,供前端调用) - */ - async enrichSessionsContactInfo( - usernames: string[], - options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } - ): Promise<{ - success: boolean - contacts?: Record - error?: string - }> { - try { - const normalizedUsernames = Array.from( - new Set( - (usernames || []) - .map((username) => String(username || '').trim()) - .filter(Boolean) - ) - ) - if (normalizedUsernames.length === 0) { - return { success: true, contacts: {} } - } - const skipDisplayName = options?.skipDisplayName === true - const onlyMissingAvatar = options?.onlyMissingAvatar === true - - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - - const now = Date.now() - const missing: string[] = [] - const result: Record = {} - const updatedEntries: Record = {} - - // 检查缓存 - for (const username of normalizedUsernames) { - const cached = this.avatarCache.get(username) - const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) - const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined - if (onlyMissingAvatar && cachedAvatarUrl) { - result[username] = { - displayName: skipDisplayName ? undefined : cached?.displayName, - avatarUrl: cachedAvatarUrl - } - continue - } - // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 - // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { - result[username] = { - displayName: skipDisplayName ? undefined : cached.displayName, - avatarUrl: cachedAvatarUrl - } - } else { - missing.push(username) - } - } - - // 批量查询缺失的联系人信息 - if (missing.length > 0) { - const displayNames = skipDisplayName - ? null - : await wcdbService.getDisplayNames(missing) - const avatarUrls = await wcdbService.getAvatarUrls(missing) - - // 收集没有头像 URL 的用户名 - const missingAvatars: string[] = [] - - for (const username of missing) { - const previous = this.avatarCache.get(username) - const displayName = displayNames?.success && displayNames.map - ? displayNames.map[username] - : undefined - let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined - - // 如果没有头像 URL,记录下来稍后从 head_image.db 获取 - if (!avatarUrl) { - missingAvatars.push(username) - } - - const cacheEntry: ContactCacheEntry = { - displayName: displayName || previous?.displayName || username, - avatarUrl, - updatedAt: now - } - result[username] = { - displayName: skipDisplayName ? undefined : (displayName || previous?.displayName), - avatarUrl - } - // 更新缓存并记录持久化 - this.avatarCache.set(username, cacheEntry) - updatedEntries[username] = cacheEntry - } - - // 从 head_image.db 获取缺失的头像 - if (missingAvatars.length > 0) { - const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars) - for (const username of missingAvatars) { - const avatarUrl = headImageAvatars[username] - if (avatarUrl) { - result[username].avatarUrl = avatarUrl - const cached = this.avatarCache.get(username) - if (cached) { - cached.avatarUrl = avatarUrl - updatedEntries[username] = cached - } - } - } - } - - if (Object.keys(updatedEntries).length > 0) { - this.contactCacheService.setEntries(updatedEntries) - } - } - return { success: true, contacts: result } - } catch (e) { - console.error('ChatService: 补充联系人信息失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 从 head_image.db 批量获取头像(转换为 base64 data URL) - */ - private async getAvatarsFromHeadImageDb(usernames: string[]): Promise> { - const result: Record = {} - if (usernames.length === 0) return result - - try { - const normalizedUsernames = Array.from( - new Set( - usernames - .map((username) => String(username || '').trim()) - .filter(Boolean) - ) - ) - if (normalizedUsernames.length === 0) return result - - const batchSize = 320 - for (let i = 0; i < normalizedUsernames.length; i += batchSize) { - const batch = normalizedUsernames.slice(i, i + batchSize) - if (batch.length === 0) continue - - const queryResult = await wcdbService.getHeadImageBuffers(batch) - if (!queryResult.success || !queryResult.map) continue - - for (const [username, rawHex] of Object.entries(queryResult.map)) { - const hex = String(rawHex || '').trim() - if (!username || !hex) continue - try { - const base64Data = Buffer.from(hex, 'hex').toString('base64') - if (base64Data) { - result[username] = `data:image/jpeg;base64,${base64Data}` - } - } catch { - // ignore invalid blob hex - } - } - } - } catch (e) { - console.error('从 head_image.db 获取头像失败:', e) - } - - return result - } - - /** - * 补充联系人信息(私有方法,保持向后兼容) - */ - private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise { - if (sessions.length === 0) return - try { - const usernames = sessions.map(s => s.username) - const result = await this.enrichSessionsContactInfo(usernames) - if (result.success && result.contacts) { - for (const session of sessions) { - const contact = result.contacts![session.username] - if (contact) { - if (contact.displayName) session.displayName = contact.displayName - if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl - } - } - } - } catch (e) { - console.error('ChatService: 获取联系人信息失败:', e) - } - } - - /** - * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) - */ - async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - - const result = await wcdbService.getContactTypeCounts() - if (!result.success || !result.counts) { - return { success: false, error: result.error || '获取联系人类型数量失败' } - } - - const counts: ExportTabCounts = { - private: Number(result.counts.private || 0), - group: Number(result.counts.group || 0), - official: Number(result.counts.official || 0), - former_friend: Number(result.counts.former_friend || 0) - } - - return { success: true, counts } - } catch (e) { - console.error('ChatService: 获取联系人类型数量失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) - */ - async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { - return this.getContactTypeCounts() - } - - private async listMessageDbPathsForCount(): Promise<{ success: boolean; dbPaths?: string[]; error?: string }> { - try { - const result = await wcdbService.listMessageDbs() - if (!result.success) { - return { success: false, error: result.error || '获取消息数据库列表失败' } - } - const normalized = Array.from(new Set( - (result.data || []) - .map(pathItem => String(pathItem || '').trim()) - .filter(Boolean) - )) - return { success: true, dbPaths: normalized } - } catch (e) { - return { success: false, error: String(e) } - } - } - - private buildMessageDbSignature(dbPaths: string[]): string { - if (!Array.isArray(dbPaths) || dbPaths.length === 0) return 'empty' - const parts: string[] = [] - const sortedPaths = [...dbPaths].sort() - for (const dbPath of sortedPaths) { - try { - const stat = statSync(dbPath) - parts.push(`${dbPath}:${stat.size}:${Math.floor(stat.mtimeMs)}`) - } catch { - parts.push(`${dbPath}:missing`) - } - } - return parts.join('|') - } - - private buildSessionHashLookup(sessionIds: string[]): { - full32: Map - short16: Map - } { - const full32 = new Map() - const short16 = new Map() - for (const sessionId of sessionIds) { - const hash = crypto.createHash('md5').update(sessionId).digest('hex').toLowerCase() - full32.set(hash, sessionId) - const shortHash = hash.slice(0, 16) - const existing = short16.get(shortHash) - if (existing === undefined) { - short16.set(shortHash, sessionId) - } else if (existing !== sessionId) { - short16.set(shortHash, null) - } - } - return { full32, short16 } - } - - private matchSessionIdByTableName( - tableName: string, - hashLookup: { - full32: Map - short16: Map - } - ): string | null { - const normalized = String(tableName || '').trim().toLowerCase() - if (!normalized.startsWith('msg_')) return null - const suffix = normalized.slice(4) - - const directFull = hashLookup.full32.get(suffix) - if (directFull) return directFull - - if (suffix.length >= 16) { - const shortCandidate = hashLookup.short16.get(suffix.slice(0, 16)) - if (typeof shortCandidate === 'string') return shortCandidate - } - - const hashMatch = normalized.match(/[a-f0-9]{32}|[a-f0-9]{16}/i) - if (!hashMatch || !hashMatch[0]) return null - const matchedHash = hashMatch[0].toLowerCase() - if (matchedHash.length >= 32) { - const full = hashLookup.full32.get(matchedHash) - if (full) return full - } - const short = hashLookup.short16.get(matchedHash.slice(0, 16)) - return typeof short === 'string' ? short : null - } - - private quoteSqlIdentifier(identifier: string): string { - return `"${String(identifier || '').replace(/"/g, '""')}"` - } - - private async countSessionMessageCountsByTableScan( - sessionIds: string[], - traceId?: string - ): Promise<{ - success: boolean - counts?: Record - error?: string - dbSignature?: string - }> { - const normalizedSessionIds = Array.from(new Set( - (sessionIds || []) - .map(id => String(id || '').trim()) - .filter(Boolean) - )) - if (normalizedSessionIds.length === 0) { - return { success: true, counts: {}, dbSignature: 'empty' } - } - - const snapshotResult = await this.getMessageDbCountSnapshot() - const dbPaths = snapshotResult.success ? (snapshotResult.dbPaths || []) : [] - const dbSignature = snapshotResult.success - ? (snapshotResult.dbSignature || this.buildMessageDbSignature(dbPaths)) - : this.buildMessageDbSignature(dbPaths) - const nativeResult = await wcdbService.getSessionMessageCounts(normalizedSessionIds) - if (!nativeResult.success || !nativeResult.counts) { - return { success: false, error: nativeResult.error || '获取会话消息总数失败', dbSignature } - } - const counts = normalizedSessionIds.reduce>((acc, sid) => { - const raw = nativeResult.counts?.[sid] - acc[sid] = Number.isFinite(raw) ? Math.max(0, Math.floor(Number(raw))) : 0 - return acc - }, {}) - - this.logExportDiag({ - traceId, - level: 'debug', - source: 'backend', - stepId: 'backend-get-session-message-counts-table-scan', - stepName: '会话消息总数表扫描', - status: 'done', - message: '按 Msg 表聚合统计完成', - data: { - dbCount: dbPaths.length, - requestedSessions: normalizedSessionIds.length - } - }) - - return { success: true, counts, dbSignature } - } - - /** - * 批量获取会话消息总数(轻量接口,用于列表优先排序) - */ - async getSessionMessageCounts( - sessionIds: string[], - options?: { preferHintCache?: boolean; bypassSessionCache?: boolean; traceId?: string } - ): Promise<{ - success: boolean - counts?: Record - error?: string - }> { - const traceId = this.normalizeExportDiagTraceId(options?.traceId) - const stepStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-get-session-message-counts', - stepName: 'ChatService.getSessionMessageCounts', - message: '开始批量读取会话消息总数', - data: { - requestedSessions: Array.isArray(sessionIds) ? sessionIds.length : 0, - preferHintCache: options?.preferHintCache !== false, - bypassSessionCache: options?.bypassSessionCache === true - } - }) - let success = false - let errorMessage = '' - let returnedCounts = 0 - - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - errorMessage = connectResult.error || '数据库未连接' - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const normalizedSessionIds = Array.from( - new Set( - (sessionIds || []) - .map((id) => String(id || '').trim()) - .filter(Boolean) - ) - ) - if (normalizedSessionIds.length === 0) { - success = true - return { success: true, counts: {} } - } - - const preferHintCache = options?.preferHintCache !== false - const bypassSessionCache = options?.bypassSessionCache === true - - this.refreshSessionMessageCountCacheScope() - const counts: Record = {} - const now = Date.now() - const pendingSessionIds: string[] = [] - const sessionIdsKey = [...normalizedSessionIds].sort().join('\u0001') - - for (const sessionId of normalizedSessionIds) { - if (!bypassSessionCache) { - const cached = this.sessionMessageCountCache.get(sessionId) - if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { - counts[sessionId] = cached.count - continue - } - } - - if (preferHintCache) { - const hintCount = this.sessionMessageCountHintCache.get(sessionId) - if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { - counts[sessionId] = Math.floor(hintCount) - this.sessionMessageCountCache.set(sessionId, { - count: Math.floor(hintCount), - updatedAt: now - }) - continue - } - } - - pendingSessionIds.push(sessionId) - } - - if (pendingSessionIds.length > 0) { - let tableScanSucceeded = false - const cachedBatch = this.sessionMessageCountBatchCache - const cachedBatchFresh = cachedBatch && - now - cachedBatch.updatedAt <= this.sessionMessageCountBatchCacheTtlMs - - if (cachedBatchFresh && cachedBatch.sessionIdsKey === sessionIdsKey) { - const snapshot = await this.getMessageDbCountSnapshot() - if (snapshot.success && snapshot.dbSignature === cachedBatch.dbSignature) { - for (const sessionId of pendingSessionIds) { - const nextCountRaw = cachedBatch.counts[sessionId] - const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 - counts[sessionId] = nextCount - this.sessionMessageCountCache.set(sessionId, { - count: nextCount, - updatedAt: now - }) - } - tableScanSucceeded = true - } - } - - if (!tableScanSucceeded) { - const tableScanResult = await this.countSessionMessageCountsByTableScan(pendingSessionIds, traceId) - if (tableScanResult.success && tableScanResult.counts) { - const nowTs = Date.now() - for (const sessionId of pendingSessionIds) { - const nextCountRaw = tableScanResult.counts[sessionId] - const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 - counts[sessionId] = nextCount - this.sessionMessageCountCache.set(sessionId, { - count: nextCount, - updatedAt: nowTs - }) - } - if (tableScanResult.dbSignature) { - this.sessionMessageCountBatchCache = { - dbSignature: tableScanResult.dbSignature, - sessionIdsKey, - counts: { ...counts }, - updatedAt: nowTs - } - } - tableScanSucceeded = true - } else { - this.logExportDiag({ - traceId, - level: 'warn', - source: 'backend', - stepId: 'backend-get-session-message-counts-table-scan', - stepName: '会话消息总数表扫描', - status: 'failed', - message: '按 Msg 表聚合统计失败,回退逐会话统计', - data: { - error: tableScanResult.error || '未知错误' - } - }) - } - } - - if (!tableScanSucceeded) { - const batchSize = 320 - for (let i = 0; i < pendingSessionIds.length; i += batchSize) { - const batch = pendingSessionIds.slice(i, i + batchSize) - this.logExportDiag({ - traceId, - level: 'debug', - source: 'backend', - stepId: 'backend-get-session-message-counts-batch', - stepName: '会话消息总数批次查询', - status: 'running', - message: `开始查询批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(pendingSessionIds.length / batchSize) || 1}`, - data: { - batchSize: batch.length - } - }) - let batchCounts: Record = {} - try { - const result = await wcdbService.getMessageCounts(batch) - if (result.success && result.counts) { - batchCounts = result.counts - } - } catch { - // noop - } - - const nowTs = Date.now() - for (const sessionId of batch) { - const nextCountRaw = batchCounts[sessionId] - const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 - counts[sessionId] = nextCount - this.sessionMessageCountCache.set(sessionId, { - count: nextCount, - updatedAt: nowTs - }) - } - } - } - } - - returnedCounts = Object.keys(counts).length - success = true - return { success: true, counts } - } catch (e) { - console.error('ChatService: 批量获取会话消息总数失败:', e) - errorMessage = String(e) - return { success: false, error: String(e) } - } finally { - this.endExportDiagStep({ - traceId, - stepId: 'backend-get-session-message-counts', - stepName: 'ChatService.getSessionMessageCounts', - startedAt: stepStartedAt, - success, - message: success ? '批量会话消息总数读取完成' : '批量会话消息总数读取失败', - data: success ? { returnedCounts } : { error: errorMessage || '未知错误' } - }) - } - } - - /** - * 获取通讯录列表 - */ - async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { - const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full' - const inFlight = this.contactsLoadInFlight - if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) { - return await inFlight.promise - } - - const promise = this.getContactsInternal(options) - this.contactsLoadInFlight = { mode, promise } - try { - return await promise - } finally { - if (this.contactsLoadInFlight?.promise === promise) { - this.contactsLoadInFlight = null - } - } - } - - private getContactsCacheScope(): string { - const dbPath = String(this.configService.get('dbPath') || '').trim() - const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - return `${dbPath}::${myWxid}` - } - - private cloneContacts(contacts: ContactInfo[]): ContactInfo[] { - return (contacts || []).map((contact) => ({ - ...contact, - labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels - })) - } - - private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null { - const cached = this.contactsMemoryCache.get(mode) - if (!cached) return null - if (cached.scope !== scope) return null - if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null - return this.cloneContacts(cached.contacts) - } - - private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void { - this.contactsMemoryCache.set(mode, { - scope, - updatedAt: Date.now(), - contacts: this.cloneContacts(contacts) - }) - } - - private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { - const isLiteMode = options?.lite === true - const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full' - const cacheScope = this.getContactsCacheScope() - const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope) - if (cachedContacts) { - return { success: true, contacts: cachedContacts } - } - if (isLiteMode) { - const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope) - if (fullCachedContacts) { - return { success: true, contacts: fullCachedContacts } - } - } - - const startedAt = Date.now() - const stageDurations: Array<{ stage: string; ms: number }> = [] - const captureStage = (stage: string, stageStartedAt: number) => { - stageDurations.push({ stage, ms: Date.now() - stageStartedAt }) - } - - try { - const connectStartedAt = Date.now() - const connectResult = await this.ensureConnected() - captureStage('ensureConnected', connectStartedAt) - if (!connectResult.success) { - return { success: false, error: connectResult.error } - } - - const contactsCompactStartedAt = Date.now() - const contactResult = await wcdbService.getContactsCompact() - captureStage('getContactsCompact', contactsCompactStartedAt) - - if (!contactResult.success || !contactResult.contacts) { - console.error('查询联系人失败:', contactResult.error) - return { success: false, error: contactResult.error || '查询联系人失败' } - } - - let rows = contactResult.contacts as Record[] - if (!isLiteMode) { - const hydrateStartedAt = Date.now() - rows = await this.hydrateContactsWithExtendedFields(rows) - captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt) - } - - // 获取会话表的最后联系时间用于排序 - const sessionsStartedAt = Date.now() - const lastContactTimeMap = new Map() - const sessionResult = await wcdbService.getSessions() - captureStage('getSessions', sessionsStartedAt) - if (sessionResult.success && sessionResult.sessions) { - for (const session of sessionResult.sessions as any[]) { - const username = session.username || session.user_name || session.userName || '' - const timestamp = session.sort_timestamp || session.sortTimestamp || 0 - if (username && timestamp) { - lastContactTimeMap.set(username, timestamp) - } - } - } - - // 转换为ContactInfo - const transformStartedAt = Date.now() - const contacts: (ContactInfo & { lastContactTime: number })[] = [] - let contactLabelNameMap = new Map() - if (!isLiteMode) { - const labelMapStartedAt = Date.now() - contactLabelNameMap = await this.getContactLabelNameMap() - captureStage('getContactLabelNameMap', labelMapStartedAt) - } - for (const row of rows) { - const username = String(row.username || '').trim() - - if (!username) continue - - let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - const loweredUsername = username.toLowerCase() - const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username) - if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) { - continue - } - const isVisibleWeixinContact = loweredUsername.startsWith('weixin') && loweredUsername !== 'weixin' - - if (username.endsWith('@chatroom')) { - type = 'group' - } else if (username.startsWith('gh_')) { - type = 'official' - } else if (isOpenimEnterprise) { - type = 'friend' - } else if (isVisibleWeixinContact) { - type = 'friend' - } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) { - type = 'friend' - } else if (localType === 0 && quanPin) { - type = 'former_friend' - } else { - continue - } - - const displayName = row.remark || row.nick_name || row.alias || username - const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap) - const detailDescription = isLiteMode ? '' : this.getContactSignature(row) - const region = isLiteMode ? '' : this.getContactRegion(row) - - contacts.push({ - username, - displayName, - remark: row.remark || undefined, - nickname: row.nick_name || undefined, - alias: row.alias || undefined, - labels: labels.length > 0 ? labels : undefined, - detailDescription: detailDescription || undefined, - region: region || undefined, - avatarUrl: undefined, - type, - lastContactTime: lastContactTimeMap.get(username) || 0 - }) - } - captureStage('transformContacts', transformStartedAt) - - - // 按最近联系时间排序 - const sortStartedAt = Date.now() - contacts.sort((a, b) => { - const timeA = a.lastContactTime || 0 - const timeB = b.lastContactTime || 0 - if (timeA && timeB) { - return timeB - timeA - } - if (timeA && !timeB) return -1 - if (!timeA && timeB) return 1 - return this.contactDisplayNameCollator.compare(a.displayName, b.displayName) - }) - captureStage('sortContacts', sortStartedAt) - - // 移除临时的lastContactTime字段 - const finalizeStartedAt = Date.now() - const result = contacts.map(({ lastContactTime, ...rest }) => rest) - captureStage('finalizeResult', finalizeStartedAt) - - const totalMs = Date.now() - startedAt - if (totalMs >= this.slowGetContactsLogThresholdMs) { - const stageSummary = stageDurations - .map((item) => `${item.stage}=${item.ms}ms`) - .join(', ') - console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`) - } - this.setContactsMemoryCache(mode, cacheScope, result) - if (!isLiteMode) { - this.setContactsMemoryCache('lite', cacheScope, result) - } - return { success: true, contacts: result } - } catch (e) { - console.error('ChatService: 获取通讯录失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 获取消息列表(支持跨多个数据库合并,已优化) - */ - async getMessages( - sessionId: string, - offset: number = 0, - limit: number = 50, - startTime: number = 0, - endTime: number = 0, - ascending: boolean = false - ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> { - let releaseMessageCursorMutex: (() => void) | null = null - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const requestLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault)) - - // 使用互斥锁保护游标状态访问 - while (this.messageCursorMutex) { - await new Promise(resolve => setTimeout(resolve, 1)) - } - this.messageCursorMutex = true - let mutexReleased = false - releaseMessageCursorMutex = () => { - if (mutexReleased) return - this.messageCursorMutex = false - mutexReleased = true - } - - let state = this.messageCursors.get(sessionId) - if (state) { - // refresh insertion order so Map iteration approximates LRU - this.messageCursors.delete(sessionId) - this.messageCursors.set(sessionId, state) - } - - // 只在以下情况重新创建游标: - // 1. 没有游标状态 - // 2. offset 变化导致游标位置不一致 - // 3. startTime/endTime 改变(视为全新查询) - // 4. ascending 改变 - // - // 注意:requestLimit 允许动态变化(前端可按“越往上拉批次越大”策略请求), - // 不应触发游标重建,否则会造成额外 reopen/skip 开销与抖动。 - const needNewCursor = !state || - offset !== state.fetched || // Offset mismatch -> must reset cursor - state.startTime !== startTime || - state.endTime !== endTime || - state.ascending !== ascending - - if (needNewCursor) { - // 关闭旧游标 - if (state) { - try { - await this.closeMessageCursorBySession(sessionId) - } catch (e) { - console.warn('[ChatService] 关闭旧游标失败:', e) - } - } - - // 创建新游标 - // 注意:WeFlow 数据库中的 create_time 是以秒为单位的 - const cursorBatchSize = Math.max(1, Math.floor(state?.batchSize || requestLimit || this.messageBatchDefault)) - const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime - const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime - const cursorResult = await wcdbService.openMessageCursor(sessionId, cursorBatchSize, ascending, beginTimestamp, endTimestamp) - if (!cursorResult.success || !cursorResult.cursor) { - console.error('[ChatService] 打开消息游标失败:', cursorResult.error) - return { success: false, error: cursorResult.error || '打开消息游标失败' } - } - - state = { cursor: cursorResult.cursor, fetched: 0, batchSize: cursorBatchSize, startTime, endTime, ascending } - this.messageCursors.set(sessionId, state) - await this.trimMessageCursorStates(sessionId) - - // 如果需要跳过消息(offset > 0),逐批获取但不返回 - // 注意:仅在 offset === 0 时重建游标最安全; - // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 - state.bufferedMessages = [] - if (offset > 0) { - console.warn(`[ChatService] 新游标需跳过 ${offset} 条消息(startTime=${startTime}, endTime=${endTime})`) - let skipped = 0 - const maxSkipAttempts = Math.ceil(offset / cursorBatchSize) + 5 // 防止无限循环 - let attempts = 0 - let emptySkipBatchStreak = 0 - while (skipped < offset && attempts < maxSkipAttempts) { - attempts++ - const skipBatch = await wcdbService.fetchMessageBatch(state.cursor) - if (!skipBatch.success) { - console.error('[ChatService] 跳过消息批次失败:', skipBatch.error) - await this.closeMessageCursorBySession(sessionId) - return { success: false, error: skipBatch.error || '跳过消息失败' } - } - if (!skipBatch.rows || skipBatch.rows.length === 0) { - if (skipBatch.hasMore && emptySkipBatchStreak < 2) { - emptySkipBatchStreak += 1 - console.warn( - `[ChatService] 跳过遇到空批次,继续重试: streak=${emptySkipBatchStreak}, skipped=${skipped}/${offset}` - ) - continue - } - - // 部分会话在“新游标 + offset 跳过”路径会出现首批空数据但实际仍有消息, - // 回退到稳定的 direct-offset 路径避免误判到底。 - if (skipped === 0 && startTime === 0 && endTime === 0 && !ascending) { - const fallbackResult = await this.getMessagesByOffsetStable(sessionId, offset, requestLimit) - if (fallbackResult.success && Array.isArray(fallbackResult.messages)) { - await this.closeMessageCursorBySession(sessionId) - releaseMessageCursorMutex?.() - this.messageCacheService.set(sessionId, fallbackResult.messages) - console.warn( - `[ChatService] 游标跳过异常,已切换 direct-offset 兜底: session=${sessionId}, offset=${offset}, returned=${fallbackResult.messages.length}, hasMore=${fallbackResult.hasMore === true}` - ) - return { - success: true, - messages: fallbackResult.messages, - hasMore: fallbackResult.hasMore === true, - nextOffset: Number.isFinite(fallbackResult.nextOffset) - ? Math.floor(fallbackResult.nextOffset as number) - : offset + fallbackResult.messages.length - } - } - } - - console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`) - await this.closeMessageCursorBySession(sessionId) - return { success: true, messages: [], hasMore: false, nextOffset: skipped } - } - emptySkipBatchStreak = 0 - - const count = skipBatch.rows.length - // Check if we overshot the offset - if (skipped + count > offset) { - const keepIndex = offset - skipped - if (keepIndex < count) { - state.bufferedMessages = skipBatch.rows.slice(keepIndex) - } - } - - skipped += count - - // If satisfied offset, break - if (skipped >= offset) break; - - if (!skipBatch.hasMore) { - console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`) - await this.closeMessageCursorBySession(sessionId) - return { success: true, messages: [], hasMore: false, nextOffset: skipped } - } - } - if (attempts >= maxSkipAttempts) { - console.error(`[ChatService] 跳过消息超过最大尝试次数: attempts=${attempts}`) - } - state.fetched = offset - console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}, buffered=${state.bufferedMessages?.length || 0}`) - } - } - - // 确保 state 已初始化 - if (!state) { - console.error('[ChatService] 游标状态未初始化') - return { success: false, error: '游标状态未初始化' } - } - - const collected = await this.collectVisibleMessagesFromCursor( - sessionId, - state.cursor, - requestLimit, - state.bufferedMessages as Record[] | undefined - ) - state.bufferedMessages = collected.bufferedRows - if (!collected.success) { - return { success: false, error: collected.error || '获取消息失败' } - } - - const rawRowsConsumed = collected.rawRowsConsumed || 0 - const filtered = collected.messages || [] - const hasMore = collected.hasMore === true - state.fetched += rawRowsConsumed - this.messageCursors.delete(sessionId) - this.messageCursors.set(sessionId, state) - releaseMessageCursorMutex?.() - - this.messageCacheService.set(sessionId, filtered) - if (offset === 0 && startTime === 0 && endTime === 0) { - this.markSyntheticUnreadRead(sessionId, filtered) - } - console.log( - `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}` - ) - return { success: true, messages: filtered, hasMore, nextOffset: state.fetched } - } catch (e) { - console.error('ChatService: 获取消息失败:', e) - return { success: false, error: String(e) } - } finally { - releaseMessageCursorMutex?.() - } - } - - async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> { - try { - if (!sessionId) return { success: true, messages: [] } - const entry = this.messageCacheService.get(sessionId) - if (!entry || !Array.isArray(entry.messages)) { - return { success: true, messages: [] } - } - return { success: true, messages: entry.messages.slice() } - } catch (error) { - console.error('ChatService: 获取缓存消息失败:', error) - return { success: false, error: String(error) } - } - } - - /** - * 尝试从 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}`) - // 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5,无 CDN URL) - this.findEmojiInLocalCache(msg) - - } catch (e) { - console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e) - } - } - - /** - * 从本地 WeFlow emoji 缓存目录按 md5 查找文件 - */ - private findEmojiInLocalCache(msg: Message): void { - if (!msg.emojiMd5) return - const cacheDir = this.getEmojiCacheDir() - if (!existsSync(cacheDir)) return - - const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] - for (const ext of extensions) { - const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`) - if (existsSync(filePath)) { - msg.emojiLocalPath = filePath - // 同步写入内存缓存,避免重复查找 - emojiCache.set(msg.emojiMd5, filePath) - return - } - } - } - - /** - * 查找 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 - } - - private async getMessagesByOffsetStable( - sessionId: string, - offset: number, - limit: number - ): Promise<{ - success: boolean - messages?: Message[] - hasMore?: boolean - nextOffset?: number - rawRows?: number - filteredOut?: number - error?: string - }> { - const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault)) - const safeOffset = Math.max(0, Math.floor(offset || 0)) - const probeLimit = Math.min(500, pageLimit + 1) - - const result = await wcdbService.getMessages(sessionId, probeLimit, safeOffset) - if (!result.success || !Array.isArray(result.messages)) { - return { success: false, error: result.error || '获取消息失败' } - } - - const rawRows = result.messages as Record[] - const hasMore = rawRows.length > pageLimit - const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows - const mapped = this.mapRowsToMessages(selectedRows, sessionId) - const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg)) - const outputMessages = (visible.length === 0 && mapped.length > 0) - ? mapped - : visible - if (visible.length === 0 && mapped.length > 0) { - console.warn(`[ChatService] getMessagesByOffsetStable 可见性过滤回退: session=${sessionId} mapped=${mapped.length}`) - } - const normalized = this.normalizeMessageOrder(outputMessages) - if (normalized.length > 0) { - await this.repairEmojiMessages(normalized) - await this.resolveQuotedMessages(normalized, sessionId) - } - - return { - success: true, - messages: normalized, - hasMore, - nextOffset: safeOffset + selectedRows.length, - rawRows: selectedRows.length, - filteredOut: Math.max(0, mapped.length - visible.length) - } - } - - - async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - // 聊天页首屏优先走稳定路径:固定 offset=0 的 direct-offset 读取。 - const stableResult = await this.getMessagesByOffsetStable(sessionId, 0, limit) - if (!stableResult.success || !Array.isArray(stableResult.messages)) { - return { success: false, error: stableResult.error || '获取最新消息失败' } - } - - console.log( - `[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${stableResult.rawRows || 0} visibleMessagesReturned=${stableResult.messages.length} filteredOut=${stableResult.filteredOut || 0} nextOffset=${stableResult.nextOffset || 0} hasMore=${stableResult.hasMore === true}` - ) - return { - success: true, - messages: stableResult.messages, - hasMore: stableResult.hasMore === true, - nextOffset: Number.isFinite(stableResult.nextOffset) - ? Math.floor(stableResult.nextOffset as number) - : stableResult.messages.length - } - } catch (e) { - console.error('ChatService: 获取最新消息失败:', e) - return { success: false, error: String(e) } - } - } - - async getMessagesAround( - sessionId: string, - target: { localId?: number; createTime: number; messageKey?: string }, - totalContextCount: number = 50 - ): Promise<{ - success: boolean - before: Message[] - after: Message[] - requested: number - error?: string - }> { - const requested = Math.max(1, Math.min(200, Math.floor(Number(totalContextCount) || 50))) - const targetCreateTime = Math.floor(Number(target?.createTime || 0)) - if (!sessionId || targetCreateTime <= 0) { - return { success: false, before: [], after: [], requested, error: '无效的目标消息' } - } - - const collect = async (ascending: boolean): Promise => { - let cursor: number | undefined - try { - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - Math.min(240, Math.max(60, requested + 20)), - ascending, - ascending ? targetCreateTime : 0, - ascending ? 0 : targetCreateTime + 1 - ) - if (!cursorResult.success || !cursorResult.cursor) { - throw new Error(cursorResult.error || '打开消息游标失败') - } - cursor = cursorResult.cursor - const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursor, requested + 1) - if (!collected.success) { - throw new Error(collected.error || '读取上下文消息失败') - } - const targetLocalId = Math.floor(Number(target?.localId || 0)) - const targetMessageKey = String(target?.messageKey || '').trim() - return (collected.messages || []).filter((message) => { - const sameLocalId = targetLocalId > 0 && Number(message.localId || 0) === targetLocalId - const sameCreateTime = Number(message.createTime || 0) === targetCreateTime - const sameKey = Boolean(targetMessageKey && message.messageKey === targetMessageKey) - return !(sameKey || (sameLocalId && sameCreateTime)) - }) - } finally { - if (cursor) { - await wcdbService.closeMessageCursor(cursor).catch(() => {}) - } - } - } - - try { - const [beforeCandidatesRaw, afterCandidatesRaw] = await Promise.all([ - collect(false), - collect(true) - ]) - const beforeCandidates = beforeCandidatesRaw - .filter((message) => Number(message.createTime || 0) <= targetCreateTime) - .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) - const afterCandidates = afterCandidatesRaw - .filter((message) => Number(message.createTime || 0) >= targetCreateTime) - .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) - - const baseBefore = Math.floor(requested / 2) - const baseAfter = requested - baseBefore - const takeAfter = Math.min(baseAfter, afterCandidates.length) - const takeBefore = Math.min(requested - takeAfter, beforeCandidates.length) - const remainingAfter = Math.max(0, requested - takeBefore - takeAfter) - const finalAfter = Math.min(afterCandidates.length, takeAfter + remainingAfter) - const finalBefore = Math.min(beforeCandidates.length, requested - finalAfter) - - return { - success: true, - before: beforeCandidates.slice(Math.max(0, beforeCandidates.length - finalBefore)), - after: afterCandidates.slice(0, finalAfter), - requested - } - } catch (error) { - return { - success: false, - before: [], - after: [], - requested, - error: (error as Error).message || String(error) - } - } - } - - async getNewMessages(sessionId: string, minTime: number, 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 res = await wcdbService.getNewMessages(sessionId, minTime, limit) - if (!res.success || !res.messages) { - return { success: false, error: res.error || '获取新消息失败' } - } - - // 转换为 Message 对象 - const messages = this.mapRowsToMessages(res.messages as Record[], sessionId) - const normalized = this.normalizeMessageOrder(messages) - - // 并发检查并修复缺失 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 } - } catch (e) { - console.error('ChatService: 获取增量消息失败:', e) - return { success: false, error: String(e) } - } - } - - private compareMessagesByTimeline(a: Message, b: Message): number { - const aSortSeq = Math.max(0, Number(a.sortSeq || 0)) - const bSortSeq = Math.max(0, Number(b.sortSeq || 0)) - const aCreateTime = Math.max(0, Number(a.createTime || 0)) - const bCreateTime = Math.max(0, Number(b.createTime || 0)) - const aLocalId = Math.max(0, Number(a.localId || 0)) - const bLocalId = Math.max(0, Number(b.localId || 0)) - const aServerId = Math.max(0, Number(a.serverId || 0)) - const bServerId = Math.max(0, Number(b.serverId || 0)) - - // 与 C++ 侧归并规则一致:当两侧都有 sortSeq 时优先 sortSeq,否则先看 createTime。 - if (aSortSeq > 0 && bSortSeq > 0 && aSortSeq !== bSortSeq) { - return aSortSeq - bSortSeq - } - if (aCreateTime !== bCreateTime) { - return aCreateTime - bCreateTime - } - if (aSortSeq !== bSortSeq) { - return aSortSeq - bSortSeq - } - if (aLocalId !== bLocalId) { - return aLocalId - bLocalId - } - if (aServerId !== bServerId) { - return aServerId - bServerId - } - - const aKey = String(a.messageKey || '') - const bKey = String(b.messageKey || '') - if (aKey < bKey) return -1 - if (aKey > bKey) return 1 - return 0 - } - - private normalizeMessageOrder(messages: Message[]): Message[] { - if (messages.length < 2) return messages - - const withIndex = messages.map((msg, index) => ({ msg, index })) - withIndex.sort((left, right) => { - const diff = this.compareMessagesByTimeline(left.msg, right.msg) - if (diff !== 0) return diff - return left.index - right.index - }) - - let changed = false - for (let index = 0; index < withIndex.length; index += 1) { - if (withIndex[index].msg !== messages[index]) { - changed = true - break - } - } - if (!changed) return messages - return withIndex.map((entry) => entry.msg) - } - - private encodeMessageKeySegment(value: unknown): string { - const normalized = String(value ?? '').trim() - return encodeURIComponent(normalized) - } - - private getMessageSourceInfo(row: Record): { dbName?: string; tableName?: string; dbPath?: string } { - const dbPath = String(row._db_path || row.db_path || '').trim() - const explicitDbName = String(row.db_name || '').trim() - const tableName = String(row.table_name || '').trim() - const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '') - return { - dbName: dbName || undefined, - tableName: tableName || undefined, - dbPath: dbPath || undefined - } - } - - private buildMessageKey(input: { - localId: number - serverId: number - createTime: number - sortSeq: number - senderUsername?: string | null - localType: number - dbName?: string - tableName?: string - dbPath?: string - }): string { - const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0 - const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0 - const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0 - const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0 - const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0 - const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '') - const dbPath = String(input.dbPath || '').trim() - const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '') - const tableName = String(input.tableName || '').trim() - const sourceScope = dbPath || dbName - - if (localId > 0 && sourceScope && tableName) { - return `${this.encodeMessageKeySegment(sourceScope)}:${this.encodeMessageKeySegment(tableName)}:${localId}` - } - - if (localId > 0 && sourceScope) { - // 当底层未返回 table_name 时,避免使用 db:_:localId(会误并同库不同表的消息)。 - return `local:${this.encodeMessageKeySegment(sourceScope)}:${localId}:${createTime}:${sortSeq}:${senderUsername}:${localType}` - } - - if (serverId > 0) { - const scopedServer = sourceScope ? `${this.encodeMessageKeySegment(sourceScope)}:${serverId}` : String(serverId) - return `server:${scopedServer}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` - } - - return `fallback:${this.encodeMessageKeySegment(sourceScope)}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` - } - - private logVisibilityAnomaly(sessionId: string, msg: Message): void { - const key = String(sessionId || '').trim() || '__unknown__' - const now = Date.now() - let state = this.visibilityAnomalyLogState.get(key) - if (!state || (now - state.windowStart) > this.visibilityAnomalyLogWindowMs) { - if (state && state.suppressed > 0) { - console.warn( - `[ChatService] 会话可见性异常日志已抑制: sessionId=${key}, suppressed=${state.suppressed}, windowMs=${this.visibilityAnomalyLogWindowMs}` - ) - } - state = { windowStart: now, total: 0, suppressed: 0 } - this.visibilityAnomalyLogState.set(key, state) - if (this.visibilityAnomalyLogState.size > 256) { - const oldest = this.visibilityAnomalyLogState.keys().next() - if (!oldest.done) { - this.visibilityAnomalyLogState.delete(oldest.value) - } - } - } - - state.total += 1 - if (state.total <= this.visibilityAnomalyLogBurst) { - console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`) - return - } - - state.suppressed += 1 - } - - private isMessageVisibleForSession(sessionId: string, msg: Message): boolean { - const isGroupChat = sessionId.includes('@chatroom') - if (isGroupChat) { - return true - } - if (!msg.senderUsername || msg.senderUsername === sessionId) { - return true - } - if (msg.isSend === 1) { - return true - } - this.logVisibilityAnomaly(sessionId, msg) - return false - } - - private async repairEmojiMessages(messages: Message[]): Promise { - const fixPromises: Promise[] = [] - for (const msg of messages) { - if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { - fixPromises.push(this.fallbackEmoticon(msg)) - } - } - if (fixPromises.length > 0) { - await Promise.allSettled(fixPromises) - } - } - - private async collectVisibleMessagesFromCursor( - sessionId: string, - cursor: number, - limit: number, - initialRows: Record[] = [] - ): Promise<{ - success: boolean - messages?: Message[] - hasMore?: boolean - error?: string - rawRowsConsumed?: number - filteredOut?: number - bufferedRows?: Record[] - }> { - const visibleMessages: Message[] = [] - const filteredCandidates: Message[] = [] - let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : [] - let rawRowsConsumed = 0 - let filteredOut = 0 - let cursorMayHaveMore = queuedRows.length > 0 - let emptyBatchStreak = 0 - - while (visibleMessages.length < limit) { - if (queuedRows.length === 0) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - console.error('[ChatService] 获取消息批次失败:', batch.error) - if (visibleMessages.length === 0) { - return { success: false, error: batch.error || '获取消息失败' } - } - cursorMayHaveMore = false - break - } - - const batchRows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - cursorMayHaveMore = batch.hasMore === true - if (batchRows.length === 0) { - if (cursorMayHaveMore && emptyBatchStreak < 2) { - emptyBatchStreak += 1 - continue - } - break - } - emptyBatchStreak = 0 - queuedRows = batchRows - } - - const rowsToProcess = queuedRows - queuedRows = [] - const mappedMessages = this.mapRowsToMessages(rowsToProcess, sessionId) - for (let index = 0; index < mappedMessages.length; index += 1) { - const msg = mappedMessages[index] - rawRowsConsumed += 1 - if (this.isMessageVisibleForSession(sessionId, msg)) { - visibleMessages.push(msg) - if (visibleMessages.length >= limit) { - if (index + 1 < rowsToProcess.length) { - queuedRows = rowsToProcess.slice(index + 1) - } - break - } - } else { - filteredOut += 1 - if (visibleMessages.length === 0 && filteredCandidates.length < limit) { - filteredCandidates.push(msg) - } - } - } - - if (visibleMessages.length >= limit) { - break - } - - if (!cursorMayHaveMore) { - break - } - } - - if (filteredOut > 0) { - console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`) - } - - let outputMessages = visibleMessages - if (outputMessages.length === 0 && filteredCandidates.length > 0) { - // 回退策略:某些会话 sender_username 与 sessionId 可能不一致,避免整批被误过滤为 0 条。 - outputMessages = filteredCandidates - console.warn( - `[ChatService] 会话可见性过滤触发回退: session=${sessionId} fallbackCount=${filteredCandidates.length}` - ) - } - - const normalized = this.normalizeMessageOrder(outputMessages) - if (normalized.length > 0) { - await this.repairEmojiMessages(normalized) - } - return { - success: true, - messages: normalized, - hasMore: queuedRows.length > 0 || cursorMayHaveMore, - rawRowsConsumed, - filteredOut, - bufferedRows: queuedRows.length > 0 ? queuedRows : undefined - } - } - - 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 parseCompactDateTimeDigitsToSeconds(raw: string): number { - const text = String(raw || '').trim() - if (!/^\d{8}(?:\d{4}(?:\d{2})?)?$/.test(text)) return 0 - - const year = Number.parseInt(text.slice(0, 4), 10) - const month = Number.parseInt(text.slice(4, 6), 10) - const day = Number.parseInt(text.slice(6, 8), 10) - const hour = text.length >= 12 ? Number.parseInt(text.slice(8, 10), 10) : 0 - const minute = text.length >= 12 ? Number.parseInt(text.slice(10, 12), 10) : 0 - const second = text.length >= 14 ? Number.parseInt(text.slice(12, 14), 10) : 0 - - if (!Number.isFinite(year) || year < 1990 || year > 2200) return 0 - if (!Number.isFinite(month) || month < 1 || month > 12) return 0 - if (!Number.isFinite(day) || day < 1 || day > 31) return 0 - if (!Number.isFinite(hour) || hour < 0 || hour > 23) return 0 - if (!Number.isFinite(minute) || minute < 0 || minute > 59) return 0 - if (!Number.isFinite(second) || second < 0 || second > 59) return 0 - - const dt = new Date(year, month - 1, day, hour, minute, second) - if ( - dt.getFullYear() !== year || - dt.getMonth() !== month - 1 || - dt.getDate() !== day || - dt.getHours() !== hour || - dt.getMinutes() !== minute || - dt.getSeconds() !== second - ) { - return 0 - } - const ts = Math.floor(dt.getTime() / 1000) - return Number.isFinite(ts) && ts > 0 ? ts : 0 - } - - private parseDateTimeTextToSeconds(raw: unknown): number { - const text = String(raw ?? '').trim() - if (!text) return 0 - - const compactDigits = this.parseCompactDateTimeDigitsToSeconds(text) - if (compactDigits > 0) return compactDigits - - if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(text)) { - const parsed = Date.parse(text) - const seconds = Math.floor(parsed / 1000) - if (Number.isFinite(seconds) && seconds > 0) return seconds - } - - const normalized = text.replace('T', ' ').replace(/\.\d+$/, '').replace(/\//g, '-') - const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/) - if (!match) return 0 - - const year = Number.parseInt(match[1], 10) - const month = Number.parseInt(match[2], 10) - const day = Number.parseInt(match[3], 10) - const hour = Number.parseInt(match[4] || '0', 10) - const minute = Number.parseInt(match[5] || '0', 10) - const second = Number.parseInt(match[6] || '0', 10) - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return 0 - const dt = new Date(year, month - 1, day, hour, minute, second) - const ts = Math.floor(dt.getTime() / 1000) - return Number.isFinite(ts) && ts > 0 ? ts : 0 - } - - private normalizeTimestampLikeToSeconds(raw: unknown): number { - if (raw === undefined || raw === null || raw === '') return 0 - const text = String(raw ?? '').trim() - if (!text) return 0 - - const compactDigits = this.parseCompactDateTimeDigitsToSeconds(text) - if (compactDigits > 0) return compactDigits - - const parsed = this.coerceRowNumber(raw) - if (Number.isFinite(parsed) && parsed > 0) { - let normalized = Math.floor(parsed) - while (normalized > 10000000000) { - normalized = Math.floor(normalized / 1000) - } - return normalized - } - - return this.parseDateTimeTextToSeconds(text) - } - - private getRowTimestampSeconds(row: Record, keys: string[], fallback = 0): number { - const raw = this.getRowField(row, keys) - if (raw === undefined || raw === null || raw === '') return fallback - const parsed = this.normalizeTimestampLikeToSeconds(raw) - return parsed > 0 ? parsed : fallback - } - - private hasAnyContactExtendedFieldKey(row: Record): boolean { - for (const key of Object.keys(row || {})) { - if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) { - return true - } - } - return false - } - - private async hydrateContactsWithExtendedFields(rows: Record[]): Promise[]> { - if (!Array.isArray(rows) || rows.length === 0) return rows - const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {})) - if (hasAnyExtendedFieldKey) { - // wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。 - // 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。 - const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null) - if (allRowsHaveUsableExtraBuffer) return rows - } - - try { - let selectableColumns = this.contactExtendedSelectableColumns - if (!selectableColumns) { - const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)') - if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) { - return rows - } - - const availableColumns = new Map() - for (const tableInfoRow of tableInfoResult.rows as Record[]) { - const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName - const name = String(rawName || '').trim() - if (!name) continue - availableColumns.set(name.toLowerCase(), name) - } - - const resolvedColumns: string[] = [] - const seenColumns = new Set() - for (const candidate of this.contactExtendedFieldCandidates) { - const actual = availableColumns.get(candidate.toLowerCase()) - if (!actual) continue - const normalized = actual.toLowerCase() - if (seenColumns.has(normalized)) continue - seenColumns.add(normalized) - resolvedColumns.push(actual) - } - - this.contactExtendedSelectableColumns = resolvedColumns - selectableColumns = resolvedColumns - } - - if (selectableColumns.length === 0) return rows - - const selectColumns = ['username', ...selectableColumns] - const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''` - const extendedResult = await wcdbService.execQuery('contact', null, sql) - if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) { - return rows - } - - const extendedByUsername = new Map>() - for (const extendedRow of extendedResult.rows as Record[]) { - const username = String(extendedRow.username || '').trim() - if (!username) continue - extendedByUsername.set(username, extendedRow) - } - if (extendedByUsername.size === 0) return rows - - return rows.map((row) => { - const username = String(row.username || row.user_name || row.userName || '').trim() - if (!username) return row - const extended = extendedByUsername.get(username) - if (!extended) return row - return { - ...extended, - ...row - } - }) - } catch (error) { - console.warn('联系人扩展字段补偿查询失败:', error) - return rows - } - } - - private async getContactLabelNameMap(): Promise> { - const now = Date.now() - if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) { - return new Map(this.contactLabelNameMapCache) - } - - const labelMap = new Map() - try { - const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)') - if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) { - this.contactLabelNameMapCache = labelMap - this.contactLabelNameMapCacheAt = now - return labelMap - } - - const availableColumns = new Map() - for (const tableInfoRow of tableInfoResult.rows as Record[]) { - const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName - const name = String(rawName || '').trim() - if (!name) continue - availableColumns.set(name.toLowerCase(), name) - } - - const pickColumn = (candidates: string[]): string | null => { - for (const candidate of candidates) { - const actual = availableColumns.get(candidate.toLowerCase()) - if (actual) return actual - } - return null - } - - const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id']) - const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name']) - if (!idColumn || !nameColumn) { - this.contactLabelNameMapCache = labelMap - this.contactLabelNameMapCacheAt = now - return labelMap - } - - const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label` - const result = await wcdbService.execQuery('contact', null, sql) - if (result.success && Array.isArray(result.rows)) { - for (const row of result.rows as Record[]) { - const id = Number(String(row.label_id ?? row.labelId ?? '').trim()) - const name = String(row.label_name ?? row.labelName ?? '').trim() - if (Number.isFinite(id) && id > 0 && name) { - labelMap.set(Math.floor(id), name) - } - } - } - } catch (error) { - console.warn('读取 contact_label 失败:', error) - } - - this.contactLabelNameMapCache = labelMap - this.contactLabelNameMapCacheAt = now - return new Map(labelMap) - } - - private toExtraBufferBytes(row: Record): Buffer | null { - const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer']) - if (raw === undefined || raw === null) return null - if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null - if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null - if (Array.isArray(raw)) { - const bytes = Buffer.from(raw) - return bytes.length > 0 ? bytes : null - } - - const text = String(raw || '').trim() - if (!text) return null - const compact = text.replace(/\s+/g, '') - if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) { - try { - const bytes = Buffer.from(compact, 'hex') - return bytes.length > 0 ? bytes : null - } catch { - return null - } - } - return null - } - - private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null { - if (!buffer || offset < 0 || offset >= buffer.length) return null - let value = 0 - let shift = 0 - let index = offset - while (index < buffer.length) { - const byte = buffer[index] - index += 1 - value += (byte & 0x7f) * Math.pow(2, shift) - if ((byte & 0x80) === 0) { - return { value, nextOffset: index } - } - shift += 7 - if (shift > 56) return null - } - return null - } - - private extractExtraBufferTopLevelFieldStrings(row: Record, targetField: number): string[] { - const bytes = this.toExtraBufferBytes(row) - if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return [] - const values: string[] = [] - let offset = 0 - while (offset < bytes.length) { - const tagResult = this.readProtoVarint(bytes, offset) - if (!tagResult) break - offset = tagResult.nextOffset - const fieldNumber = Math.floor(tagResult.value / 8) - const wireType = tagResult.value & 0x07 - - if (wireType === 0) { - const varint = this.readProtoVarint(bytes, offset) - if (!varint) break - offset = varint.nextOffset - continue - } - - if (wireType === 1) { - if (offset + 8 > bytes.length) break - offset += 8 - continue - } - - if (wireType === 2) { - const lengthResult = this.readProtoVarint(bytes, offset) - if (!lengthResult) break - const payloadLength = Math.floor(lengthResult.value) - offset = lengthResult.nextOffset - if (payloadLength < 0 || offset + payloadLength > bytes.length) break - const payload = bytes.subarray(offset, offset + payloadLength) - offset += payloadLength - if (fieldNumber === targetField) { - const text = payload.toString('utf-8').replace(/\u0000/g, '').trim() - if (text) values.push(text) - } - continue - } - - if (wireType === 5) { - if (offset + 4 > bytes.length) break - offset += 4 - continue - } - - break - } - return values - } - - private parseContactLabelsFromExtraBuffer(row: Record, labelNameMap?: Map): string[] { - const labelNames: string[] = [] - const seen = new Set() - const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30) - for (const text of texts) { - const matches = text.match(/\d+/g) || [] - for (const match of matches) { - const id = Number(match) - if (!Number.isFinite(id) || id <= 0) continue - const labelName = labelNameMap?.get(Math.floor(id)) - if (!labelName) continue - if (seen.has(labelName)) continue - seen.add(labelName) - labelNames.push(labelName) - } - } - return labelNames - } - - private parseContactLabels(row: Record, labelNameMap?: Map): string[] { - const raw = this.getRowField(row, [ - 'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList' - ]) - const normalizedFromValue = (value: unknown): string[] => { - if (Array.isArray(value)) { - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) - } - const text = String(value || '').trim() - if (!text) return [] - return Array.from(new Set( - text - .replace(/[;;、|]+/g, ',') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - )) - } - - const direct = normalizedFromValue(raw) - if (direct.length > 0) return direct - - for (const [key, value] of Object.entries(row)) { - const normalizedKey = key.toLowerCase() - if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue - if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue - const fallback = normalizedFromValue(value) - if (fallback.length > 0) return fallback - } - - const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap) - if (extraBufferLabels.length > 0) return extraBufferLabels - - return [] - } - - private getContactSignature(row: Record): string { - const normalize = (raw: unknown): string => { - const text = String(raw || '').replace(/\u0000/g, '').trim() - if (!text) return '' - const lower = text.toLowerCase() - if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') { - return '' - } - return text - } - - const value = this.getRowField(row, [ - 'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction', - 'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription' - ]) - const direct = normalize(value) - if (direct) return direct - - for (const [key, rawValue] of Object.entries(row)) { - const normalizedKey = key.toLowerCase() - const isCandidate = - normalizedKey.includes('sign') || - normalizedKey.includes('signature') || - normalizedKey.includes('profile') || - normalizedKey.includes('intro') || - normalizedKey.includes('description') || - normalizedKey.includes('detail') || - normalizedKey.includes('desc') - if (!isCandidate) continue - if ( - normalizedKey.includes('avatar') || - normalizedKey.includes('img') || - normalizedKey.includes('head') || - normalizedKey.includes('label') || - normalizedKey.includes('tag') - ) continue - const text = normalize(rawValue) - if (text) return text - } - - // contact.extra_buffer field 4: 个性签名兜底 - const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4) - for (const signature of signatures) { - const text = normalize(signature) - if (!text) continue - return text - } - - return '' - } - - private normalizeContactRegionPart(raw: unknown): string { - const text = String(raw || '').replace(/\u0000/g, '').trim() - if (!text) return '' - const lower = text.toLowerCase() - if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') { - return '' - } - return text - } - - private normalizeRegionLookupKey(raw: string): string { - return String(raw || '') - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '') - } - - private buildRegionLookupCandidates(raw: string): string[] { - const normalized = this.normalizeRegionLookupKey(raw) - if (!normalized) return [] - - const candidates = new Set([normalized]) - const withoutTrailingDigits = normalized.replace(/\d+$/g, '') - if (withoutTrailingDigits) candidates.add(withoutTrailingDigits) - - return Array.from(candidates) - } - - private normalizeChineseProvinceName(raw: string): string { - const text = String(raw || '').trim() - if (!text) return '' - return text - .replace(/特别行政区$/g, '') - .replace(/维吾尔自治区$/g, '') - .replace(/壮族自治区$/g, '') - .replace(/回族自治区$/g, '') - .replace(/自治区$/g, '') - .replace(/省$/g, '') - .replace(/市$/g, '') - .trim() - } - - private normalizeChineseCityName(raw: string): string { - const text = String(raw || '').trim() - if (!text) return '' - return text - .replace(/特别行政区$/g, '') - .replace(/自治州$/g, '') - .replace(/地区$/g, '') - .replace(/盟$/g, '') - .replace(/林区$/g, '') - .replace(/市$/g, '') - .trim() - } - - private resolveProvinceLookupKey(raw: string): string { - const candidates = this.buildRegionLookupCandidates(raw) - if (candidates.length === 0) return '' - - for (const candidate of candidates) { - const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate] - if (byName) return byName - if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate - } - - return candidates[0] - } - - private toChineseCountryName(raw: string): string { - const text = this.normalizeContactRegionPart(raw) - if (!text) return '' - - const candidates = this.buildRegionLookupCandidates(text) - for (const candidate of candidates) { - const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate] - if (mapped) return mapped - } - return text - } - - private toChineseProvinceName(raw: string): string { - const text = this.normalizeContactRegionPart(raw) - if (!text) return '' - - const candidates = this.buildRegionLookupCandidates(text) - if (candidates.length === 0) return text - const provinceKey = this.resolveProvinceLookupKey(text) - const mappedFromCandidates = candidates - .map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) - .find(Boolean) - const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates - if (mapped) return mapped - - if (/[\u4e00-\u9fa5]/.test(text)) { - return this.normalizeChineseProvinceName(text) || text - } - - return text - } - - private toChineseCityName(raw: string, provinceRaw?: string): string { - const text = this.normalizeContactRegionPart(raw) - if (!text) return '' - - const candidates = this.buildRegionLookupCandidates(text) - if (candidates.length === 0) return text - - const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || '')) - if (provinceKey) { - const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey] - if (byProvince) { - for (const candidate of candidates) { - const mappedInProvince = byProvince[candidate] - if (mappedInProvince) return mappedInProvince - } - } - } - - for (const candidate of candidates) { - const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate] - if (mapped) return mapped - } - - if (/[\u4e00-\u9fa5]/.test(text)) { - return this.normalizeChineseCityName(text) || text - } - - return text - } - - private toChineseRegionText(raw: string): string { - const text = this.normalizeContactRegionPart(raw) - if (!text) return '' - const tokens = text - .split(/[\s,,、/|·]+/) - .map((item) => this.normalizeContactRegionPart(item)) - .filter(Boolean) - if (tokens.length === 0) return text - - let provinceContext = '' - const mapped = tokens.map((token) => { - const country = this.toChineseCountryName(token) - if (country !== token) return country - - const province = this.toChineseProvinceName(token) - if (province !== token) { - provinceContext = province - return province - } - - const city = this.toChineseCityName(token, provinceContext) - if (city !== token) return city - - return token - }) - return mapped.join(' ').trim() - } - - private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean { - if (!country) return true - const normalized = country.toLowerCase() - if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') { - return hasProvinceOrCity - } - return false - } - - private getContactRegion(row: Record): string { - const pickByTokens = (tokens: string[]): string => { - for (const [key, value] of Object.entries(row || {})) { - const normalizedKey = String(key || '').toLowerCase() - if (!normalizedKey) continue - if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue - if (!tokens.some((token) => normalizedKey.includes(token))) continue - const text = this.normalizeContactRegionPart(value) - if (text) return text - } - return '' - } - - const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country']) - const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province']) - const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city']) - const directRegion = - this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) || - pickByTokens(['region', 'location', 'area', 'addr', 'address']) - - if (directRegion) { - const normalizedRegion = this.toChineseRegionText(directRegion) - const parts = normalizedRegion - .split(/\s+/) - .map((item) => this.normalizeContactRegionPart(item)) - .filter(Boolean) - if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) { - return parts.slice(1).join(' ').trim() - } - return normalizedRegion - } - - const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '') - const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '') - const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '') - - const country = this.toChineseCountryName(directCountry || fallbackCountry) - const province = this.toChineseProvinceName(directProvince || fallbackProvince) - const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince) - - const hasProvinceOrCity = Boolean(province || city) - const parts: string[] = [] - if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) { - parts.push(country) - } - if (province) { - parts.push(province) - } - if (city && city !== province) { - parts.push(city) - } - - return parts.join(' ').trim() - } - - private normalizeUnsignedIntegerToken(raw: any): string | undefined { - if (raw === undefined || raw === null || raw === '') return undefined - - if (typeof raw === 'bigint') { - return raw >= 0n ? raw.toString() : '0' - } - - if (typeof raw === 'number') { - if (!Number.isFinite(raw)) return undefined - return String(Math.max(0, Math.floor(raw))) - } - - if (Buffer.isBuffer(raw)) { - return this.normalizeUnsignedIntegerToken(raw.toString('utf-8').trim()) - } - if (raw instanceof Uint8Array) { - return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) - } - if (Array.isArray(raw)) { - return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) - } - - if (typeof raw === 'object') { - if ('value' in raw) return this.normalizeUnsignedIntegerToken(raw.value) - if ('intValue' in raw) return this.normalizeUnsignedIntegerToken(raw.intValue) - if ('low' in raw && 'high' in raw) { - try { - const low = BigInt(raw.low >>> 0) - const high = BigInt(raw.high >>> 0) - const value = (high << 32n) + low - return value >= 0n ? value.toString() : '0' - } catch { - return undefined - } - } - const text = raw.toString ? String(raw).trim() : '' - if (text && text !== '[object Object]') { - return this.normalizeUnsignedIntegerToken(text) - } - return undefined - } - - const text = String(raw).trim() - if (!text) return undefined - if (/^\d+$/.test(text)) { - return text.replace(/^0+(?=\d)/, '') || '0' - } - if (/^[+-]?\d+$/.test(text)) { - try { - const value = BigInt(text) - return value >= 0n ? value.toString() : '0' - } catch { - return undefined - } - } - - const parsed = Number(text) - if (Number.isFinite(parsed)) { - return String(Math.max(0, Math.floor(parsed))) - } - return undefined - } - - 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 this.coerceRowNumber(raw.toString('utf-8')) - } - if (raw instanceof Uint8Array) { - return this.coerceRowNumber(Buffer.from(raw).toString('utf-8')) - } - if (Array.isArray(raw)) { - return this.coerceRowNumber(Buffer.from(raw).toString('utf-8')) - } - 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]') { - return this.coerceRowNumber(text) - } - return NaN - } - const text = String(raw).trim() - if (!text) return NaN - if (/^[+-]?\d+$/.test(text)) { - const parsed = Number(text) - return Number.isFinite(parsed) ? parsed : NaN - } - if (/^[+-]?\d+\.\d+$/.test(text)) { - const parsed = Number(text) - return Number.isFinite(parsed) ? parsed : NaN - } - return NaN - } - - private buildIdentityKeys(raw: string): string[] { - const value = String(raw || '').trim() - if (!value) return [] - const lowerRaw = value.toLowerCase() - const cleaned = this.cleanAccountDirName(value).toLowerCase() - if (cleaned && cleaned !== lowerRaw) { - return [cleaned, lowerRaw] - } - return [lowerRaw] - } - - private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): { - isSend: number | null - selfMatched: boolean - correctedBySelfIdentity: boolean - } { - const normalizedRawIsSend = Number.isFinite(rawIsSend as number) ? rawIsSend : null - const senderKeys = this.buildIdentityKeys(String(senderUsername || '')) - if (senderKeys.length === 0) { - return { - isSend: normalizedRawIsSend, - selfMatched: false, - correctedBySelfIdentity: false - } - } - - const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - const selfKeys = this.buildIdentityKeys(myWxid) - if (selfKeys.length === 0) { - return { - isSend: normalizedRawIsSend, - selfMatched: false, - correctedBySelfIdentity: false - } - } - - const selfMatched = senderKeys.some(senderKey => - selfKeys.some(selfKey => - senderKey === selfKey || - senderKey.startsWith(selfKey + '_') || - selfKey.startsWith(senderKey + '_') - ) - ) - - if (selfMatched && normalizedRawIsSend !== 1) { - return { - isSend: 1, - selfMatched: true, - correctedBySelfIdentity: true - } - } - - if (normalizedRawIsSend === null) { - return { - isSend: selfMatched ? 1 : 0, - selfMatched, - correctedBySelfIdentity: false - } - } - - return { - isSend: normalizedRawIsSend, - selfMatched, - correctedBySelfIdentity: false - } - } - - private extractGroupMemberUsername(member: any): string { - if (!member) return '' - if (typeof member === 'string') return member.trim() - return String( - member.username || - member.userName || - member.user_name || - member.encryptUsername || - member.encryptUserName || - member.encrypt_username || - member.originalName || - '' - ).trim() - } - - private async getFriendIdentitySet(): Promise> { - const identities = new Set() - const contactResult = await wcdbService.getContactsCompact() - if (!contactResult.success || !contactResult.contacts) { - return identities - } - - for (const rowAny of contactResult.contacts) { - const row = rowAny as Record - const username = String(row.username || '').trim() - if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue - if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue - - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - if (localType !== 1) continue - - for (const key of this.buildIdentityKeys(username)) { - identities.add(key) - } - } - return identities - } - - private async forEachWithConcurrency( - items: T[], - limit: number, - worker: (item: T) => Promise - ): Promise { - if (items.length === 0) return - const concurrency = Math.max(1, Math.min(limit, items.length)) - let index = 0 - - const runners = Array.from({ length: concurrency }, async () => { - while (true) { - const current = index - index += 1 - if (current >= items.length) return - await worker(items[current]) - } - }) - - await Promise.all(runners) - } - - private normalizeExportDiagTraceId(traceId?: string): string { - const normalized = String(traceId || '').trim() - return normalized - } - - private logExportDiag(input: { - traceId?: string - source?: 'backend' | 'main' | 'frontend' | 'worker' - level?: 'debug' | 'info' | 'warn' | 'error' - message: string - stepId?: string - stepName?: string - status?: 'running' | 'done' | 'failed' | 'timeout' - durationMs?: number - data?: Record - }): void { - const traceId = this.normalizeExportDiagTraceId(input.traceId) - if (!traceId) return - exportCardDiagnosticsService.log({ - traceId, - source: input.source || 'backend', - level: input.level || 'info', - message: input.message, - stepId: input.stepId, - stepName: input.stepName, - status: input.status, - durationMs: input.durationMs, - data: input.data - }) - } - - private startExportDiagStep(input: { - traceId?: string - stepId: string - stepName: string - message: string - data?: Record - }): number { - const startedAt = Date.now() - const traceId = this.normalizeExportDiagTraceId(input.traceId) - if (traceId) { - exportCardDiagnosticsService.stepStart({ - traceId, - stepId: input.stepId, - stepName: input.stepName, - source: 'backend', - message: input.message, - data: input.data - }) - } - return startedAt - } - - private endExportDiagStep(input: { - traceId?: string - stepId: string - stepName: string - startedAt: number - success: boolean - message?: string - data?: Record - }): void { - const traceId = this.normalizeExportDiagTraceId(input.traceId) - if (!traceId) return - exportCardDiagnosticsService.stepEnd({ - traceId, - stepId: input.stepId, - stepName: input.stepName, - source: 'backend', - status: input.success ? 'done' : 'failed', - message: input.message || (input.success ? `${input.stepName} 完成` : `${input.stepName} 失败`), - durationMs: Math.max(0, Date.now() - input.startedAt), - data: input.data - }) - } - - private refreshSessionMessageCountCacheScope(): void { - const dbPath = String(this.configService.get('dbPath') || '') - const myWxid = String(this.configService.getMyWxidCleaned() || '') - const scope = `${dbPath}::${myWxid}` - if (scope === this.sessionMessageCountCacheScope) { - this.refreshSessionStatsCacheScope(scope) - this.refreshGroupMyMessageCountCacheScope(scope) - return - } - this.sessionMessageCountCacheScope = scope - this.sessionMessageCountCache.clear() - this.sessionMessageCountHintCache.clear() - this.sessionMessageCountBatchCache = null - this.sessionDetailFastCache.clear() - this.sessionDetailExtraCache.clear() - this.sessionStatusCache.clear() - this.sessionTablesCache.clear() - this.messageTableColumnsCache.clear() - this.messageDbCountSnapshotCache = null - this.contactsMemoryCache.clear() - this.refreshSessionStatsCacheScope(scope) - this.refreshGroupMyMessageCountCacheScope(scope) - } - - private refreshGroupMyMessageCountCacheScope(scope: string): void { - if (scope === this.groupMyMessageCountCacheScope) return - this.groupMyMessageCountCacheScope = scope - this.groupMyMessageCountMemoryCache.clear() - } - - private refreshSessionStatsCacheScope(scope: string): void { - if (scope === this.sessionStatsCacheScope) return - this.sessionStatsCacheScope = scope - this.sessionStatsMemoryCache.clear() - this.sessionStatsPendingBasic.clear() - this.sessionStatsPendingFull.clear() - this.allGroupSessionIdsCache = null - } - - private buildScopedSessionStatsKey(sessionId: string): string { - return `${this.sessionStatsCacheScope}::${sessionId}` - } - - private buildScopedGroupMyMessageCountKey(chatroomId: string): string { - return `${this.groupMyMessageCountCacheScope}::${chatroomId}` - } - - private getGroupMyMessageCountHintEntry( - chatroomId: string - ): { entry: GroupMyMessageCountCacheEntry; source: 'memory' | 'disk' } | null { - const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) - const inMemory = this.groupMyMessageCountMemoryCache.get(scopedKey) - if (inMemory) { - return { entry: inMemory, source: 'memory' } - } - - const persisted = this.groupMyMessageCountCacheService.get(this.groupMyMessageCountCacheScope, chatroomId) - if (!persisted) return null - this.groupMyMessageCountMemoryCache.set(scopedKey, persisted) - return { entry: persisted, source: 'disk' } - } - - private setGroupMyMessageCountHintEntry(chatroomId: string, messageCount: number, updatedAt?: number): number { - const nextCount = Number.isFinite(messageCount) ? Math.max(0, Math.floor(messageCount)) : 0 - const nextUpdatedAt = Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt as number)) : Date.now() - const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) - const existing = this.groupMyMessageCountMemoryCache.get(scopedKey) - if (existing && existing.updatedAt > nextUpdatedAt) { - return existing.updatedAt - } - - const entry: GroupMyMessageCountCacheEntry = { - updatedAt: nextUpdatedAt, - messageCount: nextCount - } - this.groupMyMessageCountMemoryCache.set(scopedKey, entry) - this.groupMyMessageCountCacheService.set(this.groupMyMessageCountCacheScope, chatroomId, entry) - return nextUpdatedAt - } - - private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats { - const normalized: SessionStatsCacheStats = { - totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, - voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0, - imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0, - videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0, - emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0, - transferMessages: Number.isFinite(stats.transferMessages) ? Math.max(0, Math.floor(stats.transferMessages)) : 0, - redPacketMessages: Number.isFinite(stats.redPacketMessages) ? Math.max(0, Math.floor(stats.redPacketMessages)) : 0, - callMessages: Number.isFinite(stats.callMessages) ? Math.max(0, Math.floor(stats.callMessages)) : 0 - } - - if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number)) - if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number)) - if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number)) - if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number)) - if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number)) - if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number)) - if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number)) - - return normalized - } - - private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats { - return { - totalMessages: stats.totalMessages, - voiceMessages: stats.voiceMessages, - imageMessages: stats.imageMessages, - videoMessages: stats.videoMessages, - emojiMessages: stats.emojiMessages, - transferMessages: stats.transferMessages, - redPacketMessages: stats.redPacketMessages, - callMessages: stats.callMessages, - firstTimestamp: stats.firstTimestamp, - lastTimestamp: stats.lastTimestamp, - privateMutualGroups: stats.privateMutualGroups, - groupMemberCount: stats.groupMemberCount, - groupMyMessages: stats.groupMyMessages, - groupActiveSpeakers: stats.groupActiveSpeakers, - groupMutualFriends: stats.groupMutualFriends - } - } - - private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean { - if (!includeRelations) return true - return entry.includeRelations - } - - private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null { - const scopedKey = this.buildScopedSessionStatsKey(sessionId) - const inMemory = this.sessionStatsMemoryCache.get(scopedKey) - if (inMemory) { - return { entry: inMemory, source: 'memory' } - } - - const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId) - if (!persisted) return null - this.sessionStatsMemoryCache.set(scopedKey, persisted) - return { entry: persisted, source: 'disk' } - } - - private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number { - const updatedAt = Date.now() - const normalizedStats = this.toSessionStatsCacheStats(stats) - const entry: SessionStatsCacheEntry = { - updatedAt, - includeRelations, - stats: normalizedStats - } - const scopedKey = this.buildScopedSessionStatsKey(sessionId) - this.sessionStatsMemoryCache.set(scopedKey, entry) - this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry) - if (sessionId.endsWith('@chatroom') && Number.isFinite(normalizedStats.groupMyMessages)) { - this.setGroupMyMessageCountHintEntry(sessionId, normalizedStats.groupMyMessages as number, updatedAt) - } - return updatedAt - } - - private deleteSessionStatsCacheEntry(sessionId: string): void { - const scopedKey = this.buildScopedSessionStatsKey(sessionId) - this.sessionStatsMemoryCache.delete(scopedKey) - this.sessionStatsPendingBasic.delete(scopedKey) - this.sessionStatsPendingFull.delete(scopedKey) - this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId) - } - - private clearSessionStatsCacheForScope(): void { - this.sessionStatsMemoryCache.clear() - this.sessionStatsPendingBasic.clear() - this.sessionStatsPendingFull.clear() - this.allGroupSessionIdsCache = null - this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope) - } - - private collectSessionIdsFromPayload(payload: unknown): Set { - const ids = new Set() - const walk = (value: unknown, keyHint?: string) => { - if (Array.isArray(value)) { - for (const item of value) walk(item, keyHint) - return - } - if (value && typeof value === 'object') { - for (const [k, v] of Object.entries(value as Record)) { - walk(v, k) - } - return - } - if (typeof value !== 'string') return - const normalized = value.trim() - if (!normalized) return - const lowerKey = String(keyHint || '').toLowerCase() - const keyLooksLikeSession = ( - lowerKey.includes('session') || - lowerKey.includes('talker') || - lowerKey.includes('username') || - lowerKey.includes('chatroom') - ) - if (!keyLooksLikeSession && !normalized.includes('@chatroom')) { - return - } - ids.add(normalized) - } - walk(payload) - return ids - } - - private handleSessionStatsMonitorChange(type: string, json: string): void { - this.refreshSessionMessageCountCacheScope() - if (!this.sessionStatsCacheScope) return - - const normalizedType = String(type || '').toLowerCase() - if ( - normalizedType.includes('message') || - normalizedType.includes('session') || - normalizedType.includes('db') - ) { - this.messageDbCountSnapshotCache = null - } - const maybeJson = String(json || '').trim() - let ids = new Set() - if (maybeJson) { - try { - ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson)) - } catch { - ids = this.collectSessionIdsFromPayload(maybeJson) - } - } - - if (ids.size > 0) { - ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) - if (Array.from(ids).some((id) => id.includes('@chatroom'))) { - this.allGroupSessionIdsCache = null - } - return - } - - // 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope,避免展示过旧统计。 - if ( - normalizedType.includes('message') || - normalizedType.includes('session') || - normalizedType.includes('group') || - normalizedType.includes('member') || - normalizedType.includes('contact') - ) { - this.clearSessionStatsCacheForScope() - } - } - - private async listAllGroupSessionIds(): Promise { - const now = Date.now() - if ( - this.allGroupSessionIdsCache && - now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs - ) { - return this.allGroupSessionIdsCache.ids - } - - const result = await wcdbService.getSessions() - if (!result.success || !Array.isArray(result.sessions)) { - return [] - } - - const ids = new Set() - for (const rowAny of result.sessions) { - const row = rowAny as Record - const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId - const username = String(usernameRaw || '').trim() - if (!username || !username.endsWith('@chatroom')) continue - ids.add(username) - } - - const list = Array.from(ids) - this.allGroupSessionIdsCache = { - ids: list, - updatedAt: now - } - return list - } - - private async getSessionMessageTables(sessionId: string): Promise> { - const now = Date.now() - const cached = this.sessionTablesCache.get(sessionId) - if (cached && now - cached.updatedAt <= this.sessionTablesCacheTtl && cached.tables.length > 0) { - return cached.tables - } - if (cached) { - this.sessionTablesCache.delete(sessionId) - } - - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return [] - } - - const tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, { - tables, - updatedAt: now - }) - } - return tables - } - - private async getMessageTableColumns(dbPath: string, tableName: string): Promise> { - const cacheKey = `${dbPath}\u0001${tableName}` - const now = Date.now() - const cached = this.messageTableColumnsCache.get(cacheKey) - if (cached && now - cached.updatedAt <= this.messageTableColumnsCacheTtlMs) { - return new Set(cached.columns) - } - - const result = await wcdbService.getMessageTableColumns(dbPath, tableName) - if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) return new Set() - - const columns = new Set() - for (const columnName of result.columns) { - const name = String(columnName || '').trim().toLowerCase() - if (name) columns.add(name) - } - this.messageTableColumnsCache.set(cacheKey, { - columns: new Set(columns), - updatedAt: now - }) - return columns - } - - private pickFirstColumn(columns: Set, candidates: string[]): string | undefined { - for (const candidate of candidates) { - const normalized = candidate.toLowerCase() - if (columns.has(normalized)) return normalized - } - return undefined - } - - private escapeSqlLiteral(value: string): string { - return String(value || '').replace(/'/g, "''") - } - - private extractType49XmlTypeForStats(content: string): string { - if (!content) return '' - - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) - if (appmsgMatch) { - const appmsgInner = appmsgMatch[1] - .replace(//gi, '') - .replace(//gi, '') - const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) - if (typeMatch) return String(typeMatch[1] || '').trim() - } - - return this.extractXmlValue(content, 'type') - } - - private async collectSpecialMessageCountsByCursorScan( - sessionId: string, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise<{ - transferMessages: number - redPacketMessages: number - callMessages: number - }> { - const counters = { - transferMessages: 0, - redPacketMessages: 0, - callMessages: 0 - } - - const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, beginTimestamp, endTimestamp) - if (!cursorResult.success || !cursorResult.cursor) { - return counters - } - - const cursor = cursorResult.cursor - try { - while (true) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) break - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - for (const row of rows) { - const localType = this.getRowInt(row, ['local_type'], 1) - if (localType === 50) { - counters.callMessages += 1 - continue - } - if (localType === 8589934592049) { - counters.transferMessages += 1 - continue - } - if (localType === 8594229559345) { - counters.redPacketMessages += 1 - continue - } - if (localType !== 49) continue - - const rawMessageContent = row.message_content - const rawCompressContent = row.compress_content - const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) - const xmlType = this.extractType49XmlTypeForStats(content) - if (xmlType === '2000') counters.transferMessages += 1 - if (xmlType === '2001') counters.redPacketMessages += 1 - } - - if (!batch.hasMore || rows.length === 0) break - } - } finally { - await wcdbService.closeMessageCursor(cursor) - } - - return counters - } - - private async collectSessionExportStatsByCursorScan( - sessionId: string, - selfIdentitySet: Set, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise { - const stats: ExportSessionStats = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0, - transferMessages: 0, - redPacketMessages: 0, - callMessages: 0 - } - if (sessionId.endsWith('@chatroom')) { - stats.groupMyMessages = 0 - stats.groupActiveSpeakers = 0 - } - - const senderIdentities = new Set() - const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, beginTimestamp, endTimestamp) - if (!cursorResult.success || !cursorResult.cursor) { - return stats - } - - const cursor = cursorResult.cursor - try { - while (true) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - break - } - - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - for (const row of rows) { - stats.totalMessages += 1 - - const localType = this.getRowInt(row, ['local_type'], 1) - if (localType === 34) stats.voiceMessages += 1 - if (localType === 3) stats.imageMessages += 1 - if (localType === 43) stats.videoMessages += 1 - if (localType === 47) stats.emojiMessages += 1 - if (localType === 50) stats.callMessages += 1 - if (localType === 8589934592049) stats.transferMessages += 1 - if (localType === 8594229559345) stats.redPacketMessages += 1 - if (localType === 49) { - const rawMessageContent = row.message_content - const rawCompressContent = row.compress_content - const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) - const xmlType = this.extractType49XmlTypeForStats(content) - if (xmlType === '2000') stats.transferMessages += 1 - if (xmlType === '2001') stats.redPacketMessages += 1 - } - - const createTime = this.getRowInt( - row, - ['create_time'], - 0 - ) - if (createTime > 0) { - if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { - stats.firstTimestamp = createTime - } - if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { - stats.lastTimestamp = createTime - } - } - - if (sessionId.endsWith('@chatroom')) { - const sender = String(row.sender_username || '').trim() - const senderKeys = this.buildIdentityKeys(sender) - if (senderKeys.length > 0) { - senderIdentities.add(senderKeys[0]) - if (senderKeys.some((key) => selfIdentitySet.has(key))) { - stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 - } - } else { - const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send) - if (Number.isFinite(isSend) && isSend === 1) { - stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 - } - } - } - } - - if (!batch.hasMore || rows.length === 0) { - break - } - } - } finally { - await wcdbService.closeMessageCursor(cursor) - } - - if (sessionId.endsWith('@chatroom')) { - stats.groupActiveSpeakers = senderIdentities.size - if ((beginTimestamp <= 0 && endTimestamp <= 0) && Number.isFinite(stats.groupMyMessages)) { - this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) - } - } - return stats - } - - private async collectSessionExportStats( - sessionId: string, - selfIdentitySet: Set, - preferAccurateSpecialTypes: boolean = false, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise { - const stats: ExportSessionStats = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0, - transferMessages: 0, - redPacketMessages: 0, - callMessages: 0 - } - const isGroup = sessionId.endsWith('@chatroom') - if (isGroup) { - stats.groupMyMessages = 0 - stats.groupActiveSpeakers = 0 - } - - const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, beginTimestamp, endTimestamp) - if (!nativeResult.success || !nativeResult.data) { - return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet, beginTimestamp, endTimestamp) - } - - const data = nativeResult.data as Record - stats.totalMessages = Math.max(0, Math.floor(Number(data.total_messages || 0))) - stats.voiceMessages = Math.max(0, Math.floor(Number(data.voice_messages || 0))) - stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0))) - stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0))) - stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0))) - stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0))) - stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0))) - stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0))) - - const firstTs = Math.max(0, Math.floor(Number(data.first_timestamp || 0))) - const lastTs = Math.max(0, Math.floor(Number(data.last_timestamp || 0))) - if (firstTs > 0) stats.firstTimestamp = firstTs - if (lastTs > 0) stats.lastTimestamp = lastTs - - if (preferAccurateSpecialTypes) { - try { - const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId, beginTimestamp, endTimestamp) - stats.transferMessages = preciseCounters.transferMessages - stats.redPacketMessages = preciseCounters.redPacketMessages - stats.callMessages = preciseCounters.callMessages - } catch { - // 保留 native 聚合结果作为兜底 - } - } - - if (isGroup) { - stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0))) - stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0))) - if ((beginTimestamp <= 0 && endTimestamp <= 0) && Number.isFinite(stats.groupMyMessages)) { - this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) - } - } - return stats - } - - private toExportSessionStatsFromNativeTypeRow( - sessionId: string, - row: Record, - options?: { updateGroupHint?: boolean } - ): ExportSessionStats { - const updateGroupHint = options?.updateGroupHint !== false - const stats: ExportSessionStats = { - totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))), - voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))), - imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))), - videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))), - emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))), - callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))), - transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))), - redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0))) - } - - const firstTs = Math.max(0, Math.floor(Number(row?.first_timestamp || 0))) - const lastTs = Math.max(0, Math.floor(Number(row?.last_timestamp || 0))) - if (firstTs > 0) stats.firstTimestamp = firstTs - if (lastTs > 0) stats.lastTimestamp = lastTs - - if (sessionId.endsWith('@chatroom')) { - stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0))) - stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0))) - if (updateGroupHint && Number.isFinite(stats.groupMyMessages)) { - this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) - } - } - return stats - } - - private async getMessageDbCountSnapshot(forceRefresh = false): Promise<{ - success: boolean - dbPaths?: string[] - dbSignature?: string - error?: string - }> { - const now = Date.now() - if (!forceRefresh && this.messageDbCountSnapshotCache) { - if (now - this.messageDbCountSnapshotCache.updatedAt <= this.messageDbCountSnapshotCacheTtlMs) { - return { - success: true, - dbPaths: [...this.messageDbCountSnapshotCache.dbPaths], - dbSignature: this.messageDbCountSnapshotCache.dbSignature - } - } - } - - const dbPathsResult = await this.listMessageDbPathsForCount() - if (!dbPathsResult.success || !dbPathsResult.dbPaths) { - return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } - } - const dbPaths = dbPathsResult.dbPaths - const dbSignature = this.buildMessageDbSignature(dbPaths) - this.messageDbCountSnapshotCache = { - dbPaths: [...dbPaths], - dbSignature, - updatedAt: now - } - return { success: true, dbPaths, dbSignature } - } - - private async buildGroupRelationStats( - groupSessionIds: string[], - privateSessionIds: string[], - selfIdentitySet: Set - ): Promise<{ - privateMutualGroupMap: Record - groupMutualFriendMap: Record - }> { - const privateMutualGroupMap: Record = {} - const groupMutualFriendMap: Record = {} - if (groupSessionIds.length === 0) { - return { privateMutualGroupMap, groupMutualFriendMap } - } - - const privateIndex = new Map>() - for (const sessionId of privateSessionIds) { - for (const key of this.buildIdentityKeys(sessionId)) { - const set = privateIndex.get(key) || new Set() - set.add(sessionId) - privateIndex.set(key, set) - } - privateMutualGroupMap[sessionId] = 0 - } - - const friendIdentitySet = await this.getFriendIdentitySet() - await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { - const membersResult = await wcdbService.getGroupMembers(groupId) - if (!membersResult.success || !membersResult.members) { - groupMutualFriendMap[groupId] = 0 - return - } - - const touchedPrivateSessions = new Set() - const friendMembers = new Set() - - for (const member of membersResult.members) { - const username = this.extractGroupMemberUsername(member) - const identityKeys = this.buildIdentityKeys(username) - if (identityKeys.length === 0) continue - const canonical = identityKeys[0] - - if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { - friendMembers.add(canonical) - } - - for (const key of identityKeys) { - const linked = privateIndex.get(key) - if (!linked) continue - for (const sessionId of linked) { - touchedPrivateSessions.add(sessionId) - } - } - } - - groupMutualFriendMap[groupId] = friendMembers.size - for (const sessionId of touchedPrivateSessions) { - privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 - } - }) - - return { privateMutualGroupMap, groupMutualFriendMap } - } - - private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats { - const isGroup = sessionId.endsWith('@chatroom') - const stats: ExportSessionStats = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0, - transferMessages: 0, - redPacketMessages: 0, - callMessages: 0 - } - if (isGroup) { - stats.groupMyMessages = 0 - stats.groupActiveSpeakers = 0 - stats.groupMemberCount = 0 - if (includeRelations) { - stats.groupMutualFriends = 0 - } - } else if (includeRelations) { - stats.privateMutualGroups = 0 - } - return stats - } - - private async computeSessionExportStats( - sessionId: string, - selfIdentitySet: Set, - includeRelations: boolean, - preferAccurateSpecialTypes: boolean = false, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise { - const stats = await this.collectSessionExportStats( - sessionId, - selfIdentitySet, - preferAccurateSpecialTypes, - beginTimestamp, - endTimestamp - ) - const isGroup = sessionId.endsWith('@chatroom') - - if (isGroup) { - const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId]) - const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} - stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0 - } - - if (includeRelations) { - if (isGroup) { - try { - const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet) - stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0 - } catch { - stats.groupMutualFriends = 0 - } - } else { - const allGroups = await this.listAllGroupSessionIds() - if (allGroups.length === 0) { - stats.privateMutualGroups = 0 - } else { - try { - const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet) - stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0 - } catch { - stats.privateMutualGroups = 0 - } - } - } - } - - return stats - } - - private async computeSessionExportStatsBatch( - sessionIds: string[], - includeRelations: boolean, - selfIdentitySet: Set, - preferAccurateSpecialTypes: boolean = false, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise> { - const normalizedSessionIds = Array.from( - new Set( - (sessionIds || []) - .map((id) => String(id || '').trim()) - .filter(Boolean) - ) - ) - const result: Record = {} - if (normalizedSessionIds.length === 0) { - return result - } - - const groupSessionIds = normalizedSessionIds.filter(sessionId => sessionId.endsWith('@chatroom')) - const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom')) - - let memberCountMap: Record = {} - const shouldLoadGroupMemberCount = groupSessionIds.length > 0 && (includeRelations || normalizedSessionIds.length === 1) - if (shouldLoadGroupMemberCount) { - try { - const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) - memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} - } catch { - memberCountMap = {} - } - } - - let privateMutualGroupMap: Record = {} - let groupMutualFriendMap: Record = {} - if (includeRelations) { - let relationGroupSessionIds: string[] = [] - if (privateSessionIds.length > 0) { - const allGroups = await this.listAllGroupSessionIds() - relationGroupSessionIds = Array.from(new Set([...allGroups, ...groupSessionIds])) - } else if (groupSessionIds.length > 0) { - relationGroupSessionIds = groupSessionIds - } - - if (relationGroupSessionIds.length > 0) { - try { - const relation = await this.buildGroupRelationStats( - relationGroupSessionIds, - privateSessionIds, - selfIdentitySet - ) - privateMutualGroupMap = relation.privateMutualGroupMap || {} - groupMutualFriendMap = relation.groupMutualFriendMap || {} - } catch { - privateMutualGroupMap = {} - groupMutualFriendMap = {} - } - } - } - - const nativeBatchStats: Record = {} - let hasNativeBatchStats = false - if (!preferAccurateSpecialTypes) { - try { - const quickMode = !includeRelations && normalizedSessionIds.length > 1 - const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, { - beginTimestamp, - endTimestamp, - quickMode, - includeGroupSenderCount: true - }) - if (nativeBatch.success && nativeBatch.data) { - for (const sessionId of normalizedSessionIds) { - const row = nativeBatch.data?.[sessionId] as Record | undefined - if (!row || typeof row !== 'object') continue - nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row, { - updateGroupHint: beginTimestamp <= 0 && endTimestamp <= 0 - }) - } - hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0 - } else { - console.warn('[fallback-exec] getSessionMessageTypeStatsBatch failed, fallback to per-session stats path') - } - } catch (error) { - console.warn('[fallback-exec] getSessionMessageTypeStatsBatch exception, fallback to per-session stats path:', error) - } - } - - await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { - try { - const stats = hasNativeBatchStats && nativeBatchStats[sessionId] - ? { ...nativeBatchStats[sessionId] } - : await this.collectSessionExportStats( - sessionId, - selfIdentitySet, - preferAccurateSpecialTypes, - beginTimestamp, - endTimestamp - ) - if (sessionId.endsWith('@chatroom')) { - if (shouldLoadGroupMemberCount) { - stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' - ? Math.max(0, Math.floor(memberCountMap[sessionId])) - : 0 - } - if (includeRelations) { - stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number' - ? Math.max(0, Math.floor(groupMutualFriendMap[sessionId])) - : 0 - } - } else if (includeRelations) { - stats.privateMutualGroups = typeof privateMutualGroupMap[sessionId] === 'number' - ? Math.max(0, Math.floor(privateMutualGroupMap[sessionId])) - : 0 - } - result[sessionId] = stats - } catch { - result[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) - } - }) - - return result - } - - private async getOrComputeSessionExportStats( - sessionId: string, - includeRelations: boolean, - selfIdentitySet: Set, - preferAccurateSpecialTypes: boolean = false, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise { - if (preferAccurateSpecialTypes) { - return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true, beginTimestamp, endTimestamp) - } - - const scopedKey = this.buildScopedSessionStatsKey(sessionId) - - if (!includeRelations) { - const pendingFull = this.sessionStatsPendingFull.get(scopedKey) - if (pendingFull) return pendingFull - const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey) - if (pendingBasic) return pendingBasic - } else { - const pendingFull = this.sessionStatsPendingFull.get(scopedKey) - if (pendingFull) return pendingFull - } - - const shouldUsePendingPool = beginTimestamp <= 0 && endTimestamp <= 0 - if (!shouldUsePendingPool) { - return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false, beginTimestamp, endTimestamp) - } - - const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic - const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false, beginTimestamp, endTimestamp) - targetMap.set(scopedKey, pending) - try { - return await pending - } finally { - targetMap.delete(scopedKey) - } - } - - /** - * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 - */ - mapRowsToMessagesForApi(rows: Record[], sessionId: string): Message[] { - return this.mapRowsToMessages(rows, sessionId) - } - - mapRowsToMessagesLiteForApi(rows: Record[]): Message[] { - const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - const messages: Message[] = [] - for (const row of rows) { - const sourceInfo = this.getMessageSourceInfo(row) - const localType = this.getRowInt(row, ['local_type'], 1) - const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0) - const localId = this.getRowInt(row, ['local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) - const serverId = this.getRowInt(row, ['server_id'], 0) - const content = this.decodeMessageContent(row.message_content, row.compress_content) - - const isSendRaw = row.computed_is_send ?? row.is_send - const parsedRawIsSend = isSendRaw === null || isSendRaw === undefined - ? null - : parseInt(String(isSendRaw), 10) - const normalizedIsSend = typeof parsedRawIsSend === 'number' && Number.isFinite(parsedRawIsSend) - ? parsedRawIsSend - : null - const senderFromRow = String(row.sender_username || '').trim() || this.extractSenderUsernameFromContent(content) || null - const { isSend } = this.resolveMessageIsSend(normalizedIsSend, senderFromRow) - const senderUsername = senderFromRow || (isSend === 1 && myWxid ? myWxid : null) - - messages.push({ - messageKey: this.buildMessageKey({ - localId, - serverId, - createTime, - sortSeq, - senderUsername, - localType, - ...sourceInfo - }), - localId, - serverId, - serverIdRaw, - localType, - createTime, - sortSeq, - isSend, - senderUsername, - parsedContent: '', - rawContent: content, - content, - _db_path: sourceInfo.dbPath - }) - } - return messages - } - - private mapRowsToMessages(rows: Record[], sessionId: string): Message[] { - const myWxid = this.configService.getMyWxidCleaned() - - const messages: Message[] = [] - for (const row of rows) { - const sourceInfo = this.getMessageSourceInfo(row) - const rawMessageContent = row.message_content - const rawCompressContent = row.compress_content - - const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); - const localType = this.getRowInt(row, ['local_type'], 1) - const isSendRaw = row.computed_is_send ?? row.is_send - const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = row.sender_username - || this.extractSenderUsernameFromContent(content) - || null - const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) - const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0) - - if (senderUsername && !myWxid) { - // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 - if (messages.length < 5) { - console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`) - } - } - - 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 videoMd5: string | undefined - let aesKey: string | undefined - let encrypVer: number | undefined - let cdnThumbUrl: string | undefined - let voiceDurationSeconds: number | undefined - // Type 49 细分字段 - let linkTitle: string | undefined - let linkUrl: string | undefined - let linkThumb: string | undefined - let fileName: string | undefined - let fileSize: number | undefined - let fileExt: string | undefined - let fileMd5: string | undefined - let xmlType: string | undefined - let appMsgKind: string | undefined - let appMsgDesc: string | undefined - let appMsgAppName: string | undefined - let appMsgSourceName: string | undefined - let appMsgSourceUsername: string | undefined - let appMsgThumbUrl: string | undefined - let appMsgMusicUrl: string | undefined - let appMsgDataUrl: string | undefined - let appMsgLocationLabel: string | undefined - let finderNickname: string | undefined - let finderUsername: string | undefined - let finderCoverUrl: string | undefined - let finderAvatar: string | undefined - let finderDuration: number | undefined - let locationLat: number | undefined - let locationLng: number | undefined - let locationPoiname: string | undefined - let locationLabel: string | undefined - let musicAlbumUrl: string | undefined - let musicUrl: string | undefined - let giftImageUrl: string | undefined - let giftWish: string | undefined - let giftPrice: string | undefined - // 名片消息 - let cardUsername: string | undefined - let cardNickname: string | undefined - let cardAvatarUrl: string | undefined - // 转账消息 - let transferPayerUsername: string | undefined - let transferReceiverUsername: string | undefined - // 聊天记录 - let chatRecordTitle: string | undefined - let chatRecordList: Array<{ - datatype: number - sourcename: string - sourcetime: string - sourceheadurl?: string - datadesc?: string - datatitle?: string - fileext?: string - datasize?: number - messageuuid?: string - dataurl?: string - datathumburl?: string - datacdnurl?: string - cdndatakey?: string - cdnthumbkey?: string - aeskey?: string - md5?: string - fullmd5?: string - thumbfullmd5?: string - srcMsgLocalid?: number - imgheight?: number - imgwidth?: number - duration?: number - chatRecordTitle?: string - chatRecordDesc?: string - chatRecordList?: any[] - }> | undefined - - if (localType === 47 && content) { - const emojiInfo = this.parseEmojiInfo(content) - emojiCdnUrl = emojiInfo.cdnUrl - emojiMd5 = emojiInfo.md5 - cdnThumbUrl = emojiInfo.thumbUrl // 复用 cdnThumbUrl 字段或使用 emojiThumbUrl - // 注意:Message 接口定义的 emojiThumbUrl,这里我们统一一下 - // 如果 Message 接口有 emojiThumbUrl,则使用它 - } 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) - // 解析图片消息中的引用信息 - const quoteInfo = this.parseMediaQuoteMessage(content, sessionId) - if (quoteInfo.content) quotedContent = quoteInfo.content - if (quoteInfo.sender) quotedSender = quoteInfo.sender - } else if (localType === 43) { - // 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML - videoMd5 = this.parseVideoFileNameFromRow(row, content) - // 解析视频消息中的引用信息 - const quoteInfo = this.parseMediaQuoteMessage(content, sessionId) - if (quoteInfo.content) quotedContent = quoteInfo.content - if (quoteInfo.sender) quotedSender = quoteInfo.sender - } else if (localType === 34 && content) { - voiceDurationSeconds = this.parseVoiceDurationSeconds(content) - // 解析语音消息中的引用信息 - const quoteInfo = this.parseMediaQuoteMessage(content, sessionId) - if (quoteInfo.content) quotedContent = quoteInfo.content - if (quoteInfo.sender) quotedSender = quoteInfo.sender - } else if (localType === 42 && content) { - // 名片消息 - const cardInfo = this.parseCardInfo(content) - cardUsername = cardInfo.username - cardNickname = cardInfo.nickname - cardAvatarUrl = cardInfo.avatarUrl - } else if (localType === 48 && content) { - // 位置消息 - const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') - const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') - if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v } - if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v } - locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined - locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined - } else if ((localType === 49 || localType === 8589934592049) && content) { - // Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型 - const type49Info = this.parseType49Message(content) - xmlType = type49Info.xmlType - linkTitle = type49Info.linkTitle - linkUrl = type49Info.linkUrl - linkThumb = type49Info.linkThumb - fileName = type49Info.fileName - fileSize = type49Info.fileSize - fileExt = type49Info.fileExt - fileMd5 = type49Info.fileMd5 - chatRecordTitle = type49Info.chatRecordTitle - chatRecordList = type49Info.chatRecordList - transferPayerUsername = type49Info.transferPayerUsername - transferReceiverUsername = type49Info.transferReceiverUsername - // 引用消息(appmsg type=57)的 quotedContent/quotedSender - if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent - if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender - } else if (localType === 244813135921 || (content && content.includes('57'))) { - const quoteInfo = this.parseQuoteMessage(content) - quotedContent = quoteInfo.content - quotedSender = quoteInfo.sender - } - - const looksLikeAppMsg = Boolean(content && (content.includes(' 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') - // 从 appmsg 直接子节点提取 type,避免匹配到 refermsg 内部的 - let type = '' - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) - if (appmsgMatch) { - const inner = appmsgMatch[1] - .replace(//gi, '') - .replace(//gi, '') - const typeMatch = /([\s\S]*?)<\/type>/i.exec(inner) - if (typeMatch) type = typeMatch[1].trim() - } - if (!type) type = this.extractXmlValue(content, 'type') - const normalized = content.toLowerCase() - const locationLabel = - this.extractXmlAttribute(content, 'location', 'label') || - this.extractXmlAttribute(content, 'location', 'poiname') || - this.extractXmlValue(content, 'label') || - this.extractXmlValue(content, 'poiname') - const isFinder = - type === '51' || - normalized.includes('') || - normalized.includes('') || - normalized.includes('') - - // 群公告消息(type 87)特殊处理 - if (type === '87') { - const textAnnouncement = this.extractXmlValue(content, 'textannouncement') - if (textAnnouncement) { - return `[群公告] ${textAnnouncement}` - } - return '[群公告]' - } - - if (isFinder) { - return title ? `[视频号] ${title}` : '[视频号]' - } - if (isRedPacket) { - return title ? `[红包] ${title}` : '[红包]' - } - if (locationLabel) { - return `[位置] ${locationLabel}` - } - if (isMusic) { - return title ? `[音乐] ${title}` : '[音乐]' - } - - if (title) { - switch (type) { - case '5': - case '49': - return `[链接] ${title}` - case '6': - return `[文件] ${title}` - case '19': - return `[聊天记录] ${title}` - case '33': - case '36': - return `[小程序] ${title}` - case '57': - // 引用消息,title 就是回复的内容 - return title - case '53': - return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` - case '2000': - return `[转账] ${title}` - case '2001': - return `[红包] ${title}` - default: - return title - } - } - - // 如果没有 title,根据 type 返回默认标签 - switch (type) { - case '6': - return '[文件]' - case '19': - return '[聊天记录]' - case '33': - case '36': - return '[小程序]' - case '2000': - return '[转账]' - case '2001': - return '[红包]' - case '3': - return '[音乐]' - case '5': - case '49': - return '[链接]' - case '87': - return '[群公告]' - case '53': - return '[接龙]' - default: - return '[消息]' - } - } - - /** - * 解析表情包信息 - */ - private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string; thumbUrl?: string; encryptUrl?: string; aesKey?: 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 { } - } - } - - // 提取 thumburl - let thumbUrl: string | undefined - const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) - if (thumbUrlMatch) { - thumbUrl = thumbUrlMatch[1].replace(/&/g, '&') - if (thumbUrl.includes('%')) { - try { - thumbUrl = decodeURIComponent(thumbUrl) - } 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 - - // 提取 encrypturl - let encryptUrl: string | undefined - const encryptUrlMatch = /encrypturl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /encrypturl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) - if (encryptUrlMatch) { - encryptUrl = encryptUrlMatch[1].replace(/&/g, '&') - if (encryptUrl.includes('%')) { - try { - encryptUrl = decodeURIComponent(encryptUrl) - } catch { } - } - } - - // 提取 aeskey - const aesKeyMatch = /aeskey\s*=\s*['"]([a-zA-Z0-9]+)['"]/i.exec(content) || /aeskey\s*=\s*([a-zA-Z0-9]+)/i.exec(content) - const aesKey = aesKeyMatch ? aesKeyMatch[1] : undefined - - return { cdnUrl, md5, thumbUrl, encryptUrl, aesKey } - } 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 {} - } - } - - /** - * 解析视频MD5 - * 注意:提取 md5 字段用于查询 hardlink.db,获取实际视频文件名 - */ - private parseVideoMd5(content: string): string | undefined { - if (!content) return undefined - - try { - // 优先取 md5 属性(收到的视频) - const md5 = this.extractXmlAttribute(content, 'videomsg', 'md5') - if (md5) return md5.toLowerCase() - - // 自己发的视频没有 md5,只有 rawmd5 - const rawMd5 = this.extractXmlAttribute(content, 'videomsg', 'rawmd5') - if (rawMd5) return rawMd5.toLowerCase() - - // 兜底: 标签 - const tagMd5 = this.extractXmlValue(content, 'md5') - if (tagMd5) return tagMd5.toLowerCase() - - return undefined - } catch { - return undefined - } - } - - /** - * 解析通话消息 - * 格式: 0/1... - * room_type: 0 = 语音通话, 1 = 视频通话 - * msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等 - */ - private parseVoipMessage(content: string): string { - try { - if (!content) return '[通话]' - - // 提取 msg 内容(中文通话状态) - const msgMatch = /<\/msg>/i.exec(content) - const msg = msgMatch?.[1]?.trim() || '' - - // 提取 room_type(0=视频,1=语音) - const roomTypeMatch = /(\d+)<\/room_type>/i.exec(content) - const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 - - // 构建通话类型标签 - let callType: string - if (roomType === 0) { - callType = '视频通话' - } else if (roomType === 1) { - callType = '语音通话' - } else { - callType = '通话' - } - - // 解析通话状态 - if (msg.includes('通话时长')) { - // 已接听的通话,提取时长 - const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) - const duration = durationMatch?.[1] || '' - if (duration) { - return `[${callType}] ${duration}` - } - return `[${callType}] 已接听` - } else if (msg.includes('对方无应答')) { - return `[${callType}] 对方无应答` - } else if (msg.includes('已取消')) { - return `[${callType}] 已取消` - } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { - return `[${callType}] 已在其他设备接听` - } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { - return `[${callType}] 对方已拒绝` - } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { - return `[${callType}] 忙线未接听` - } else if (msg.includes('未接听')) { - return `[${callType}] 未接听` - } else if (msg) { - // 其他状态直接使用 msg 内容 - return `[${callType}] ${msg}` - } - - return `[${callType}]` - } catch (e) { - console.error('[ChatService] Failed to parse VOIP message:', e) - return '[通话]' - } - } - - private parseImageDatNameFromRow(row: Record): string | undefined { - const packed = this.getRowField(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - 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 parseVideoFileNameFromRow(row: Record, content?: string): string | undefined { - const packed = this.getRowField(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - const packedToken = this.extractVideoTokenFromPackedRaw(packed) - if (packedToken) return packedToken - - const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [ - 'video_md5', - 'videoMd5', - 'raw_md5', - 'rawMd5', - 'video_file_name', - 'videoFileName' - ])) - if (byColumn) return byColumn - - return this.normalizeVideoFileToken(this.parseVideoMd5(content || '')) - } - - private normalizeVideoFileToken(value: unknown): string | undefined { - let text = String(value || '').trim().toLowerCase() - if (!text) return undefined - text = text.replace(/^.*[\\/]/, '') - text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') - text = text.replace(/_thumb$/, '') - const directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) - if (directMatch) { - const suffix = /_raw$/i.test(text) ? '_raw' : '' - return `${directMatch[1].toLowerCase()}${suffix}` - } - const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) - if (preferred32?.[1]) return preferred32[1].toLowerCase() - const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) - return generic?.[1]?.toLowerCase() - } - - private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined { - const buffer = this.decodePackedInfo(raw) - if (!buffer || buffer.length === 0) return undefined - const candidates: string[] = [] - let current = '' - for (const byte of buffer) { - const isHex = - (byte >= 0x30 && byte <= 0x39) || - (byte >= 0x41 && byte <= 0x46) || - (byte >= 0x61 && byte <= 0x66) - if (isHex) { - current += String.fromCharCode(byte) - continue - } - if (current.length >= 16) candidates.push(current) - current = '' - } - if (current.length >= 16) candidates.push(current) - if (candidates.length === 0) return undefined - - const exact32 = candidates.find((item) => item.length === 32) - if (exact32) return exact32.toLowerCase() - - const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) - return fallback?.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() - const compactHex = trimmed.replace(/\s+/g, '') - if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { - try { - return Buffer.from(compactHex, '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 { - const normalizedContent = this.decodeHtmlEntities(content || '') - // 提取 refermsg 部分 - const referMsgStart = normalizedContent.indexOf('') - const referMsgEnd = normalizedContent.indexOf('') - - if (referMsgStart === -1 || referMsgEnd === -1) { - return {} - } - - const referMsgXml = normalizedContent.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': - // 文本消息优先取“部分引用”字段,缺失时再回退到完整 content - displayContent = this.extractPreferredQuotedText(referMsgXml) - break - case '3': - displayContent = '[图片]' - break - case '34': - displayContent = '[语音]' - break - case '43': - displayContent = '[视频]' - break - case '47': - displayContent = '[动画表情]' - break - case '49': { - // 链接类消息 (type=49):需区分真正的链接和嵌套引用 - // 嵌套引用的 referContent 中 xmlType=57,真正的链接 xmlType=49 或 5 - const decodedReferContent = this.decodeHtmlEntities(referContent || '') - const innerInfo = this.parseType49Message(decodedReferContent) - if (innerInfo.xmlType === '57' && innerInfo.linkTitle) { - displayContent = innerInfo.linkTitle - } else { - 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 parseMediaQuoteMessage(content: string, sessionId: string): { content?: string; sender?: string } { - try { - const normalizedContent = this.decodeHtmlEntities(content || '') - const referMsgStart = normalizedContent.indexOf('') - const referMsgEnd = normalizedContent.indexOf('') - - if (referMsgStart === -1 || referMsgEnd === -1) { - return {} - } - - const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11) - const svrid = this.extractXmlValue(referMsgXml, 'svrid') - - console.log('[DEBUG] parseMediaQuoteMessage - svrid:', svrid) - - if (!svrid) { - return {} - } - - // 简化方案:返回 svrid 标记 - console.log('[DEBUG] parseMediaQuoteMessage - 返回标记:', `__SVRID__${svrid}__`) - return { content: `__SVRID__${svrid}__` } - } catch { - return {} - } - } - - async resolveQuotedMessages(messages: Message[], sessionId: string): Promise { - console.log('[DEBUG] resolveQuotedMessages - 开始解析,消息数量:', messages.length) - const svridsToResolve: Array<{ msg: Message; svrid: string }> = [] - - for (const msg of messages) { - if (msg.quotedContent && msg.quotedContent.startsWith('__SVRID__')) { - const match = msg.quotedContent.match(/__SVRID__(.+?)__/) - if (match) { - console.log('[DEBUG] resolveQuotedMessages - 找到需要解析的svrid:', match[1]) - svridsToResolve.push({ msg, svrid: match[1] }) - } - } - } - - console.log('[DEBUG] resolveQuotedMessages - 需要解析的数量:', svridsToResolve.length) - - if (svridsToResolve.length === 0) return - - const results = await Promise.allSettled( - svridsToResolve.map(({ svrid }) => { - console.log('[DEBUG] resolveQuotedMessages - 查询svrid:', svrid, 'sessionId:', sessionId) - return wcdbService.getMessageByServerId(sessionId, svrid) - }) - ) - - console.log('[DEBUG] resolveQuotedMessages - 查询结果数量:', results.length) - - for (let i = 0; i < results.length; i++) { - const result = results[i] - const { msg, svrid } = svridsToResolve[i] - - console.log('[DEBUG] resolveQuotedMessages - 处理结果', i, ':', { - status: result.status, - success: result.status === 'fulfilled' ? result.value.success : false, - hasRow: result.status === 'fulfilled' && result.value.row ? true : false, - error: result.status === 'fulfilled' ? result.value.error : undefined, - svrid - }) - - if (result.status === 'fulfilled' && result.value.success && result.value.row) { - const localType = parseInt(result.value.row.local_type || '0', 10) - const rawMessageContent = result.value.row.message_content - const rawCompressContent = result.value.row.compress_content - - console.log('[DEBUG] resolveQuotedMessages - 原始数据:', { - hasMessageContent: !!rawMessageContent, - hasCompressContent: !!rawCompressContent, - messageContentType: typeof rawMessageContent, - messageContentLength: rawMessageContent ? rawMessageContent.length : 0 - }) - - const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) - - console.log('[DEBUG] resolveQuotedMessages - 解码后:', { localType, contentLength: content.length, contentPreview: content.substring(0, 50) }) - - if (localType === 1) { - msg.quotedContent = this.sanitizeQuotedContent(content) - } else if (localType === 3) { - msg.quotedContent = '[图片]' - } else if (localType === 34) { - msg.quotedContent = '[语音]' - } else if (localType === 43) { - msg.quotedContent = '[视频]' - } else if (localType === 47) { - msg.quotedContent = '[动画表情]' - } else if (localType === 49) { - msg.quotedContent = '[链接]' - } else { - msg.quotedContent = '[消息]' - } - console.log('[DEBUG] resolveQuotedMessages - 更新后的quotedContent:', msg.quotedContent) - } else { - msg.quotedContent = '[引用消息]' - console.log('[DEBUG] resolveQuotedMessages - 查询失败,使用占位符') - } - } - console.log('[DEBUG] resolveQuotedMessages - 完成') - } - - private extractPreferredQuotedText(referMsgXml: string): string { - if (!referMsgXml) return '' - - const sources = [this.decodeHtmlEntities(referMsgXml)] - const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') - if (rawMsgSource) { - const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) - if (decodedMsgSource) { - sources.push(decodedMsgSource) - } - } - - const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) - const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) - if (partialText) return partialText - - const candidateTags = [ - 'selectedcontent', - 'selectedtext', - 'selectcontent', - 'selecttext', - 'quotecontent', - 'quotetext', - 'partcontent', - 'parttext', - 'excerpt', - 'summary', - 'preview' - ] - - for (const source of sources) { - for (const tag of candidateTags) { - const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) - if (value) return value - } - } - - return fullContent - } - - private extractPartialQuotedText(xml: string, fullContent: string): string { - if (!xml || !fullContent) return '' - - const startChar = this.extractXmlValue(xml, 'start') - const endChar = this.extractXmlValue(xml, 'end') - const startIndexRaw = this.extractXmlValue(xml, 'startindex') - const endIndexRaw = this.extractXmlValue(xml, 'endindex') - const startIndex = Number.parseInt(startIndexRaw, 10) - const endIndex = Number.parseInt(endIndexRaw, 10) - - if (startChar && endChar) { - const startPos = fullContent.indexOf(startChar) - if (startPos !== -1) { - const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) - if (endPos !== -1 && endPos >= startPos) { - const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() - if (sliced) return sliced - } - } - } - - if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { - const chars = Array.from(fullContent) - const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() - if (sliced) return sliced - } - - return '' - } - - /** - * 解析名片消息 - * 格式: - */ - private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } { - try { - if (!content) return {} - - // 提取 username - const username = this.extractXmlAttribute(content, 'msg', 'username') || undefined - - // 提取 nickname - const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined - - // 提取头像 - const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') || - this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined - - return { username, nickname, avatarUrl } - } catch (e) { - console.error('[ChatService] 名片解析失败:', e) - return {} - } - } - - /** - * 解析 Type 49 消息(链接、文件、小程序、转账等) - * 根据 X 区分不同类型 - */ - private parseType49Message(content: string): { - xmlType?: string - quotedContent?: string - quotedSender?: string - linkTitle?: string - linkUrl?: string - linkThumb?: string - appMsgKind?: string - appMsgDesc?: string - appMsgAppName?: string - appMsgSourceName?: string - appMsgSourceUsername?: string - appMsgThumbUrl?: string - appMsgMusicUrl?: string - appMsgDataUrl?: string - appMsgLocationLabel?: string - finderNickname?: string - finderUsername?: string - finderCoverUrl?: string - finderAvatar?: string - finderDuration?: number - locationLat?: number - locationLng?: number - locationPoiname?: string - locationLabel?: string - musicAlbumUrl?: string - musicUrl?: string - giftImageUrl?: string - giftWish?: string - giftPrice?: string - cardAvatarUrl?: string - fileName?: string - fileSize?: number - fileExt?: string - fileMd5?: string - transferPayerUsername?: string - transferReceiverUsername?: string - chatRecordTitle?: string - chatRecordList?: Array<{ - datatype: number - sourcename: string - sourcetime: string - sourceheadurl?: string - datadesc?: string - datatitle?: string - fileext?: string - datasize?: number - messageuuid?: string - dataurl?: string - datathumburl?: string - datacdnurl?: string - cdndatakey?: string - cdnthumbkey?: string - aeskey?: string - md5?: string - fullmd5?: string - thumbfullmd5?: string - srcMsgLocalid?: number - imgheight?: number - imgwidth?: number - duration?: number - chatRecordTitle?: string - chatRecordDesc?: string - chatRecordList?: any[] - }> - } { - try { - if (!content) return {} - - // 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 - // 先尝试从 ... 块内提取,再用正则跳过嵌套标签 - let xmlType = '' - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) - if (appmsgMatch) { - // 在 appmsg 内容中,找第一个 但跳过在子元素内部的(如 refermsg > type) - // 策略:去掉所有嵌套块(refermsg、patMsg 等),再提取 type - const appmsgInner = appmsgMatch[1] - .replace(//gi, '') - .replace(//gi, '') - const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) - if (typeMatch) xmlType = typeMatch[1].trim() - } - if (!xmlType) xmlType = this.extractXmlValue(content, 'type') - if (!xmlType) return {} - - const result: any = { xmlType } - - // 提取通用字段 - const title = this.extractXmlValue(content, 'title') - const url = this.extractXmlValue(content, 'url') - const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description') - const appName = this.extractXmlValue(content, 'appname') - const sourceName = this.extractXmlValue(content, 'sourcename') - const sourceUsername = this.extractXmlValue(content, 'sourceusername') - const thumbUrl = - this.extractXmlValue(content, 'thumburl') || - this.extractXmlValue(content, 'cdnthumburl') || - this.extractXmlValue(content, 'cover') || - this.extractXmlValue(content, 'coverurl') || - this.extractXmlValue(content, 'thumb_url') - const musicUrl = - this.extractXmlValue(content, 'musicurl') || - this.extractXmlValue(content, 'playurl') || - this.extractXmlValue(content, 'songalbumurl') - const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl') - const locationLabel = - this.extractXmlAttribute(content, 'location', 'label') || - this.extractXmlAttribute(content, 'location', 'poiname') || - this.extractXmlValue(content, 'label') || - this.extractXmlValue(content, 'poiname') - const finderUsername = - this.extractXmlValue(content, 'finderusername') || - this.extractXmlValue(content, 'finder_username') || - this.extractXmlValue(content, 'finderuser') - const finderNickname = - this.extractXmlValue(content, 'findernickname') || - this.extractXmlValue(content, 'finder_nickname') - const normalized = content.toLowerCase() - const isFinder = xmlType === '51' - const isRedPacket = xmlType === '2001' - const isMusic = xmlType === '3' - const isLocation = Boolean(locationLabel) - - result.linkTitle = title || undefined - result.linkUrl = url || undefined - result.linkThumb = thumbUrl || undefined - result.appMsgDesc = desc || undefined - result.appMsgAppName = appName || undefined - result.appMsgSourceName = sourceName || undefined - result.appMsgSourceUsername = sourceUsername || undefined - result.appMsgThumbUrl = thumbUrl || undefined - result.appMsgMusicUrl = musicUrl || undefined - result.appMsgDataUrl = dataUrl || undefined - result.appMsgLocationLabel = locationLabel || undefined - result.finderUsername = finderUsername || undefined - result.finderNickname = finderNickname || undefined - - // 视频号封面/头像/时长 - if (isFinder) { - const finderCover = - this.extractXmlValue(content, 'thumbUrl') || - this.extractXmlValue(content, 'coverUrl') || - this.extractXmlValue(content, 'thumburl') || - this.extractXmlValue(content, 'coverurl') - if (finderCover) result.finderCoverUrl = finderCover - const finderAvatar = this.extractXmlValue(content, 'avatar') - if (finderAvatar) result.finderAvatar = finderAvatar - const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration') - if (durationStr) { - const d = parseInt(durationStr, 10) - if (Number.isFinite(d) && d > 0) result.finderDuration = d - } - } - - // 位置经纬度 - if (isLocation) { - const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') - const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') - if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v } - if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v } - result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined - result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined - } - - // 音乐专辑封面 - if (isMusic) { - const albumUrl = this.extractXmlValue(content, 'songalbumurl') - if (albumUrl) result.musicAlbumUrl = albumUrl - result.musicUrl = musicUrl || dataUrl || url || undefined - } - - // 礼物消息 - const isGift = xmlType === '115' - if (isGift) { - result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined - result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined - result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined - } - - if (isFinder) { - result.appMsgKind = 'finder' - } else if (isRedPacket) { - result.appMsgKind = 'red-packet' - } else if (isGift) { - result.appMsgKind = 'gift' - } else if (isLocation) { - result.appMsgKind = 'location' - } else if (isMusic) { - result.appMsgKind = 'music' - } else if (xmlType === '33' || xmlType === '36') { - result.appMsgKind = 'miniapp' - } else if (xmlType === '6') { - result.appMsgKind = 'file' - } else if (xmlType === '19') { - result.appMsgKind = 'chat-record' - } else if (xmlType === '2000') { - result.appMsgKind = 'transfer' - } else if (xmlType === '87') { - result.appMsgKind = 'announcement' - } else if (xmlType === '57') { - // 引用回复消息,解析 refermsg - result.appMsgKind = 'quote' - const quoteInfo = this.parseQuoteMessage(content) - result.quotedContent = quoteInfo.content - result.quotedSender = quoteInfo.sender - } else if (xmlType === '53') { - result.appMsgKind = 'solitaire' - } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { - result.appMsgKind = 'official-link' - } else if (url) { - result.appMsgKind = 'link' - } else { - result.appMsgKind = 'card' - } - - switch (xmlType) { - case '6': { - // 文件消息 - result.fileName = title || this.extractXmlValue(content, 'filename') - result.linkTitle = result.fileName - - // 提取文件大小 - const fileSizeStr = this.extractXmlValue(content, 'totallen') || - this.extractXmlValue(content, 'filesize') - if (fileSizeStr) { - const size = parseInt(fileSizeStr, 10) - if (!isNaN(size)) { - result.fileSize = size - } - } - - // 提取文件扩展名 - const fileExt = this.extractXmlValue(content, 'fileext') - const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5') - if (fileExt) { - result.fileExt = fileExt - } else if (result.fileName) { - // 从文件名提取扩展名 - const match = /\.([^.]+)$/.exec(result.fileName) - if (match) { - result.fileExt = match[1] - } - } - if (fileMd5) { - result.fileMd5 = fileMd5.toLowerCase() - } - break - } - - case '19': { - // 聊天记录 - result.chatRecordTitle = title || '聊天记录' - const recordList = this.parseForwardChatRecordList(content) - if (recordList && recordList.length > 0) { - result.chatRecordList = recordList - } - break - } - - case '33': - case '36': { - // 小程序 - result.linkTitle = title - result.linkUrl = url - - // 提取缩略图 - const thumbUrl = this.extractXmlValue(content, 'thumburl') || - this.extractXmlValue(content, 'cdnthumburl') - if (thumbUrl) { - result.linkThumb = thumbUrl - } - break - } - - case '2000': { - // 转账 - result.linkTitle = title || '[转账]' - - // 可以提取转账金额等信息 - const payMemo = this.extractXmlValue(content, 'pay_memo') - const feedesc = this.extractXmlValue(content, 'feedesc') - - if (payMemo) { - result.linkTitle = payMemo - } else if (feedesc) { - result.linkTitle = feedesc - } - - // 提取转账双方 wxid - const payerUsername = this.extractXmlValue(content, 'payer_username') - const receiverUsername = this.extractXmlValue(content, 'receiver_username') - if (payerUsername) { - result.transferPayerUsername = payerUsername - } - if (receiverUsername) { - result.transferReceiverUsername = receiverUsername - } - break - } - - default: { - // 其他类型,提取通用字段 - result.linkTitle = title - result.linkUrl = url - - const thumbUrl = this.extractXmlValue(content, 'thumburl') || - this.extractXmlValue(content, 'cdnthumburl') - if (thumbUrl) { - result.linkThumb = thumbUrl - } - } - } - - return result - } catch (e) { - console.error('[ChatService] Type 49 消息解析失败:', e) - return {} - } - } - - private parseForwardChatRecordList(content: string): any[] | undefined { - const normalized = this.decodeHtmlEntities(content || '') - if (!normalized.includes('() - const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi - let recordItemMatch: RegExpExecArray | null - while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { - const parsed = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') - for (const item of parsed) { - const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` - if (!dedupe.has(key)) { - dedupe.add(key) - items.push(item) - } - } - } - - if (items.length === 0 && normalized.includes(' 0 ? items : undefined - } - - private extractTopLevelXmlElements(source: string, tagName: string): Array<{ attrs: string; inner: string }> { - const xml = source || '' - if (!xml) return [] - - const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') - const result: Array<{ attrs: string; inner: string }> = [] - let match: RegExpExecArray | null - let depth = 0 - let openEnd = -1 - let openStart = -1 - let openAttrs = '' - - while ((match = pattern.exec(xml)) !== null) { - const isClosing = match[1] === '/' - const attrs = match[2] || '' - const rawTag = match[0] || '' - const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) - - if (!isClosing) { - if (depth === 0) { - openStart = match.index - openEnd = pattern.lastIndex - openAttrs = attrs - } - if (!selfClosing) { - depth += 1 - } else if (depth === 0 && openEnd >= 0) { - result.push({ attrs: openAttrs, inner: '' }) - openStart = -1 - openEnd = -1 - openAttrs = '' - } - continue - } - - if (depth <= 0) continue - depth -= 1 - if (depth === 0 && openEnd >= 0 && openStart >= 0) { - result.push({ - attrs: openAttrs, - inner: xml.slice(openEnd, match.index) - }) - openStart = -1 - openEnd = -1 - openAttrs = '' - } - } - - return result - } - - private parseForwardChatRecordContainer(containerXml: string): any[] { - const source = containerXml || '' - if (!source) return [] - - const segments: string[] = [source] - const decodedContainer = this.decodeHtmlEntities(source) - if (decodedContainer !== source) { - segments.push(decodedContainer) - } - - const cdataRegex = //g - let cdataMatch: RegExpExecArray | null - while ((cdataMatch = cdataRegex.exec(source)) !== null) { - const cdataInner = cdataMatch[1] || '' - if (!cdataInner) continue - segments.push(cdataInner) - const decodedInner = this.decodeHtmlEntities(cdataInner) - if (decodedInner !== cdataInner) { - segments.push(decodedInner) - } - } - - const items: any[] = [] - const seen = new Set() - for (const segment of segments) { - if (!segment) continue - const dataItems = this.extractTopLevelXmlElements(segment, 'dataitem') - for (const dataItem of dataItems) { - const parsed = this.parseForwardChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') - if (!parsed) continue - const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}|${parsed.messageuuid || ''}` - if (!seen.has(key)) { - seen.add(key) - items.push(parsed) - } - } - } - - if (items.length > 0) return items - const fallback = this.parseForwardChatRecordDataItem(source, '') - return fallback ? [fallback] : [] - } - - private parseForwardChatRecordDataItem(itemXml: string, attrs: string): any | null { - const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') - const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(this.extractXmlValue(itemXml, 'datatype') || '0', 10) - const sourcename = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'sourcename') || '') - const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') || '' - const sourceheadurl = this.extractXmlValue(itemXml, 'sourceheadurl') || undefined - const datadesc = this.decodeHtmlEntities( - this.extractXmlValue(itemXml, 'datadesc') || - this.extractXmlValue(itemXml, 'content') || - '' - ) || undefined - const datatitle = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'datatitle') || '') || undefined - const fileext = this.extractXmlValue(itemXml, 'fileext') || undefined - const datasize = parseInt(this.extractXmlValue(itemXml, 'datasize') || '0', 10) || undefined - const messageuuid = this.extractXmlValue(itemXml, 'messageuuid') || undefined - const dataurl = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'dataurl') || '') || undefined - const datathumburl = this.decodeHtmlEntities( - this.extractXmlValue(itemXml, 'datathumburl') || - this.extractXmlValue(itemXml, 'thumburl') || - this.extractXmlValue(itemXml, 'cdnthumburl') || - '' - ) || undefined - const datacdnurl = this.decodeHtmlEntities( - this.extractXmlValue(itemXml, 'datacdnurl') || - this.extractXmlValue(itemXml, 'cdnurl') || - this.extractXmlValue(itemXml, 'cdndataurl') || - '' - ) || undefined - const cdndatakey = this.extractXmlValue(itemXml, 'cdndatakey') || undefined - const cdnthumbkey = this.extractXmlValue(itemXml, 'cdnthumbkey') || undefined - const aeskey = this.decodeHtmlEntities( - this.extractXmlValue(itemXml, 'aeskey') || - this.extractXmlValue(itemXml, 'qaeskey') || - '' - ) || undefined - const md5 = this.extractXmlValue(itemXml, 'md5') || this.extractXmlValue(itemXml, 'datamd5') || undefined - const fullmd5 = this.extractXmlValue(itemXml, 'fullmd5') || undefined - const thumbfullmd5 = this.extractXmlValue(itemXml, 'thumbfullmd5') || undefined - const srcMsgLocalid = parseInt(this.extractXmlValue(itemXml, 'srcMsgLocalid') || '0', 10) || undefined - const imgheight = parseInt(this.extractXmlValue(itemXml, 'imgheight') || '0', 10) || undefined - const imgwidth = parseInt(this.extractXmlValue(itemXml, 'imgwidth') || '0', 10) || undefined - const duration = parseInt(this.extractXmlValue(itemXml, 'duration') || '0', 10) || undefined - const nestedRecordXml = this.extractXmlValue(itemXml, 'recordxml') || undefined - const chatRecordTitle = this.decodeHtmlEntities( - (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || - datatitle || - '' - ) || undefined - const chatRecordDesc = this.decodeHtmlEntities( - (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || - datadesc || - '' - ) || undefined - const chatRecordList = - datatype === 17 && nestedRecordXml - ? this.parseForwardChatRecordContainer(nestedRecordXml) - : undefined - - if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null - - return { - datatype: Number.isFinite(datatype) ? datatype : 0, - sourcename, - sourcetime, - sourceheadurl, - datadesc, - datatitle, - fileext, - datasize, - messageuuid, - dataurl, - datathumburl, - datacdnurl, - cdndatakey, - cdnthumbkey, - aeskey, - md5, - fullmd5, - thumbfullmd5, - srcMsgLocalid, - imgheight, - imgwidth, - duration, - chatRecordTitle, - chatRecordDesc, - chatRecordList - } - } - - //手动查找 media_*.db 文件(当 WCDB数据服务不支持 listMediaDbs 时的 fallback) - private async findMediaDbsManually(): Promise { - try { - const dbPath = this.configService.get('dbPath') - const myWxid = this.configService.get('myWxid') - if (!dbPath || !myWxid) return [] - - // 可能的目录结构: - // 1. dbPath 直接指向 db_storage: D:\weixin\WeChat Files\wxid_xxx\db_storage - // 2. dbPath 指向账号目录: D:\weixin\WeChat Files\wxid_xxx - // 3. dbPath 指向 WeChat Files: D:\weixin\WeChat Files - // 4. dbPath 指向微信根目录: D:\weixin - // 5. dbPath 指向非标准目录: D:\weixin\xwechat_files - - const searchDirs: string[] = [] - - // 尝试1: dbPath 本身就是 db_storage - if (basename(dbPath).toLowerCase() === 'db_storage') { - searchDirs.push(dbPath) - } - - // 尝试2: dbPath/db_storage - const dbStorage1 = join(dbPath, 'db_storage') - if (existsSync(dbStorage1)) { - searchDirs.push(dbStorage1) - } - - // 尝试3: dbPath/WeChat Files/[wxid]/db_storage - const wechatFiles = join(dbPath, 'WeChat Files') - if (existsSync(wechatFiles)) { - const wxidDir = join(wechatFiles, myWxid) - if (existsSync(wxidDir)) { - const dbStorage2 = join(wxidDir, 'db_storage') - if (existsSync(dbStorage2)) { - searchDirs.push(dbStorage2) - } - } - } - - // 尝试4: 如果 dbPath 已经包含 WeChat Files,直接在其中查找 - if (dbPath.includes('WeChat Files')) { - const parts = dbPath.split(path.sep) - const wechatFilesIndex = parts.findIndex(p => p === 'WeChat Files') - if (wechatFilesIndex >= 0) { - const wechatFilesPath = parts.slice(0, wechatFilesIndex + 1).join(path.sep) - const wxidDir = join(wechatFilesPath, myWxid) - if (existsSync(wxidDir)) { - const dbStorage3 = join(wxidDir, 'db_storage') - if (existsSync(dbStorage3) && !searchDirs.includes(dbStorage3)) { - searchDirs.push(dbStorage3) - } - } - } - } - - // 尝试5: 直接尝试 dbPath/[wxid]/db_storage (适用于 xwechat_files 等非标准目录名) - const wxidDirDirect = join(dbPath, myWxid) - if (existsSync(wxidDirDirect)) { - const dbStorage5 = join(wxidDirDirect, 'db_storage') - if (existsSync(dbStorage5) && !searchDirs.includes(dbStorage5)) { - searchDirs.push(dbStorage5) - } - } - - // 在所有可能的目录中查找 media_*.db - const mediaDbFiles: string[] = [] - for (const dir of searchDirs) { - if (!existsSync(dir)) continue - - // 直接在当前目录查找 - const entries = readdirSync(dir) - for (const entry of entries) { - if (entry.toLowerCase().startsWith('media_') && entry.toLowerCase().endsWith('.db')) { - const fullPath = join(dir, entry) - if (existsSync(fullPath) && statSync(fullPath).isFile()) { - if (!mediaDbFiles.includes(fullPath)) { - mediaDbFiles.push(fullPath) - } - } - } - } - - // 也检查子目录(特别是 message 子目录) - for (const entry of entries) { - const subDir = join(dir, entry) - if (existsSync(subDir) && statSync(subDir).isDirectory()) { - try { - const subEntries = readdirSync(subDir) - for (const subEntry of subEntries) { - if (subEntry.toLowerCase().startsWith('media_') && subEntry.toLowerCase().endsWith('.db')) { - const fullPath = join(subDir, subEntry) - if (existsSync(fullPath) && statSync(fullPath).isFile()) { - if (!mediaDbFiles.includes(fullPath)) { - mediaDbFiles.push(fullPath) - } - } - } - } - } catch (e) { - // 忽略无法访问的子目录 - } - } - } - } - - return mediaDbFiles - } catch (e) { - console.error('[ChatService] 手动查找 media 数据库失败:', e) - 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 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 escapeSqlString(value: string): string { - return value.replace(/'/g, "''") - } - - private async resolveMessageName2IdTableName(dbPath: string): Promise { - const normalizedDbPath = String(dbPath || '').trim() - if (!normalizedDbPath) return null - if (this.messageName2IdTableCache.has(normalizedDbPath)) { - return this.messageName2IdTableCache.get(normalizedDbPath) || null - } - - // fallback-exec: 当前缺少按 message.db 反查 Name2Id 表名的专属接口 - const result = await wcdbService.execQuery( - 'message', - normalizedDbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" - ) - const tableName = result.success && result.rows && result.rows.length > 0 - ? String(result.rows[0]?.name || '').trim() || null - : null - this.messageName2IdTableCache.set(normalizedDbPath, tableName) - return tableName - } - - private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise { - const normalizedDbPath = String(dbPath || '').trim() - const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10) - if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) { - return null - } - - const cacheKey = `${normalizedDbPath}::${numericSenderId}` - if (this.messageSenderIdCache.has(cacheKey)) { - return this.messageSenderIdCache.get(cacheKey) || null - } - - const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath) - if (!name2IdTable) { - this.messageSenderIdCache.set(cacheKey, null) - return null - } - - const escapedTableName = String(name2IdTable).replace(/"/g, '""') - // fallback-exec: 当前缺少按 rowid -> user_name 的 message.db 专属接口 - const result = await wcdbService.execQuery( - 'message', - normalizedDbPath, - `SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1` - ) - const username = result.success && result.rows && result.rows.length > 0 - ? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null - : null - this.messageSenderIdCache.set(cacheKey, username) - return username - } - - private async resolveSenderUsernameForMessageRow( - row: Record, - rawContent: string - ): Promise { - const directSender = row.sender_username - || this.extractSenderUsernameFromContent(rawContent) - if (directSender) { - return directSender - } - - const dbPath = row._db_path - const realSenderId = row.real_sender_id - if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { - return null - } - - return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId) - } - - /** - * 判断是否像 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 regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i') - const match = regex.exec(xml) - return match ? match[1] : '' - } - - private cleanSystemMessage(content: string): string { - if (!content) return '[系统消息]' - - const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content))) - const readableSysmsg = this.extractReadableSystemMessageText(normalized) - if (readableSysmsg) { - return readableSysmsg - } - - // 移除 XML 声明 - let cleaned = normalized.replace(/<\?xml[^?]*\?>/gi, '') - // 移除所有 XML/HTML 标签 - cleaned = cleaned.replace(/<[^>]+>/g, '') - // 移除尾部的数字(如撤回消息后的时间戳) - cleaned = cleaned.replace(/\d+\s*$/, '') - // 清理多余空白 - cleaned = this.stripSenderPrefix(cleaned).replace(/\s+/g, ' ').trim() - return cleaned || '[系统消息]' - } - - private extractReadableSystemMessageText(content: string): string { - const sysmsgMatch = /]*>([\s\S]*?)<\/sysmsg>/i.exec(content) - const source = sysmsgMatch?.[1] || content - const text = - this.extractXmlValue(source, 'plain') || - this.extractXmlValue(source, 'text') || - '' - return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim() - } - - private stripSenderPrefix(content: string): string { - return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '') - } - - private extractSenderUsernameFromContent(content: string): string | null { - if (!content) return null - - const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content))) - const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(normalized) - if (!match?.[1]) return null - - const candidate = match[1].trim() - return candidate || null - } - - 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, '') - } - } - - /** - * 清理拍一拍消息 - * 格式示例: - * 纯文本: 我拍了拍 "XX" - * XML: "XX"拍了拍"XX"相信未来!... - */ - private cleanPatMessage(content: string): string { - if (!content) return '拍一拍' - - // 1. 优先从 XML 标签提取内容 - const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) - if (titleMatch) { - const title = titleMatch[1] - .replace(/<!\[CDATA\[/g, '') - .replace(/\]\]>/g, '') - .trim() - if (title) { - return title - } - } - - // 2. 尝试匹配标准的 "A拍了拍B" 格式 - const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) - if (match) { - return match[1].trim() - } - - // 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) - let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid - cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符 - cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字 - cleaned = cleaned.replace(/\s+/g, ' ').trim() // 清理空格 - - // 移除不可见字符 - cleaned = this.cleanUtf16(cleaned) - - // 如果清理后还有内容,返回 - if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { - return cleaned - } - - return '拍一拍' - } - - /** - * 解码消息内容(处理 BLOB 和压缩数据) - */ - private decodeMessageContent(messageContent: any, compressContent: any): string { - // 优先使用 compress_content - let content = this.decodeMaybeCompressed(compressContent, 'compress_content') - if (!content || content.length === 0) { - content = this.decodeMaybeCompressed(messageContent, 'message_content') - } - return content - } - - /** - * 尝试解码可能压缩的内容 - */ - private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string { - if (!raw) return '' - - // - - // 如果是 Buffer/Uint8Array - if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { - return this.decodeBinaryContent(Buffer.from(raw), String(raw)) - } - - // 如果是字符串 - if (typeof raw === 'string') { - if (raw.length === 0) return '' - const compactRaw = this.compactEncodedPayload(raw) - - // 检查是否是 hex 编码 - // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 - // 短字符串(如 "123456" 等纯数字)容易被误判为 hex - if (compactRaw.length > 16 && this.looksLikeHex(compactRaw)) { - const bytes = Buffer.from(compactRaw, 'hex') - if (bytes.length > 0) { - const result = this.decodeBinaryContent(bytes, raw) - // - return result - } - } - - // 检查是否是 base64 编码 - // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 - // 短字符串(如 "test", "home" 等)容易被误判为 base64 - if (compactRaw.length > 16 && this.looksLikeBase64(compactRaw)) { - try { - const bytes = Buffer.from(compactRaw, 'base64') - return this.decodeBinaryContent(bytes, raw) - } catch { } - } - - // 普通字符串 - return raw - } - - return '' - } - - /** - * 解码二进制内容(处理 zstd 压缩) - */ - private decodeBinaryContent(data: Buffer, fallbackValue?: string): 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, '') - } - - // 如果提供了 fallbackValue,且解码结果看起来像二进制垃圾,则返回 fallbackValue - if (fallbackValue && replacementCount > 0) { - // - return fallbackValue - } - - // 尝试 latin1 解码 - return data.toString('latin1') - } catch { - return fallbackValue || '' - } - } - - /** - * 检查是否像 hex 编码 - */ - private looksLikeHex(s: string): boolean { - const compact = this.compactEncodedPayload(s) - if (compact.length % 2 !== 0) return false - return /^[0-9a-fA-F]+$/.test(compact) - } - - /** - * 检查是否像 base64 编码 - */ - private looksLikeBase64(s: string): boolean { - const compact = this.compactEncodedPayload(s) - if (compact.length % 4 !== 0) return false - return /^[A-Za-z0-9+/=]+$/.test(compact) - } - - private compactEncodedPayload(raw: string): string { - return String(raw || '').replace(/\s+/g, '').trim() - } - - private getSessionLocalType(row: Record<string, any>): number | undefined { - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) - return Number.isFinite(localType) ? Math.floor(localType) : undefined - } - - private async loadContactLocalTypeMapForEnterpriseOpenim(usernames: string[]): Promise<Map<string, number>> { - const normalizedUsernames = Array.from(new Set( - (usernames || []) - .map((value) => String(value || '').trim()) - .filter((value) => value && this.isEnterpriseOpenimUsername(value)) - )) - const localTypeMap = new Map<string, number>() - if (normalizedUsernames.length === 0) { - return localTypeMap - } - try { - const contactResult = await wcdbService.getContactsCompact(normalizedUsernames) - if (!contactResult.success || !Array.isArray(contactResult.contacts)) { - return localTypeMap - } - for (const row of contactResult.contacts as Record<string, any>[]) { - const username = String(row.username || '').trim() - if (!username) continue - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN) - if (!Number.isFinite(localType)) continue - localTypeMap.set(username, Math.floor(localType)) - } - } catch { - return localTypeMap - } - return localTypeMap - } - - private isEnterpriseOpenimUsername(username: string): boolean { - const lowered = String(username || '').trim().toLowerCase() - return lowered.includes('@openim') && !lowered.includes('@kefu.openim') - } - - private isAllowedEnterpriseOpenimByLocalType(username: string, localType?: number): boolean { - if (!this.isEnterpriseOpenimUsername(username)) return false - return Number.isFinite(localType) && Math.floor(localType as number) === 5 - } - - private shouldKeepSession(username: string, localType?: number): boolean { - if (!username) return false - const lowered = username.toLowerCase() - // 排除所有 placeholder 会话(包括折叠群) - if (lowered.includes('@placeholder')) return false - if (username.startsWith('gh_')) return false - - if (lowered === 'weixin') return false - - const excludeList = [ - 'qqmail', 'fmessage', 'medianote', 'floatbottle', - 'newsapp', 'brandsessionholder', 'brandservicesessionholder', - 'notifymessage', 'opencustomerservicemsg', 'notification_messages', - 'userexperience_alarm', 'helper_folders', - '@helper_folders' - ] - - for (const prefix of excludeList) { - if (username.startsWith(prefix) || username === prefix) return false - } - - if (username.includes('@kefu.openim')) return false - // 全局约束:企业 openim 仅允许 localType=5。 - if (this.isEnterpriseOpenimUsername(username)) { - return this.isAllowedEnterpriseOpenimByLocalType(username, localType) - } - if (username.includes('service_')) return false - - return true - } - - async getContact(username: string): Promise<Contact | null> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) return null - const result = await wcdbService.getContact(username) - if (!result.success || !result.contact) return null - const contact = result.contact as Record<string, any> - let alias = String(contact.alias || contact.Alias || '') - //数据服务有时不返回 alias 字段,补一条直接 SQL 查询兜底 - if (!alias) { - try { - const aliasResult = await wcdbService.getContactAliasMap([username]) - if (aliasResult.success && aliasResult.map && aliasResult.map[username]) { - alias = String(aliasResult.map[username] || '') - } - } catch { - // 兜底失败不影响主流程 - } - } - return { - username: String(contact.username || contact.user_name || contact.userName || username || ''), - alias, - remark: String(contact.remark || contact.Remark || ''), - // 兼容不同表结构字段,避免 nick_name 丢失导致侧边栏退化到 wxid。 - nickName: String(contact.nickName || contact.nick_name || contact.nickname || 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) - // 检查缓存是否有效,且头像不是错误的 hex 格式 - const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) - if (cached && isValidAvatar && 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]) - let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined - if (!this.isValidAvatarUrl(avatarUrl)) { - avatarUrl = undefined - } - if (!avatarUrl) { - const headImageAvatars = await this.getAvatarsFromHeadImageDb([username]) - const fallbackAvatarUrl = headImageAvatars[username] - if (this.isValidAvatarUrl(fallbackAvatarUrl)) { - avatarUrl = fallbackAvatarUrl - } - } - const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username - const cacheEntry: ContactCacheEntry = { - avatarUrl, - displayName, - updatedAt: Date.now() - } - this.avatarCache.set(username, cacheEntry) - this.contactCacheService.setEntries({ [username]: cacheEntry }) - return { avatarUrl, displayName } - } catch { - return null - } - } - - /** - * 解析转账消息中的付款方和收款方显示名称 - * 优先使用群昵称,群昵称为空时回退到微信昵称/备注 - */ - async resolveTransferDisplayNames( - chatroomId: string, - payerUsername: string, - receiverUsername: string - ): Promise<{ payerName: string; receiverName: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { payerName: payerUsername, receiverName: receiverUsername } - } - - // 如果是群聊,尝试获取群昵称 - const groupNicknames = new Map<string, string>() - if (chatroomId.endsWith('@chatroom')) { - const nickResult = await wcdbService.getGroupNicknames(chatroomId) - if (nickResult.success && nickResult.nicknames) { - const nicknameBuckets = new Map<string, Set<string>>() - for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) { - const memberId = String(memberIdRaw || '').trim().toLowerCase() - const nickname = String(nicknameRaw || '').trim() - if (!memberId || !nickname) continue - const slot = nicknameBuckets.get(memberId) - if (slot) { - slot.add(nickname) - } else { - nicknameBuckets.set(memberId, new Set([nickname])) - } - } - for (const [memberId, nicknameSet] of nicknameBuckets.entries()) { - if (nicknameSet.size !== 1) continue - groupNicknames.set(memberId, Array.from(nicknameSet)[0]) - } - } - } - - const lookupGroupNickname = (username?: string | null): string => { - const key = String(username || '').trim().toLowerCase() - if (!key) return '' - return groupNicknames.get(key) || '' - } - - // 获取当前用户 wxid,用于识别"自己" - const myWxid = this.configService.getMyWxidCleaned() - const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' - - // 解析付款方名称:自己 > 群昵称 > 备注 > 昵称 > alias > wxid - const resolveName = async (username: string): Promise<string> => { - // 特判:如果是当前用户自己(contact 表通常不包含自己) - if (myWxid && (username === myWxid || username === cleanedMyWxid)) { - // 先查群昵称中是否有自己 - const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid) - if (myGroupNick) return myGroupNick - // 尝试从缓存获取自己的昵称 - const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid) - if (cached?.displayName) return cached.displayName - return '我' - } - - // 先查群昵称 - const groupNick = lookupGroupNickname(username) - if (groupNick) return groupNick - - // 再查联系人信息 - const contact = await this.getContact(username) - if (contact) { - return contact.remark || contact.nickName || contact.alias || username - } - - // 兜底:查缓存 - const cached = this.avatarCache.get(username) - if (cached?.displayName) return cached.displayName - - return username - } - - const [payerName, receiverName] = await Promise.all([ - resolveName(payerUsername), - resolveName(receiverUsername) - ]) - - return { payerName, receiverName } - } catch { - return { payerName: payerUsername, receiverName: receiverUsername } - } - } - - /** - * 获取当前用户的头像 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.getMyWxidCleaned() - if (!myWxid) { - return { success: false, error: '未配置微信ID' } - } - - const cleanedWxid = this.cleanAccountDirName(myWxid) - // 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中 - const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self'])) - - const result = await wcdbService.getAvatarUrls(fetchList) - - if (result.success && result.map) { - // 按优先级尝试匹配 - const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self'] - if (avatarUrl) { - return { success: true, avatarUrl } - } - return { success: true, avatarUrl: undefined } - } - - return { success: true, avatarUrl: undefined } - } catch (e) { - console.error('ChatService: 获取当前用户头像失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 获取表情包缓存目录 - */ - /** - * 获取语音缓存目录 - */ - private getVoiceCacheDir(): string { - const cachePath = this.configService.get('cachePath') - if (cachePath) { - return join(cachePath, 'Voices') - } - // 回退到默认目录 - const documentsPath = app.getPath('documents') - return join(documentsPath, 'WeFlow', 'Voices') - } - - 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') - } - - clearCaches(options?: { includeMessages?: boolean; includeContacts?: boolean; includeEmojis?: boolean }): { success: boolean; error?: string } { - const includeMessages = options?.includeMessages !== false - const includeContacts = options?.includeContacts !== false - const includeEmojis = options?.includeEmojis !== false - const errors: string[] = [] - - if (includeContacts) { - this.avatarCache.clear() - this.contactCacheService.clear() - this.contactsMemoryCache.clear() - } - - if (includeMessages) { - this.messageCacheService.clear() - this.voiceWavCache.clear() - this.voiceTranscriptCache.clear() - this.voiceTranscriptPending.clear() - } - - if (includeMessages || includeContacts) { - this.sessionStatsMemoryCache.clear() - this.sessionStatsPendingBasic.clear() - this.sessionStatsPendingFull.clear() - this.allGroupSessionIdsCache = null - this.sessionStatsCacheService.clearAll() - this.groupMyMessageCountMemoryCache.clear() - this.groupMyMessageCountCacheService.clearAll() - } - - if (includeEmojis) { - emojiCache.clear() - emojiDownloading.clear() - const emojiDir = this.getEmojiCacheDir() - try { - fs.rmSync(emojiDir, { recursive: true, force: true }) - } catch (error) { - errors.push(String(error)) - } - } - - if (errors.length > 0) { - return { success: false, error: errors.join('; ') } - } - return { success: true } - } - - /** - * 下载并缓存表情包 - */ - 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)) { - return { success: true, localPath: cached } - } - - // 检查是否正在下载 - const downloading = emojiDownloading.get(cacheKey) - if (downloading) { - const result = await downloading - if (result) { - return { success: true, localPath: result } - } - 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) - return { success: true, localPath: filePath } - } - } - - // 开始下载 - 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) - return { success: true, localPath } - } - 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<string, string> = { - '.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<string | null> { - 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 getSessionDetailFast(sessionId: string): Promise<{ - success: boolean - detail?: SessionDetailFast - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - this.refreshSessionMessageCountCacheScope() - - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) { - return { success: false, error: '会话ID不能为空' } - } - - const now = Date.now() - const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) - if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { - return { success: true, detail: cachedDetail.detail } - } - - let displayName = normalizedSessionId - let remark: string | undefined - let nickName: string | undefined - let alias: string | undefined - let avatarUrl: string | undefined - const cachedContact = this.avatarCache.get(normalizedSessionId) - if (cachedContact) { - displayName = cachedContact.displayName || normalizedSessionId - if (this.isValidAvatarUrl(cachedContact.avatarUrl)) { - avatarUrl = cachedContact.avatarUrl - } - } - - const contactPromise = wcdbService.getContact(normalizedSessionId) - const avatarPromise = avatarUrl - ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) - : wcdbService.getAvatarUrls([normalizedSessionId]) - - let messageCount: number | undefined - const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) - if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { - messageCount = cachedCount.count - } else { - const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) - if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { - messageCount = Math.floor(hintCount) - this.sessionMessageCountCache.set(normalizedSessionId, { - count: messageCount, - updatedAt: now - }) - } - } - - const messageCountPromise = Number.isFinite(messageCount) - ? Promise.resolve<{ success: boolean; count?: number }>({ - success: true, - count: Math.max(0, Math.floor(messageCount as number)) - }) - : wcdbService.getMessageCount(normalizedSessionId) - - const [contactResult, avatarResult, messageCountResult] = await Promise.allSettled([ - contactPromise, - avatarPromise, - messageCountPromise - ]) - - if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { - remark = contactResult.value.contact.remark || undefined - nickName = contactResult.value.contact.nickName || undefined - alias = contactResult.value.contact.alias || undefined - displayName = remark || nickName || alias || displayName - } - - if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { - const avatarCandidate = avatarResult.value.map[normalizedSessionId] - if (this.isValidAvatarUrl(avatarCandidate)) { - avatarUrl = avatarCandidate - } - } - if (!avatarUrl) { - const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId]) - const fallbackAvatarUrl = headImageAvatars[normalizedSessionId] - if (this.isValidAvatarUrl(fallbackAvatarUrl)) { - avatarUrl = fallbackAvatarUrl - } - } - - if (!Number.isFinite(messageCount)) { - messageCount = messageCountResult.status === 'fulfilled' && - messageCountResult.value.success && - Number.isFinite(messageCountResult.value.count) - ? Math.max(0, Math.floor(messageCountResult.value.count || 0)) - : 0 - this.sessionMessageCountCache.set(normalizedSessionId, { - count: messageCount, - updatedAt: Date.now() - }) - } - - const detail: SessionDetailFast = { - wxid: normalizedSessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: Math.max(0, Math.floor(messageCount || 0)) - } - - this.sessionDetailFastCache.set(normalizedSessionId, { - detail, - updatedAt: Date.now() - }) - - return { success: true, detail } - } catch (e) { - console.error('ChatService: 获取会话详情快速信息失败:', e) - return { success: false, error: String(e) } - } - } - - async getSessionDetailExtra(sessionId: string): Promise<{ - success: boolean - detail?: SessionDetailExtra - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - this.refreshSessionMessageCountCacheScope() - - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) { - return { success: false, error: '会话ID不能为空' } - } - - const now = Date.now() - const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) - if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { - return { success: true, detail: cachedDetail.detail } - } - - const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId) - - const messageTables: { dbName: string; tableName: string; count: number }[] = [] - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined - if (tableStatsResult.success && tableStatsResult.tables) { - for (const row of tableStatsResult.tables) { - messageTables.push({ - dbName: basename(row.db_path || ''), - tableName: row.table_name || '', - count: parseInt(row.count || '0', 10) - }) - - const firstTs = this.getRowInt( - row, - ['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'], - 0 - ) - if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) { - firstMessageTime = firstTs - } - - const lastTs = this.getRowInt( - row, - ['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'], - 0 - ) - if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) { - latestMessageTime = lastTs - } - } - } - - const detail: SessionDetailExtra = { - firstMessageTime, - latestMessageTime, - messageTables - } - - this.sessionDetailExtraCache.set(normalizedSessionId, { - detail, - updatedAt: Date.now() - }) - - return { - success: true, - detail - } - } catch (e) { - console.error('ChatService: 获取会话详情补充统计失败:', e) - return { success: false, error: String(e) } - } - } - - async getSessionDetail(sessionId: string): Promise<{ - success: boolean - detail?: SessionDetail - error?: string - }> { - try { - const fastResult = await this.getSessionDetailFast(sessionId) - if (!fastResult.success || !fastResult.detail) { - return { success: false, error: fastResult.error || '获取会话详情失败' } - } - - const extraResult = await this.getSessionDetailExtra(sessionId) - const detail: SessionDetail = { - ...fastResult.detail, - firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, - latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, - messageTables: extraResult.success && extraResult.detail?.messageTables - ? extraResult.detail.messageTables - : [] - } - - return { success: true, detail } - } catch (e) { - console.error('ChatService: 获取会话详情失败:', e) - return { success: false, error: String(e) } - } - } - - async getGroupMyMessageCountHint(chatroomId: string): Promise<{ - success: boolean - count?: number - updatedAt?: number - source?: 'memory' | 'disk' - error?: string - }> { - try { - this.refreshSessionMessageCountCacheScope() - const normalizedChatroomId = String(chatroomId || '').trim() - if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { - return { success: false, error: '群聊ID无效' } - } - - const cached = this.getGroupMyMessageCountHintEntry(normalizedChatroomId) - if (!cached) return { success: true } - return { - success: true, - count: cached.entry.messageCount, - updatedAt: cached.entry.updatedAt, - source: cached.source - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async setGroupMyMessageCountHint( - chatroomId: string, - messageCount: number, - updatedAt?: number - ): Promise<{ success: boolean; updatedAt?: number; error?: string }> { - try { - this.refreshSessionMessageCountCacheScope() - const normalizedChatroomId = String(chatroomId || '').trim() - if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { - return { success: false, error: '群聊ID无效' } - } - const savedAt = this.setGroupMyMessageCountHintEntry(normalizedChatroomId, messageCount, updatedAt) - return { success: true, updatedAt: savedAt } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{ - success: boolean - data?: Record<string, ExportSessionStats> - cache?: Record<string, ExportSessionStatsCacheMeta> - needsRefresh?: string[] - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - this.refreshSessionMessageCountCacheScope() - - const includeRelations = options.includeRelations ?? true - const forceRefresh = options.forceRefresh === true - const allowStaleCache = options.allowStaleCache === true - const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true - const cacheOnly = options.cacheOnly === true - const beginTimestamp = this.normalizeTimestampSeconds(Number(options.beginTimestamp || 0)) - const endTimestamp = this.normalizeTimestampSeconds(Number(options.endTimestamp || 0)) - const useRangeFilter = beginTimestamp > 0 || endTimestamp > 0 - - const normalizedSessionIds = Array.from( - new Set( - (sessionIds || []) - .map((id) => String(id || '').trim()) - .filter(Boolean) - ) - ) - if (normalizedSessionIds.length === 0) { - return { success: true, data: {}, cache: {} } - } - - const resultMap: Record<string, ExportSessionStats> = {} - const cacheMeta: Record<string, ExportSessionStatsCacheMeta> = {} - const needsRefreshSet = new Set<string>() - const pendingSessionIds: string[] = [] - const now = Date.now() - - for (const sessionId of normalizedSessionIds) { - const groupMyMessagesHint = sessionId.endsWith('@chatroom') - ? this.getGroupMyMessageCountHintEntry(sessionId) - : null - const cachedResult = this.getSessionStatsCacheEntry(sessionId) - const canUseCache = !useRangeFilter && (cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)) - if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { - const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs - if (!stale || allowStaleCache || cacheOnly) { - resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) - if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { - resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount - } - cacheMeta[sessionId] = { - updatedAt: cachedResult.entry.updatedAt, - stale, - includeRelations: cachedResult.entry.includeRelations, - source: cachedResult.source - } - if (stale) { - needsRefreshSet.add(sessionId) - } - continue - } - } - // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。 - if (canUseCache && allowStaleCache && cachedResult) { - needsRefreshSet.add(sessionId) - continue - } - if (cacheOnly) { - continue - } - pendingSessionIds.push(sessionId) - } - - if (pendingSessionIds.length > 0) { - const myWxid = this.configService.getMyWxidCleaned() || '' - const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid)) - let usedBatchedCompute = false - if (pendingSessionIds.length === 1) { - const sessionId = pendingSessionIds[0] - try { - const stats = await this.getOrComputeSessionExportStats( - sessionId, - includeRelations, - selfIdentitySet, - preferAccurateSpecialTypes, - beginTimestamp, - endTimestamp - ) - resultMap[sessionId] = stats - if (!useRangeFilter) { - const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) - cacheMeta[sessionId] = { - updatedAt, - stale: false, - includeRelations, - source: 'fresh' - } - } - usedBatchedCompute = true - } catch { - usedBatchedCompute = false - } - } else { - try { - const batchedStatsMap = await this.computeSessionExportStatsBatch( - pendingSessionIds, - includeRelations, - selfIdentitySet, - preferAccurateSpecialTypes, - beginTimestamp, - endTimestamp - ) - for (const sessionId of pendingSessionIds) { - const stats = batchedStatsMap[sessionId] - if (!stats) continue - resultMap[sessionId] = stats - if (!useRangeFilter) { - const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) - cacheMeta[sessionId] = { - updatedAt, - stale: false, - includeRelations, - source: 'fresh' - } - } - } - usedBatchedCompute = true - } catch { - usedBatchedCompute = false - } - } - - if (!usedBatchedCompute) { - await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => { - try { - const stats = await this.getOrComputeSessionExportStats( - sessionId, - includeRelations, - selfIdentitySet, - preferAccurateSpecialTypes, - beginTimestamp, - endTimestamp - ) - resultMap[sessionId] = stats - if (!useRangeFilter) { - const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) - cacheMeta[sessionId] = { - updatedAt, - stale: false, - includeRelations, - source: 'fresh' - } - } - } catch { - resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) - } - }) - } - } - - const response: { - success: boolean - data?: Record<string, ExportSessionStats> - cache?: Record<string, ExportSessionStatsCacheMeta> - needsRefresh?: string[] - } = { - success: true, - data: resultMap, - cache: cacheMeta - } - if (needsRefreshSet.size > 0) { - response.needsRefresh = Array.from(needsRefreshSet) - } - return response - } 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. 获取消息详情 - const msgResult = await this.getMessageByLocalId(sessionId, localId) - if (!msgResult.success || !msgResult.message) { - return { success: false, error: '未找到消息' } - } - const msg = msgResult.message - const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {} - const imageMd5 = msg.imageMd5 || rawImageInfo.md5 - const imageDatName = msg.imageDatName - - if (!imageMd5 && !imageDatName) { - return { success: false, error: '图片缺少 md5/datName,无法定位原文件' } - } - - // 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识) - const result = await this.imageDecryptService.decryptImage({ - sessionId, - imageMd5, - imageDatName, - createTime: msg.createTime, - force: false, - preferFilePath: true, - hardlinkOnly: true - }) - - if (!result.success || !result.localPath) { - return { success: false, error: result.error || '图片解密失败' } - } - - // 3. 读取解密后的文件并转成 base64 - // 如果已经是 data URL,直接返回 base64 部分 - if (result.localPath.startsWith('data:')) { - const base64Data = result.localPath.split(',')[1] - return { success: true, data: base64Data } - } - - // localPath 是 file:// URL,需要转换成文件路径 - const filePath = result.localPath.startsWith('file://') - ? result.localPath.replace(/^file:\/\//, '') - : result.localPath - - const imageData = readFileSync(filePath) - return { success: true, data: imageData.toString('base64') } - } catch (e) { - console.error('ChatService: getImageData 失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * getVoiceData(主用批量专属接口读取语音数据) - */ - async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { - const startTime = Date.now() - const verboseVoiceTrace = process.env.WEFLOW_VOICE_TRACE === '1' - const msgCreateTimeLabel = (value?: number): string => { - return Number.isFinite(Number(value)) ? String(Math.floor(Number(value))) : '无' - } - const lookupPath: string[] = [] - const logLookupPath = (status: 'success' | 'fail', error?: string): void => { - const timeline = lookupPath.map((step, idx) => `${idx + 1}.${step}`).join(' -> ') - if (status === 'success') { - if (verboseVoiceTrace) { - console.info(`[Voice] 定位流程成功: ${timeline}`) - } - } else { - console.warn(`[Voice] 定位流程失败${error ? `(${error})` : ''}: ${timeline}`) - } - } - - try { - lookupPath.push(`会话=${sessionId}, 消息=${msgId}, 传入createTime=${msgCreateTimeLabel(createTime)}, serverId=${String(serverId || 0)}`) - lookupPath.push(`消息来源提示=${senderWxidOpt || '无'}`) - - const localId = parseInt(msgId, 10) - if (isNaN(localId)) { - logLookupPath('fail', '无效的消息ID') - return { success: false, error: '无效的消息ID' } - } - - let msgCreateTime = createTime - let senderWxid: string | null = senderWxidOpt || null - let resolvedServerId: string | number = this.normalizeUnsignedIntegerToken(serverId) || 0 - let locatedMsg: Message | null = null - let rejectedNonVoiceLookup = false - - lookupPath.push(`初始解析localId=${localId}成功`) - - // 已提供强键(createTime + serverId)时,直接走语音定位,避免 localId 反查噪音与误导 - const hasStrongInput = Number.isFinite(Number(msgCreateTime)) && Number(msgCreateTime) > 0 - && Boolean(this.normalizeUnsignedIntegerToken(serverId)) - - if (hasStrongInput) { - lookupPath.push('调用入参已具备强键(createTime+serverId),跳过localId反查') - } else { - const t1 = Date.now() - const msgResult = await this.getMessageByLocalId(sessionId, localId) - const t2 = Date.now() - lookupPath.push(`消息反查耗时=${t2 - t1}ms`) - if (!msgResult.success || !msgResult.message) { - lookupPath.push('未命中: getMessageByLocalId') - } else { - const dbMsg = msgResult.message as Message - const locatedServerId = this.normalizeUnsignedIntegerToken(dbMsg.serverIdRaw ?? dbMsg.serverId) - const incomingServerId = this.normalizeUnsignedIntegerToken(serverId) - lookupPath.push(`命中消息定位: localId=${dbMsg.localId}, createTime=${dbMsg.createTime}, sender=${dbMsg.senderUsername || ''}, serverId=${locatedServerId || '0'}, localType=${dbMsg.localType}, voice时长=${dbMsg.voiceDurationSeconds ?? 0}`) - - if (incomingServerId && locatedServerId && incomingServerId !== locatedServerId) { - lookupPath.push(`serverId纠正: input=${incomingServerId}, db=${locatedServerId}`) - } - - // localId 在不同表可能重复,反查命中非语音时不覆盖调用侧入参 - if (Number(dbMsg.localType) === 34) { - locatedMsg = dbMsg - msgCreateTime = dbMsg.createTime || msgCreateTime - senderWxid = dbMsg.senderUsername || senderWxid || null - if (locatedServerId) { - resolvedServerId = locatedServerId - } - } else { - rejectedNonVoiceLookup = true - lookupPath.push('消息反查命中但localType!=34,忽略反查覆盖,继续使用调用入参定位') - } - } - } - - if (!msgCreateTime) { - lookupPath.push('定位失败: 未找到消息时间戳') - logLookupPath('fail', '未找到消息时间戳') - return { success: false, error: '未找到消息时间戳' } - } - if (!locatedMsg) { - lookupPath.push(rejectedNonVoiceLookup - ? `定位结果: 反查命中非语音并已忽略, createTime=${msgCreateTime}, sender=${senderWxid || '无'}` - : `定位结果: 未走消息反查流程, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) - } else { - lookupPath.push(`定位结果: 语音消息被确认 localId=${localId}, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) - } - lookupPath.push(`最终serverId=${String(resolvedServerId || 0)}`) - - if (verboseVoiceTrace) { - if (locatedMsg) { - console.log('[Voice] 定位到的具体语音消息:', { - sessionId, - msgId, - localId: locatedMsg.localId, - createTime: locatedMsg.createTime, - senderUsername: locatedMsg.senderUsername, - serverId: locatedMsg.serverIdRaw || locatedMsg.serverId, - localType: locatedMsg.localType, - voiceDurationSeconds: locatedMsg.voiceDurationSeconds - }) - } else { - console.log('[Voice] 定位到的语音消息:', { - sessionId, - msgId, - localId, - createTime: msgCreateTime, - senderUsername: senderWxid, - serverId: resolvedServerId - }) - } - } - - // 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音 - const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime) - - // 检查 WAV 内存缓存 - const wavCache = this.voiceWavCache.get(cacheKey) - if (wavCache) { - lookupPath.push('命中内存WAV缓存') - logLookupPath('success', '内存缓存') - return { success: true, data: wavCache.toString('base64') } - } - - // 检查 WAV 文件缓存 - const voiceCacheDir = this.getVoiceCacheDir() - const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - if (existsSync(wavFilePath)) { - try { - const wavData = readFileSync(wavFilePath) - this.cacheVoiceWav(cacheKey, wavData) - lookupPath.push('命中磁盘WAV缓存') - logLookupPath('success', '磁盘缓存') - return { success: true, data: wavData.toString('base64') } - } catch (e) { - lookupPath.push('命中磁盘WAV缓存但读取失败') - console.error('[Voice] 读取缓存文件失败:', e) - } - } - lookupPath.push('缓存未命中,进入DB定位') - - // 构建查找候选 - const candidates: string[] = [] - const myWxid = this.configService.getMyWxidCleaned() as string - - // 如果有 senderWxid,优先使用(群聊中最重要) - if (senderWxid) { - candidates.push(senderWxid) - } - - // sessionId(1对1聊天时是对方wxid,群聊时是群id) - if (sessionId && !candidates.includes(sessionId)) { - candidates.push(sessionId) - } - - // 我的wxid(兜底) - if (myWxid && !candidates.includes(myWxid)) { - candidates.push(myWxid) - } - lookupPath.push(`定位候选链=${JSON.stringify(candidates)}`) - - const t3 = Date.now() - // 从数据库读取 silk 数据 - const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, resolvedServerId || 0, candidates, lookupPath, myWxid) - const t4 = Date.now() - lookupPath.push(`DB定位耗时=${t4 - t3}ms`) - - - if (!silkData) { - logLookupPath('fail', '未找到语音数据') - return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } - } - lookupPath.push('语音二进制定位完成') - - const t5 = Date.now() - // 使用 silk-wasm 解码 - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - const t6 = Date.now() - lookupPath.push(`silk解码耗时=${t6 - t5}ms`) - - - if (!pcmData) { - logLookupPath('fail', 'Silk解码失败') - return { success: false, error: 'Silk 解码失败' } - } - lookupPath.push('silk解码成功') - - const t7 = Date.now() - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - const t8 = Date.now() - lookupPath.push(`WAV转码耗时=${t8 - t7}ms`) - - - // 缓存 WAV 数据到内存 - this.cacheVoiceWav(cacheKey, wavData) - - // 缓存 WAV 数据到文件(异步,不阻塞返回) - this.cacheVoiceWavToFile(cacheKey, wavData) - - lookupPath.push(`总耗时=${t8 - startTime}ms`) - logLookupPath('success') - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - lookupPath.push(`异常: ${String(e)}`) - logLookupPath('fail', String(e)) - console.error('ChatService: getVoiceData 失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 缓存 WAV 数据到文件(异步) - */ - private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise<void> { - try { - const voiceCacheDir = this.getVoiceCacheDir() - await fsPromises.mkdir(voiceCacheDir, { recursive: true }) - const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - await fsPromises.writeFile(wavFilePath, wavData) - } catch (e) { - console.error('[Voice] 缓存文件失败:', e) - } - } - - /** - * 通过 WCDB 专属接口查询语音数据 - * 策略:批量查询 + 单条 native 兜底 - */ - private async getVoiceDataFromMediaDb( - sessionId: string, - createTime: number, - localId: number, - svrId: string | number, - candidates: string[], - lookupPath?: string[], - myWxid?: string - ): Promise<Buffer | null> { - try { - const candidatesList = Array.isArray(candidates) - ? candidates.filter((value, index, arr) => { - const key = String(value || '').trim() - return Boolean(key) && arr.findIndex(v => String(v || '').trim() === key) === index - }) - : [] - const createTimeInt = Math.max(0, Math.floor(Number(createTime || 0))) - const localIdInt = Math.max(0, Math.floor(Number(localId || 0))) - const svrIdToken = svrId || 0 - - const plans: Array<{ label: string; list: string[] }> = [] - if (candidatesList.length > 0) { - const strict = String(myWxid || '').trim() - ? candidatesList.filter(item => item !== String(myWxid || '').trim()) - : candidatesList.slice() - if (strict.length > 0 && strict.length !== candidatesList.length) { - plans.push({ label: 'strict(no-self)', list: strict }) - } - plans.push({ label: 'full', list: candidatesList }) - } else { - plans.push({ label: 'empty', list: [] }) - } - - lookupPath?.push(`构建音频查询参数 createTime=${createTimeInt}, localId=${localIdInt}, svrId=${svrIdToken}, plans=${plans.map(p => `${p.label}:${p.list.length}`).join('|')}`) - - for (const plan of plans) { - lookupPath?.push(`尝试候选集[${plan.label}]=${JSON.stringify(plan.list)}`) - // 先走单条 native:svr_id 通过 int64 直传,避免 batch JSON 的大整数精度/解析差异 - lookupPath?.push(`先尝试单条查询(${plan.label})`) - const single = await wcdbService.getVoiceData( - sessionId, - createTimeInt, - plan.list, - localIdInt, - svrIdToken - ) - lookupPath?.push(`单条查询(${plan.label})结果: success=${single.success}, hasHex=${Boolean(single.hex)}`) - if (single.success && single.hex) { - const decoded = this.decodeVoiceBlob(single.hex) - if (decoded && decoded.length > 0) { - lookupPath?.push(`单条查询(${plan.label})解码成功`) - return decoded - } - lookupPath?.push(`单条查询(${plan.label})解码为空`) - } - - const batchResult = await wcdbService.getVoiceDataBatch([{ - session_id: sessionId, - create_time: createTimeInt, - local_id: localIdInt, - svr_id: svrIdToken, - candidates: plan.list - }]) - lookupPath?.push(`批量查询(${plan.label})结果: success=${batchResult.success}, rows=${Array.isArray(batchResult.rows) ? batchResult.rows.length : 0}`) - if (!batchResult.success) { - lookupPath?.push(`批量查询(${plan.label})失败: ${batchResult.error || '无错误信息'}`) - } - - if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) { - const hex = String(batchResult.rows[0]?.hex || '').trim() - lookupPath?.push(`命中批量结果(${plan.label})[0], hexLen=${hex.length}`) - if (hex) { - const decoded = this.decodeVoiceBlob(hex) - if (decoded && decoded.length > 0) { - lookupPath?.push(`批量结果(${plan.label})解码成功`) - return decoded - } - lookupPath?.push(`批量结果(${plan.label})解码为空`) - } - } else { - lookupPath?.push(`批量结果(${plan.label})未命中`) - } - } - - lookupPath?.push('音频定位失败:未命中任何结果') - return null - } catch (e) { - lookupPath?.push(`音频定位异常: ${String(e)}`) - return null - } - } - - async preloadVoiceDataBatch( - sessionId: string, - messages: Array<{ - localId?: number | string - createTime?: number | string - serverId?: number | string - senderWxid?: string | null - }>, - options?: { chunkSize?: number; decodeConcurrency?: number } - ): Promise<{ success: boolean; prepared?: number; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return { success: true, prepared: 0 } - if (!Array.isArray(messages) || messages.length === 0) return { success: true, prepared: 0 } - - const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - const nowPrepared = new Set<string>() - const pending: Array<{ - cacheKey: string - request: { session_id: string; create_time: number; local_id: number; svr_id: string | number; candidates: string[] } - }> = [] - - for (const item of messages) { - const localId = Math.max(0, Math.floor(Number(item?.localId || 0))) - const createTime = Math.max(0, Math.floor(Number(item?.createTime || 0))) - if (!localId || !createTime) continue - - const cacheKey = this.getVoiceCacheKey(normalizedSessionId, String(localId), createTime) - if (nowPrepared.has(cacheKey)) continue - nowPrepared.add(cacheKey) - - const inMemory = this.voiceWavCache.get(cacheKey) - if (inMemory && inMemory.length > 0) continue - - const wavFilePath = join(this.getVoiceCacheDir(), `${cacheKey}.wav`) - if (existsSync(wavFilePath)) { - try { - const wavData = readFileSync(wavFilePath) - if (wavData.length > 0) { - this.cacheVoiceWav(cacheKey, wavData) - continue - } - } catch { - // ignore corrupted cache file - } - } - - const senderWxid = String(item?.senderWxid || '').trim() - const candidates: string[] = [] - if (senderWxid) candidates.push(senderWxid) - if (!candidates.includes(normalizedSessionId)) candidates.push(normalizedSessionId) - if (myWxid && !candidates.includes(myWxid)) candidates.push(myWxid) - - pending.push({ - cacheKey, - request: { - session_id: normalizedSessionId, - create_time: createTime, - local_id: localId, - svr_id: item?.serverId || 0, - candidates - } - }) - } - - if (pending.length === 0) { - return { success: true, prepared: nowPrepared.size } - } - - const chunkSize = Math.max(8, Math.min(128, Math.floor(Number(options?.chunkSize || 48)))) - const decodeConcurrency = Math.max(1, Math.min(6, Math.floor(Number(options?.decodeConcurrency || 3)))) - let prepared = nowPrepared.size - pending.length - - for (let i = 0; i < pending.length; i += chunkSize) { - const chunk = pending.slice(i, i + chunkSize) - const batchResult = await wcdbService.getVoiceDataBatch(chunk.map(item => item.request)) - if (!batchResult.success || !Array.isArray(batchResult.rows)) { - continue - } - - const byIndex = new Map<number, string>() - for (const row of batchResult.rows as Array<Record<string, any>>) { - const idx = Number.parseInt(String(row?.index ?? ''), 10) - const hex = String(row?.hex || '').trim() - if (!Number.isFinite(idx) || idx < 0 || !hex) continue - byIndex.set(idx, hex) - } - - const readyItems: Array<{ cacheKey: string; hex: string }> = [] - for (let rowIdx = 0; rowIdx < chunk.length; rowIdx += 1) { - const hex = byIndex.get(rowIdx) - if (!hex) continue - readyItems.push({ cacheKey: chunk[rowIdx].cacheKey, hex }) - } - - await this.forEachWithConcurrency(readyItems, decodeConcurrency, async (item) => { - const silkData = this.decodeVoiceBlob(item.hex) - if (!silkData || silkData.length === 0) return - - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData || pcmData.length === 0) return - - const wavData = this.createWavBuffer(pcmData, 24000) - this.cacheVoiceWav(item.cacheKey, wavData) - this.cacheVoiceWavToFile(item.cacheKey, wavData) - prepared += 1 - }) - } - - return { success: true, prepared } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 检查语音是否已有缓存(只检查内存,不查询数据库) - */ - async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> { - try { - // 直接用 msgId 生成 cacheKey,不查询数据库 - // 注意:这里的 cacheKey 可能不准确(因为没有 createTime),但只是用来快速检查缓存 - // 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询 - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - - // 检查内存缓存 - const inMemory = this.voiceWavCache.get(cacheKey) - if (inMemory) { - return { success: true, hasCache: true, data: inMemory.toString('base64') } - } - - return { success: true, hasCache: false } - } catch (e) { - return { success: false, hasCache: false } - } - } - - async getVoiceData_Legacy(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 - const senderWxid = msg.senderUsername || undefined - return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverIdRaw || msg.serverId, senderWxid) - } catch (e) { - console.error('ChatService: getVoiceData 失败:', e) - return { success: false, error: String(e) } - } - } - - - - /** - * 解码 Silk 数据为 PCM (silk-wasm) - */ - private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> { - try { - let wasmPath: string - const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged - const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath - const appPath = this.runtimeConfig?.appPath ?? app.getAppPath() - - if (isPackaged) { - wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') - if (!existsSync(wasmPath)) { - wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') - } - } else { - wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') - } - - if (!existsSync(wasmPath)) { - console.error('[ChatService][Voice] silk.wasm not found at:', wasmPath) - return null - } - - // 在 worker 环境中使用 createRequire 来正确加载模块 - const requireFromApp = createRequire(join(appPath, 'package.json')) - const silkWasm = requireFromApp('silk-wasm') - if (!silkWasm || !silkWasm.decode) { - console.error('[ChatService][Voice] silk-wasm module invalid') - return null - } - - const result = await silkWasm.decode(silkData, sampleRate) - return Buffer.from(result.data) - } catch (e) { - console.error('[ChatService][Voice] internal decode error:', e) - return null - } - } - - /** - * 创建 WAV 文件 Buffer - */ - private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer { - const pcmLength = pcmData.length - 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 Buffer.concat([header, pcmData]) - } - - async getVoiceTranscript( - sessionId: string, - msgId: string, - createTime?: number, - onPartial?: (text: string) => void, - senderWxid?: string - ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const startTime = Date.now() - - // 确保磁盘缓存已加载 - this.loadTranscriptCacheIfNeeded() - - try { - let msgCreateTime = createTime - let serverId: string | number | undefined - - // 如果前端没传 createTime,才需要查询消息(这个很慢) - if (!msgCreateTime) { - const t1 = Date.now() - const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) - const t2 = Date.now() - - - if (msgResult.success && msgResult.message) { - msgCreateTime = msgResult.message.createTime - serverId = msgResult.message.serverIdRaw || msgResult.message.serverId - - } - } - - if (!msgCreateTime) { - console.error(`[Transcribe] 未找到消息时间戳`) - return { success: false, error: '未找到消息时间戳' } - } - - // 使用正确的 cacheKey(包含 createTime) - const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) - - - // 检查转写缓存 - const cached = this.voiceTranscriptCache.get(cacheKey) - if (cached) { - - return { success: true, transcript: cached } - } - - // 检查是否正在转写 - const pending = this.voiceTranscriptPending.get(cacheKey) - if (pending) { - - return pending - } - - const task = (async () => { - try { - // 检查内存中是否有 WAV 数据 - let wavData = this.voiceWavCache.get(cacheKey) - if (wavData) { - - } else { - // 检查文件缓存 - const voiceCacheDir = this.getVoiceCacheDir() - const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - if (existsSync(wavFilePath)) { - try { - wavData = readFileSync(wavFilePath) - - // 同时缓存到内存 - this.cacheVoiceWav(cacheKey, wavData) - } catch (e) { - console.error(`[Transcribe] 读取缓存文件失败:`, e) - } - } - } - - if (!wavData) { - - const t3 = Date.now() - // 调用 getVoiceData 获取并解码 - const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid) - const t4 = Date.now() - - - if (!voiceResult.success || !voiceResult.data) { - console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`) - return { success: false, error: voiceResult.error || '语音解码失败' } - } - wavData = Buffer.from(voiceResult.data, 'base64') - - } - - // 转写 - - const t5 = Date.now() - const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { - - onPartial?.(text) - }) - const t6 = Date.now() - - - if (result.success && result.transcript) { - - this.cacheVoiceTranscript(cacheKey, result.transcript) - } else { - console.error(`[Transcribe] 转写失败: ${result.error}`) - } - - - return result - } catch (error) { - console.error(`[Transcribe] 异常:`, error) - return { success: false, error: String(error) } - } finally { - this.voiceTranscriptPending.delete(cacheKey) - } - })() - - this.voiceTranscriptPending.set(cacheKey, task) - return task - } catch (error) { - console.error(`[Transcribe] 外层异常:`, error) - return { success: false, error: String(error) } - } - } - - - - private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { - // createTime + msgId 可避免同会话同秒多条语音互相覆盖 - if (createTime) { - return `${sessionId}_${createTime}_${msgId}` - } - return `${sessionId}_${msgId}` - } - - private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { - this.voiceWavCache.set(cacheKey, wavData) - // LRU缓存会自动处理大小限制,无需手动清理 - } - - /** 获取持久化转写缓存文件路径 */ - private getTranscriptCachePath(): string { - const cachePath = this.configService.get('cachePath') - const base = cachePath || join(app.getPath('documents'), 'WeFlow') - return join(base, 'Voices', 'transcripts.json') - } - - /** 首次访问时从磁盘加载转写缓存 */ - private loadTranscriptCacheIfNeeded(): void { - if (this.transcriptCacheLoaded) return - this.transcriptCacheLoaded = true - try { - const filePath = this.getTranscriptCachePath() - if (existsSync(filePath)) { - const raw = readFileSync(filePath, 'utf-8') - const data = JSON.parse(raw) as Record<string, string> - for (const [k, v] of Object.entries(data)) { - if (typeof v === 'string') this.voiceTranscriptCache.set(k, v) - } - console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`) - } - } catch (e) { - console.error('[Transcribe] 加载转写缓存失败:', e) - } - } - - /** 将转写缓存持久化到磁盘(防抖 3 秒) */ - private scheduleTranscriptFlush(): void { - if (this.transcriptFlushTimer) return - this.transcriptFlushTimer = setTimeout(() => { - this.transcriptFlushTimer = null - this.flushTranscriptCache() - }, 3000) - } - - /** 立即写入转写缓存到磁盘 */ - flushTranscriptCache(): void { - if (!this.transcriptCacheDirty) return - try { - const filePath = this.getTranscriptCachePath() - const dir = dirname(filePath) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const obj: Record<string, string> = {} - for (const [k, v] of this.voiceTranscriptCache) obj[k] = v - writeFileSync(filePath, JSON.stringify(obj), 'utf-8') - this.transcriptCacheDirty = false - } catch (e) { - console.error('[Transcribe] 写入转写缓存失败:', e) - } - } - - private cacheVoiceTranscript(cacheKey: string, transcript: string): void { - this.voiceTranscriptCache.set(cacheKey, transcript) - this.transcriptCacheDirty = true - this.scheduleTranscriptFlush() - } - - /** - * 检查某个语音消息是否已有缓存的转写结果 - */ - hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean { - this.loadTranscriptCacheIfNeeded() - const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime) - return this.voiceTranscriptCache.has(cacheKey) - } - - /** - * 批量统计转写缓存命中数(按会话维度)。 - * 仅基于本地 transcripts cache key 统计,用于导出前快速预估。 - */ - getCachedVoiceTranscriptCountMap(sessionIds: string[]): Record<string, number> { - this.loadTranscriptCacheIfNeeded() - const normalizedIds = Array.from( - new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) - ) - const targetSet = new Set(normalizedIds) - const countMap: Record<string, number> = {} - for (const sessionId of normalizedIds) { - countMap[sessionId] = 0 - } - if (targetSet.size === 0) return countMap - - for (const key of this.voiceTranscriptCache.keys()) { - const rawKey = String(key || '') - if (!rawKey) continue - // 新 key: `${sessionId}_${createTime}_${msgId}`;旧 key: `${sessionId}_${createTime}` - const matchNew = /^(.*)_(\d+)_(\d+)$/.exec(rawKey) - const matchOld = matchNew ? null : /^(.*)_(\d+)$/.exec(rawKey) - const sessionId = String((matchNew ? matchNew[1] : (matchOld ? matchOld[1] : '')) || '').trim() - if (!sessionId || !targetSet.has(sessionId)) continue - countMap[sessionId] = (countMap[sessionId] || 0) + 1 - } - - return countMap - } - - /** - * 获取某会话的所有语音消息(localType=34),用于批量转写 - */ - async getAllVoiceMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const result = await wcdbService.getMessagesByType(sessionId, 34, false, 0, 0) - if (!result.success || !Array.isArray(result.rows)) { - return { success: false, error: result.error || '查询语音消息失败' } - } - - let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId) - - // 按 createTime 降序排序 - allVoiceMessages.sort((a, b) => b.createTime - a.createTime) - - // 去重 - const seen = new Set<string>() - allVoiceMessages = allVoiceMessages.filter(msg => { - const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - - console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`) - return { success: true, messages: allVoiceMessages } - } catch (e) { - console.error('[ChatService] 获取所有语音消息失败:', e) - return { success: false, error: String(e) } - } - } - - /** - * 获取某会话中有消息的日期列表 - * 返回 YYYY-MM-DD 格式的日期字符串数组 - */ - /** - * 获取某会话的全部图片消息(用于聊天页批量图片解密) - */ - async getAllImageMessages( - sessionId: string - ): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const result = await wcdbService.getMessagesByType(sessionId, 3, false, 0, 0) - if (!result.success || !Array.isArray(result.rows)) { - return { success: false, error: result.error || '查询图片消息失败' } - } - - const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId) - let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped - .filter(msg => msg.localType === 3) - .map(msg => ({ - imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName || undefined, - createTime: msg.createTime || undefined - })) - .filter(img => Boolean(img.imageMd5 || img.imageDatName)) - - allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) - - const seen = new Set<string>() - allImages = allImages.filter(img => { - const key = img.imageMd5 || img.imageDatName || '' - if (!key || seen.has(key)) return false - seen.add(key) - return true - }) - - console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`) - return { success: true, images: allImages } - } catch (e) { - console.error('[ChatService] 获取全部图片消息失败:', e) - return { success: false, error: String(e) } - } - } - - private resolveResourceType(message: Message): ResourceMessageType | null { - if (message.localType === 3) return 'image' - if (message.localType === 43) return 'video' - if (message.localType === 34) return 'voice' - if ( - message.localType === 49 || - message.localType === 34359738417 || - message.localType === 103079215153 || - message.localType === 25769803825 - ) { - if (message.appMsgKind === 'file' || message.xmlType === '6') return 'file' - if (message.localType !== 49) return 'file' - } - return null - } - - async getResourceMessages(options?: { - sessionId?: string - types?: ResourceMessageType[] - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }): Promise<{ - success: boolean - items?: ResourceMessageItem[] - total?: number - hasMore?: boolean - error?: string - }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const requestedTypes = Array.isArray(options?.types) - ? options.types.filter((type): type is ResourceMessageType => ['image', 'video', 'voice', 'file'].includes(type)) - : [] - const typeSet = new Set<ResourceMessageType>(requestedTypes.length > 0 ? requestedTypes : ['image', 'video', 'voice', 'file']) - - const beginTimestamp = Number(options?.beginTimestamp || 0) - const endTimestamp = Number(options?.endTimestamp || 0) - const offset = Math.max(0, Number(options?.offset || 0)) - const limitRaw = Number(options?.limit || 0) - const limit = Number.isFinite(limitRaw) ? Math.min(2000, Math.max(1, Math.floor(limitRaw || 300))) : 300 - - const sessionsResult = await this.getSessions() - if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) { - return { success: false, error: sessionsResult.error || '获取会话失败' } - } - - const sessionNameMap = new Map<string, string>() - sessionsResult.sessions.forEach((session) => { - sessionNameMap.set(session.username, session.displayName || session.username) - }) - - const requestedSessionId = String(options?.sessionId || '').trim() - const sortedSessions = [...sessionsResult.sessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) - const targetSessionIds = requestedSessionId - ? [requestedSessionId] - : sortedSessions.map((session) => session.username) - - const localTypes: number[] = [] - if (typeSet.has('image')) localTypes.push(3) - if (typeSet.has('video')) localTypes.push(43) - if (typeSet.has('voice')) localTypes.push(34) - if (typeSet.has('file')) { - localTypes.push(49, 34359738417, 103079215153, 25769803825) - } - const uniqueLocalTypes = Array.from(new Set(localTypes)) - - const allItems: ResourceMessageItem[] = [] - const dedup = new Set<string>() - const targetCount = offset + limit - const candidateBuffer = Math.max(180, limit) - const perTypeFetch = requestedSessionId - ? Math.min(2000, Math.max(200, targetCount * 2)) - : (beginTimestamp > 0 || endTimestamp > 0 ? 140 : 90) - const maxSessionScan = requestedSessionId - ? 1 - : (beginTimestamp > 0 || endTimestamp > 0 ? 240 : 80) - const scanSessionIds = targetSessionIds.slice(0, maxSessionScan) - - let maybeHasMore = targetSessionIds.length > scanSessionIds.length - let stopEarly = false - - for (const sessionId of scanSessionIds) { - const batchRows = await Promise.all( - uniqueLocalTypes.map((localType) => - wcdbService.getMessagesByType(sessionId, localType, false, perTypeFetch, 0) - ) - ) - for (const result of batchRows) { - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue - if (result.rows.length >= perTypeFetch) maybeHasMore = true - - const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId) - for (const message of mapped) { - const resourceType = this.resolveResourceType(message) - if (!resourceType || !typeSet.has(resourceType)) continue - if (beginTimestamp > 0 && message.createTime < beginTimestamp) continue - if (endTimestamp > 0 && message.createTime > endTimestamp) continue - - const dedupKey = `${sessionId}:${message.localId}:${message.serverId}:${message.createTime}:${message.localType}` - if (dedup.has(dedupKey)) continue - dedup.add(dedupKey) - - allItems.push({ - ...message, - sessionId, - sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, - resourceType - }) - } - } - - if (allItems.length >= targetCount + candidateBuffer) { - stopEarly = true - maybeHasMore = true - break - } - } - - allItems.sort((a, b) => { - const timeDiff = (b.createTime || 0) - (a.createTime || 0) - if (timeDiff !== 0) return timeDiff - return (b.localId || 0) - (a.localId || 0) - }) - - const total = allItems.length - const start = Math.min(offset, total) - const end = Math.min(start + limit, total) - - return { - success: true, - items: allItems.slice(start, end), - total, - hasMore: end < total || maybeHasMore || stopEarly - } - } catch (e) { - console.error('[ChatService] 获取资源消息失败:', e) - return { success: false, error: String(e) } - } - } - - async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const result = await wcdbService.getMessageDates(sessionId) - if (!result.success) { - throw new Error(result.error || '查询失败') - } - - const dates = result.dates || [] - - console.log(`[ChatService] 会话 ${sessionId} 共有 ${dates.length} 个有消息的日期`) - return { success: true, dates } - } catch (e) { - console.error('[ChatService] 获取消息日期失败:', e) - return { success: false, error: String(e) } - } - } - - async getMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const result = await wcdbService.getSessionMessageDateCounts(sessionId) - if (!result.success || !result.counts) { - return { success: false, error: result.error || '查询每日消息数失败' } - } - const counts = result.counts - - console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) - return { success: true, counts } - } catch (error) { - console.error('[ChatService] 获取每日消息数失败:', error) - return { success: false, error: String(error) } - } - } - - async getMyFootprintStats( - beginTimestamp: number, - endTimestamp: number, - options?: { - myWxid?: string - privateSessionIds?: string[] - groupSessionIds?: string[] - mentionLimit?: number - privateLimit?: number - mentionMode?: 'text_at_me' | string - } - ): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - - const begin = this.normalizeTimestampSeconds(beginTimestamp) - const end = this.normalizeTimestampSeconds(endTimestamp) - const normalizedEnd = begin > 0 && end > 0 && end < begin ? begin : end - const mentionLimitRaw = Number(options?.mentionLimit ?? 0) - const privateLimitRaw = Number(options?.privateLimit ?? 0) - const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 - ? Math.floor(mentionLimitRaw) - : 0 - const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 - ? Math.floor(privateLimitRaw) - : 0 - - let myWxid = String(options?.myWxid || '').trim() - if (!myWxid) { - myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - } - if (!myWxid) { - return { success: false, error: '未识别当前账号 wxid' } - } - - let privateSessionIds = Array.isArray(options?.privateSessionIds) - ? options!.privateSessionIds!.map((value) => String(value || '').trim()).filter(Boolean) - : [] - let groupSessionIds = Array.isArray(options?.groupSessionIds) - ? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean) - : [] - const privateSessionLocalTypeMap = new Map<string, number>() - const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds) - && options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0) - - if (privateSessionIds.length === 0 && groupSessionIds.length === 0) { - const sessionsResult = await wcdbService.getSessions() - if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) { - return { success: false, error: sessionsResult.error || '读取会话列表失败' } - } - const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim( - (sessionsResult.sessions as Array<Record<string, any>>).map((session) => String(session.username || session.user_name || '').trim()) - ) - for (const session of sessionsResult.sessions as Array<Record<string, any>>) { - const sessionId = String(session.username || session.user_name || '').trim() - if (!sessionId) continue - let sessionLocalType = this.getSessionLocalType(session) - if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(sessionId)) { - sessionLocalType = openimLocalTypeMap.get(sessionId) - } - if (typeof sessionLocalType === 'number' && Number.isFinite(sessionLocalType)) { - privateSessionLocalTypeMap.set(sessionId, sessionLocalType) - } - const sessionLastTs = this.normalizeTimestampSeconds( - Number(session.lastTimestamp || session.sortTimestamp || 0) - ) - if (sessionId.endsWith('@chatroom')) { - groupSessionIds.push(sessionId) - } else { - if (!this.shouldKeepSession(sessionId, sessionLocalType)) continue - if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue - privateSessionIds.push(sessionId) - } - } - } - - const unresolvedOpenimPrivateSessionIds = privateSessionIds.filter((value) => - this.isEnterpriseOpenimUsername(value) && !privateSessionLocalTypeMap.has(value) - ) - if (unresolvedOpenimPrivateSessionIds.length > 0) { - const fallbackMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(unresolvedOpenimPrivateSessionIds) - for (const [username, localType] of fallbackMap.entries()) { - privateSessionLocalTypeMap.set(username, localType) - } - } - - privateSessionIds = Array.from(new Set( - privateSessionIds - .map((value) => String(value || '').trim()) - .filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value, privateSessionLocalTypeMap.get(value))) - )) - groupSessionIds = Array.from(new Set( - groupSessionIds - .map((value) => String(value || '').trim()) - .filter((value) => value && value.endsWith('@chatroom')) - )) - if (!hasExplicitGroupScope) { - groupSessionIds = await this.resolveMyFootprintGroupSessionIds(groupSessionIds, begin, normalizedEnd) - } - - privateSessionIds = await this.filterMyFootprintPrivateSessions(privateSessionIds) - - let data: MyFootprintData | null = null - const effectivePrivateLimit = privateLimit - // native 候选上限:0 表示不截断候选,确保前端 source 二次过滤有完整输入 - const nativeMentionCandidateLimit = 0 - let nativePasses = 0 - const candidateLimitUsed = nativeMentionCandidateLimit - let nativeGroupChunks = 0 - - const runNativePass = async (passOptions: { - label: string - passPrivateSessionIds: string[] - passGroupSessionIds: string[] - candidateLimit: number - passPrivateLimit: number - }): Promise<MyFootprintData> => { - nativePasses += 1 - const nativeResult = await wcdbService.getMyFootprintStats({ - beginTimestamp: begin, - endTimestamp: normalizedEnd, - myWxid, - privateSessionIds: passOptions.passPrivateSessionIds, - groupSessionIds: passOptions.passGroupSessionIds, - mentionLimit: passOptions.candidateLimit, - privateLimit: passOptions.passPrivateLimit, - mentionMode: options?.mentionMode || 'text_at_me' - }) - if (!nativeResult.success || !nativeResult.data) { - throw new Error(nativeResult.error || '获取我的足迹统计失败') - } - const normalized = this.normalizeMyFootprintData(nativeResult.data) - return normalized - } - - const runGroupPasses = async (targetGroupSessionIds: string[]): Promise<{ raw: MyFootprintData | null; chunks: number }> => { - if (!Array.isArray(targetGroupSessionIds) || targetGroupSessionIds.length === 0) { - return { raw: null, chunks: 0 } - } - const singleGroupThresholdRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_SINGLE_GROUP_THRESHOLD || 40) - const singleGroupThreshold = Number.isFinite(singleGroupThresholdRaw) && singleGroupThresholdRaw >= 1 - ? Math.floor(singleGroupThresholdRaw) - : 40 - - let aggregated: MyFootprintData | null = null - let chunks = 0 - if (targetGroupSessionIds.length <= singleGroupThreshold) { - chunks = targetGroupSessionIds.length - for (const sessionId of targetGroupSessionIds) { - const chunkRaw = await runNativePass({ - label: `group-single:${sessionId}`, - passPrivateSessionIds: [], - passGroupSessionIds: [sessionId], - candidateLimit: candidateLimitUsed, - passPrivateLimit: 0 - }) - aggregated = aggregated - ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw) - : chunkRaw - } - } else { - const groupChunks = splitGroupSessionsForNative(targetGroupSessionIds) - chunks = groupChunks.length - for (const chunk of groupChunks) { - const chunkRaw = await runNativePass({ - label: `group-chunk:${chunk[0] || ''}..(${chunk.length})`, - passPrivateSessionIds: [], - passGroupSessionIds: chunk, - candidateLimit: candidateLimitUsed, - passPrivateLimit: 0 - }) - aggregated = aggregated - ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw) - : chunkRaw - } - } - return { raw: aggregated, chunks } - } - - const splitGroupSessionsForNative = (sessionIds: string[]): string[][] => { - const normalized = Array.from(new Set( - (sessionIds || []) - .map((value) => String(value || '').trim()) - .filter((value) => value.endsWith('@chatroom')) - )) - if (normalized.length === 0) return [] - - // 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。 - const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900) - const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512 - ? Math.floor(maxBytesRaw) - : 900 - const estimateBytes = (groups: string[]): number => Buffer.byteLength(JSON.stringify({ - begin, - end: normalizedEnd, - my_wxid: myWxid, - private_session_ids: [], - group_session_ids: groups, - mention_limit: candidateLimitUsed, - private_limit: 0, - mention_mode: options?.mentionMode || 'text_at_me' - }), 'utf8') - - const chunks: string[][] = [] - let current: string[] = [] - for (const sessionId of normalized) { - if (current.length === 0) { - current.push(sessionId) - continue - } - const next = [...current, sessionId] - if (estimateBytes(next) > maxBytes) { - chunks.push(current) - current = [sessionId] - } else { - current = next - } - } - if (current.length > 0) chunks.push(current) - return chunks - } - - let privateNativeRaw: MyFootprintData | null = null - let mentionNativeRaw: MyFootprintData | null = null - - if (privateSessionIds.length > 0) { - privateNativeRaw = await runNativePass({ - label: 'private', - passPrivateSessionIds: privateSessionIds, - passGroupSessionIds: [], - candidateLimit: 0, - passPrivateLimit: effectivePrivateLimit - }) - } - - if (groupSessionIds.length > 0) { - const firstPass = await runGroupPasses(groupSessionIds) - mentionNativeRaw = firstPass.raw - nativeGroupChunks = firstPass.chunks - - if ((mentionNativeRaw?.mentions.length || 0) === 0) { - const probeIndexes = Array.from(new Set([ - 0, - Math.floor(groupSessionIds.length / 2), - groupSessionIds.length - 1 - ])).filter((index) => index >= 0 && index < groupSessionIds.length) - let probeHit = false - for (const index of probeIndexes) { - const sessionId = groupSessionIds[index] - const probeRaw = await runNativePass({ - label: `group-probe:${sessionId}`, - passPrivateSessionIds: [], - passGroupSessionIds: [sessionId], - candidateLimit: candidateLimitUsed, - passPrivateLimit: 0 - }) - if (probeRaw.mentions.length > 0 || probeRaw.summary.mention_count > 0) { - probeHit = true - break - } - } - - if (probeHit) { - await wcdbService.getSessions().catch(() => ({ success: false })) - const retryPass = await runGroupPasses(groupSessionIds) - mentionNativeRaw = retryPass.raw - nativeGroupChunks = retryPass.chunks - } - } - } - - let nativeRaw = privateNativeRaw || mentionNativeRaw || this.normalizeMyFootprintData({}) - if (privateNativeRaw && mentionNativeRaw) { - nativeRaw = this.mergeMyFootprintMentionResult(privateNativeRaw, mentionNativeRaw) - } - - data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit) - - if (data.private_sessions.length > 0) { - const sessionsWithMessages = data.private_sessions.map(s => s.session_id) - const privateSegments = await this.rebuildMyFootprintPrivateSegments({ - begin, - end: normalizedEnd, - myWxid, - privateSessionIds: sessionsWithMessages - }) - if (privateSegments.length > 0) { - data = { - ...data, - private_segments: privateSegments - } - } - } - - if (data.mentions.length === 0) { - if (this.shouldRunMyFootprintHeavyDebug()) { - const privatePassRawMentions = privateNativeRaw?.mentions.length || 0 - const mentionPassRawMentions = mentionNativeRaw?.mentions.length || 0 - console.warn( - `[MyFootprint][diag] zero filtered mentions begin=${begin} end=${normalizedEnd} groups=${groupSessionIds.length} raw=${nativeRaw.mentions.length} splitRaw(private=${privatePassRawMentions},group=${mentionPassRawMentions}) passes=${nativePasses} groupChunks=${nativeGroupChunks}` - ) - await this.printMyFootprintNativeLogs('zero_filtered_mentions') - await this.logMyFootprintNativeQuickProbe({ - begin, - end: normalizedEnd, - myWxid, - groupSessionIds, - mentionMode: options?.mentionMode || 'text_at_me' - }) - await this.logMyFootprintZeroMentionDebug({ - begin, - end: normalizedEnd, - myWxid, - groupSessionIds, - nativeData: nativeRaw - }) - } - } - - const enriched = await this.enrichMyFootprintData(data) - return { success: true, data: enriched } - } catch (error) { - console.error('[ChatService] 获取我的足迹统计失败:', error) - return { success: false, error: String(error) } - } - } - - private async logMyFootprintNativeQuickProbe(params: { - begin: number - end: number - myWxid: string - groupSessionIds: string[] - mentionMode: string - }): Promise<void> { - try { - const groups = Array.from(new Set( - (params.groupSessionIds || []) - .map((value) => String(value || '').trim()) - .filter((value) => value.endsWith('@chatroom')) - )) - if (groups.length === 0) { - console.warn('[MyFootprint][native-quick] skipped: no groups') - return - } - const indices = Array.from(new Set([ - 0, - Math.floor(groups.length / 2), - groups.length - 1 - ])).filter((index) => index >= 0 && index < groups.length) - - for (const index of indices) { - const sessionId = groups[index] - const result = await wcdbService.getMyFootprintStats({ - beginTimestamp: params.begin, - endTimestamp: params.end, - myWxid: params.myWxid, - privateSessionIds: [], - groupSessionIds: [sessionId], - mentionLimit: 0, - privateLimit: 0, - mentionMode: params.mentionMode - }) - if (!result.success || !result.data) { - console.warn( - `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] fail err=${result.error || 'unknown'}` - ) - continue - } - const raw = this.normalizeMyFootprintData(result.data) - console.warn( - `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] mentions=${raw.mentions.length} mentionGroups=${raw.mention_groups.length} summaryMention=${raw.summary.mention_count} diagScanned=${raw.diagnostics.scanned_dbs} diagElapsed=${raw.diagnostics.elapsed_ms}` - ) - } - } catch (error) { - console.warn('[MyFootprint][native-quick] exception:', error) - } - } - - private async rebuildMyFootprintPrivateSegments(params: { - begin: number - end: number - myWxid: string - privateSessionIds: string[] - }): Promise<MyFootprintPrivateSegment[]> { - const sessionGapSeconds = 5 * 60 - const segments: MyFootprintPrivateSegment[] = [] - - type WorkingSegment = { - segment_index: number - start_ts: number - end_ts: number - incoming_count: number - outgoing_count: number - first_incoming_ts: number - first_reply_ts: number - anchor_local_id: number - anchor_create_time: number - latest_local_id: number - latest_create_time: number - } - - for (const sessionId of params.privateSessionIds) { - const cursorResult = await wcdbService.openMessageCursor( - sessionId, - 360, - true, - 0, - 0 - ) - if (!cursorResult.success || !cursorResult.cursor) { - console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`) - continue - } - - let segmentCursor = 0 - let active: WorkingSegment | null = null - let lastMessageTs = 0 - const commit = () => { - if (!active) return - const startTs = active.start_ts > 0 ? active.start_ts : active.anchor_create_time - const endTs = active.end_ts > 0 ? active.end_ts : startTs - const incoming = Math.max(0, active.incoming_count) - const outgoing = Math.max(0, active.outgoing_count) - const messageCount = incoming + outgoing - if (startTs > 0 && messageCount > 0) { - segments.push({ - session_id: sessionId, - segment_index: active.segment_index, - start_ts: startTs, - end_ts: endTs, - duration_sec: Math.max(0, endTs - startTs), - incoming_count: incoming, - outgoing_count: outgoing, - message_count: messageCount, - replied: incoming > 0 && outgoing > 0, - first_incoming_ts: active.first_incoming_ts, - first_reply_ts: active.first_reply_ts, - latest_ts: endTs, - anchor_local_id: active.anchor_local_id, - anchor_create_time: startTs - }) - } - active = null - } - - let hasMore = true - let batchCount = 0 - let totalMessages = 0 - try { - while (hasMore) { - const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) - batchCount++ - if (!batchResult.success || !Array.isArray(batchResult.rows)) break - hasMore = Boolean(batchResult.hasMore) - totalMessages += batchResult.rows.length - - for (const row of batchResult.rows as Array<Record<string, any>>) { - const createTime = this.toSafeInt(row.create_time, 0) - const localId = this.toSafeInt(row.local_id, 0) - const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) - - // 过滤时间范围外的消息 - if (createTime > 0 && (createTime < params.begin || createTime > params.end)) { - continue - } - - if (createTime > 0) { - const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0) - const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0 - const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds) - if (needNew) { - commit() - segmentCursor += 1 - active = { - segment_index: segmentCursor, - start_ts: createTime, - end_ts: createTime, - incoming_count: 0, - outgoing_count: 0, - first_incoming_ts: 0, - first_reply_ts: 0, - anchor_local_id: localId, - anchor_create_time: createTime, - latest_local_id: localId, - latest_create_time: createTime - } - } - } else if (!active) { - segmentCursor += 1 - active = { - segment_index: segmentCursor, - start_ts: 0, - end_ts: 0, - incoming_count: 0, - outgoing_count: 0, - first_incoming_ts: 0, - first_reply_ts: 0, - anchor_local_id: localId, - anchor_create_time: 0, - latest_local_id: localId, - latest_create_time: 0 - } - } - - if (isSend) { - if (active) { - active.outgoing_count += 1 - if ( - createTime > 0 - && active.first_incoming_ts > 0 - && createTime >= active.first_incoming_ts - && active.first_reply_ts <= 0 - ) { - active.first_reply_ts = createTime - } - } - } else if (active) { - active.incoming_count += 1 - if (active.first_incoming_ts <= 0 || (createTime > 0 && createTime < active.first_incoming_ts)) { - active.first_incoming_ts = createTime - } - } - - if (active && createTime > 0) { - active.end_ts = createTime - active.latest_create_time = createTime - active.latest_local_id = localId - lastMessageTs = createTime - } - } - } - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) - } - - commit() - } - - return segments.sort((a, b) => { - if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts - if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id) - return a.segment_index - b.segment_index - }) - } - - async exportMyFootprint( - beginTimestamp: number, - endTimestamp: number, - format: 'csv' | 'json', - filePath: string - ): Promise<{ success: boolean; filePath?: string; error?: string }> { - try { - const normalizedFormat = String(format || '').toLowerCase() === 'csv' ? 'csv' : 'json' - const targetPath = String(filePath || '').trim() - if (!targetPath) { - return { success: false, error: '导出路径不能为空' } - } - - const statsResult = await this.getMyFootprintStats(beginTimestamp, endTimestamp) - if (!statsResult.success || !statsResult.data) { - return { success: false, error: statsResult.error || '导出前获取统计失败' } - } - - mkdirSync(dirname(targetPath), { recursive: true }) - if (normalizedFormat === 'json') { - writeFileSync(targetPath, JSON.stringify(statsResult.data, null, 2), 'utf-8') - } else { - const csv = this.buildMyFootprintCsv(statsResult.data) - writeFileSync(targetPath, `\uFEFF${csv}`, 'utf-8') - } - - return { success: true, filePath: targetPath } - } catch (error) { - console.error('[ChatService] 导出我的足迹失败:', error) - return { success: false, error: String(error) } - } - } - - async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { - try { - const nativeResult = await wcdbService.getMessageById(sessionId, localId) - if (nativeResult.success && nativeResult.message) { - const message = await this.parseMessage(nativeResult.message as Record<string, any>, { source: 'detail', sessionId }) - if (message.localId !== 0) return { success: true, message } - } - return { success: false, error: nativeResult.error || '未找到消息' } - } catch (e) { - console.error('ChatService: getMessageById 失败:', e) - return { success: false, error: String(e) } - } - } - - async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> { - try { - const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) - if (!result.success || !result.messages) { - return { success: false, error: result.error || '搜索失败' } - } - const messages: Message[] = [] - const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom')) - - for (const row of result.messages) { - let message = await this.parseMessage(row, { source: 'search', sessionId }) - const resolvedSessionId = String(sessionId || row._session_id || '').trim() - const needsDetailHydration = isGroupSearch && - Boolean(sessionId) && - message.localId > 0 && - (!message.senderUsername || message.isSend === null) - - if (needsDetailHydration && sessionId) { - const detail = await this.getMessageById(sessionId, message.localId) - if (detail.success && detail.message) { - message = { - ...message, - ...detail.message, - parsedContent: message.parsedContent || detail.message.parsedContent, - rawContent: message.rawContent || detail.message.rawContent, - content: message.content || detail.message.content - } - } - } - - if (resolvedSessionId) { - ;(message as Message & { sessionId?: string }).sessionId = resolvedSessionId - } - messages.push(message) - } - - return { success: true, messages } - } catch (e) { - console.error('ChatService: searchMessages 失败:', e) - return { success: false, error: String(e) } - } - } - - private normalizeTimestampSeconds(value: number): number { - const numeric = Number(value || 0) - if (!Number.isFinite(numeric) || numeric <= 0) return 0 - let normalized = Math.floor(numeric) - while (normalized > 10000000000) { - normalized = Math.floor(normalized / 1000) - } - return normalized - } - - private toSafeInt(value: unknown, fallback = 0): number { - const parsed = Number.parseInt(String(value ?? '').trim(), 10) - return Number.isFinite(parsed) ? parsed : fallback - } - - private toSafeNumber(value: unknown, fallback = 0): number { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback - } - - private resolveFootprintRowIsSend(row: Record<string, any>, myWxid: string): boolean { - const raw = row.computed_is_send ?? row.is_send - if (raw === 1 || raw === '1' || raw === true || raw === 'true') return true - if (raw === 0 || raw === '0' || raw === false || raw === 'false') return false - const senderUsername = String(row.sender_username || row.senderUsername || '').trim() - return Boolean(senderUsername && myWxid && senderUsername === myWxid) - } - - private splitAtUserList(raw: string): string[] { - const tokens = String(raw || '') - .split(/[,\s;|]+/g) - .map((token) => token.trim().replace(/^@+/, '').replace(/^["']+|["']+$/g, '')) - .filter(Boolean) - return Array.from(new Set(tokens)) - } - - private containsAtSign(text: string): boolean { - if (!text) return false - return text.includes('@') || text.includes('@') - } - - private footprintMessageLikelyContainsAt(rawContent: unknown): boolean { - if (rawContent === null || rawContent === undefined) return false - const text = typeof rawContent === 'string' ? rawContent : String(rawContent || '') - return this.containsAtSign(text) - } - - private matchesMyFootprintIdentity(rawToken: string, identitySet: Set<string>): boolean { - const token = String(rawToken || '').trim().replace(/^@+/, '') - if (!token) return false - - const normalizedCandidates = new Set<string>() - const addCandidate = (value: string) => { - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) return - normalizedCandidates.add(normalized) - } - - addCandidate(token) - addCandidate(token.replace(/@chatroom$/i, '')) - addCandidate(token.replace(/@openim$/i, '')) - - for (const candidate of normalizedCandidates) { - if (!candidate) continue - for (const selfId of identitySet) { - if (!selfId) continue - if (candidate === selfId) return true - if (candidate.startsWith(`${selfId}_`) || selfId.startsWith(`${candidate}_`)) return true - } - } - return false - } - - private buildMyFootprintIdentitySet(myWxid: string): Set<string> { - const set = new Set<string>() - const add = (value: string) => { - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) return - set.add(normalized) - } - - const raw = String(myWxid || '').trim() - add(raw) - add(this.cleanAccountDirName(raw)) - for (const key of this.buildIdentityKeys(raw)) { - add(key) - } - return set - } - - private buildFootprintSourceCandidates(source: unknown): string[] { - const sourceCandidates: string[] = [] - const seen = new Set<string>() - const pushCandidate = (value: unknown) => { - const normalized = this.cleanUtf16(String(value || '').trim()) - if (!normalized) return - if (seen.has(normalized)) return - seen.add(normalized) - sourceCandidates.push(normalized) - } - - const rawSource = typeof source === 'string' - ? source - : Buffer.isBuffer(source) || source instanceof Uint8Array - ? Buffer.from(source).toString('utf-8') - : typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data) - ? Buffer.from((source as { data: number[] }).data).toString('utf-8') - : String(source || '') - const normalizedSource = String(rawSource || '').trim() - pushCandidate(normalizedSource) - if (normalizedSource.includes('&')) { - pushCandidate(this.decodeHtmlEntities(normalizedSource)) - } - - const sourceLooksEncoded = normalizedSource.length > 16 - && (this.looksLikeHex(normalizedSource) || this.looksLikeBase64(normalizedSource)) - if (sourceLooksEncoded) { - const decodedFromText = this.decodeMaybeCompressed(normalizedSource, 'footprint_source') - pushCandidate(decodedFromText) - if (decodedFromText.includes('&')) { - pushCandidate(this.decodeHtmlEntities(decodedFromText)) - } - } else if (typeof source !== 'string') { - const decodedFromBinary = this.decodeMaybeCompressed(source, 'footprint_source') - pushCandidate(decodedFromBinary) - if (decodedFromBinary.includes('&')) { - pushCandidate(this.decodeHtmlEntities(decodedFromBinary)) - } - } - - return sourceCandidates - } - - private normalizeFootprintSourceForOutput(source: unknown): string { - if (source === null || source === undefined) return '' - if (typeof source === 'string') return source.trim() - if (Buffer.isBuffer(source) || source instanceof Uint8Array) { - return this.decodeBinaryContent(Buffer.from(source), '').trim() - } - if (typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data)) { - return this.decodeBinaryContent(Buffer.from((source as { data: number[] }).data), '').trim() - } - return String(source || '').trim() - } - - private extractAtUserListTokensFromSource(source: unknown, prebuiltCandidates?: string[]): string[] { - const tokens = new Set<string>() - const sourceCandidates = Array.isArray(prebuiltCandidates) && prebuiltCandidates.length > 0 - ? prebuiltCandidates - : this.buildFootprintSourceCandidates(source) - const addTokens = (values: string[]) => { - for (const value of values) { - const normalized = String(value || '').trim() - if (!normalized) continue - tokens.add(normalized) - } - } - - const xmlPattern = /<atuserlist[^>]*>([\s\S]*?)<\/atuserlist>/gi - const cdataPattern = /<!\[CDATA\[([\s\S]*?)\]\]>/i - for (const candidateSource of sourceCandidates) { - if (!candidateSource.toLowerCase().includes('atuserlist')) continue - - const trimmedCandidateSource = candidateSource.trim() - const maybeJson = trimmedCandidateSource.startsWith('{') - || trimmedCandidateSource.startsWith('[') - || trimmedCandidateSource.includes('"atuserlist"') - if (maybeJson) { - try { - const parsed = JSON.parse(candidateSource) - const atUserList = parsed?.atuserlist - if (Array.isArray(atUserList)) { - const values = atUserList - .map((item: unknown) => this.splitAtUserList(String(item || ''))) - .flat() - addTokens(values) - } - if (typeof atUserList === 'string') { - addTokens(this.splitAtUserList(atUserList)) - } - } catch { - // ignore JSON parse error and continue fallback parsing - } - } - - const jsonMatch = candidateSource.match(/"atuserlist"\s*:\s*(\[[^\]]*\]|"[^"]*"|'[^']*'|[^,}\s]+)/i) - if (jsonMatch) { - const jsonCandidate = String(jsonMatch[1] || '').trim() - if (jsonCandidate.startsWith('[')) { - try { - const arr = JSON.parse(jsonCandidate) - if (Array.isArray(arr)) { - const values = arr - .map((item) => this.splitAtUserList(String(item || ''))) - .flat() - addTokens(values) - } - } catch { - // ignore array parse error - } - } - const unquoted = jsonCandidate.replace(/^["']+|["']+$/g, '') - addTokens(this.splitAtUserList(unquoted)) - } - - xmlPattern.lastIndex = 0 - let xmlMatch: RegExpExecArray | null - while ((xmlMatch = xmlPattern.exec(candidateSource)) !== null) { - let xmlValue = String(xmlMatch[1] || '') - const cdataMatch = xmlValue.match(cdataPattern) - if (cdataMatch?.[1]) { - xmlValue = cdataMatch[1] - } - addTokens(this.splitAtUserList(xmlValue)) - } - } - - return Array.from(tokens) - } - - private sourceAtUserListContains(source: unknown, myWxid: string): boolean { - const selfIdentitySet = this.buildMyFootprintIdentitySet(myWxid) - return this.sourceAtUserListContainsWithIdentitySet(source, selfIdentitySet) - } - - private sourceAtUserListContainsWithIdentitySet(source: unknown, selfIdentitySet: Set<string>): boolean { - if (selfIdentitySet.size === 0) return false - if (typeof source === 'string') { - const raw = source.trim() - if (!raw) return false - const loweredRaw = raw.toLowerCase() - if (loweredRaw.includes('atuserlist')) { - for (const identity of selfIdentitySet) { - if (identity && loweredRaw.includes(identity)) { - return true - } - } - const quickXmlMatch = raw.match(/<atuserlist[^>]*>([\s\S]*?)<\/atuserlist>/i) - if (quickXmlMatch?.[1]) { - const inner = quickXmlMatch[1] - const cdata = inner.match(/<!\[CDATA\[([\s\S]*?)\]\]>/i)?.[1] || inner - const quickTokens = this.splitAtUserList(cdata) - if (quickTokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet))) { - return true - } - } - } else if (raw.length <= 16 || (!this.looksLikeHex(raw) && !this.looksLikeBase64(raw))) { - return false - } - } - const sourceCandidates = this.buildFootprintSourceCandidates(source) - for (const candidate of sourceCandidates) { - const normalized = String(candidate || '').toLowerCase() - if (!normalized || !normalized.includes('atuserlist')) continue - for (const identity of selfIdentitySet) { - if (identity && normalized.includes(identity)) { - return true - } - } - } - const tokens = this.extractAtUserListTokensFromSource(source, sourceCandidates) - if (tokens.length === 0) return false - return tokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet)) - } - - private async resolveMyFootprintGroupSessionIds( - groupSessionIds: string[], - beginTimestamp = 0, - endTimestamp = 0 - ): Promise<string[]> { - const normalized = Array.from(new Set( - (groupSessionIds || []) - .map((value) => String(value || '').trim()) - .filter((value) => value.endsWith('@chatroom')) - )) - const begin = this.normalizeTimestampSeconds(beginTimestamp) - const end = this.normalizeTimestampSeconds(endTimestamp) - void begin - void end - - const merged: string[] = [] - const seen = new Set<string>() - const sessionLastTsMap = new Map<string, number>() - const hasSessionRank = new Set<string>() - const shouldKeepByLastTs = (sessionId: string, preferKeepUnknown: boolean): boolean => { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return false - const lastTs = this.normalizeTimestampSeconds(sessionLastTsMap.get(normalizedSessionId) || 0) - const known = hasSessionRank.has(normalizedSessionId) - if (!known) return preferKeepUnknown || begin <= 0 - if (begin > 0 && lastTs > 0 && lastTs < begin) return false - return true - } - const push = (value: string) => { - const normalizedValue = String(value || '').trim() - if (!normalizedValue || !normalizedValue.endsWith('@chatroom')) return - if (seen.has(normalizedValue)) return - seen.add(normalizedValue) - merged.push(normalizedValue) - } - - try { - const sessionsResult = await this.getSessions() - if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { - const rankedGroups = sessionsResult.sessions - .map((session) => { - const sessionId = String(session?.username || '').trim() - const lastTs = this.normalizeTimestampSeconds( - Number(session?.lastTimestamp || session?.sortTimestamp || 0) - ) - if (sessionId.endsWith('@chatroom')) { - hasSessionRank.add(sessionId) - sessionLastTsMap.set(sessionId, lastTs) - } - return { sessionId, lastTs } - }) - .filter((item) => item.sessionId.endsWith('@chatroom')) - .filter((item) => shouldKeepByLastTs(item.sessionId, false)) - .sort((a, b) => { - if (a.lastTs !== b.lastTs) return b.lastTs - a.lastTs - return a.sessionId.localeCompare(b.sessionId) - }) - for (const item of rankedGroups) { - push(item.sessionId) - } - } - } catch { - // ignore session-based scope resolution failure - } - - try { - const contactGroups = await this.listMyFootprintGroupSessionIdsFromContact() - for (const sessionId of contactGroups) { - if (!shouldKeepByLastTs(sessionId, false)) continue - push(sessionId) - } - } catch { - // ignore contact-based scope resolution failure - } - - for (const sessionId of normalized) { - if (!shouldKeepByLastTs(sessionId, true)) continue - push(sessionId) - } - - return merged.length > 0 ? merged : normalized - } - - private async listMyFootprintGroupSessionIdsFromContact(): Promise<string[]> { - try { - const result = await wcdbService.execQuery( - 'contact', - null, - "SELECT username FROM contact WHERE username IS NOT NULL AND username != '' AND username LIKE '%@chatroom'" - ) - if (!result.success || !Array.isArray(result.rows)) { - return [] - } - - return Array.from(new Set( - (result.rows as Array<Record<string, any>>) - .map((row) => String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim()) - .filter((value) => value.endsWith('@chatroom')) - )) - } catch { - return [] - } - } - - private async filterMyFootprintPrivateSessions(privateSessionIds: string[]): Promise<string[]> { - const normalized = Array.from(new Set( - (privateSessionIds || []) - .map((value) => String(value || '').trim()) - .filter((value) => value && !value.endsWith('@chatroom')) - )) - if (normalized.length === 0) return normalized - - try { - const officialSessionIds = await this.getMyFootprintOfficialSessionIdSet(normalized) - if (officialSessionIds.size === 0) return normalized - return normalized.filter((sessionId) => !officialSessionIds.has(sessionId)) - } catch { - return normalized - } - } - - private async getMyFootprintOfficialSessionIdSet(privateSessionIds: string[]): Promise<Set<string>> { - const officialSessionIds = new Set<string>() - const normalized = Array.from(new Set( - (privateSessionIds || []) - .map((value) => String(value || '').trim()) - .filter((value) => value && !value.endsWith('@chatroom')) - )) - if (normalized.length === 0) return officialSessionIds - - for (const sessionId of normalized) { - if (sessionId.startsWith('gh_')) { - officialSessionIds.add(sessionId) - } - } - - const chunkSize = 320 - const buildInListSql = (values: string[]) => values - .map((value) => `'${this.escapeSqlString(value)}'`) - .join(',') - - try { - const bizInfoTableResult = await wcdbService.execQuery( - 'contact', - null, - "SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='biz_info' LIMIT 1" - ) - const bizInfoTableName = bizInfoTableResult.success && Array.isArray(bizInfoTableResult.rows) - ? String((bizInfoTableResult.rows[0] as Record<string, any> | undefined)?.name || '').trim() - : '' - if (bizInfoTableName) { - const tableSqlName = this.quoteSqlIdentifier(bizInfoTableName) - for (let index = 0; index < normalized.length; index += chunkSize) { - const batch = normalized.slice(index, index + chunkSize) - if (batch.length === 0) continue - const inListSql = buildInListSql(batch) - const sql = `SELECT username FROM ${tableSqlName} WHERE username IN (${inListSql})` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !Array.isArray(result.rows)) continue - for (const row of result.rows as Array<Record<string, any>>) { - const username = String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim() - if (username) officialSessionIds.add(username) - } - } - } - } catch { - // ignore biz_info lookup failure - } - - try { - const tableInfo = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)') - if (tableInfo.success && Array.isArray(tableInfo.rows)) { - const availableColumns = new Map<string, string>() - for (const row of tableInfo.rows as Array<Record<string, any>>) { - const rawName = row.name ?? row.column_name ?? row.columnName - const name = String(rawName || '').trim() - if (!name) continue - availableColumns.set(name.toLowerCase(), name) - } - - const pickColumn = (candidates: string[]): string | null => { - for (const candidate of candidates) { - const actual = availableColumns.get(candidate.toLowerCase()) - if (actual) return actual - } - return null - } - - const usernameColumn = pickColumn(['username', 'user_name', 'userName']) - const officialFlagColumns = [ - pickColumn(['verify_flag', 'verifyFlag', 'verifyflag']), - pickColumn(['verify_status', 'verifyStatus']), - pickColumn(['verify_type', 'verifyType']), - pickColumn(['biz_type', 'bizType']), - pickColumn(['brand_flag', 'brandFlag']), - pickColumn(['service_type', 'serviceType']) - ].filter((column): column is string => Boolean(column)) - - if (usernameColumn && officialFlagColumns.length > 0) { - const selectColumns = Array.from(new Set([usernameColumn, ...officialFlagColumns])) - const selectSql = selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ') - for (let index = 0; index < normalized.length; index += chunkSize) { - const batch = normalized.slice(index, index + chunkSize) - if (batch.length === 0) continue - const inListSql = buildInListSql(batch) - const sql = `SELECT ${selectSql} FROM contact WHERE ${this.quoteSqlIdentifier(usernameColumn)} IN (${inListSql})` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !Array.isArray(result.rows)) continue - for (const row of result.rows as Array<Record<string, any>>) { - const username = String(this.getRowField(row, [usernameColumn, 'username', 'user_name', 'userName']) || '').trim() - if (!username) continue - const hasOfficialFlag = officialFlagColumns.some((column) => ( - this.isTruthyMyFootprintOfficialFlag(this.getRowField(row, [column])) - )) - if (hasOfficialFlag) { - officialSessionIds.add(username) - } - } - } - } - } - } catch { - // ignore contact-flag lookup failure - } - - return officialSessionIds - } - - private isTruthyMyFootprintOfficialFlag(value: unknown): boolean { - if (value === null || value === undefined) return false - if (typeof value === 'boolean') return value - if (typeof value === 'number') return Number.isFinite(value) && value > 0 - - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) return false - if (normalized === '0' || normalized === 'false' || normalized === 'null' || normalized === 'undefined') { - return false - } - - const numeric = Number(normalized) - if (Number.isFinite(numeric)) { - return numeric > 0 - } - return true - } - - private normalizeMyFootprintData(raw: any): MyFootprintData { - const summaryRaw = raw?.summary || {} - const privateSessionsRaw = Array.isArray(raw?.private_sessions) ? raw.private_sessions : [] - const privateSegmentsRaw = Array.isArray(raw?.private_segments) ? raw.private_segments : [] - const mentionsRaw = Array.isArray(raw?.mentions) ? raw.mentions : [] - const mentionGroupsRaw = Array.isArray(raw?.mention_groups) ? raw.mention_groups : [] - const diagnosticsRaw = raw?.diagnostics || {} - - const summary: MyFootprintSummary = { - private_inbound_people: this.toSafeInt(summaryRaw.private_inbound_people, 0), - private_replied_people: this.toSafeInt(summaryRaw.private_replied_people, 0), - private_outbound_people: this.toSafeInt(summaryRaw.private_outbound_people, 0), - private_reply_rate: this.toSafeNumber(summaryRaw.private_reply_rate, 0), - mention_count: this.toSafeInt(summaryRaw.mention_count, 0), - mention_group_count: this.toSafeInt(summaryRaw.mention_group_count, 0) - } - - const private_sessions: MyFootprintPrivateSession[] = privateSessionsRaw.map((item: any) => ({ - session_id: String(item?.session_id || '').trim(), - incoming_count: this.toSafeInt(item?.incoming_count, 0), - outgoing_count: this.toSafeInt(item?.outgoing_count, 0), - replied: Boolean(item?.replied), - first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0), - first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0), - latest_ts: this.toSafeInt(item?.latest_ts, 0), - anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), - anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0) - })).filter((item: MyFootprintPrivateSession) => item.session_id) - - const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({ - session_id: String(item?.session_id || '').trim(), - segment_index: this.toSafeInt(item?.segment_index, 0), - start_ts: this.toSafeInt(item?.start_ts, 0), - end_ts: this.toSafeInt(item?.end_ts, 0), - duration_sec: this.toSafeInt(item?.duration_sec, 0), - incoming_count: this.toSafeInt(item?.incoming_count, 0), - outgoing_count: this.toSafeInt(item?.outgoing_count, 0), - message_count: this.toSafeInt(item?.message_count, 0), - replied: Boolean(item?.replied), - first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0), - first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0), - latest_ts: this.toSafeInt(item?.latest_ts, 0), - anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), - anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0), - displayName: String(item?.displayName || '').trim() || undefined, - avatarUrl: String(item?.avatarUrl || '').trim() || undefined - })).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0) - - const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({ - session_id: String(item?.session_id || '').trim(), - local_id: this.toSafeInt(item?.local_id, 0), - create_time: this.toSafeInt(item?.create_time, 0), - sender_username: String(item?.sender_username || '').trim(), - message_content: String(item?.message_content || ''), - source: String(item?.source || '') - })).filter((item: MyFootprintMentionItem) => item.session_id) - - const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({ - session_id: String(item?.session_id || '').trim(), - count: this.toSafeInt(item?.count, 0), - latest_ts: this.toSafeInt(item?.latest_ts, 0) - })).filter((item: MyFootprintMentionGroup) => item.session_id) - - const diagnostics: MyFootprintDiagnostics = { - truncated: Boolean(diagnosticsRaw.truncated), - scanned_dbs: this.toSafeInt(diagnosticsRaw.scanned_dbs, 0), - elapsed_ms: this.toSafeInt(diagnosticsRaw.elapsed_ms, 0), - mention_truncated: Boolean(diagnosticsRaw.mention_truncated), - private_truncated: Boolean(diagnosticsRaw.private_truncated) - } - - return { - summary, - private_sessions, - private_segments, - mentions, - mention_groups, - diagnostics - } - } - - private filterMyFootprintMentionsBySource(data: MyFootprintData, myWxid: string, mentionLimit: number): MyFootprintData { - const identitySet = this.buildMyFootprintIdentitySet(myWxid) - if (identitySet.size === 0) { - return { - ...data, - summary: { - ...data.summary, - mention_count: 0, - mention_group_count: 0 - }, - mentions: [], - mention_groups: [] - } - } - - const sourceMatchCache = new Map<string, boolean>() - const filteredMentions = data.mentions.filter((item) => { - const sourceKey = String(item.source || '') - const cachedMatched = sourceMatchCache.get(sourceKey) - if (cachedMatched !== undefined) return cachedMatched - const matched = this.sourceAtUserListContainsWithIdentitySet(item.source, identitySet) - if (sourceMatchCache.size < 4096) { - sourceMatchCache.set(sourceKey, matched) - } - return matched - }) - .sort((a, b) => { - if (b.create_time !== a.create_time) return b.create_time - a.create_time - return b.local_id - a.local_id - }) - - let truncatedByFrontendLimit = false - if (mentionLimit > 0 && filteredMentions.length > mentionLimit) { - filteredMentions.length = mentionLimit - truncatedByFrontendLimit = true - } - - const mentionGroupMap = new Map<string, MyFootprintMentionGroup>() - for (const mention of filteredMentions) { - const group = mentionGroupMap.get(mention.session_id) || { - session_id: mention.session_id, - count: 0, - latest_ts: 0 - } - group.count += 1 - if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time - mentionGroupMap.set(mention.session_id, group) - } - - const filteredMentionGroups = Array.from(mentionGroupMap.values()) - .sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts - return a.session_id.localeCompare(b.session_id) - }) - - const nextSummary: MyFootprintSummary = { - ...data.summary, - mention_count: filteredMentions.length, - mention_group_count: filteredMentionGroups.length - } - - return { - ...data, - summary: nextSummary, - mentions: filteredMentions, - mention_groups: filteredMentionGroups, - diagnostics: { - ...data.diagnostics, - truncated: Boolean(data.diagnostics.truncated || truncatedByFrontendLimit) - } - } - } - - private mergeMyFootprintMentionResult(base: MyFootprintData, mentionResult: MyFootprintData): MyFootprintData { - const mentionMap = new Map<string, MyFootprintMentionItem>() - const pushMention = (item: MyFootprintMentionItem) => { - const key = `${item.session_id}#${item.local_id}#${item.create_time}` - mentionMap.set(key, item) - } - for (const item of base.mentions) pushMention(item) - for (const item of mentionResult.mentions) pushMention(item) - - const mergedMentions = Array.from(mentionMap.values()) - .sort((a, b) => { - if (b.create_time !== a.create_time) return b.create_time - a.create_time - return b.local_id - a.local_id - }) - - const mentionGroupMetaMap = new Map<string, Pick<MyFootprintMentionGroup, 'displayName' | 'avatarUrl'>>() - const pushGroupMeta = (group: MyFootprintMentionGroup) => { - const prev = mentionGroupMetaMap.get(group.session_id) || {} - mentionGroupMetaMap.set(group.session_id, { - displayName: group.displayName || prev.displayName, - avatarUrl: group.avatarUrl || prev.avatarUrl - }) - } - for (const group of base.mention_groups) pushGroupMeta(group) - for (const group of mentionResult.mention_groups) pushGroupMeta(group) - - const mentionGroupMap = new Map<string, MyFootprintMentionGroup>() - for (const mention of mergedMentions) { - const current = mentionGroupMap.get(mention.session_id) || { - session_id: mention.session_id, - count: 0, - latest_ts: 0 - } - current.count += 1 - if (mention.create_time > current.latest_ts) { - current.latest_ts = mention.create_time - } - mentionGroupMap.set(mention.session_id, current) - } - - const mergedMentionGroups = Array.from(mentionGroupMap.values()) - .map((group) => { - const meta = mentionGroupMetaMap.get(group.session_id) - return { - ...group, - displayName: meta?.displayName, - avatarUrl: meta?.avatarUrl - } - }) - .sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts - return a.session_id.localeCompare(b.session_id) - }) - - return { - ...base, - summary: { - ...base.summary, - mention_count: mergedMentions.length, - mention_group_count: mergedMentionGroups.length - }, - private_segments: mentionResult.private_segments.length > 0 - ? mentionResult.private_segments - : base.private_segments, - mentions: mergedMentions, - mention_groups: mergedMentionGroups, - diagnostics: { - ...base.diagnostics, - truncated: Boolean(base.diagnostics.truncated || mentionResult.diagnostics.truncated), - scanned_dbs: Math.max(base.diagnostics.scanned_dbs || 0, mentionResult.diagnostics.scanned_dbs || 0), - elapsed_ms: Math.max(base.diagnostics.elapsed_ms || 0, mentionResult.diagnostics.elapsed_ms || 0) - } - } - } - - private shouldRunMyFootprintHeavyDebug(): boolean { - const flag = String(process.env.WEFLOW_MY_FOOTPRINT_DEBUG || '').trim().toLowerCase() - return flag === '1' || flag === 'true' || flag === 'yes' || flag === 'on' - } - - private async logMyFootprintZeroMentionDebug(params: { - begin: number - end: number - myWxid: string - groupSessionIds: string[] - nativeData: MyFootprintData - }): Promise<void> { - try { - const identityKeySet = this.buildMyFootprintIdentitySet(params.myWxid) - const identitySet = Array.from(identityKeySet) - console.warn( - `[MyFootprint][debug] zero mentions: myWxid=${params.myWxid} identityKeys=${identitySet.join('|')} groups=${params.groupSessionIds.length} nativeMentions=${params.nativeData.mentions.length} nativeMentionGroups=${params.nativeData.mention_groups.length} scannedDbs=${params.nativeData.diagnostics.scanned_dbs}` - ) - - if (params.nativeData.mentions.length > 0) { - const samples = params.nativeData.mentions.slice(0, 5).map((item) => { - const tokens = this.extractAtUserListTokensFromSource(item.source) - const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet)) - return { - sessionId: item.session_id, - localId: item.local_id, - createTime: item.create_time, - tokens, - matched - } - }) - console.warn(`[MyFootprint][debug] native mention samples=${JSON.stringify(samples)}`) - } - - const allGroups = params.groupSessionIds - console.warn(`[MyFootprint][debug] start group scan: totalGroups=${allGroups.length}`) - let skippedNoTableGroups = 0 - let sqlProbeCount = 0 - let nativeSingleProbeCount = 0 - for (let index = 0; index < allGroups.length; index += 1) { - const sessionId = allGroups[index] - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - 120, - false, - params.begin, - params.end - ) - if (!cursorResult.success || !cursorResult.cursor) { - const openCursorError = String(cursorResult.error || 'unknown') - if (openCursorError.includes('-3')) { - skippedNoTableGroups += 1 - console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] skipped(no message table): ${openCursorError}`) - } else { - console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] open cursor failed: ${openCursorError}`) - } - continue - } - - let rows = 0 - let atContentRows = 0 - let sourcePresentRows = 0 - let atUserListRows = 0 - let matchedRows = 0 - const unmatchedSamples: Array<{ - localId: number - createTime: number - tokens: string[] - sourcePreview: string - }> = [] - - let hasMore = true - try { - while (hasMore && rows < 200) { - const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batchResult.success || !Array.isArray(batchResult.rows)) { - break - } - hasMore = Boolean(batchResult.hasMore) - for (const row of batchResult.rows as Array<Record<string, any>>) { - rows += 1 - if (rows > 200) break - - const messageContentRaw = row.message_content ?? row.messageContent ?? row.content - const hasAtInContent = this.footprintMessageLikelyContainsAt(messageContentRaw) - if (hasAtInContent) atContentRows += 1 - - const sourceRaw = row.source ?? row.msg_source ?? row.message_source - if (sourceRaw !== null && sourceRaw !== undefined && String(sourceRaw).trim().length > 0) { - sourcePresentRows += 1 - } - if (!hasAtInContent) continue - - const tokens = this.extractAtUserListTokensFromSource(sourceRaw) - if (tokens.length > 0) atUserListRows += 1 - const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet)) - if (matched) { - matchedRows += 1 - } else if (tokens.length > 0 && unmatchedSamples.length < 3) { - const sourceDecoded = this.decodeMaybeCompressed(sourceRaw, 'footprint_source') || String(sourceRaw || '') - unmatchedSamples.push({ - localId: this.toSafeInt(row.local_id, 0), - createTime: this.toSafeInt(row.create_time, 0), - tokens, - sourcePreview: sourceDecoded.replace(/\s+/g, ' ').slice(0, 260) - }) - } - } - } - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) - } - - console.warn( - `[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] rows=${rows} atContentRows=${atContentRows} sourcePresentRows=${sourcePresentRows} atUserListRows=${atUserListRows} matchedRows=${matchedRows}` - ) - if (unmatchedSamples.length > 0) { - console.warn(`[MyFootprint][debug][${sessionId}] unmatchedSamples=${JSON.stringify(unmatchedSamples)}`) - } - - if ((matchedRows > 0 || atContentRows > 0 || atUserListRows > 0) && sqlProbeCount < 6) { - sqlProbeCount += 1 - await this.logMyFootprintNativeSqlProbe(sessionId, params.begin, params.end) - } - if (matchedRows > 0 && nativeSingleProbeCount < 4) { - nativeSingleProbeCount += 1 - await this.logMyFootprintNativeSingleGroupProbe(sessionId, params.begin, params.end, params.myWxid) - } - } - if (skippedNoTableGroups > 0) { - console.warn(`[MyFootprint][debug] skippedNoTableGroups=${skippedNoTableGroups}/${allGroups.length}`) - } - } catch (error) { - console.warn('[MyFootprint][debug] zero mention diagnostics failed:', error) - } - } - - private async printMyFootprintNativeLogs(tag: string): Promise<void> { - try { - const logsResult = await wcdbService.getLogs() - if (!logsResult.success || !Array.isArray(logsResult.logs)) { - console.warn(`[MyFootprint][native-log][${tag}] getLogs failed: ${logsResult.error || 'unknown'}`) - return - } - - const logs = logsResult.logs - .map((line) => String(line || '').trim()) - .filter(Boolean) - const keywords = [ - 'wcdb_get_my_footprint_stats', - 'message_db_cache_refresh', - 'open_message_cursor', - 'open_message_cursor_lite', - 'cursor_init', - 'schema mismatch', - 'no message db', - 'get_sessions' - ] - const related = logs.filter((line) => { - const lowered = line.toLowerCase() - return keywords.some((keyword) => lowered.includes(keyword.toLowerCase())) - }) - - console.warn( - `[MyFootprint][native-log][${tag}] total=${logs.length} related=${related.length}` - ) - const tail = related.slice(-240) - for (const line of tail) { - console.warn(`[MyFootprint][native-log] ${line}`) - } - } catch (error) { - console.warn(`[MyFootprint][native-log][${tag}] exception:`, error) - } - } - - private async logMyFootprintNativeSqlProbe(sessionId: string, begin: number, end: number): Promise<void> { - try { - const tables = await this.getSessionMessageTables(sessionId) - if (!Array.isArray(tables) || tables.length === 0) { - console.warn(`[MyFootprint][sql-probe][${sessionId}] no tables`) - return - } - - const beginTs = this.normalizeTimestampSeconds(begin) - const endTs = this.normalizeTimestampSeconds(end) - const clauseTime = [ - beginTs > 0 ? `"create_time" >= ${beginTs}` : '', - endTs > 0 ? `"create_time" <= ${endTs}` : '' - ].filter(Boolean).join(' AND ') - const whereParts: string[] = [] - if (clauseTime) whereParts.push(clauseTime) - whereParts.push(`"source" IS NOT NULL`) - whereParts.push(`"source" != ''`) - whereParts.push(`(("message_content" IS NOT NULL AND "message_content" != '' AND (instr("message_content", '@') > 0 OR instr("message_content", '@') > 0)) OR instr(lower("source"), 'atuserlist') > 0)`) - const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(' AND ')}` : '' - - let total = 0 - for (const table of tables) { - const tableName = String(table.tableName || '').trim() - const dbPath = String(table.dbPath || '').trim() - if (!tableName || !dbPath) continue - const sql = `SELECT COUNT(1) AS cnt FROM ${this.quoteSqlIdentifier(tableName)}${whereSql}` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) { - console.warn(`[MyFootprint][sql-probe][${sessionId}] query failed db=${dbPath} table=${tableName} err=${result.error || 'unknown'}`) - continue - } - const cnt = this.toSafeInt((result.rows[0] as Record<string, any>).cnt, 0) - total += cnt - if (cnt > 0) { - console.warn(`[MyFootprint][sql-probe][${sessionId}] db=${dbPath} table=${tableName} cnt=${cnt}`) - } - } - console.warn(`[MyFootprint][sql-probe][${sessionId}] total=${total}`) - } catch (error) { - console.warn(`[MyFootprint][sql-probe][${sessionId}] exception:`, error) - } - } - - private async logMyFootprintNativeSingleGroupProbe(sessionId: string, begin: number, end: number, myWxid: string): Promise<void> { - try { - const probeResult = await wcdbService.getMyFootprintStats({ - beginTimestamp: begin, - endTimestamp: end, - myWxid, - privateSessionIds: [], - groupSessionIds: [sessionId], - mentionLimit: 0, - privateLimit: 0, - mentionMode: 'text_at_me' - }) - if (!probeResult.success || !probeResult.data) { - console.warn(`[MyFootprint][single-native][${sessionId}] failed err=${probeResult.error || 'unknown'}`) - return - } - - const raw = this.normalizeMyFootprintData(probeResult.data) - const first = raw.mentions[0] - console.warn( - `[MyFootprint][single-native][${sessionId}] mentions=${raw.mentions.length} groups=${raw.mention_groups.length} truncated=${raw.diagnostics.truncated} firstLocalId=${first?.local_id || 0} firstTs=${first?.create_time || 0}` - ) - } catch (error) { - console.warn(`[MyFootprint][single-native][${sessionId}] exception:`, error) - } - } - - private async getMyFootprintStatsByCursorFallback(params: { - begin: number - end: number - myWxid: string - privateSessionIds: string[] - groupSessionIds: string[] - mentionLimit: number - privateLimit: number - skipPrivateScan?: boolean - mentionScanLimitPerGroup?: number - }): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> { - const startedAt = Date.now() - let truncated = false - - try { - const privateSessionMap = new Map<string, MyFootprintPrivateSession>() - type PrivateSegmentWorking = { - segment_index: number - start_ts: number - end_ts: number - incoming_count: number - outgoing_count: number - first_incoming_ts: number - first_reply_ts: number - anchor_local_id: number - anchor_create_time: number - latest_local_id: number - latest_create_time: number - } - const privateSegments: MyFootprintPrivateSegment[] = [] - const mentionGroupsMap = new Map<string, MyFootprintMentionGroup>() - const mentions: MyFootprintMentionItem[] = [] - const mentionIdentitySet = this.buildMyFootprintIdentitySet(params.myWxid) - const mentionSourceMatchCache = new Map<string, boolean>() - const mentionScanLimit = Number.isFinite(params.mentionScanLimitPerGroup as number) - ? Math.max(60, Math.floor(Number(params.mentionScanLimitPerGroup))) - : Math.max(params.mentionLimit * 12, 4000) - const privateScanLimitPerSession = Math.max( - 120, - Math.min( - 600, - Math.floor((params.privateLimit * 2) / Math.max(params.privateSessionIds.length || 1, 1)) - ) - ) - const privateBatchSize = Math.min(200, privateScanLimitPerSession) - const privateSessionGapSeconds = 10 * 60 - const mentionBatchSize = 360 - const skipPrivateScan = params.skipPrivateScan === true - - if (!skipPrivateScan) for (const sessionId of params.privateSessionIds) { - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - privateBatchSize, - true, - params.begin, - params.end - ) - if (!cursorResult.success || !cursorResult.cursor) continue - - const stat: MyFootprintPrivateSession = { - session_id: sessionId, - incoming_count: 0, - outgoing_count: 0, - replied: false, - first_incoming_ts: 0, - first_reply_ts: 0, - latest_ts: 0, - anchor_local_id: 0, - anchor_create_time: 0 - } - let segmentCursor = 0 - let activeSegment: PrivateSegmentWorking | null = null - let lastSegmentMessageTs = 0 - const commitActiveSegment = () => { - if (!activeSegment) return - - const normalizedStart = activeSegment.start_ts > 0 ? activeSegment.start_ts : activeSegment.anchor_create_time - const normalizedEnd = activeSegment.end_ts > 0 ? activeSegment.end_ts : normalizedStart - const incomingCount = Math.max(0, activeSegment.incoming_count) - const outgoingCount = Math.max(0, activeSegment.outgoing_count) - const messageCount = incomingCount + outgoingCount - if (normalizedStart > 0 && messageCount > 0) { - privateSegments.push({ - session_id: sessionId, - segment_index: activeSegment.segment_index, - start_ts: normalizedStart, - end_ts: normalizedEnd, - duration_sec: Math.max(0, normalizedEnd - normalizedStart), - incoming_count: incomingCount, - outgoing_count: outgoingCount, - message_count: messageCount, - replied: incomingCount > 0 && outgoingCount > 0, - first_incoming_ts: activeSegment.first_incoming_ts, - first_reply_ts: activeSegment.first_reply_ts, - latest_ts: normalizedEnd, - anchor_local_id: activeSegment.anchor_local_id, - anchor_create_time: normalizedStart - }) - } - activeSegment = null - } - - let processed = 0 - let hasMore = true - try { - while (hasMore) { - const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batchResult.success || !Array.isArray(batchResult.rows)) { - break - } - hasMore = Boolean(batchResult.hasMore) - for (const row of batchResult.rows as Array<Record<string, any>>) { - if (processed >= privateScanLimitPerSession) { - if (hasMore || batchResult.rows.length > 0) truncated = true - hasMore = false - break - } - processed += 1 - - const createTime = this.toSafeInt(row.create_time, 0) - const localId = this.toSafeInt(row.local_id, 0) - const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) - - if (createTime > 0) { - const startNewSegment = !activeSegment - || (lastSegmentMessageTs > 0 && createTime - lastSegmentMessageTs > privateSessionGapSeconds) - if (startNewSegment) { - commitActiveSegment() - segmentCursor += 1 - activeSegment = { - segment_index: segmentCursor, - start_ts: createTime, - end_ts: createTime, - incoming_count: 0, - outgoing_count: 0, - first_incoming_ts: 0, - first_reply_ts: 0, - anchor_local_id: localId, - anchor_create_time: createTime, - latest_local_id: localId, - latest_create_time: createTime - } - } - } else if (!activeSegment) { - segmentCursor += 1 - activeSegment = { - segment_index: segmentCursor, - start_ts: 0, - end_ts: 0, - incoming_count: 0, - outgoing_count: 0, - first_incoming_ts: 0, - first_reply_ts: 0, - anchor_local_id: localId, - anchor_create_time: 0, - latest_local_id: localId, - latest_create_time: 0 - } - } - - if (isSend) { - stat.outgoing_count += 1 - if ( - createTime > 0 - && stat.first_incoming_ts > 0 - && createTime >= stat.first_incoming_ts - && stat.first_reply_ts <= 0 - ) { - stat.first_reply_ts = createTime - } - if (activeSegment) { - activeSegment.outgoing_count += 1 - if ( - createTime > 0 - && activeSegment.first_incoming_ts > 0 - && createTime >= activeSegment.first_incoming_ts - && activeSegment.first_reply_ts <= 0 - ) { - activeSegment.first_reply_ts = createTime - } - } - } else { - stat.incoming_count += 1 - if (stat.first_incoming_ts <= 0 || (createTime > 0 && createTime < stat.first_incoming_ts)) { - stat.first_incoming_ts = createTime - } - if (activeSegment) { - activeSegment.incoming_count += 1 - if (activeSegment.first_incoming_ts <= 0 || (createTime > 0 && createTime < activeSegment.first_incoming_ts)) { - activeSegment.first_incoming_ts = createTime - } - } - } - - if (stat.latest_ts <= 0 || createTime > stat.latest_ts || (createTime === stat.latest_ts && localId > stat.anchor_local_id)) { - stat.latest_ts = createTime - stat.anchor_local_id = localId - stat.anchor_create_time = createTime - } - - if (activeSegment && createTime > 0) { - activeSegment.end_ts = createTime - activeSegment.latest_create_time = createTime - activeSegment.latest_local_id = localId - lastSegmentMessageTs = createTime - } - } - } - if (hasMore) truncated = true - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) - } - commitActiveSegment() - stat.replied = stat.incoming_count > 0 && stat.outgoing_count > 0 - - if (stat.incoming_count > 0 || stat.outgoing_count > 0 || stat.latest_ts > 0) { - privateSessionMap.set(sessionId, stat) - } - } - - for (const sessionId of params.groupSessionIds) { - if (mentions.length >= params.mentionLimit) { - truncated = true - break - } - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - mentionBatchSize, - false, - params.begin, - params.end - ) - if (!cursorResult.success || !cursorResult.cursor) continue - - let scanned = 0 - let hasMore = true - try { - while (hasMore && scanned < mentionScanLimit) { - const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batchResult.success || !Array.isArray(batchResult.rows)) { - break - } - hasMore = Boolean(batchResult.hasMore) - for (const row of batchResult.rows as Array<Record<string, any>>) { - if (mentions.length >= params.mentionLimit) { - truncated = true - hasMore = false - break - } - scanned += 1 - const messageContentRaw = row.message_content ?? row.messageContent ?? row.content - if (!this.footprintMessageLikelyContainsAt(messageContentRaw)) continue - const sourceRaw = row.source ?? row.msg_source ?? row.message_source - let sourceMatched = false - if (typeof sourceRaw === 'string') { - const sourceKey = sourceRaw - const cachedMatched = mentionSourceMatchCache.get(sourceKey) - if (cachedMatched !== undefined) { - sourceMatched = cachedMatched - } else { - sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet) - if (mentionSourceMatchCache.size < 8192) { - mentionSourceMatchCache.set(sourceKey, sourceMatched) - } - } - } else { - sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet) - } - if (!sourceMatched) continue - const normalizedSource = this.normalizeFootprintSourceForOutput(sourceRaw) - - let senderUsername = String(row.sender_username || row.senderUsername || '').trim() - if (!senderUsername && row._db_path && row.real_sender_id) { - senderUsername = await this.resolveMessageSenderUsernameById( - String(row._db_path), - row.real_sender_id - ) || '' - } - - const mention: MyFootprintMentionItem = { - session_id: sessionId, - local_id: this.toSafeInt(row.local_id, 0), - create_time: this.toSafeInt(row.create_time, 0), - sender_username: senderUsername, - message_content: String(row.message_content || row.messageContent || ''), - source: normalizedSource - } - mentions.push(mention) - - const group = mentionGroupsMap.get(sessionId) || { - session_id: sessionId, - count: 0, - latest_ts: 0 - } - group.count += 1 - if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time - mentionGroupsMap.set(sessionId, group) - } - } - if (hasMore || scanned >= mentionScanLimit) { - truncated = true - } - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) - } - } - - mentions.sort((a, b) => { - if (b.create_time !== a.create_time) return b.create_time - a.create_time - return b.local_id - a.local_id - }) - if (mentions.length > params.mentionLimit) { - mentions.length = params.mentionLimit - truncated = true - } - - const private_sessions = Array.from(privateSessionMap.values()) - .sort((a, b) => { - if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts - return a.session_id.localeCompare(b.session_id) - }) - const private_segments = [...privateSegments] - .sort((a, b) => { - if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts - if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id) - return a.segment_index - b.segment_index - }) - const mention_groups = Array.from(mentionGroupsMap.values()) - .sort((a, b) => { - if (b.count !== a.count) return b.count - a.count - if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts - return a.session_id.localeCompare(b.session_id) - }) - - const private_inbound_people = private_sessions.filter((item) => item.incoming_count > 0).length - const private_replied_people = private_sessions.filter((item) => item.replied).length - const private_outbound_people = private_sessions.filter((item) => item.outgoing_count > 0).length - const mention_count = mention_groups.reduce((sum, item) => sum + item.count, 0) - const mention_group_count = mention_groups.length - - const summary: MyFootprintSummary = { - private_inbound_people, - private_replied_people, - private_outbound_people, - private_reply_rate: private_inbound_people > 0 ? private_replied_people / private_inbound_people : 0, - mention_count, - mention_group_count - } - - const diagnostics: MyFootprintDiagnostics = { - truncated, - scanned_dbs: 0, - elapsed_ms: Math.max(0, Date.now() - startedAt) - } - - return { - success: true, - data: { - summary, - private_sessions, - private_segments, - mentions, - mention_groups, - diagnostics - } - } - } catch (error) { - return { success: false, error: String(error) } - } - } - - private async enrichMyFootprintData(data: MyFootprintData): Promise<MyFootprintData> { - try { - const sessionIds = Array.from(new Set([ - ...data.private_sessions.map((item) => item.session_id), - ...data.private_segments.map((item) => item.session_id), - ...data.mention_groups.map((item) => item.session_id), - ...data.mentions.map((item) => item.session_id) - ].filter(Boolean))) - const senderUsernames = Array.from(new Set( - data.mentions - .map((item) => item.sender_username) - .filter((value) => String(value || '').trim()) - )) - - const usernames = Array.from(new Set([...sessionIds, ...senderUsernames])) - if (usernames.length === 0) return data - - const enrichResult = await this.enrichSessionsContactInfo(usernames) - if (!enrichResult.success || !enrichResult.contacts) return data - const contacts = enrichResult.contacts - - const nextPrivateSessions = data.private_sessions.map((item) => { - const contact = contacts[item.session_id] - return { - ...item, - displayName: contact?.displayName || item.displayName, - avatarUrl: contact?.avatarUrl || item.avatarUrl - } - }) - const nextPrivateSegments = data.private_segments.map((item) => { - const contact = contacts[item.session_id] - return { - ...item, - displayName: contact?.displayName || item.displayName, - avatarUrl: contact?.avatarUrl || item.avatarUrl - } - }) - - const nextMentionGroups = data.mention_groups.map((item) => { - const contact = contacts[item.session_id] - return { - ...item, - displayName: contact?.displayName || item.displayName, - avatarUrl: contact?.avatarUrl || item.avatarUrl - } - }) - - const nextMentions = await Promise.all(data.mentions.map(async (item) => { - const sessionContact = contacts[item.session_id] - const senderContact = item.sender_username ? contacts[item.sender_username] : undefined - - let normalizedContent = this.normalizeMyFootprintMentionContent(item.message_content) - if (this.isLikelyUnreadableFootprintContent(normalizedContent) && item.session_id && item.local_id > 0) { - const detailResult = await this.getMessageById(item.session_id, item.local_id) - if (detailResult.success && detailResult.message) { - const detailMessage = detailResult.message - const detailRaw = String( - detailMessage.rawContent - || detailMessage.content - || detailMessage.parsedContent - || '' - ) - const resolvedFromDetail = this.normalizeMyFootprintMentionContent(detailRaw) - if (resolvedFromDetail && !this.isLikelyUnreadableFootprintContent(resolvedFromDetail)) { - normalizedContent = resolvedFromDetail - } else { - const parsedFallback = String(detailMessage.parsedContent || '').trim() - if (parsedFallback && !this.isLikelyUnreadableFootprintContent(parsedFallback)) { - normalizedContent = parsedFallback - } - } - } - } - - return { - ...item, - message_content: normalizedContent, - sessionDisplayName: sessionContact?.displayName || item.sessionDisplayName, - senderDisplayName: senderContact?.displayName || item.senderDisplayName || item.sender_username, - senderAvatarUrl: senderContact?.avatarUrl || item.senderAvatarUrl - } - })) - - return { - ...data, - private_sessions: nextPrivateSessions, - private_segments: nextPrivateSegments, - mention_groups: nextMentionGroups, - mentions: nextMentions - } - } catch (error) { - console.error('[ChatService] 补充我的足迹展示信息失败:', error) - return data - } - } - - private normalizeMyFootprintMentionContent(rawContent: unknown): string { - const decodedRaw = this.decodeMaybeCompressed(rawContent, 'footprint_message_content') - let content = String(decodedRaw || rawContent || '') - if (!content) return '' - - content = this.cleanUtf16(this.decodeHtmlEntities(content)).trim() - if (!content) return '' - - const looksLikeXml = content.includes('<appmsg') - || content.includes('<appmsg') - || content.includes('<msg') - || content.includes('<msg') - - if (looksLikeXml) { - const xml = this.decodeHtmlEntities(content) - const type49Info = this.parseType49Message(xml) - - if (type49Info.appMsgKind === 'quote') { - const title = this.stripSenderPrefix(this.extractXmlValue(xml, 'title')) - const quotedSender = String(type49Info.quotedSender || '').trim() - const quotedContent = this.sanitizeQuotedContent(String(type49Info.quotedContent || '').trim()) - if (title) { - if (quotedContent) { - return `${title}\n\n引用:${quotedSender ? `${quotedSender}:` : ''}${quotedContent}` - } - return title - } - if (quotedContent) { - return quotedSender ? `${quotedSender}:${quotedContent}` : quotedContent - } - } - - const parsed = this.parseMessageContent(xml, 49) - const normalizedParsed = this.stripSenderPrefix(String(parsed || '').trim()) - if (normalizedParsed && normalizedParsed !== '[链接]' && normalizedParsed !== '[消息]') { - return normalizedParsed - } - - const xmlTitle = this.stripSenderPrefix(this.extractXmlValue(xml, 'title')) - if (xmlTitle) return xmlTitle - } - - return this.stripSenderPrefix(content) - } - - private isLikelyUnreadableFootprintContent(content: string): boolean { - const text = String(content || '').trim() - if (!text) return false - const compact = this.compactEncodedPayload(text) - if (compact.length <= 80) return false - if (this.looksLikeHex(compact)) return true - if (this.looksLikeBase64(compact) && !compact.includes('<') && !compact.includes('>')) return true - return false - } - - private formatFootprintTime(timestamp: number): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return '' - const date = new Date(timestamp * 1000) - const y = date.getFullYear() - const m = `${date.getMonth() + 1}`.padStart(2, '0') - const d = `${date.getDate()}`.padStart(2, '0') - const hh = `${date.getHours()}`.padStart(2, '0') - const mm = `${date.getMinutes()}`.padStart(2, '0') - const ss = `${date.getSeconds()}`.padStart(2, '0') - return `${y}-${m}-${d} ${hh}:${mm}:${ss}` - } - - private escapeCsvCell(value: unknown): string { - const text = String(value ?? '') - if (!text) return '' - if (!/[",\n\r]/.test(text)) return text - return `"${text.replace(/"/g, '""')}"` - } - - private buildMyFootprintCsv(data: MyFootprintData): string { - const lines: string[] = [] - const pushRow = (...columns: unknown[]) => { - lines.push(columns.map((value) => this.escapeCsvCell(value)).join(',')) - } - - pushRow('模块', '指标', '数值') - pushRow('summary', '私聊找我人数', data.summary.private_inbound_people) - pushRow('summary', '我回复人数', data.summary.private_replied_people) - pushRow('summary', '我主动联系人数', data.summary.private_outbound_people) - pushRow('summary', '私聊回复率', data.summary.private_reply_rate) - pushRow('summary', '@我次数', data.summary.mention_count) - pushRow('summary', '@我群聊数', data.summary.mention_group_count) - pushRow('summary', '诊断:是否截断', data.diagnostics.truncated ? 'true' : 'false') - pushRow('summary', '诊断:扫描分库数', data.diagnostics.scanned_dbs) - pushRow('summary', '诊断:耗时ms', data.diagnostics.elapsed_ms) - - lines.push('') - pushRow('private_sessions', 'session_id', 'display_name', 'incoming_count', 'outgoing_count', 'replied', 'first_incoming_ts', 'first_reply_ts', 'latest_ts', 'anchor_local_id', 'anchor_create_time') - for (const row of data.private_sessions) { - pushRow( - 'private_sessions', - row.session_id, - row.displayName || '', - row.incoming_count, - row.outgoing_count, - row.replied ? 'true' : 'false', - this.formatFootprintTime(row.first_incoming_ts), - this.formatFootprintTime(row.first_reply_ts), - this.formatFootprintTime(row.latest_ts), - row.anchor_local_id, - row.anchor_create_time - ) - } - - lines.push('') - pushRow( - 'private_segments', - 'session_id', - 'display_name', - 'segment_index', - 'start_ts', - 'end_ts', - 'duration_sec', - 'incoming_count', - 'outgoing_count', - 'message_count', - 'replied', - 'first_incoming_ts', - 'first_reply_ts', - 'latest_ts', - 'anchor_local_id', - 'anchor_create_time' - ) - for (const row of data.private_segments) { - pushRow( - 'private_segments', - row.session_id, - row.displayName || '', - row.segment_index, - this.formatFootprintTime(row.start_ts), - this.formatFootprintTime(row.end_ts), - row.duration_sec, - row.incoming_count, - row.outgoing_count, - row.message_count, - row.replied ? 'true' : 'false', - this.formatFootprintTime(row.first_incoming_ts), - this.formatFootprintTime(row.first_reply_ts), - this.formatFootprintTime(row.latest_ts), - row.anchor_local_id, - row.anchor_create_time - ) - } - - lines.push('') - pushRow('mentions', 'session_id', 'session_display_name', 'local_id', 'create_time', 'sender_username', 'sender_display_name', 'message_content', 'source') - for (const row of data.mentions) { - pushRow( - 'mentions', - row.session_id, - row.sessionDisplayName || '', - row.local_id, - this.formatFootprintTime(row.create_time), - row.sender_username, - row.senderDisplayName || '', - row.message_content, - row.source - ) - } - - lines.push('') - pushRow('mention_groups', 'session_id', 'display_name', 'count', 'latest_ts') - for (const row of data.mention_groups) { - pushRow( - 'mention_groups', - row.session_id, - row.displayName || '', - row.count, - this.formatFootprintTime(row.latest_ts) - ) - } - - return lines.join('\n') - } - - private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> { - const sourceInfo = this.getMessageSourceInfo(row) - const rawContent = this.decodeMessageContent( - row.message_content, - row.compress_content - ) - // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 - // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 - const localId = this.getRowInt(row, ['local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) - const serverId = this.getRowInt(row, ['server_id'], 0) - const localType = this.getRowInt(row, ['local_type'], 0) - const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0) - const rawIsSend = row.computed_is_send ?? row.is_send - const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) - const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) - const msg: Message = { - messageKey: this.buildMessageKey({ - localId, - serverId, - createTime, - sortSeq, - senderUsername, - localType, - ...sourceInfo - }), - localId, - serverId, - serverIdRaw, - localType, - createTime, - sortSeq, - isSend: sendState.isSend, - senderUsername, - rawContent: rawContent, - content: rawContent, // 添加原始内容供视频MD5解析使用 - parsedContent: this.parseMessageContent(rawContent, localType), - _db_path: sourceInfo.dbPath - } - - if (msg.localId === 0 || msg.createTime === 0) { - const rawLocalId = row.local_id - const rawCreateTime = row.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) - } else if (msg.localType === 43) { // Video - msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent) - } else if (msg.localType === 47) { // Emoji - const emojiInfo = this.parseEmojiInfo(rawContent) - msg.emojiCdnUrl = emojiInfo.cdnUrl - msg.emojiMd5 = emojiInfo.md5 - msg.emojiThumbUrl = emojiInfo.thumbUrl - msg.emojiEncryptUrl = emojiInfo.encryptUrl - msg.emojiAesKey = emojiInfo.aesKey - } else if (msg.localType === 42) { - const cardInfo = this.parseCardInfo(rawContent) - msg.cardUsername = cardInfo.username - msg.cardNickname = cardInfo.nickname - msg.cardAvatarUrl = cardInfo.avatarUrl - } - - if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('<appmsg'))) { - Object.assign(msg, this.parseType49Message(rawContent)) - } - - 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(/[\\\\/]+$/, '') - - // 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件) - // 则向上回溯到账号目录 - if (basename(normalized).toLowerCase() === 'db_storage') { - return dirname(normalized) - } - const dir = dirname(normalized) - if (basename(dir).toLowerCase() === 'db_storage') { - return dirname(dir) - } - - // 否则,dbPath 应该是数据库根目录(如 xwechat_files) - // 账号目录应该是 {dbPath}/{wxid} - const accountDirWithWxid = join(normalized, wxid) - if (existsSync(accountDirWithWxid)) { - return accountDirWithWxid - } - - // 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录) - return normalized - } - - private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> { - const normalized = this.normalizeDatBase(baseName) - - 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 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 = 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) as Buffer - } - - const remaining = payload.subarray(alignedAesSize) - if (xorSize < 0 || xorSize > remaining.length) { - throw new Error('文件格式异常:XOR 数据长度不合法') - } - - let rawData: Buffer = Buffer.alloc(0) - let xoredData: Buffer = Buffer.alloc(0) - if (xorSize > 0) { - const rawLength = remaining.length - xorSize - if (rawLength < 0) { - throw new Error('文件格式异常:原始数据长度小于XOR长度') - } - rawData = remaining.subarray(0, rawLength) as Buffer - 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 as Buffer - 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 - } - - async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - return { success: false, error: connectResult.error || '数据库未连接' } - } - // fallback-exec: 仅用于诊断/低频兼容,不作为业务主路径 - return wcdbService.execQuery(kind, path, sql) - } catch (e) { - console.error('ChatService: 执行自定义查询失败:', e) - return { success: false, error: String(e) } - } - } - - - /** - * 下载表情包文件(用于导出,返回文件路径) - */ - async downloadEmojiFile(msg: Message): Promise<string | null> { - if (!msg.emojiMd5) return null - let url = msg.emojiCdnUrl - - // 尝试获取 URL - if (!url && msg.emojiEncryptUrl) { - console.warn('[ChatService] Emoji has only encryptUrl:', msg.emojiMd5) - } - - if (!url) { - await this.fallbackEmoticon(msg) - url = msg.emojiCdnUrl - } - - if (!url) return null - - // Reuse existing downloadEmoji method - const result = await this.downloadEmoji(url, msg.emojiMd5) - if (result.success && result.localPath) { - return result.localPath - } - return null - } -} - -export const chatService = new ChatService() diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts deleted file mode 100644 index 198c5df..0000000 --- a/electron/services/cloudControlService.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { app } from 'electron' -import { wcdbService } from './wcdbService' - -interface UsageStats { - appVersion: string - platform: string - deviceId: string - timestamp: number - online: boolean - pages: string[] -} - -class CloudControlService { - private deviceId: string = '' - private timer: NodeJS.Timeout | null = null - private pages: Set<string> = new Set() - private platformVersionCache: string | null = null - private pendingReports: UsageStats[] = [] - private flushInProgress = false - private retryDelayMs = 5_000 - private consecutiveFailures = 0 - private circuitOpenedAt = 0 - private nextDelayOverrideMs: number | null = null - private initialized = false - - private static readonly BASE_FLUSH_MS = 300_000 - private static readonly JITTER_MS = 30_000 - private static readonly MAX_BUFFER_REPORTS = 200 - private static readonly MAX_BATCH_REPORTS = 20 - private static readonly MAX_RETRY_MS = 120_000 - private static readonly CIRCUIT_FAIL_THRESHOLD = 5 - private static readonly CIRCUIT_COOLDOWN_MS = 120_000 - - async init() { - if (this.initialized) return - this.initialized = true - this.deviceId = this.getDeviceId() - await wcdbService.cloudInit(300) - this.enqueueCurrentReport() - await this.flushQueue(true) - this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined) - this.nextDelayOverrideMs = null - } - - private getDeviceId(): string { - const crypto = require('crypto') - const os = require('os') - const machineId = os.hostname() + os.platform() + os.arch() - return crypto.createHash('md5').update(machineId).digest('hex') - } - - private buildCurrentReport(): UsageStats { - return { - appVersion: app.getVersion(), - platform: this.getPlatformVersion(), - deviceId: this.deviceId, - timestamp: Date.now(), - online: true, - pages: Array.from(this.pages) - } - } - - private enqueueCurrentReport() { - const report = this.buildCurrentReport() - this.pendingReports.push(report) - if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) { - this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS) - } - this.pages.clear() - } - - private isCircuitOpen(nowMs: number): boolean { - if (this.circuitOpenedAt <= 0) return false - return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS - } - - private scheduleNextFlush(delayMs?: number) { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS) - const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter) - this.timer = setTimeout(() => { - this.enqueueCurrentReport() - this.flushQueue(false).finally(() => { - this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined) - this.nextDelayOverrideMs = null - }) - }, nextDelay) - } - - private async flushQueue(force: boolean) { - if (this.flushInProgress) return - if (this.pendingReports.length === 0) return - const now = Date.now() - if (!force && this.isCircuitOpen(now)) { - return - } - this.flushInProgress = true - try { - while (this.pendingReports.length > 0) { - const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS) - const result = await wcdbService.cloudReport(JSON.stringify(batch)) - if (!result || result.success !== true) { - this.consecutiveFailures += 1 - this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2) - if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) { - this.circuitOpenedAt = Date.now() - } - this.nextDelayOverrideMs = this.retryDelayMs - return - } - this.pendingReports.splice(0, batch.length) - this.consecutiveFailures = 0 - this.retryDelayMs = 5_000 - this.circuitOpenedAt = 0 - } - } finally { - this.flushInProgress = false - } - } - - private getPlatformVersion(): string { - if (this.platformVersionCache) { - return this.platformVersionCache - } - - const os = require('os') - const fs = require('fs') - const platform = process.platform - - if (platform === 'win32') { - const release = os.release() - const parts = release.split('.') - const major = parseInt(parts[0]) - const minor = parseInt(parts[1] || '0') - const build = parseInt(parts[2] || '0') - - // Windows 11 是 10.0.22000+,且主版本必须是 10.0 - if (major === 10 && minor === 0 && build >= 22000) { - this.platformVersionCache = 'Windows 11' - return this.platformVersionCache - } else if (major === 10) { - this.platformVersionCache = 'Windows 10' - return this.platformVersionCache - } - this.platformVersionCache = `Windows ${release}` - return this.platformVersionCache - } - - if (platform === 'darwin') { - // `os.release()` returns Darwin kernel version (e.g. 25.3.0), - // while cloud reporting expects the macOS product version (e.g. 26.3). - const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release() - this.platformVersionCache = `macOS ${macVersion}` - return this.platformVersionCache - } - - if (platform === 'linux') { - try { - const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] - for (const filePath of osReleasePaths) { - if (!fs.existsSync(filePath)) { - continue - } - - const content = fs.readFileSync(filePath, 'utf8') - const values: Record<string, string> = {} - - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - continue - } - - const separatorIndex = trimmed.indexOf('=') - if (separatorIndex <= 0) { - continue - } - - const key = trimmed.slice(0, separatorIndex) - let value = trimmed.slice(separatorIndex + 1).trim() - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { - value = value.slice(1, -1) - } - values[key] = value - } - - if (values.PRETTY_NAME) { - this.platformVersionCache = values.PRETTY_NAME - return this.platformVersionCache - } - - if (values.NAME && values.VERSION_ID) { - this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}` - return this.platformVersionCache - } - - if (values.NAME) { - this.platformVersionCache = values.NAME - return this.platformVersionCache - } - } - } catch (error) { - console.warn('[CloudControl] Failed to detect Linux distro version:', error) - } - - this.platformVersionCache = `Linux ${os.release()}` - return this.platformVersionCache - } - - this.platformVersionCache = platform - return this.platformVersionCache - } - - recordPage(pageName: string) { - this.pages.add(pageName) - } - - async stop(): Promise<void> { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - this.pendingReports = [] - this.flushInProgress = false - this.retryDelayMs = 5_000 - this.consecutiveFailures = 0 - this.circuitOpenedAt = 0 - this.nextDelayOverrideMs = null - this.initialized = false - if (wcdbService.isReady()) { - try { - await wcdbService.cloudStop() - } catch { - // 忽略停止失败,避免阻塞主进程退出 - } - } - } - - async getLogs() { - return wcdbService.getLogs() - } -} - -export const cloudControlService = new CloudControlService() diff --git a/electron/services/config.ts b/electron/services/config.ts deleted file mode 100644 index 74180a2..0000000 --- a/electron/services/config.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import { join } from 'path' -import { existsSync, readdirSync, statSync } from 'fs' -import crypto from 'crypto' -import Store from 'electron-store' -import { expandHomePath } from '../utils/pathUtils' - -// 条件导入 electron(Worker 环境中不可用) -let app: any = null -let safeStorage: any = null -const isWorkerThread = process.env.WEFLOW_WORKER === '1' -if (!isWorkerThread) { - try { - const electron = require('electron') - app = electron.app - safeStorage = electron.safeStorage - } catch { - // Worker 环境中 electron 不可用 - } -} - -// 加密前缀标记 -const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) -const isSafeStorageAvailable = (): boolean => { - try { - return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable() - } catch { - return false - } -} -const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式) - -interface ConfigSchema { - // 数据库相关 - dbPath: string - decryptKey: string - myWxid: string - onboardingDone: boolean - imageXorKey: number - imageAesKey: string - wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }> - exportPath?: string; - // 缓存相关 - cachePath: string - lastOpenedDb: string - lastSession: string - - // 界面相关 - theme: 'light' | 'dark' | 'system' - themeId: string - language: string - logEnabled: boolean - launchAtStartup?: boolean - silentStartup?: boolean - llmModelPath: string - whisperModelName: string - whisperModelDir: string - whisperDownloadSource: string - autoTranscribeVoice: boolean - transcribeLanguages: string[] - exportDefaultConcurrency: number - analyticsExcludedUsernames: string[] - - // 安全相关 - authEnabled: boolean - authPassword: string // SHA-256 hash(safeStorage 加密) - authUseHello: boolean - authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用) - - // 更新相关 - ignoredUpdateVersion: string - updateChannel: 'auto' | 'stable' | 'preview' | 'dev' - - // 通知 - notificationEnabled: boolean - aiInsightNotificationEnabled: boolean - notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' - notificationFilterMode: 'all' | 'whitelist' | 'blacklist' - notificationFilterList: string[] - messagePushEnabled: boolean - messagePushFilterMode: 'all' | 'whitelist' | 'blacklist' - messagePushFilterList: string[] - httpApiEnabled: boolean - httpApiPort: number - httpApiHost: string - httpApiToken: string - windowCloseBehavior: 'ask' | 'tray' | 'quit' - quoteLayout: 'quote-top' | 'quote-bottom' - wordCloudExcludeWords: string[] - exportWriteLayout: 'A' | 'B' | 'C' - exportAutomationTaskMap: Record<string, unknown> - - // AI 见解 - aiModelApiBaseUrl: string - aiModelApiKey: string - aiModelApiModel: string - aiModelApiMaxTokens: number - aiInsightEnabled: boolean - aiInsightApiBaseUrl: string - aiInsightApiKey: string - aiInsightApiModel: string - aiInsightSilenceDays: number - aiInsightAllowContext: boolean - aiInsightAllowMomentsContext: boolean - aiInsightMomentsContextCount: number - aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }> - aiInsightAllowSocialContext: boolean - aiInsightSocialContextCount: number - aiInsightWeiboCookie: string - aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }> - aiInsightFilterMode: 'whitelist' | 'blacklist' - aiInsightFilterList: string[] - aiInsightWhitelistEnabled: boolean - aiInsightWhitelist: string[] - /** 活跃分析冷却时间(分钟),0 表示无冷却 */ - aiInsightCooldownMinutes: number - /** 沉默联系人扫描间隔(小时) */ - aiInsightScanIntervalHours: number - /** 发送上下文时的最大消息条数 */ - aiInsightContextCount: number - /** 自定义 system prompt,空字符串表示使用内置默认值 */ - aiInsightSystemPrompt: string - /** 是否启用 Telegram 推送 */ - aiInsightTelegramEnabled: boolean - /** Telegram Bot Token */ - aiInsightTelegramToken: string - /** Telegram 接收 Chat ID,逗号分隔,支持多个 */ - aiInsightTelegramChatIds: string - - // AI 足迹 - aiFootprintEnabled: boolean - aiFootprintSystemPrompt: string - aiGroupSummaryEnabled: boolean - aiGroupSummaryIntervalHours: number - aiGroupSummarySystemPrompt: string - aiGroupSummaryFilterMode: 'whitelist' | 'blacklist' - aiGroupSummaryFilterList: string[] - aiMessageInsightEnabled: boolean - aiMessageInsightContextCount: number - aiMessageInsightSystemPrompt: string - /** 是否将 AI 见解调试日志输出到桌面 */ - aiInsightDebugLogEnabled: boolean - autoDownloadHighRes: boolean - autoDownloadWhitelist: string[] -} - -// 需要 safeStorage 加密的字段(普通模式) -const ENCRYPTED_STRING_KEYS: Set<string> = new Set([ - 'decryptKey', - 'imageAesKey', - 'authPassword', - 'httpApiToken', - 'aiModelApiKey', - 'aiInsightApiKey', - 'aiInsightWeiboCookie' -]) -const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello']) -const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey']) - -// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密) -const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey']) -const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey']) - -export class ConfigService { - private static instance: ConfigService - private store!: Store<ConfigSchema> - - // 锁定模式运行时状态 - private unlockedKeys: Map<string, any> = new Map() - private unlockPassword: string | null = null - - // 账号目录缓存 - private accountDirCache: Map<string, string> = new Map() - - static getInstance(): ConfigService { - if (!ConfigService.instance) { - ConfigService.instance = new ConfigService() - } - return ConfigService.instance - } - - constructor() { - if (ConfigService.instance) { - return ConfigService.instance - } - ConfigService.instance = this - const defaults: ConfigSchema = { - dbPath: '', - decryptKey: '', - myWxid: '', - onboardingDone: false, - imageXorKey: 0, - imageAesKey: '', - wxidConfigs: {}, - cachePath: '', - lastOpenedDb: '', - lastSession: '', - theme: 'system', - themeId: 'cloud-dancer', - language: 'zh-CN', - logEnabled: false, - silentStartup: false, - llmModelPath: '', - whisperModelName: 'base', - whisperModelDir: '', - whisperDownloadSource: 'tsinghua', - autoTranscribeVoice: false, - transcribeLanguages: ['zh'], - exportDefaultConcurrency: 4, - analyticsExcludedUsernames: [], - authEnabled: false, - authPassword: '', - authUseHello: false, - authHelloSecret: '', - ignoredUpdateVersion: '', - updateChannel: 'auto', - notificationEnabled: true, - aiInsightNotificationEnabled: true, - notificationPosition: 'top-right', - notificationFilterMode: 'all', - notificationFilterList: [], - httpApiToken: '', - httpApiEnabled: false, - httpApiPort: 5031, - httpApiHost: '127.0.0.1', - messagePushEnabled: false, - messagePushFilterMode: 'all', - messagePushFilterList: [], - windowCloseBehavior: 'ask', - quoteLayout: 'quote-top', - wordCloudExcludeWords: [], - exportWriteLayout: 'A', - exportAutomationTaskMap: {}, - aiModelApiBaseUrl: '', - aiModelApiKey: '', - aiModelApiModel: 'gpt-4o-mini', - aiModelApiMaxTokens: 1024, - aiInsightEnabled: false, - aiInsightApiBaseUrl: '', - aiInsightApiKey: '', - aiInsightApiModel: 'gpt-4o-mini', - aiInsightSilenceDays: 3, - aiInsightAllowContext: false, - aiInsightAllowMomentsContext: false, - aiInsightMomentsContextCount: 5, - aiInsightMomentsBindings: {}, - aiInsightAllowSocialContext: false, - aiInsightFilterMode: 'whitelist', - aiInsightFilterList: [], - aiInsightWhitelistEnabled: false, - aiInsightWhitelist: [], - aiInsightCooldownMinutes: 120, - aiInsightScanIntervalHours: 4, - aiInsightContextCount: 40, - aiInsightSocialContextCount: 3, - aiInsightSystemPrompt: '', - aiInsightTelegramEnabled: false, - aiInsightTelegramToken: '', - aiInsightTelegramChatIds: '', - aiInsightWeiboCookie: '', - aiInsightWeiboBindings: {}, - aiFootprintEnabled: false, - aiFootprintSystemPrompt: '', - aiGroupSummaryEnabled: false, - aiGroupSummaryIntervalHours: 4, - aiGroupSummarySystemPrompt: '', - aiGroupSummaryFilterMode: 'whitelist', - aiGroupSummaryFilterList: [], - aiMessageInsightEnabled: false, - aiMessageInsightContextCount: 50, - aiMessageInsightSystemPrompt: '', - aiInsightDebugLogEnabled: false, - autoDownloadHighRes: false, - autoDownloadWhitelist: [] - } - - const storeOptions: any = { - name: 'WeFlow-config', - defaults, - projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' - } - const runningInWorker = process.env.WEFLOW_WORKER === '1' - if (runningInWorker) { - const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() - if (cwd) { - storeOptions.cwd = cwd - } - } - - try { - this.store = new Store<ConfigSchema>(storeOptions) - } catch (error) { - const message = String((error as Error)?.message || error || '') - if (message.includes('projectName')) { - const fallbackOptions = { - ...storeOptions, - projectName: 'WeFlow', - cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd() - } - this.store = new Store<ConfigSchema>(fallbackOptions) - } else { - throw error - } - } - this.migrateAuthFields() - this.migrateAiConfig() - } - - // === 状态查询 === - - isLockMode(): boolean { - const raw: any = this.store.get('decryptKey') - return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX) - } - - isUnlocked(): boolean { - return !this.isLockMode() || this.unlockedKeys.size > 0 - } - - // === get / set === - - get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] { - const raw = this.store.get(key) - - if (ENCRYPTED_BOOL_KEYS.has(key)) { - const str = typeof raw === 'string' ? raw : '' - if (!str || !str.startsWith(SAFE_PREFIX)) return raw - return (this.safeDecrypt(str) === 'true') as ConfigSchema[K] - } - - if (ENCRYPTED_NUMBER_KEYS.has(key)) { - const str = typeof raw === 'string' ? raw : '' - if (!str) return raw - if (str.startsWith(LOCK_PREFIX)) { - const cached = this.unlockedKeys.get(key as string) - return (cached !== undefined ? cached : 0) as ConfigSchema[K] - } - if (!str.startsWith(SAFE_PREFIX)) return raw - const num = Number(this.safeDecrypt(str)) - return (Number.isFinite(num) ? num : 0) as ConfigSchema[K] - } - - if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') { - if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K] - if (raw.startsWith(LOCK_PREFIX)) { - const cached = this.unlockedKeys.get(key as string) - return (cached !== undefined ? cached : '') as ConfigSchema[K] - } - return this.safeDecrypt(raw) as ConfigSchema[K] - } - - if (key === 'wxidConfigs' && raw && typeof raw === 'object') { - return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] - } - - if (key === 'dbPath' && typeof raw === 'string') { - return expandHomePath(raw) as ConfigSchema[K] - } - - return raw - } - - set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void { - let toStore = value - const inLockMode = this.isLockMode() && this.unlockPassword - - if (key === 'dbPath' && typeof value === 'string') { - toStore = expandHomePath(value) as ConfigSchema[K] - } - - if (ENCRYPTED_BOOL_KEYS.has(key)) { - const boolValue = value === true || value === 'true' - // `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗 - toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K] - } else if (ENCRYPTED_NUMBER_KEYS.has(key)) { - if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { - toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] - this.unlockedKeys.set(key as string, value) - } else { - toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] - } - } else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { - if (key === 'authPassword') { - toStore = this.safeEncrypt(value) as ConfigSchema[K] - } else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) { - toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K] - this.unlockedKeys.set(key as string, value) - } else { - toStore = this.safeEncrypt(value) as ConfigSchema[K] - } - } else if (key === 'wxidConfigs' && value && typeof value === 'object') { - if (inLockMode) { - toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K] - } else { - toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] - } - } - - this.store.set(key, toStore) - } - - // === 加密/解密工具 === - - private safeEncrypt(plaintext: string): string { - if (!plaintext) return '' - if (plaintext.startsWith(SAFE_PREFIX)) return plaintext - if (!isSafeStorageAvailable()) return plaintext - const encrypted = safeStorage.encryptString(plaintext) - return SAFE_PREFIX + encrypted.toString('base64') - } - - private safeDecrypt(stored: string): string { - if (!stored) return '' - if (!stored.startsWith(SAFE_PREFIX)) return stored - if (!isSafeStorageAvailable()) return '' - try { - const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') - return safeStorage.decryptString(buf) - } catch { - return '' - } - } - - private lockEncrypt(plaintext: string, password: string): string { - if (!plaintext) return '' - const salt = crypto.randomBytes(16) - const iv = crypto.randomBytes(12) - const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') - const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv) - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) - const authTag = cipher.getAuthTag() - const combined = Buffer.concat([salt, iv, authTag, encrypted]) - return LOCK_PREFIX + combined.toString('base64') - } - - private lockDecrypt(stored: string, password: string): string | null { - if (!stored || !stored.startsWith(LOCK_PREFIX)) return null - try { - const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64') - const salt = combined.subarray(0, 16) - const iv = combined.subarray(16, 28) - const authTag = combined.subarray(28, 44) - const ciphertext = combined.subarray(44) - const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') - const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv) - decipher.setAuthTag(authTag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return decrypted.toString('utf8') - } catch { - return null - } - } - - // 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用) - private verifyPasswordByDecrypt(password: string): boolean { - // 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性 - const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const - for (const key of lockFields) { - const raw: any = this.store.get(key as any) - if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) { - const result = this.lockDecrypt(raw, password) - // lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功 - return result !== null - } - } - return false - } - - // === wxidConfigs 加密/解密 === - - private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { - const result: ConfigSchema['wxidConfigs'] = {} - for (const [wxid, cfg] of Object.entries(configs)) { - result[wxid] = { ...cfg } - if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey) - if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey) - if (cfg.imageXorKey !== undefined) { - (result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) - } - } - return result - } - - private decryptLockedWxidConfigs(password: string): void { - const wxidConfigs = this.store.get('wxidConfigs') - if (!wxidConfigs || typeof wxidConfigs !== 'object') return - for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { - if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(cfg.decryptKey, password) - if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d) - } - if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(cfg.imageAesKey, password) - if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d) - } - if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(cfg.imageXorKey, password) - if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d)) - } - } - } - - private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { - const result: ConfigSchema['wxidConfigs'] = {} - for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) { - result[wxid] = { ...cfg, updatedAt: cfg.updatedAt } - // decryptKey - if (typeof cfg.decryptKey === 'string') { - if (cfg.decryptKey.startsWith(LOCK_PREFIX)) { - result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? '' - } else { - result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) - } - } - // imageAesKey - if (typeof cfg.imageAesKey === 'string') { - if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) { - result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? '' - } else { - result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) - } - } - // imageXorKey - if (typeof cfg.imageXorKey === 'string') { - if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) { - result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0 - } else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) { - const num = Number(this.safeDecrypt(cfg.imageXorKey)) - result[wxid].imageXorKey = Number.isFinite(num) ? num : 0 - } - } - } - return result - } - private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { - const result: ConfigSchema['wxidConfigs'] = {} - for (const [wxid, cfg] of Object.entries(configs)) { - result[wxid] = { ...cfg } - if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any - if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any - if (cfg.imageXorKey !== undefined) { - (result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!) - } - } - return result - } - - // === 业务方法 === - - enableLock(password: string): { success: boolean; error?: string } { - try { - // 先读取当前所有明文密钥 - const decryptKey = this.get('decryptKey') - const imageAesKey = this.get('imageAesKey') - const imageXorKey = this.get('imageXorKey') - const wxidConfigs = this.get('wxidConfigs') - - // 存储密码 hash(safeStorage 加密) - const passwordHash = crypto.createHash('sha256').update(password).digest('hex') - this.store.set('authPassword', this.safeEncrypt(passwordHash) as any) - this.store.set('authEnabled', this.safeEncrypt('true') as any) - - // 设置运行时状态 - this.unlockPassword = password - this.unlockedKeys.set('decryptKey', decryptKey) - this.unlockedKeys.set('imageAesKey', imageAesKey) - this.unlockedKeys.set('imageXorKey', imageXorKey) - - // 用密码派生密钥重新加密所有敏感字段 - if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any) - if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any) - if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any) - - // 处理 wxidConfigs 中的嵌套密钥 - if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { - const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) - this.store.set('wxidConfigs', lockedConfigs) - for (const [wxid, cfg] of Object.entries(wxidConfigs)) { - if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey) - if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey) - if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey) - } - } - - return { success: true } - } catch (e: any) { - return { success: false, error: e.message } - } - } - - unlock(password: string): { success: boolean; error?: string } { - try { - // 验证密码 - const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) - const inputHash = crypto.createHash('sha256').update(password).digest('hex') - - if (storedHash && storedHash !== inputHash) { - // authPassword 存在但密码不匹配 - return { success: false, error: '密码错误' } - } - - if (!storedHash) { - // authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证 - const verified = this.verifyPasswordByDecrypt(password) - if (!verified) { - return { success: false, error: '密码错误' } - } - // 密码正确,自愈 authPassword - const newHash = crypto.createHash('sha256').update(password).digest('hex') - this.store.set('authPassword', this.safeEncrypt(newHash) as any) - this.store.set('authEnabled', this.safeEncrypt('true') as any) - } - - // 解密所有 lock: 字段到内存缓存 - const rawDecryptKey: any = this.store.get('decryptKey') - if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(rawDecryptKey, password) - if (d !== null) this.unlockedKeys.set('decryptKey', d) - } - - const rawImageAesKey: any = this.store.get('imageAesKey') - if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(rawImageAesKey, password) - if (d !== null) this.unlockedKeys.set('imageAesKey', d) - } - - const rawImageXorKey: any = this.store.get('imageXorKey') - if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) { - const d = this.lockDecrypt(rawImageXorKey, password) - if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d)) - } - - // 解密 wxidConfigs 嵌套密钥 - this.decryptLockedWxidConfigs(password) - - // 保留密码供 set() 使用 - this.unlockPassword = password - return { success: true } - } catch (e: any) { - return { success: false, error: e.message } - } - } - - disableLock(password: string): { success: boolean; error?: string } { - try { - // 验证密码 - const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) - const inputHash = crypto.createHash('sha256').update(password).digest('hex') - if (storedHash !== inputHash) { - return { success: false, error: '密码错误' } - } - - // 先解密所有 lock: 字段 - if (this.unlockedKeys.size === 0) { - this.unlock(password) - } - - // 将所有密钥转回 safe: 格式 - const decryptKey = this.unlockedKeys.get('decryptKey') - const imageAesKey = this.unlockedKeys.get('imageAesKey') - const imageXorKey = this.unlockedKeys.get('imageXorKey') - - if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any) - if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any) - if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any) - - // 转换 wxidConfigs - const wxidConfigs = this.get('wxidConfigs') - if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { - const safeConfigs = this.encryptWxidConfigs(wxidConfigs) - this.store.set('wxidConfigs', safeConfigs) - } - - // 清除 auth 字段 - this.store.set('authEnabled', false as any) - this.store.set('authPassword', '' as any) - this.store.set('authUseHello', false as any) - this.store.set('authHelloSecret', '' as any) - - // 清除运行时状态 - this.unlockedKeys.clear() - this.unlockPassword = null - - return { success: true } - } catch (e: any) { - return { success: false, error: e.message } - } - } - - changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } { - try { - // 验证旧密码 - const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) - const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex') - if (storedHash !== oldHash) { - return { success: false, error: '旧密码错误' } - } - - // 确保已解锁 - if (this.unlockedKeys.size === 0) { - this.unlock(oldPassword) - } - - // 用新密码重新加密所有密钥 - const decryptKey = this.unlockedKeys.get('decryptKey') - const imageAesKey = this.unlockedKeys.get('imageAesKey') - const imageXorKey = this.unlockedKeys.get('imageXorKey') - - if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any) - if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any) - if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any) - - // 重新加密 wxidConfigs - const wxidConfigs = this.get('wxidConfigs') - if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { - this.unlockPassword = newPassword - const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) - this.store.set('wxidConfigs', lockedConfigs) - } - - // 更新密码 hash - const newHash = crypto.createHash('sha256').update(newPassword).digest('hex') - this.store.set('authPassword', this.safeEncrypt(newHash) as any) - - // 更新 Hello secret(如果启用了 Hello) - const useHello = this.get('authUseHello') - if (useHello) { - this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any) - } - - this.unlockPassword = newPassword - return { success: true } - } catch (e: any) { - return { success: false, error: e.message } - } - } - - // === Hello 相关 === - - setHelloSecret(password: string): void { - this.store.set('authHelloSecret', this.safeEncrypt(password) as any) - this.store.set('authUseHello', this.safeEncrypt('true') as any) - } - - getHelloSecret(): string { - const raw: any = this.store.get('authHelloSecret') - if (!raw || typeof raw !== 'string') return '' - return this.safeDecrypt(raw) - } - - clearHelloSecret(): void { - this.store.set('authHelloSecret', '' as any) - this.store.set('authUseHello', false as any) - } - - // === 迁移 === - - private migrateAuthFields(): void { - // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 - // 如果已经是 safe: 或 lock: 前缀则跳过 - const rawEnabled: any = this.store.get('authEnabled') - if (rawEnabled === true || rawEnabled === 'true') { - this.store.set('authEnabled', this.safeEncrypt('true') as any) - } else if (rawEnabled === false || rawEnabled === 'false') { - // 保持 false 为明文布尔,避免冷启动访问 keychain - this.store.set('authEnabled', false as any) - } - - const rawUseHello: any = this.store.get('authUseHello') - if (rawUseHello === true || rawUseHello === 'true') { - this.store.set('authUseHello', this.safeEncrypt('true') as any) - } else if (rawUseHello === false || rawUseHello === 'false') { - this.store.set('authUseHello', false as any) - } - - const rawPassword: any = this.store.get('authPassword') - if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) { - this.store.set('authPassword', this.safeEncrypt(rawPassword) as any) - } - - // 迁移敏感密钥字段(明文 → safe:) - for (const key of LOCKABLE_STRING_KEYS) { - const raw: any = this.store.get(key as any) - if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) { - this.store.set(key as any, this.safeEncrypt(raw) as any) - } - } - - // imageXorKey: 数字 → safe: - const rawXor: any = this.store.get('imageXorKey') - if (typeof rawXor === 'number' && rawXor !== 0) { - this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any) - } - - // wxidConfigs 中的嵌套密钥 - const wxidConfigs: any = this.store.get('wxidConfigs') - if (wxidConfigs && typeof wxidConfigs === 'object') { - let changed = false - for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { - if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) { - cfg.decryptKey = this.safeEncrypt(cfg.decryptKey) - changed = true - } - if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) { - cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey) - changed = true - } - if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) { - cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) - changed = true - } - } - if (changed) { - this.store.set('wxidConfigs', wxidConfigs) - } - } - } - - private migrateAiConfig(): void { - const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim() - const sharedApiKey = String(this.get('aiModelApiKey') || '').trim() - const sharedModel = String(this.get('aiModelApiModel') || '').trim() - - const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim() - const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim() - const legacyModel = String(this.get('aiInsightApiModel') || '').trim() - - if (!sharedBaseUrl && legacyBaseUrl) { - this.set('aiModelApiBaseUrl', legacyBaseUrl) - } - if (!sharedApiKey && legacyApiKey) { - this.set('aiModelApiKey', legacyApiKey) - } - if (!sharedModel && legacyModel) { - this.set('aiModelApiModel', legacyModel) - } - - const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim() - if (groupSummaryFilterMode === 'blacklist') { - this.store.set('aiGroupSummaryFilterList' as any, [] as any) - this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any) - } - } - - // === 验证 === - - verifyAuthEnabled(): boolean { - // 先检查 authEnabled 字段 - const rawEnabled: any = this.store.get('authEnabled') - if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { - if (this.safeDecrypt(rawEnabled) === 'true') return true - } - - // 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁 - const rawDecryptKey: any = this.store.get('decryptKey') - return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX); - - - } - - // === 工具方法 === - - /** - * 获取当前用户 wxid(清洗后,不带后缀) - */ - getMyWxidCleaned(): string { - const wxid = this.get('myWxid') - return wxid ? this.cleanAccountDirName(wxid) : '' - } - - /** - * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置 - */ - getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { - const wxid = this.get('myWxid') - if (wxid) { - const wxidConfigs = this.get('wxidConfigs') - const cfg = wxidConfigs?.[wxid] - if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) { - return { - xorKey: cfg.imageXorKey ?? this.get('imageXorKey'), - aesKey: cfg.imageAesKey ?? this.get('imageAesKey') - } - } - } - return { - xorKey: this.get('imageXorKey'), - aesKey: this.get('imageAesKey') - } - } - - /** - * 清理账号目录名称(移除后缀) - */ - private cleanAccountDirName(dirName: string): string { - const trimmed = dirName.trim() - if (!trimmed) return trimmed - - // wxid_ 开头的特殊处理 - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - if (match) return match[1] - return trimmed - } - - // 移除4位后缀 - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - if (suffixMatch) return suffixMatch[1] - - return trimmed - } - - /** - * 检查是否是目录 - */ - private isDirectory(path: string): boolean { - try { - return statSync(path).isDirectory() - } catch { - return false - } - } - - /** - * 浅层判定一个目录"看起来像不像账号目录": - * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 - * - * 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录 - * (例如自定义微信号后微信遗留下来的旧 wxid 主目录)。 - */ - private accountDirLooksValid(entryPath: string): boolean { - return ( - existsSync(join(entryPath, 'db_storage')) || - existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) - ) - } - - /** - * 检测账号目录下是否存在 session.db。 - * - * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, - * 同时兼容微信 4.x 两种已知布局: - * - db_storage/session/session.db (新版本嵌套布局) - * - db_storage/session.db (部分版本扁平布局) - */ - private accountDirHasSessionDb(entryPath: string): boolean { - const candidates = [ - join(entryPath, 'db_storage', 'session', 'session.db'), - join(entryPath, 'db_storage', 'session.db'), - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return true - } - return false - } - - /** - * 获取账号目录的真实绝对路径。 - * - * 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取 - * 账号目录,而不要自行拼接 `join(dbPath, wxid)`。 - * - * ## 修复 #996(错误码 -3001:未找到数据库目录) - * - * ### 旧实现存在的两处严重缺陷 - * 1. **对 wxid_ 开头强制要求"带后缀"**: - * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), - * 旧逻辑把它过滤掉,导致这类用户根本匹配不到自己的账号目录。 - * - * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,不校验目录有效性**: - * 旧实现写法是 - * ```ts - * if (!lowerWxid.startsWith('wxid_')) { - * const direct = join(root, cleanedWxid) - * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage - * } - * ``` - * 叠加 {@link cleanAccountDirName} 会把 `<自定义号>_<4位后缀>` 清洗成 - * `<自定义号>`,于是无论用户保存的是哪个 wxid,都会命中旧的、 - * 无后缀的空目录(它真实存在但里面没有 db_storage),最终在 - * wcdbCore.open 阶段触发 -3001。 - * - * ### 修复后的统一匹配流程 - * 1. 扫描 dbPath 下所有子目录; - * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 - * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; - * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项; - * 4. 在剩余候选中按以下优先级排序,取最优: - * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; - * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; - * - **修改时间更新** > 更旧:兜底。 - * - * @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`) - * @param wxid 微信 ID(可选,默认从配置读取 `myWxid`) - * @returns 账号目录的完整绝对路径;找不到返回 null - */ - getAccountDir(dbPath?: string, wxid?: string): string | null { - const actualDbPath = dbPath || this.get('dbPath') - const actualWxid = wxid || this.get('myWxid') - - if (!actualDbPath || !actualWxid) return null - - const cleanedWxid = this.cleanAccountDirName(actualWxid) - const normalized = actualDbPath.replace(/[\\/]+$/, '') - const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` - - // 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除 - const cached = this.accountDirCache.get(cacheKey) - if (cached && existsSync(cached)) return cached - if (cached && !existsSync(cached)) { - this.accountDirCache.delete(cacheKey) - } - - const lowerWxid = cleanedWxid.toLowerCase() - - try { - const entries = readdirSync(normalized) - type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } - const candidates: Candidate[] = [] - - for (const entry of entries) { - const entryPath = join(normalized, entry) - if (!this.isDirectory(entryPath)) continue - - const lowerEntry = entry.toLowerCase() - const isExactMatch = lowerEntry === lowerWxid - const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) - // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 - if (!isExactMatch && !isSuffixMatch) continue - - // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 - // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 - // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 - if (!this.accountDirLooksValid(entryPath)) continue - - let mtime = 0 - try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } - candidates.push({ - entryPath, - isExact: isExactMatch, - hasSession: this.accountDirHasSessionDb(entryPath), - mtime, - }) - } - - if (candidates.length > 0) { - candidates.sort((a, b) => { - // 1) 优先选有 session.db 的(真实写入数据的目录) - if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 - // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) - if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 - // 3) 最后按修改时间倒序(最新的优先) - return b.mtime - a.mtime - }) - const best = candidates[0].entryPath - this.accountDirCache.set(cacheKey, best) - return best - } - } catch { } - - return null - } - - private getUserDataPath(): string { - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - if (workerUserDataPath) { - return workerUserDataPath - } - return app?.getPath?.('userData') || process.cwd() - } - - getCacheBasePath(): string { - return join(this.getUserDataPath(), 'cache') - } - - getAll(): Partial<ConfigSchema> { - return this.store.store - } - - clear(): void { - this.store.clear() - this.unlockedKeys.clear() - this.unlockPassword = null - } -} - diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts deleted file mode 100644 index a481227..0000000 --- a/electron/services/contactCacheService.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { join, dirname } from 'path' -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' -import { app } from 'electron' -import { ConfigService } from './config' - -export interface ContactCacheEntry { - displayName?: string - avatarUrl?: string - updatedAt: number -} - -export class ContactCacheService { - private readonly cacheFilePath: string - private cache: Record<string, ContactCacheEntry> = {} - - constructor(cacheBasePath?: string) { - const basePath = cacheBasePath && cacheBasePath.trim().length > 0 - ? cacheBasePath - : ConfigService.getInstance().getCacheBasePath() - this.cacheFilePath = join(basePath, 'contacts.json') - this.ensureCacheDir() - this.loadCache() - } - - private ensureCacheDir() { - const dir = dirname(this.cacheFilePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - } - - private loadCache() { - if (!existsSync(this.cacheFilePath)) return - try { - const raw = readFileSync(this.cacheFilePath, 'utf8') - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - // 清除无效的头像数据(hex 格式而非正确的 base64) - for (const key of Object.keys(parsed)) { - const entry = parsed[key] - if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) { - // 这是错误的 hex 格式,清除它 - entry.avatarUrl = undefined - } - } - this.cache = parsed - } - } catch (error) { - console.error('ContactCacheService: 载入缓存失败', error) - this.cache = {} - } - } - - get(username: string): ContactCacheEntry | undefined { - return this.cache[username] - } - - getAllEntries(): Record<string, ContactCacheEntry> { - return { ...this.cache } - } - - setEntries(entries: Record<string, ContactCacheEntry>): void { - if (Object.keys(entries).length === 0) return - let changed = false - for (const [username, entry] of Object.entries(entries)) { - const existing = this.cache[username] - if (!existing || entry.updatedAt >= existing.updatedAt) { - this.cache[username] = entry - changed = true - } - } - if (changed) { - this.persist() - } - } - - private persist() { - try { - writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8') - } catch (error) { - console.error('ContactCacheService: 保存缓存失败', error) - } - } - - clear(): void { - this.cache = {} - try { - rmSync(this.cacheFilePath, { force: true }) - } catch (error) { - console.error('ContactCacheService: 清理缓存失败', error) - } - } -} diff --git a/electron/services/contactExportService.ts b/electron/services/contactExportService.ts deleted file mode 100644 index 3134313..0000000 --- a/electron/services/contactExportService.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import { chatService } from './chatService' - -interface ContactExportOptions { - format: 'json' | 'csv' | 'vcf' - exportAvatars: boolean - contactTypes: { - friends: boolean - groups: boolean - officials: boolean - } - selectedUsernames?: string[] -} - -/** - * 联系人导出服务 - */ -class ContactExportService { - /** - * 导出联系人 - */ - async exportContacts( - outputDir: string, - options: ContactExportOptions - ): Promise<{ success: boolean; successCount?: number; error?: string }> { - try { - // 获取所有联系人 - const contactsResult = await chatService.getContacts() - if (!contactsResult.success || !contactsResult.contacts) { - return { success: false, error: contactsResult.error || '获取联系人失败' } - } - - let contacts = contactsResult.contacts - - // 根据类型过滤 - contacts = contacts.filter(c => { - if (c.type === 'friend' && !options.contactTypes.friends) return false - if (c.type === 'group' && !options.contactTypes.groups) return false - if (c.type === 'official' && !options.contactTypes.officials) return false - return true - }) - - if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) { - const selectedSet = new Set(options.selectedUsernames) - contacts = contacts.filter(c => selectedSet.has(c.username)) - } - - if (contacts.length === 0) { - return { success: false, error: '没有符合条件的联系人' } - } - - // 确保输出目录存在 - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }) - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) - let outputPath: string - - switch (options.format) { - case 'json': - outputPath = path.join(outputDir, `contacts_${timestamp}.json`) - await this.exportToJSON(contacts, outputPath) - break - case 'csv': - outputPath = path.join(outputDir, `contacts_${timestamp}.csv`) - await this.exportToCSV(contacts, outputPath) - break - case 'vcf': - outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`) - await this.exportToVCF(contacts, outputPath) - break - default: - return { success: false, error: '不支持的导出格式' } - } - - return { success: true, successCount: contacts.length } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 导出为JSON格式 - */ - private async exportToJSON(contacts: any[], outputPath: string): Promise<void> { - const data = { - exportedAt: new Date().toISOString(), - count: contacts.length, - contacts: contacts.map(c => ({ - username: c.username, - displayName: c.displayName, - remark: c.remark, - nickname: c.nickname, - alias: c.alias, - labels: Array.isArray(c.labels) ? c.labels : [], - detailDescription: c.detailDescription, - type: c.type - })) - } - fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8') - } - - /** - * 导出为CSV格式 - */ - private async exportToCSV(contacts: any[], outputPath: string): Promise<void> { - const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型'] - const rows = contacts.map(c => [ - c.username || '', - c.displayName || '', - c.remark || '', - c.nickname || '', - c.alias || '', - Array.isArray(c.labels) ? c.labels.join(' | ') : '', - c.detailDescription || '', - this.getTypeLabel(c.type) - ]) - - const csvContent = [ - headers.join(','), - ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) - ].join('\n') - - fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel - } - - /** - * 导出为VCF格式(vCard) - */ - private async exportToVCF(contacts: any[], outputPath: string): Promise<void> { - const vcards = contacts - .filter(c => c.type === 'friend') // VCF通常只用于个人联系人 - .map(c => { - const lines = ['BEGIN:VCARD', 'VERSION:3.0'] - - // 全名 - lines.push(`FN:${c.displayName || c.username}`) - - // 昵称 - if (c.nickname) { - lines.push(`NICKNAME:${c.nickname}`) - } - - const noteParts = [ - c.remark ? String(c.remark) : '', - Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '', - c.detailDescription ? `详细描述: ${c.detailDescription}` : '' - ].filter(Boolean) - if (noteParts.length > 0) { - lines.push(`NOTE:${noteParts.join('\\n')}`) - } - - // 微信ID - lines.push(`X-WECHAT-ID:${c.username}`) - - lines.push('END:VCARD') - return lines.join('\r\n') - }) - - fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8') - } - - private getTypeLabel(type: string): string { - switch (type) { - case 'friend': return '好友' - case 'group': return '群聊' - case 'official': return '公众号' - default: return '其他' - } - } -} - -export const contactExportService = new ContactExportService() diff --git a/electron/services/contactRegionLookupData.ts b/electron/services/contactRegionLookupData.ts deleted file mode 100644 index db597c1..0000000 --- a/electron/services/contactRegionLookupData.ts +++ /dev/null @@ -1,9440 +0,0 @@ -// Auto-generated from province-city-china (8.5.8) + pypinyin (0.55.0). -// Do not hand-edit this file. - -export interface ContactRegionLookupData { - countryNameByKey: Record<string, string> - provinceNameByKey: Record<string, string> - provinceKeyByName: Record<string, string> - cityNameByProvinceKey: Record<string, Record<string, string>> - cityNameByKey: Record<string, string> -} - -export const CONTACT_REGION_LOOKUP_DATA: ContactRegionLookupData = { - "countryNameByKey": { - "10": "南极洲", - "100": "保加利亚", - "104": "缅甸", - "108": "布隆迪", - "112": "白俄罗斯", - "116": "柬埔寨", - "12": "阿尔及利亚", - "120": "喀麦隆", - "124": "加拿大", - "132": "佛得角", - "136": "开曼群岛", - "140": "中非", - "144": "斯里兰卡", - "148": "乍得", - "152": "智利", - "156": "中国", - "16": "美属萨摩亚", - "162": "圣诞岛", - "166": "科科斯(基林)群岛", - "170": "哥伦比亚", - "174": "科摩罗", - "175": "马约特", - "178": "刚果(布)", - "180": "刚果(金)", - "184": "库克群岛", - "188": "哥斯达黎加", - "191": "克罗地亚", - "192": "古巴", - "196": "塞浦路斯", - "20": "安道尔", - "203": "捷克", - "204": "贝宁", - "208": "丹麦", - "212": "多米尼克", - "214": "多米尼加", - "218": "厄瓜多尔", - "222": "萨尔瓦多", - "226": "赤道几内亚", - "231": "埃塞俄比亚", - "232": "厄立特里亚", - "233": "爱沙尼亚", - "234": "法罗群岛", - "238": "福克兰群岛(马尔维纳斯)", - "239": "南乔治亚岛和南桑德韦奇岛", - "24": "安哥拉", - "242": "斐济", - "246": "芬兰", - "248": "奥兰群岛", - "250": "法国", - "254": "法属圭亚那", - "258": "法属波利尼西亚", - "260": "法属南部领地", - "262": "吉布提", - "266": "加蓬", - "268": "格鲁吉亚", - "270": "冈比亚", - "275": "巴勒斯坦", - "276": "德国", - "28": "安提瓜和巴布达", - "288": "加纳", - "292": "直布罗陀", - "296": "基里巴斯", - "300": "希腊", - "304": "格陵兰", - "308": "格林纳达", - "31": "阿塞拜疆", - "312": "瓜德罗普", - "316": "关岛", - "32": "阿根廷", - "320": "危地马拉", - "324": "几内亚", - "328": "圭亚那", - "332": "海地", - "334": "赫德岛和麦克唐纳岛", - "336": "梵蒂冈", - "340": "洪都拉斯", - "348": "匈牙利", - "352": "冰岛", - "356": "印度", - "36": "澳大利亚", - "360": "印度尼西亚", - "364": "伊朗", - "368": "伊拉克", - "372": "爱尔兰", - "376": "以色列", - "380": "意大利", - "384": "科特迪瓦", - "388": "牙买加", - "392": "日本", - "398": "哈萨克斯坦", - "4": "阿富汗", - "40": "奥地利", - "400": "约旦", - "404": "肯尼亚", - "408": "朝鲜", - "410": "韩国", - "414": "科威特", - "417": "吉尔吉斯斯坦", - "418": "老挝", - "422": "黎巴嫩", - "426": "莱索托", - "428": "拉脱维亚", - "430": "利比里亚", - "434": "利比亚", - "438": "列支敦士登", - "44": "巴哈马", - "440": "立陶宛", - "442": "卢森堡", - "450": "马达加斯加", - "454": "马拉维", - "458": "马来西亚", - "462": "马尔代夫", - "466": "马里", - "470": "马耳他", - "474": "马提尼克", - "478": "毛利塔尼亚", - "48": "巴林", - "480": "毛里求斯", - "484": "墨西哥", - "492": "摩纳哥", - "496": "蒙古", - "498": "摩尔多瓦", - "499": "黑山", - "50": "孟加拉国", - "500": "蒙特塞拉特", - "504": "摩洛哥", - "508": "莫桑比克", - "51": "亚美尼亚", - "512": "阿曼", - "516": "纳米比亚", - "52": "巴巴多斯", - "520": "瑙鲁", - "524": "尼泊尔", - "528": "荷兰", - "530": "荷属安的列斯", - "533": "阿鲁巴", - "540": "新喀里多尼亚", - "548": "瓦努阿图", - "554": "新西兰", - "558": "尼加拉瓜", - "56": "比利时", - "562": "尼日尔", - "566": "尼日利亚", - "570": "纽埃", - "574": "诺福克岛", - "578": "挪威", - "580": "北马里亚纳", - "581": "美国本土外小岛屿", - "583": "密克罗尼西亚联邦", - "584": "马绍尔群岛", - "585": "帕劳", - "586": "巴基斯坦", - "591": "巴拿马", - "598": "巴布亚新几内亚", - "60": "百慕大", - "600": "巴拉圭", - "604": "秘鲁", - "608": "菲律宾", - "612": "皮特凯恩", - "616": "波兰", - "620": "葡萄牙", - "624": "几内亚比绍", - "626": "东帝汶", - "630": "波多黎各", - "634": "卡塔尔", - "638": "留尼汪", - "64": "不丹", - "642": "罗马尼亚", - "643": "俄罗斯联邦", - "646": "卢旺达", - "654": "圣赫勒拿", - "659": "圣基茨和尼维斯", - "660": "安圭拉", - "662": "圣卢西亚", - "666": "圣皮埃尔和密克隆", - "670": "圣文森特和格林纳丁斯", - "674": "圣马力诺", - "678": "圣多美和普林西比", - "68": "玻利维亚", - "682": "沙特阿拉伯", - "686": "塞内加尔", - "688": "塞尔维亚", - "690": "塞舌尔", - "694": "塞拉利昂", - "70": "波黑", - "702": "新加坡", - "703": "斯洛伐克", - "704": "越南", - "705": "斯洛文尼亚", - "706": "索马里", - "710": "南非", - "716": "津巴布韦", - "72": "博茨瓦纳", - "724": "西班牙", - "732": "西撒哈拉", - "736": "苏丹", - "74": "布维岛", - "740": "苏里南", - "744": "斯瓦尔巴岛和扬马延岛", - "748": "斯威士兰", - "752": "瑞典", - "756": "瑞士", - "76": "巴西", - "760": "叙利亚", - "762": "塔吉克斯坦", - "764": "泰国", - "768": "多哥", - "772": "托克劳", - "776": "汤加", - "780": "特立尼达和多巴哥", - "784": "阿联酋", - "788": "突尼斯", - "792": "土耳其", - "795": "土库曼斯坦", - "796": "特克斯和凯科斯群岛", - "798": "图瓦卢", - "8": "阿尔巴尼亚", - "800": "乌干达", - "804": "乌克兰", - "807": "前南马其顿", - "818": "埃及", - "826": "英国", - "831": "格恩西岛", - "832": "泽西岛", - "833": "英国属地曼岛", - "834": "坦桑尼亚", - "84": "伯利兹", - "840": "美国", - "850": "美属维尔京群岛", - "854": "布基纳法索", - "858": "乌拉圭", - "86": "英属印度洋领地", - "860": "乌兹别克斯坦", - "862": "委内瑞拉", - "876": "瓦利斯和富图纳", - "882": "萨摩亚", - "887": "也门", - "894": "赞比亚", - "90": "所罗门群岛", - "92": "英属维尔京群岛", - "96": "文莱", - "abw": "阿鲁巴", - "ad": "安道尔", - "ae": "阿联酋", - "af": "阿富汗", - "afg": "阿富汗", - "afghanistan": "阿富汗", - "ag": "安提瓜和巴布达", - "ago": "安哥拉", - "ai": "安圭拉", - "aia": "安圭拉", - "al": "阿尔巴尼亚", - "ala": "奥兰群岛", - "alandislands": "奥兰群岛", - "alb": "阿尔巴尼亚", - "albania": "阿尔巴尼亚", - "algeria": "阿尔及利亚", - "am": "亚美尼亚", - "americansamoa": "美属萨摩亚", - "an": "荷属安的列斯", - "and": "安道尔", - "andorra": "安道尔", - "angola": "安哥拉", - "anguilla": "安圭拉", - "ant": "荷属安的列斯", - "antarctica": "南极洲", - "antiguaandbarbuda": "安提瓜和巴布达", - "ao": "安哥拉", - "aq": "南极洲", - "ar": "阿根廷", - "arabrepublicofegypt": "埃及", - "are": "阿联酋", - "arg": "阿根廷", - "argentina": "阿根廷", - "argentinerepublic": "阿根廷", - "arm": "亚美尼亚", - "armenia": "亚美尼亚", - "aruba": "阿鲁巴", - "as": "美属萨摩亚", - "asm": "美属萨摩亚", - "at": "奥地利", - "ata": "南极洲", - "atf": "法属南部领地", - "atg": "安提瓜和巴布达", - "au": "澳大利亚", - "aus": "澳大利亚", - "australia": "澳大利亚", - "austria": "奥地利", - "aut": "奥地利", - "aw": "阿鲁巴", - "ax": "奥兰群岛", - "az": "阿塞拜疆", - "aze": "阿塞拜疆", - "azerbaijan": "阿塞拜疆", - "ba": "波黑", - "bahamas": "巴哈马", - "bahamasthe": "巴哈马", - "bahrain": "巴林", - "bangladesh": "孟加拉国", - "barbados": "巴巴多斯", - "bb": "巴巴多斯", - "bd": "孟加拉国", - "bdi": "布隆迪", - "be": "比利时", - "bel": "比利时", - "belarus": "白俄罗斯", - "belgium": "比利时", - "belize": "伯利兹", - "ben": "贝宁", - "benin": "贝宁", - "bermuda": "百慕大", - "bf": "布基纳法索", - "bfa": "布基纳法索", - "bg": "保加利亚", - "bgd": "孟加拉国", - "bgr": "保加利亚", - "bh": "巴林", - "bhr": "巴林", - "bhs": "巴哈马", - "bhutan": "不丹", - "bi": "布隆迪", - "bih": "波黑", - "bj": "贝宁", - "blr": "白俄罗斯", - "blz": "伯利兹", - "bm": "百慕大", - "bmu": "百慕大", - "bn": "文莱", - "bo": "玻利维亚", - "bol": "玻利维亚", - "bolivarianrepublicofvenezuela": "委内瑞拉", - "bolivia": "玻利维亚", - "bosniaandherzegovina": "波黑", - "botswana": "博茨瓦纳", - "bouvetisland": "布维岛", - "br": "巴西", - "bra": "巴西", - "brazil": "巴西", - "brb": "巴巴多斯", - "britishindianoceanterritory": "英属印度洋领地", - "britishindianoceanterritorythe": "英属印度洋领地", - "britishvirginislands": "英属维尔京群岛", - "britishvirginislandsthe": "英属维尔京群岛", - "brn": "文莱", - "bruneidarussalam": "文莱", - "bs": "巴哈马", - "bt": "不丹", - "btn": "不丹", - "bulgaria": "保加利亚", - "burkinafaso": "布基纳法索", - "burundi": "布隆迪", - "bv": "布维岛", - "bvt": "布维岛", - "bw": "博茨瓦纳", - "bwa": "博茨瓦纳", - "by": "白俄罗斯", - "bz": "伯利兹", - "ca": "加拿大", - "caf": "中非", - "cambodia": "柬埔寨", - "cameroon": "喀麦隆", - "can": "加拿大", - "canada": "加拿大", - "capeverde": "佛得角", - "caymanislands": "开曼群岛", - "caymanislandsthe": "开曼群岛", - "cc": "科科斯(基林)群岛", - "cck": "科科斯(基林)群岛", - "cd": "刚果(金)", - "centralafricanrepublic": "中非", - "centralafricanrepublicthe": "中非", - "cf": "中非", - "cg": "刚果(布)", - "ch": "瑞士", - "chad": "乍得", - "che": "瑞士", - "chile": "智利", - "china": "中国", - "chl": "智利", - "chn": "中国", - "christmasisland": "圣诞岛", - "ci": "科特迪瓦", - "civ": "科特迪瓦", - "ck": "库克群岛", - "cl": "智利", - "cm": "喀麦隆", - "cmr": "喀麦隆", - "cn": "中国", - "co": "哥伦比亚", - "cocoskeelingislands": "科科斯(基林)群岛", - "cocoskeelingislandsthe": "科科斯(基林)群岛", - "cod": "刚果(金)", - "cog": "刚果(布)", - "cok": "库克群岛", - "col": "哥伦比亚", - "colombia": "哥伦比亚", - "com": "科摩罗", - "commonwealthofbahamas": "巴哈马", - "commonwealthofdominica": "多米尼克", - "commonwealthofnorrnmarianaislands": "北马里亚纳", - "comoros": "科摩罗", - "congo": "刚果(布)", - "congodemocraticrepublicof": "刚果(金)", - "congothedemocraticrepublicofthe": "刚果(金)", - "cookislands": "库克群岛", - "cookislandsthe": "库克群岛", - "costarica": "哥斯达黎加", - "cpv": "佛得角", - "cr": "哥斯达黎加", - "cri": "哥斯达黎加", - "croatia": "克罗地亚", - "ctedivoire": "科特迪瓦", - "cu": "古巴", - "cub": "古巴", - "cuba": "古巴", - "cv": "佛得角", - "cx": "圣诞岛", - "cxr": "圣诞岛", - "cy": "塞浦路斯", - "cym": "开曼群岛", - "cyp": "塞浦路斯", - "cyprus": "塞浦路斯", - "cz": "捷克", - "cze": "捷克", - "czechrepublic": "捷克", - "czechrepublicthe": "捷克", - "de": "德国", - "democraticpeoplesrepublicofkorea": "朝鲜", - "democraticrepublicofcongo": "刚果(金)", - "democraticrepublicofsaotomeandprincipe": "圣多美和普林西比", - "democraticrepublicoftimorleste": "东帝汶", - "democraticsocialistrepublicofsrilanka": "斯里兰卡", - "denmark": "丹麦", - "deu": "德国", - "dj": "吉布提", - "dji": "吉布提", - "djibouti": "吉布提", - "dk": "丹麦", - "dm": "多米尼克", - "dma": "多米尼克", - "dnk": "丹麦", - "do": "多米尼加", - "dom": "多米尼加", - "dominica": "多米尼克", - "dominicanrepublic": "多米尼加", - "dominicanrepublicthe": "多米尼加", - "dz": "阿尔及利亚", - "dza": "阿尔及利亚", - "easternrepublicofuruguay": "乌拉圭", - "ec": "厄瓜多尔", - "ecu": "厄瓜多尔", - "ecuador": "厄瓜多尔", - "ee": "爱沙尼亚", - "eg": "埃及", - "egy": "埃及", - "egypt": "埃及", - "eh": "西撒哈拉", - "elsalvador": "萨尔瓦多", - "equatorialguinea": "赤道几内亚", - "er": "厄立特里亚", - "eri": "厄立特里亚", - "eritrea": "厄立特里亚", - "es": "西班牙", - "esh": "西撒哈拉", - "esp": "西班牙", - "est": "爱沙尼亚", - "estonia": "爱沙尼亚", - "et": "埃塞俄比亚", - "eth": "埃塞俄比亚", - "ethiopia": "埃塞俄比亚", - "falklandislandsmalvinas": "福克兰群岛(马尔维纳斯)", - "falklandislandsthemalvinas": "福克兰群岛(马尔维纳斯)", - "faroeislands": "法罗群岛", - "faroeislandsthe": "法罗群岛", - "federaldemocraticrepublicofethiopia": "埃塞俄比亚", - "federalrepublicofnigeria": "尼日利亚", - "federatedstatesofmicronesia": "密克罗尼西亚联邦", - "federativerepublicofbrazil": "巴西", - "fi": "芬兰", - "fiji": "斐济", - "fin": "芬兰", - "finland": "芬兰", - "fj": "斐济", - "fji": "斐济", - "fk": "福克兰群岛(马尔维纳斯)", - "flk": "福克兰群岛(马尔维纳斯)", - "fm": "密克罗尼西亚联邦", - "fo": "法罗群岛", - "formeryugoslavrepublicofmacedonia": "前南马其顿", - "fr": "法国", - "fra": "法国", - "france": "法国", - "frenchguiana": "法属圭亚那", - "frenchpolynesia": "法属波利尼西亚", - "frenchrepublic": "法国", - "frenchsournterritories": "法属南部领地", - "frenchsouthernterritoriesthe": "法属南部领地", - "fro": "法罗群岛", - "fsm": "密克罗尼西亚联邦", - "ga": "加蓬", - "gab": "加蓬", - "gabon": "加蓬", - "gaboneserepublic": "加蓬", - "gambia": "冈比亚", - "gambiathe": "冈比亚", - "gb": "英国", - "gbr": "英国", - "gd": "格林纳达", - "ge": "格鲁吉亚", - "geo": "格鲁吉亚", - "georgia": "格鲁吉亚", - "germany": "德国", - "gf": "法属圭亚那", - "gg": "格恩西岛", - "ggy": "格恩西岛", - "gh": "加纳", - "gha": "加纳", - "ghana": "加纳", - "gi": "直布罗陀", - "gib": "直布罗陀", - "gibraltar": "直布罗陀", - "gin": "几内亚", - "gl": "格陵兰", - "glp": "瓜德罗普", - "gm": "冈比亚", - "gmb": "冈比亚", - "gn": "几内亚", - "gnb": "几内亚比绍", - "gnq": "赤道几内亚", - "gp": "瓜德罗普", - "gq": "赤道几内亚", - "gr": "希腊", - "grandduchyofluxembourg": "卢森堡", - "grc": "希腊", - "grd": "格林纳达", - "greece": "希腊", - "greenland": "格陵兰", - "grenada": "格林纳达", - "grl": "格陵兰", - "gs": "南乔治亚岛和南桑德韦奇岛", - "gt": "危地马拉", - "gtm": "危地马拉", - "gu": "关岛", - "guadeloupe": "瓜德罗普", - "guam": "关岛", - "guatemala": "危地马拉", - "guernsey": "格恩西岛", - "guf": "法属圭亚那", - "guinea": "几内亚", - "guineabissau": "几内亚比绍", - "gum": "关岛", - "guy": "圭亚那", - "guyana": "圭亚那", - "gw": "几内亚比绍", - "gy": "圭亚那", - "haiti": "海地", - "hashemitekingdomofjordan": "约旦", - "heardislandandmcdonaldislands": "赫德岛和麦克唐纳岛", - "hefederalrepublicofgermany": "德国", - "hellenicrepublic": "希腊", - "herepublicofmontenegro": "黑山", - "hestateofkuwait": "科威特", - "hm": "赫德岛和麦克唐纳岛", - "hmd": "赫德岛和麦克唐纳岛", - "hn": "洪都拉斯", - "hnd": "洪都拉斯", - "holyseethevaticancitystate": "梵蒂冈", - "holyseevaticancitystate": "梵蒂冈", - "honduras": "洪都拉斯", - "hr": "克罗地亚", - "hrv": "克罗地亚", - "ht": "海地", - "hti": "海地", - "hu": "匈牙利", - "hun": "匈牙利", - "hungary": "匈牙利", - "iceland": "冰岛", - "id": "印度尼西亚", - "idn": "印度尼西亚", - "ie": "爱尔兰", - "il": "以色列", - "im": "英国属地曼岛", - "imn": "英国属地曼岛", - "in": "印度", - "ind": "印度", - "independentstateofsamoa": "萨摩亚", - "india": "印度", - "indonesia": "印度尼西亚", - "io": "英属印度洋领地", - "iot": "英属印度洋领地", - "iq": "伊拉克", - "ir": "伊朗", - "iranislamicrepublicof": "伊朗", - "irantheislamicrepublicof": "伊朗", - "iraq": "伊拉克", - "ireland": "爱尔兰", - "irl": "爱尔兰", - "irn": "伊朗", - "irq": "伊拉克", - "is": "冰岛", - "isl": "冰岛", - "islamicrepublicofafghanistan": "阿富汗", - "islamicrepublicofiran": "伊朗", - "islamicrepublicofmauritania": "毛利塔尼亚", - "islamicrepublicofpakistan": "巴基斯坦", - "isleofman": "英国属地曼岛", - "isr": "以色列", - "israel": "以色列", - "it": "意大利", - "ita": "意大利", - "italy": "意大利", - "jam": "牙买加", - "jamaica": "牙买加", - "japan": "日本", - "je": "泽西岛", - "jersey": "泽西岛", - "jey": "泽西岛", - "jm": "牙买加", - "jo": "约旦", - "jor": "约旦", - "jordan": "约旦", - "jp": "日本", - "jpn": "日本", - "kaz": "哈萨克斯坦", - "kazakhstan": "哈萨克斯坦", - "ke": "肯尼亚", - "ken": "肯尼亚", - "kenya": "肯尼亚", - "kg": "吉尔吉斯斯坦", - "kgz": "吉尔吉斯斯坦", - "kh": "柬埔寨", - "khm": "柬埔寨", - "ki": "基里巴斯", - "kingdomofbahrain": "巴林", - "kingdomofbelgium": "比利时", - "kingdomofbhutan": "不丹", - "kingdomofcambodia": "柬埔寨", - "kingdomofdenmark": "丹麦", - "kingdomoflesotho": "莱索托", - "kingdomofmorocco": "摩洛哥", - "kingdomofnerlands": "荷兰", - "kingdomofnorway": "挪威", - "kingdomofsaudiarabia": "沙特阿拉伯", - "kingdomofspain": "西班牙", - "kingdomofswaziland": "斯威士兰", - "kingdomofsweden": "瑞典", - "kingdomofthailand": "泰国", - "kingdomoftonga": "汤加", - "kir": "基里巴斯", - "kiribati": "基里巴斯", - "km": "科摩罗", - "kn": "圣基茨和尼维斯", - "kna": "圣基茨和尼维斯", - "kor": "韩国", - "koreademocraticpeoplesrepublicof": "朝鲜", - "korearepublicof": "韩国", - "koreathedemocraticpeoplesrepublicof": "朝鲜", - "koreatherepublicof": "韩国", - "kp": "朝鲜", - "kr": "韩国", - "kuwait": "科威特", - "kw": "科威特", - "kwt": "科威特", - "ky": "开曼群岛", - "kyrgyzrepublic": "吉尔吉斯斯坦", - "kyrgyzstan": "吉尔吉斯斯坦", - "kz": "哈萨克斯坦", - "la": "老挝", - "lao": "老挝", - "laopeoplesdemocraticrepublic": "老挝", - "laopeoplesdemocraticrepublicthe": "老挝", - "latvia": "拉脱维亚", - "lb": "黎巴嫩", - "lbn": "黎巴嫩", - "lbr": "利比里亚", - "lby": "利比亚", - "lc": "圣卢西亚", - "lca": "圣卢西亚", - "lebaneserepublic": "黎巴嫩", - "lebanon": "黎巴嫩", - "lesotho": "莱索托", - "li": "列支敦士登", - "liberia": "利比里亚", - "libyanarabjamahiriya": "利比亚", - "libyanarabjamahiriyathe": "利比亚", - "lie": "列支敦士登", - "liechtenstein": "列支敦士登", - "lithuania": "立陶宛", - "lk": "斯里兰卡", - "lka": "斯里兰卡", - "lr": "利比里亚", - "ls": "莱索托", - "lso": "莱索托", - "lt": "立陶宛", - "ltu": "立陶宛", - "lu": "卢森堡", - "lux": "卢森堡", - "luxembourg": "卢森堡", - "lv": "拉脱维亚", - "lva": "拉脱维亚", - "ly": "利比亚", - "ma": "摩洛哥", - "macedoniaformeryugoslavrepublicof": "前南马其顿", - "macedoniatheformeryugoslavrepublicof": "前南马其顿", - "madagascar": "马达加斯加", - "malawi": "马拉维", - "malaysia": "马来西亚", - "maldives": "马尔代夫", - "mali": "马里", - "malta": "马耳他", - "mar": "摩洛哥", - "marshallislands": "马绍尔群岛", - "marshallislandsthe": "马绍尔群岛", - "martinique": "马提尼克", - "mauritania": "毛利塔尼亚", - "mauritius": "毛里求斯", - "mayotte": "马约特", - "mc": "摩纳哥", - "mco": "摩纳哥", - "md": "摩尔多瓦", - "mda": "摩尔多瓦", - "mdg": "马达加斯加", - "mdv": "马尔代夫", - "me": "黑山", - "mex": "墨西哥", - "mexico": "墨西哥", - "mg": "马达加斯加", - "mh": "马绍尔群岛", - "mhl": "马绍尔群岛", - "micronesiafederatedstatesof": "密克罗尼西亚联邦", - "micronesiathefederatedstatesof": "密克罗尼西亚联邦", - "mk": "前南马其顿", - "mkd": "前南马其顿", - "ml": "马里", - "mli": "马里", - "mlt": "马耳他", - "mm": "缅甸", - "mmr": "缅甸", - "mn": "蒙古", - "mne": "黑山", - "mng": "蒙古", - "mnp": "北马里亚纳", - "moldovarepublicof": "摩尔多瓦", - "moldovatherepublicof": "摩尔多瓦", - "monaco": "摩纳哥", - "mongolia": "蒙古", - "montenegro": "黑山", - "montserrat": "蒙特塞拉特", - "morocco": "摩洛哥", - "moz": "莫桑比克", - "mozambique": "莫桑比克", - "mp": "北马里亚纳", - "mq": "马提尼克", - "mr": "毛利塔尼亚", - "mrt": "毛利塔尼亚", - "ms": "蒙特塞拉特", - "msr": "蒙特塞拉特", - "mt": "马耳他", - "mtq": "马提尼克", - "mu": "毛里求斯", - "mus": "毛里求斯", - "mv": "马尔代夫", - "mw": "马拉维", - "mwi": "马拉维", - "mx": "墨西哥", - "my": "马来西亚", - "myanmar": "缅甸", - "mys": "马来西亚", - "myt": "马约特", - "mz": "莫桑比克", - "na": "纳米比亚", - "nam": "纳米比亚", - "namibia": "纳米比亚", - "nauru": "瑙鲁", - "nc": "新喀里多尼亚", - "ncl": "新喀里多尼亚", - "ne": "尼日尔", - "nepal": "尼泊尔", - "ner": "尼日尔", - "nerlands": "荷兰", - "nerlandsantilles": "荷属安的列斯", - "netherlandsantillesthe": "荷属安的列斯", - "netherlandsthe": "荷兰", - "newcaledonia": "新喀里多尼亚", - "newzealand": "新西兰", - "nf": "诺福克岛", - "nfk": "诺福克岛", - "ng": "尼日利亚", - "nga": "尼日利亚", - "ni": "尼加拉瓜", - "nic": "尼加拉瓜", - "nicaragua": "尼加拉瓜", - "niger": "尼日尔", - "nigeria": "尼日利亚", - "nigerthe": "尼日尔", - "niu": "纽埃", - "niue": "纽埃", - "nl": "荷兰", - "nld": "荷兰", - "no": "挪威", - "nor": "挪威", - "norfolkisland": "诺福克岛", - "norrnmarianaislands": "北马里亚纳", - "northernmarianaislandsthe": "北马里亚纳", - "norway": "挪威", - "np": "尼泊尔", - "npl": "尼泊尔", - "nr": "瑙鲁", - "nru": "瑙鲁", - "nu": "纽埃", - "nz": "新西兰", - "nzl": "新西兰", - "occupiedpalestinianterritory": "巴勒斯坦", - "om": "阿曼", - "oman": "阿曼", - "omn": "阿曼", - "pa": "巴拿马", - "pak": "巴基斯坦", - "pakistan": "巴基斯坦", - "palau": "帕劳", - "palestinianterritoryoccupied": "巴勒斯坦", - "palestinianterritorytheoccupied": "巴勒斯坦", - "pan": "巴拿马", - "panama": "巴拿马", - "papuanewguinea": "巴布亚新几内亚", - "paraguay": "巴拉圭", - "pcn": "皮特凯恩", - "pe": "秘鲁", - "peoplesdemocraticrepublicofalgeria": "阿尔及利亚", - "peoplesrepublicofbangladesh": "孟加拉国", - "peoplesrepublicofchina": "中国", - "per": "秘鲁", - "peru": "秘鲁", - "pf": "法属波利尼西亚", - "pg": "巴布亚新几内亚", - "ph": "菲律宾", - "philippines": "菲律宾", - "philippinesthe": "菲律宾", - "phl": "菲律宾", - "pitcairn": "皮特凯恩", - "pk": "巴基斯坦", - "pl": "波兰", - "plw": "帕劳", - "pm": "圣皮埃尔和密克隆", - "pn": "皮特凯恩", - "png": "巴布亚新几内亚", - "pol": "波兰", - "poland": "波兰", - "portugal": "葡萄牙", - "portugueserepublic": "葡萄牙", - "pr": "波多黎各", - "pri": "波多黎各", - "principalityofandorra": "安道尔", - "principalityofliechtenstein": "列支敦士登", - "principalityofmonaco": "摩纳哥", - "prk": "朝鲜", - "prt": "葡萄牙", - "pry": "巴拉圭", - "ps": "巴勒斯坦", - "pse": "巴勒斯坦", - "pt": "葡萄牙", - "puertorico": "波多黎各", - "pw": "帕劳", - "py": "巴拉圭", - "pyf": "法属波利尼西亚", - "qa": "卡塔尔", - "qat": "卡塔尔", - "qatar": "卡塔尔", - "re": "留尼汪", - "republicofalbania": "阿尔巴尼亚", - "republicofangola": "安哥拉", - "republicofarmenia": "亚美尼亚", - "republicofaustria": "奥地利", - "republicofazerbaijan": "阿塞拜疆", - "republicofbelarus": "白俄罗斯", - "republicofbenin": "贝宁", - "republicofbolivia": "玻利维亚", - "republicofbotswana": "博茨瓦纳", - "republicofbulgaria": "保加利亚", - "republicofburundi": "布隆迪", - "republicofcameroon": "喀麦隆", - "republicofcapeverde": "佛得角", - "republicofchad": "乍得", - "republicofchile": "智利", - "republicofcolombia": "哥伦比亚", - "republicofcongo": "刚果(布)", - "republicofcostarica": "哥斯达黎加", - "republicofcroatia": "克罗地亚", - "republicofctedivoire": "科特迪瓦", - "republicofcuba": "古巴", - "republicofcyprus": "塞浦路斯", - "republicofdjibouti": "吉布提", - "republicofecuador": "厄瓜多尔", - "republicofelsalvador": "萨尔瓦多", - "republicofequatorialguinea": "赤道几内亚", - "republicofestonia": "爱沙尼亚", - "republicoffijiislands": "斐济", - "republicoffinland": "芬兰", - "republicofgambia": "冈比亚", - "republicofghana": "加纳", - "republicofguatemala": "危地马拉", - "republicofguinea": "几内亚", - "republicofguineabissau": "几内亚比绍", - "republicofguyana": "圭亚那", - "republicofhaiti": "海地", - "republicofhonduras": "洪都拉斯", - "republicofhungary": "匈牙利", - "republicoficeland": "冰岛", - "republicofindia": "印度", - "republicofindonesia": "印度尼西亚", - "republicofiraq": "伊拉克", - "republicofitaly": "意大利", - "republicofkazakhstan": "哈萨克斯坦", - "republicofkenya": "肯尼亚", - "republicofkiribati": "基里巴斯", - "republicofkorea": "韩国", - "republicoflatvia": "拉脱维亚", - "republicofliberia": "利比里亚", - "republicoflithuania": "立陶宛", - "republicofmadagascar": "马达加斯加", - "republicofmalawi": "马拉维", - "republicofmaldives": "马尔代夫", - "republicofmali": "马里", - "republicofmalta": "马耳他", - "republicofmarshallislands": "马绍尔群岛", - "republicofmauritius": "毛里求斯", - "republicofmoldova": "摩尔多瓦", - "republicofmozambique": "莫桑比克", - "republicofnamibia": "纳米比亚", - "republicofnauru": "瑙鲁", - "republicofnicaragua": "尼加拉瓜", - "republicofniger": "尼日尔", - "republicofniue": "纽埃", - "republicofpalau": "帕劳", - "republicofpanama": "巴拿马", - "republicofparaguay": "巴拉圭", - "republicofperu": "秘鲁", - "republicofphilippines": "菲律宾", - "republicofpoland": "波兰", - "republicofrwanda": "卢旺达", - "republicofsanmarino": "圣马力诺", - "republicofsenegal": "塞内加尔", - "republicofserbia": "塞尔维亚", - "republicofseychelles": "塞舌尔", - "republicofsierraleone": "塞拉利昂", - "republicofsingapore": "新加坡", - "republicofslovenia": "斯洛文尼亚", - "republicofsouthafrica": "南非", - "republicofsudan": "苏丹", - "republicofsuriname": "苏里南", - "republicoftajikistan": "塔吉克斯坦", - "republicoftrinidadandtobago": "特立尼达和多巴哥", - "republicoftunisia": "突尼斯", - "republicofturkey": "土耳其", - "republicofuganda": "乌干达", - "republicofuzbekistan": "乌兹别克斯坦", - "republicofvanuatu": "瓦努阿图", - "republicofyemen": "也门", - "republicofzambia": "赞比亚", - "republicofzimbabwe": "津巴布韦", - "reu": "留尼汪", - "ro": "罗马尼亚", - "romania": "罗马尼亚", - "rou": "罗马尼亚", - "rs": "塞尔维亚", - "ru": "俄罗斯联邦", - "runion": "留尼汪", - "rus": "俄罗斯联邦", - "russianfederation": "俄罗斯联邦", - "russianfederationthe": "俄罗斯联邦", - "rw": "卢旺达", - "rwa": "卢旺达", - "rwanda": "卢旺达", - "sa": "沙特阿拉伯", - "sainlena": "圣赫勒拿", - "sainthelena": "圣赫勒拿", - "saintkittsandnevis": "圣基茨和尼维斯", - "saintlucia": "圣卢西亚", - "saintpierreandmiquelon": "圣皮埃尔和密克隆", - "saintvincentandgrenadines": "圣文森特和格林纳丁斯", - "saintvincentandthegrenadines": "圣文森特和格林纳丁斯", - "samoa": "萨摩亚", - "sanmarino": "圣马力诺", - "saotomeandprincipe": "圣多美和普林西比", - "sau": "沙特阿拉伯", - "saudiarabia": "沙特阿拉伯", - "sb": "所罗门群岛", - "sc": "塞舌尔", - "sd": "苏丹", - "sdn": "苏丹", - "se": "瑞典", - "sen": "塞内加尔", - "senegal": "塞内加尔", - "serbia": "塞尔维亚", - "seychelles": "塞舌尔", - "sg": "新加坡", - "sgp": "新加坡", - "sgs": "南乔治亚岛和南桑德韦奇岛", - "sh": "圣赫勒拿", - "shn": "圣赫勒拿", - "si": "斯洛文尼亚", - "sierraleone": "塞拉利昂", - "singapore": "新加坡", - "sj": "斯瓦尔巴岛和扬马延岛", - "sjm": "斯瓦尔巴岛和扬马延岛", - "sk": "斯洛伐克", - "sl": "塞拉利昂", - "slb": "所罗门群岛", - "sle": "塞拉利昂", - "slovakia": "斯洛伐克", - "slovakrepublic": "斯洛伐克", - "slovenia": "斯洛文尼亚", - "slv": "萨尔瓦多", - "sm": "圣马力诺", - "smr": "圣马力诺", - "sn": "塞内加尔", - "so": "索马里", - "socialistpeopleslibyanarabjamahiriya": "利比亚", - "socialistrepublicofvietnam": "越南", - "solomonislands": "所罗门群岛", - "solomonislandsthe": "所罗门群岛", - "som": "索马里", - "somalia": "索马里", - "somalirepublic": "索马里", - "southafrica": "南非", - "southgeorgiaandsouthsandwichislands": "南乔治亚岛和南桑德韦奇岛", - "southgeorgiaandthesouthsandwichislands": "南乔治亚岛和南桑德韦奇岛", - "spain": "西班牙", - "spm": "圣皮埃尔和密克隆", - "sr": "苏里南", - "srb": "塞尔维亚", - "srilanka": "斯里兰卡", - "st": "圣多美和普林西比", - "stateofisrael": "以色列", - "stateofqatar": "卡塔尔", - "stp": "圣多美和普林西比", - "sudan": "苏丹", - "sudanthe": "苏丹", - "sultanateofoman": "阿曼", - "sur": "苏里南", - "suriname": "苏里南", - "sv": "萨尔瓦多", - "svalbardandjanmayen": "斯瓦尔巴岛和扬马延岛", - "svk": "斯洛伐克", - "svn": "斯洛文尼亚", - "swaziland": "斯威士兰", - "swe": "瑞典", - "sweden": "瑞典", - "swissconfederation": "瑞士", - "switzerland": "瑞士", - "swz": "斯威士兰", - "sy": "叙利亚", - "syc": "塞舌尔", - "syr": "叙利亚", - "syrianarabrepublic": "叙利亚", - "syrianarabrepublicthe": "叙利亚", - "sz": "斯威士兰", - "tajikistan": "塔吉克斯坦", - "tanzaniaunitedrepublicof": "坦桑尼亚", - "tc": "特克斯和凯科斯群岛", - "tca": "特克斯和凯科斯群岛", - "tcd": "乍得", - "td": "乍得", - "tf": "法属南部领地", - "tg": "多哥", - "tgo": "多哥", - "th": "泰国", - "tha": "泰国", - "thailand": "泰国", - "thearabrepublicofegypt": "埃及", - "theargentinerepublic": "阿根廷", - "thebolivarianrepublicofvenezuela": "委内瑞拉", - "thecentralafricanrepublic": "中非", - "thecommonwealthofdominica": "多米尼克", - "thecommonwealthofthebahamas": "巴哈马", - "thecommonwealthofthenorthernmarianaislands": "北马里亚纳", - "theczechrepublic": "捷克", - "thedemocraticpeoplesrepublicofkorea": "朝鲜", - "thedemocraticrepublicofsaotomeandprincipe": "圣多美和普林西比", - "thedemocraticrepublicofthecongo": "刚果(金)", - "thedemocraticrepublicoftimorleste": "东帝汶", - "thedemocraticsocialistrepublicofsrilanka": "斯里兰卡", - "thedominicanrepublic": "多米尼加", - "theeasternrepublicofuruguay": "乌拉圭", - "thefederaldemocraticrepublicofethiopia": "埃塞俄比亚", - "thefederalrepublicofnigeria": "尼日利亚", - "thefederatedstatesofmicronesia": "密克罗尼西亚联邦", - "thefederativerepublicofbrazil": "巴西", - "theformeryugoslavrepublicofmacedonia": "前南马其顿", - "thefrenchrepublic": "法国", - "thegaboneserepublic": "加蓬", - "thegrandduchyofluxembourg": "卢森堡", - "thehashemitekingdomofjordan": "约旦", - "thehellenicrepublic": "希腊", - "theindependentstateofsamoa": "萨摩亚", - "theislamicrepublicofafghanistan": "阿富汗", - "theislamicrepublicofiran": "伊朗", - "theislamicrepublicofmauritania": "毛利塔尼亚", - "theislamicrepublicofpakistan": "巴基斯坦", - "thekingdomofbahrain": "巴林", - "thekingdomofbelgium": "比利时", - "thekingdomofbhutan": "不丹", - "thekingdomofcambodia": "柬埔寨", - "thekingdomofdenmark": "丹麦", - "thekingdomoflesotho": "莱索托", - "thekingdomofmorocco": "摩洛哥", - "thekingdomofnorway": "挪威", - "thekingdomofsaudiarabia": "沙特阿拉伯", - "thekingdomofspain": "西班牙", - "thekingdomofswaziland": "斯威士兰", - "thekingdomofsweden": "瑞典", - "thekingdomofthailand": "泰国", - "thekingdomofthenetherlands": "荷兰", - "thekingdomoftonga": "汤加", - "thekyrgyzrepublic": "吉尔吉斯斯坦", - "thelaopeoplesdemocraticrepublic": "老挝", - "thelebaneserepublic": "黎巴嫩", - "theoccupiedpalestinianterritory": "巴勒斯坦", - "thepeoplesdemocraticrepublicofalgeria": "阿尔及利亚", - "thepeoplesrepublicofbangladesh": "孟加拉国", - "thepeoplesrepublicofchina": "中国", - "theportugueserepublic": "葡萄牙", - "theprincipalityofandorra": "安道尔", - "theprincipalityofliechtenstein": "列支敦士登", - "theprincipalityofmonaco": "摩纳哥", - "therepublicofalbania": "阿尔巴尼亚", - "therepublicofangola": "安哥拉", - "therepublicofarmenia": "亚美尼亚", - "therepublicofaustria": "奥地利", - "therepublicofazerbaijan": "阿塞拜疆", - "therepublicofbelarus": "白俄罗斯", - "therepublicofbenin": "贝宁", - "therepublicofbolivia": "玻利维亚", - "therepublicofbotswana": "博茨瓦纳", - "therepublicofbulgaria": "保加利亚", - "therepublicofburundi": "布隆迪", - "therepublicofcameroon": "喀麦隆", - "therepublicofcapeverde": "佛得角", - "therepublicofchad": "乍得", - "therepublicofchile": "智利", - "therepublicofcolombia": "哥伦比亚", - "therepublicofcostarica": "哥斯达黎加", - "therepublicofcroatia": "克罗地亚", - "therepublicofctedivoire": "科特迪瓦", - "therepublicofcuba": "古巴", - "therepublicofcyprus": "塞浦路斯", - "therepublicofdjibouti": "吉布提", - "therepublicofecuador": "厄瓜多尔", - "therepublicofelsalvador": "萨尔瓦多", - "therepublicofequatorialguinea": "赤道几内亚", - "therepublicofestonia": "爱沙尼亚", - "therepublicoffinland": "芬兰", - "therepublicofghana": "加纳", - "therepublicofguatemala": "危地马拉", - "therepublicofguinea": "几内亚", - "therepublicofguineabissau": "几内亚比绍", - "therepublicofguyana": "圭亚那", - "therepublicofhaiti": "海地", - "therepublicofhonduras": "洪都拉斯", - "therepublicofhungary": "匈牙利", - "therepublicoficeland": "冰岛", - "therepublicofindia": "印度", - "therepublicofindonesia": "印度尼西亚", - "therepublicofiraq": "伊拉克", - "therepublicofitaly": "意大利", - "therepublicofkazakhstan": "哈萨克斯坦", - "therepublicofkenya": "肯尼亚", - "therepublicofkiribati": "基里巴斯", - "therepublicofkorea": "韩国", - "therepublicoflatvia": "拉脱维亚", - "therepublicofliberia": "利比里亚", - "therepublicoflithuania": "立陶宛", - "therepublicofmadagascar": "马达加斯加", - "therepublicofmalawi": "马拉维", - "therepublicofmaldives": "马尔代夫", - "therepublicofmali": "马里", - "therepublicofmalta": "马耳他", - "therepublicofmauritius": "毛里求斯", - "therepublicofmoldova": "摩尔多瓦", - "therepublicofmozambique": "莫桑比克", - "therepublicofnamibia": "纳米比亚", - "therepublicofnauru": "瑙鲁", - "therepublicofnicaragua": "尼加拉瓜", - "therepublicofniue": "纽埃", - "therepublicofpalau": "帕劳", - "therepublicofpanama": "巴拿马", - "therepublicofparaguay": "巴拉圭", - "therepublicofperu": "秘鲁", - "therepublicofpoland": "波兰", - "therepublicofrwanda": "卢旺达", - "therepublicofsanmarino": "圣马力诺", - "therepublicofsenegal": "塞内加尔", - "therepublicofserbia": "塞尔维亚", - "therepublicofseychelles": "塞舌尔", - "therepublicofsierraleone": "塞拉利昂", - "therepublicofsingapore": "新加坡", - "therepublicofslovenia": "斯洛文尼亚", - "therepublicofsouthafrica": "南非", - "therepublicofsuriname": "苏里南", - "therepublicoftajikistan": "塔吉克斯坦", - "therepublicofthecongo": "刚果(布)", - "therepublicofthefijiislands": "斐济", - "therepublicofthegambia": "冈比亚", - "therepublicofthemarshallislands": "马绍尔群岛", - "therepublicoftheniger": "尼日尔", - "therepublicofthephilippines": "菲律宾", - "therepublicofthesudan": "苏丹", - "therepublicoftrinidadandtobago": "特立尼达和多巴哥", - "therepublicoftunisia": "突尼斯", - "therepublicofturkey": "土耳其", - "therepublicofuganda": "乌干达", - "therepublicofuzbekistan": "乌兹别克斯坦", - "therepublicofvanuatu": "瓦努阿图", - "therepublicofyemen": "也门", - "therepublicofzambia": "赞比亚", - "therepublicofzimbabwe": "津巴布韦", - "therussianfederation": "俄罗斯联邦", - "theslovakrepublic": "斯洛伐克", - "thesocialistpeopleslibyanarabjamahiriya": "利比亚", - "thesocialistrepublicofvietnam": "越南", - "thesomalirepublic": "索马里", - "thestateofisrael": "以色列", - "thestateofqatar": "卡塔尔", - "thesultanateofoman": "阿曼", - "theswissconfederation": "瑞士", - "thesyrianarabrepublic": "叙利亚", - "thetogoleserepublic": "多哥", - "theunionofmyanmar": "缅甸", - "theunionofthecomoros": "科摩罗", - "theunitedarabemirates": "阿联酋", - "theunitedkingdomofgreatbritainandnorthernireland": "英国", - "theunitedmexicanstates": "墨西哥", - "theunitedrepublicoftanzania": "坦桑尼亚", - "theunitedstatesofamerica": "美国", - "thevirginislandsoftheunitedstates": "美属维尔京群岛", - "timorleste": "东帝汶", - "tj": "塔吉克斯坦", - "tjk": "塔吉克斯坦", - "tk": "托克劳", - "tkl": "托克劳", - "tkm": "土库曼斯坦", - "tl": "东帝汶", - "tls": "东帝汶", - "tm": "土库曼斯坦", - "tn": "突尼斯", - "to": "汤加", - "togo": "多哥", - "togoleserepublic": "多哥", - "tokelau": "托克劳", - "ton": "汤加", - "tonga": "汤加", - "tr": "土耳其", - "trinidadandtobago": "特立尼达和多巴哥", - "tt": "特立尼达和多巴哥", - "tto": "特立尼达和多巴哥", - "tun": "突尼斯", - "tunisia": "突尼斯", - "tur": "土耳其", - "turkey": "土耳其", - "turkmenistan": "土库曼斯坦", - "turksandcaicosislands": "特克斯和凯科斯群岛", - "turksandcaicosislandsthe": "特克斯和凯科斯群岛", - "tuv": "图瓦卢", - "tuvalu": "图瓦卢", - "tv": "图瓦卢", - "tz": "坦桑尼亚", - "tza": "坦桑尼亚", - "ua": "乌克兰", - "ug": "乌干达", - "uga": "乌干达", - "uganda": "乌干达", - "ukr": "乌克兰", - "ukraine": "乌克兰", - "um": "美国本土外小岛屿", - "umi": "美国本土外小岛屿", - "unionofcomoros": "科摩罗", - "unionofmyanmar": "缅甸", - "unitedarabemirates": "阿联酋", - "unitedarabemiratesthe": "阿联酋", - "unitedkingdom": "英国", - "unitedkingdomofgreatbritainandnorrnireland": "英国", - "unitedkingdomthe": "英国", - "unitedmexicanstates": "墨西哥", - "unitedrepublicoftanzania": "坦桑尼亚", - "unitedstates": "美国", - "unitedstatesminoroutlyingislands": "美国本土外小岛屿", - "unitedstatesminoroutlyingislandsthe": "美国本土外小岛屿", - "unitedstatesofamerica": "美国", - "unitedstatesthe": "美国", - "uruguay": "乌拉圭", - "ury": "乌拉圭", - "us": "美国", - "usa": "美国", - "uy": "乌拉圭", - "uz": "乌兹别克斯坦", - "uzb": "乌兹别克斯坦", - "uzbekistan": "乌兹别克斯坦", - "va": "梵蒂冈", - "vanuatu": "瓦努阿图", - "vat": "梵蒂冈", - "vc": "圣文森特和格林纳丁斯", - "vct": "圣文森特和格林纳丁斯", - "ve": "委内瑞拉", - "ven": "委内瑞拉", - "venezuela": "委内瑞拉", - "vg": "英属维尔京群岛", - "vgb": "英属维尔京群岛", - "vi": "美属维尔京群岛", - "vietnam": "越南", - "vir": "美属维尔京群岛", - "virginislandsbritish": "英属维尔京群岛", - "virginislandsofunitedstates": "美属维尔京群岛", - "virginislandsus": "美属维尔京群岛", - "vn": "越南", - "vnm": "越南", - "vu": "瓦努阿图", - "vut": "瓦努阿图", - "wallisandfutuna": "瓦利斯和富图纳", - "wallisandfutunaislands": "瓦利斯和富图纳", - "westernsahara": "西撒哈拉", - "wf": "瓦利斯和富图纳", - "wlf": "瓦利斯和富图纳", - "ws": "萨摩亚", - "wsm": "萨摩亚", - "ye": "也门", - "yem": "也门", - "yemen": "也门", - "yt": "马约特", - "za": "南非", - "zaf": "南非", - "zambia": "赞比亚", - "zhongguo": "中国", - "zimbabwe": "津巴布韦", - "zm": "赞比亚", - "zmb": "赞比亚", - "zw": "津巴布韦", - "zwe": "津巴布韦" - }, - "provinceNameByKey": { - "anhui": "安徽", - "anhuisheng": "安徽", - "aomen": "澳门", - "aomentebiexingzhengqu": "澳门", - "beijing": "北京", - "beijingshi": "北京", - "chongqing": "重庆", - "chongqingshi": "重庆", - "fujian": "福建", - "fujiansheng": "福建", - "gansu": "甘肃", - "gansusheng": "甘肃", - "guangdong": "广东", - "guangdongsheng": "广东", - "guangxi": "广西", - "guangxizhuangzu": "广西", - "guangxizhuangzuzizhiqu": "广西", - "guizhou": "贵州", - "guizhousheng": "贵州", - "hainan": "海南", - "hainansheng": "海南", - "hebei": "河北", - "hebeisheng": "河北", - "heilongjiang": "黑龙江", - "heilongjiangsheng": "黑龙江", - "henan": "河南", - "henansheng": "河南", - "hubei": "湖北", - "hubeisheng": "湖北", - "hunan": "湖南", - "hunansheng": "湖南", - "jiangsu": "江苏", - "jiangsusheng": "江苏", - "jiangxi": "江西", - "jiangxisheng": "江西", - "jilin": "吉林", - "jilinsheng": "吉林", - "liaoning": "辽宁", - "liaoningsheng": "辽宁", - "neimenggu": "内蒙古", - "neimengguzizhiqu": "内蒙古", - "ningxia": "宁夏", - "ningxiahuizu": "宁夏", - "ningxiahuizuzizhiqu": "宁夏", - "qinghai": "青海", - "qinghaisheng": "青海", - "shandong": "山东", - "shandongsheng": "山东", - "shanghai": "上海", - "shanghaishi": "上海", - "shanxi": "陕西", - "shanxisheng": "陕西", - "sichuan": "四川", - "sichuansheng": "四川", - "taiwan": "台湾", - "taiwansheng": "台湾", - "tianjin": "天津", - "tianjinshi": "天津", - "xianggang": "香港", - "xianggangtebiexingzhengqu": "香港", - "xinjiang": "新疆", - "xinjiangweiwuer": "新疆", - "xinjiangweiwuerzizhiqu": "新疆", - "xizang": "西藏", - "xizangzizhiqu": "西藏", - "yunnan": "云南", - "yunnansheng": "云南", - "zhejiang": "浙江", - "zhejiangsheng": "浙江" - }, - "provinceKeyByName": { - "上海": "shanghai", - "上海市": "shanghai", - "云南": "yunnan", - "云南省": "yunnan", - "内蒙古": "neimenggu", - "内蒙古自治区": "neimenggu", - "北京": "beijing", - "北京市": "beijing", - "台湾": "taiwan", - "台湾省": "taiwan", - "吉林": "jilin", - "吉林省": "jilin", - "四川": "sichuan", - "四川省": "sichuan", - "天津": "tianjin", - "天津市": "tianjin", - "宁夏": "ningxia", - "宁夏回族自治区": "ningxia", - "安徽": "anhui", - "安徽省": "anhui", - "山东": "shandong", - "山东省": "shandong", - "山西": "shanxi", - "山西省": "shanxi", - "广东": "guangdong", - "广东省": "guangdong", - "广西": "guangxi", - "广西壮族自治区": "guangxi", - "新疆": "xinjiang", - "新疆维吾尔自治区": "xinjiang", - "江苏": "jiangsu", - "江苏省": "jiangsu", - "江西": "jiangxi", - "江西省": "jiangxi", - "河北": "hebei", - "河北省": "hebei", - "河南": "henan", - "河南省": "henan", - "浙江": "zhejiang", - "浙江省": "zhejiang", - "海南": "hainan", - "海南省": "hainan", - "湖北": "hubei", - "湖北省": "hubei", - "湖南": "hunan", - "湖南省": "hunan", - "澳门": "aomen", - "澳门特别行政区": "aomen", - "甘肃": "gansu", - "甘肃省": "gansu", - "福建": "fujian", - "福建省": "fujian", - "西藏": "xizang", - "西藏自治区": "xizang", - "贵州": "guizhou", - "贵州省": "guizhou", - "辽宁": "liaoning", - "辽宁省": "liaoning", - "重庆": "chongqing", - "重庆市": "chongqing", - "陕西": "shanxi", - "陕西省": "shanxi", - "青海": "qinghai", - "青海省": "qinghai", - "香港": "xianggang", - "香港特别行政区": "xianggang", - "黑龙江": "heilongjiang", - "黑龙江省": "heilongjiang" - }, - "cityNameByProvinceKey": { - "anhui": { - "anhuianqingjingjikaifaqu": "安徽安庆经济开发区", - "anhuiwuhusanshanjingjikaifaqu": "安徽芜湖三山经济开发区", - "anqing": "安庆", - "anqingshi": "安庆", - "bagongshanqu": "八公山区", - "bangshanqu": "蚌山区", - "baohequ": "包河区", - "bengbu": "蚌埠", - "bengbushi": "蚌埠", - "bengbushigaoxinjishukaifaqu": "蚌埠市高新技术开发区", - "bengbushijingjikaifaqu": "蚌埠市经济开发区", - "bowangqu": "博望区", - "bozhou": "亳州", - "bozhoushi": "亳州", - "chaohu": "巢湖市", - "chaohushi": "巢湖市", - "chizhou": "池州", - "chizhoushi": "池州", - "chuzhou": "滁州", - "chuzhoujingjijishukaifaqu": "滁州经济技术开发区", - "chuzhoushi": "滁州", - "daguanqu": "大观区", - "dangshanxian": "砀山县", - "dangtuxian": "当涂县", - "datongqu": "大通区", - "dingyuanxian": "定远县", - "dongzhixian": "东至县", - "dujiqu": "杜集区", - "fanchangqu": "繁昌区", - "feidongxian": "肥东县", - "feixixian": "肥西县", - "fengtaixian": "凤台县", - "fengyangxian": "凤阳县", - "funanxian": "阜南县", - "fuyang": "阜阳", - "fuyanghefeixiandaichanyeyuanqu": "阜阳合肥现代产业园区", - "fuyangjingjijishukaifaqu": "阜阳经济技术开发区", - "fuyangshi": "阜阳", - "guangde": "广德市", - "guangdeshi": "广德市", - "guichiqu": "贵池区", - "guzhenxian": "固镇县", - "hanshanxian": "含山县", - "hefei": "合肥", - "hefeigaoxinjishuchanyekaifaqu": "合肥高新技术产业开发区", - "hefeijingjijishukaifaqu": "合肥经济技术开发区", - "hefeishi": "合肥", - "hefeixinzhangaoxinjishuchanyekaifaqu": "合肥新站高新技术产业开发区", - "hexian": "和县", - "huaibei": "淮北", - "huaibeishi": "淮北", - "huainan": "淮南", - "huainanshi": "淮南", - "huainingxian": "怀宁县", - "huaishangqu": "淮上区", - "huaiyuanxian": "怀远县", - "huangshan": "黄山", - "huangshanqu": "黄山区", - "huangshanshi": "黄山", - "huashanqu": "花山区", - "huizhouqu": "徽州区", - "huoqiuxian": "霍邱县", - "huoshanxian": "霍山县", - "jiaoqu": "郊区", - "jieshou": "界首市", - "jieshoushi": "界首市", - "jinanqu": "金安区", - "jingdexian": "旌德县", - "jinghuqu": "镜湖区", - "jingxian": "泾县", - "jinzhaixian": "金寨县", - "jiujiangqu": "鸠江区", - "jixixian": "绩溪县", - "laianxian": "来安县", - "langxixian": "郎溪县", - "langyaqu": "琅琊区", - "lieshanqu": "烈山区", - "lingbixian": "灵璧县", - "linquanxian": "临泉县", - "lixinxian": "利辛县", - "longzihuqu": "龙子湖区", - "luan": "六安", - "luanshi": "六安", - "lujiangxian": "庐江县", - "luyangqu": "庐阳区", - "maanshan": "马鞍山", - "maanshanshi": "马鞍山", - "mengchengxian": "蒙城县", - "mingguang": "明光市", - "mingguangshi": "明光市", - "nanlingxian": "南陵县", - "nanqiaoqu": "南谯区", - "ningguo": "宁国市", - "ningguoshi": "宁国市", - "panjiqu": "潘集区", - "qianshan": "潜山市", - "qianshanshi": "潜山市", - "qiaochengqu": "谯城区", - "qimenxian": "祁门县", - "qingyangxian": "青阳县", - "quanjiaoxian": "全椒县", - "shexian": "歙县", - "shitaixian": "石台县", - "shixiaqu": "市辖区", - "shouxian": "寿县", - "shuchengxian": "舒城县", - "shushanqu": "蜀山区", - "sixian": "泗县", - "suixixian": "濉溪县", - "susongxian": "宿松县", - "suzhou": "宿州", - "suzhoujingjijishukaifaqu": "宿州经济技术开发区", - "suzhoumaanshanxiandaichanyeyuanqu": "宿州马鞍山现代产业园区", - "suzhoushi": "宿州", - "taihexian": "太和县", - "taihuxian": "太湖县", - "tianjiaanqu": "田家庵区", - "tianzhang": "天长市", - "tianzhangshi": "天长市", - "tongcheng": "桐城市", - "tongchengshi": "桐城市", - "tongguanqu": "铜官区", - "tongling": "铜陵", - "tonglingshi": "铜陵", - "tunxiqu": "屯溪区", - "wangjiangxian": "望江县", - "wanzhiqu": "湾沚区", - "woyangxian": "涡阳县", - "wuhexian": "五河县", - "wuhu": "芜湖", - "wuhujingjijishukaifaqu": "芜湖经济技术开发区", - "wuhushi": "芜湖", - "wuwei": "无为市", - "wuweishi": "无为市", - "xiangshanqu": "相山区", - "xiaoxian": "萧县", - "xiejiajiqu": "谢家集区", - "xiuningxian": "休宁县", - "xuancheng": "宣城", - "xuanchengshi": "宣城", - "xuanchengshijingjikaifaqu": "宣城市经济开发区", - "xuanzhouqu": "宣州区", - "yaohaiqu": "瑶海区", - "yejiqu": "叶集区", - "yianqu": "义安区", - "yijiangqu": "弋江区", - "yingdongqu": "颍东区", - "yingjiangqu": "迎江区", - "yingquanqu": "颍泉区", - "yingshangxian": "颍上县", - "yingzhouqu": "颍州区", - "yixian": "黟县", - "yixiuqu": "宜秀区", - "yongqiaoqu": "埇桥区", - "yuanqu": "裕安区", - "yuexixian": "岳西县", - "yuhuiqu": "禹会区", - "yushanqu": "雨山区", - "zhangfengxian": "长丰县", - "zhongxinsuchugaoxinjishuchanyekaifaqu": "中新苏滁高新技术产业开发区", - "zongyangxian": "枞阳县" - }, - "aomen": { - "datangqu": "大堂区", - "fengshuntangqu": "风顺堂区", - "huadimatangqu": "花地玛堂区", - "huawangtangqu": "花王堂区", - "jiamotangqu": "嘉模堂区", - "ludangtianhaiqu": "路凼填海区", - "shengfangjigetangqu": "圣方济各堂区", - "wangdetangqu": "望德堂区" - }, - "beijing": { - "changpingqu": "昌平区", - "chaoyangqu": "朝阳区", - "daxingqu": "大兴区", - "dongchengqu": "东城区", - "fangshanqu": "房山区", - "fengtaiqu": "丰台区", - "haidianqu": "海淀区", - "huairouqu": "怀柔区", - "mentougouqu": "门头沟区", - "miyunqu": "密云区", - "pingguqu": "平谷区", - "shijingshanqu": "石景山区", - "shunyiqu": "顺义区", - "tongzhouqu": "通州区", - "xichengqu": "西城区", - "yanqingqu": "延庆区" - }, - "chongqing": { - "bananqu": "巴南区", - "beibeiqu": "北碚区", - "bishanqu": "璧山区", - "changshouqu": "长寿区", - "chengkouxian": "城口县", - "dadukouqu": "大渡口区", - "dazuqu": "大足区", - "dianjiangxian": "垫江县", - "fengdouxian": "丰都县", - "fengjiexian": "奉节县", - "fulingqu": "涪陵区", - "hechuanqu": "合川区", - "jiangbeiqu": "江北区", - "jiangjinqu": "江津区", - "jiulongpoqu": "九龙坡区", - "kaizhouqu": "开州区", - "liangpingqu": "梁平区", - "nananqu": "南岸区", - "nanchuanqu": "南川区", - "pengshuimiaozutujiazuzizhixian": "彭水苗族土家族自治县", - "qianjiangqu": "黔江区", - "qijiangqu": "綦江区", - "rongchangqu": "荣昌区", - "shapingbaqu": "沙坪坝区", - "shizhutujiazuzizhixian": "石柱土家族自治县", - "tongliangqu": "铜梁区", - "tongnanqu": "潼南区", - "wanzhouqu": "万州区", - "wulongqu": "武隆区", - "wushanxian": "巫山县", - "wuxixian": "巫溪县", - "xiushantujiazumiaozuzizhixian": "秀山土家族苗族自治县", - "yongchuanqu": "永川区", - "youyangtujiazumiaozuzizhixian": "酉阳土家族苗族自治县", - "yubeiqu": "渝北区", - "yunyangxian": "云阳县", - "yuzhongqu": "渝中区", - "zhongxian": "忠县" - }, - "fujian": { - "anxixian": "安溪县", - "cangshanqu": "仓山区", - "changlequ": "长乐区", - "changtingxian": "长汀县", - "chengxiangqu": "城厢区", - "datianxian": "大田县", - "dehuaxian": "德化县", - "dongshanxian": "东山县", - "fengzequ": "丰泽区", - "fuan": "福安市", - "fuanshi": "福安市", - "fuding": "福鼎市", - "fudingshi": "福鼎市", - "fuqing": "福清市", - "fuqingshi": "福清市", - "fuzhou": "福州", - "fuzhoushi": "福州", - "guangzexian": "光泽县", - "gulouqu": "鼓楼区", - "gutianxian": "古田县", - "haicangqu": "海沧区", - "hanjiangqu": "涵江区", - "huaanxian": "华安县", - "huianxian": "惠安县", - "huliqu": "湖里区", - "jianglexian": "将乐县", - "jianningxian": "建宁县", - "jianou": "建瓯市", - "jianoushi": "建瓯市", - "jianyangqu": "建阳区", - "jiaochengqu": "蕉城区", - "jimeiqu": "集美区", - "jinanqu": "晋安区", - "jinjiang": "晋江市", - "jinjiangshi": "晋江市", - "jinmenxian": "金门县", - "lianchengxian": "连城县", - "lianjiangxian": "连江县", - "lichengqu": "荔城区", - "longhai": "龙海市", - "longhaiqu": "龙海区", - "longhaishi": "龙海市", - "longwenqu": "龙文区", - "longyan": "龙岩", - "longyanshi": "龙岩", - "luojiangqu": "洛江区", - "luoyuanxian": "罗源县", - "mayiqu": "马尾区", - "meiliequ": "梅列区", - "mingxixian": "明溪县", - "minhouxian": "闽侯县", - "minqingxian": "闽清县", - "nanan": "南安市", - "nananshi": "南安市", - "nanjingxian": "南靖县", - "nanping": "南平", - "nanpingshi": "南平", - "ningde": "宁德", - "ningdeshi": "宁德", - "ninghuaxian": "宁化县", - "pinghexian": "平和县", - "pingnanxian": "屏南县", - "pingtanxian": "平潭县", - "puchengxian": "浦城县", - "putian": "莆田", - "putianshi": "莆田", - "qingliuxian": "清流县", - "quangangqu": "泉港区", - "quanzhou": "泉州", - "quanzhoushi": "泉州", - "sanming": "三明", - "sanmingshi": "三明", - "sanyuanqu": "三元区", - "shanghangxian": "上杭县", - "shaowu": "邵武市", - "shaowushi": "邵武市", - "shaxian": "沙县", - "shaxianqu": "沙县区", - "shishi": "石狮市", - "shishishi": "石狮市", - "shixiaqu": "市辖区", - "shouningxian": "寿宁县", - "shunchangxian": "顺昌县", - "simingqu": "思明区", - "songxixian": "松溪县", - "taijiangqu": "台江区", - "tainingxian": "泰宁县", - "tonganqu": "同安区", - "wupingxian": "武平县", - "wuyishan": "武夷山市", - "wuyishanshi": "武夷山市", - "xiamen": "厦门", - "xiamenshi": "厦门", - "xianganqu": "翔安区", - "xiangchengqu": "芗城区", - "xianyouxian": "仙游县", - "xiapuxian": "霞浦县", - "xinluoqu": "新罗区", - "xiuyuqu": "秀屿区", - "yanpingqu": "延平区", - "yongan": "永安市", - "yonganshi": "永安市", - "yongchunxian": "永春县", - "yongdingqu": "永定区", - "yongtaixian": "永泰县", - "youxixian": "尤溪县", - "yunxiaoxian": "云霄县", - "zhangping": "漳平市", - "zhangpingshi": "漳平市", - "zhangpuxian": "漳浦县", - "zhangtaiqu": "长泰区", - "zhangtaixian": "长泰县", - "zhangzhou": "漳州", - "zhangzhoushi": "漳州", - "zhaoanxian": "诏安县", - "zhenghexian": "政和县", - "zherongxian": "柘荣县", - "zhouningxian": "周宁县" - }, - "gansu": { - "akesaihasakezuzizhixian": "阿克塞哈萨克族自治县", - "andingqu": "安定区", - "anningqu": "安宁区", - "baiyin": "白银", - "baiyinqu": "白银区", - "baiyinshi": "白银", - "chengguanqu": "城关区", - "chengxian": "成县", - "chongxinxian": "崇信县", - "dangchangxian": "宕昌县", - "diebuxian": "迭部县", - "dingxi": "定西", - "dingxishi": "定西", - "dongxiangzuzizhixian": "东乡族自治县", - "dunhuang": "敦煌市", - "dunhuangshi": "敦煌市", - "ganguxian": "甘谷县", - "gannanzangzu": "甘南藏族", - "gannanzangzuzizhizhou": "甘南藏族", - "ganzhouqu": "甘州区", - "gaolanxian": "皋兰县", - "gaotaixian": "高台县", - "guanghexian": "广河县", - "guazhouxian": "瓜州县", - "gulangxian": "古浪县", - "heshuixian": "合水县", - "hezhengxian": "和政县", - "hezuo": "合作市", - "hezuoshi": "合作市", - "hongguqu": "红古区", - "huachixian": "华池县", - "huanxian": "环县", - "huating": "华亭市", - "huatingshi": "华亭市", - "huiningxian": "会宁县", - "huixian": "徽县", - "jiayuguan": "嘉峪关", - "jiayuguanshi": "嘉峪关", - "jinchang": "金昌", - "jinchangshi": "金昌", - "jinchuanqu": "金川区", - "jingchuanxian": "泾川县", - "jingningxian": "静宁县", - "jingtaixian": "景泰县", - "jingyuanxian": "靖远县", - "jintaxian": "金塔县", - "jishishanbaoanzudongxiangzusalazuzizhixian": "积石山保安族东乡族撒拉族自治县", - "jiuquan": "酒泉", - "jiuquanshi": "酒泉", - "kanglexian": "康乐县", - "kangxian": "康县", - "kongdongqu": "崆峒区", - "lanzhou": "兰州", - "lanzhoushi": "兰州", - "lanzhouxinqu": "兰州新区", - "liangdangxian": "两当县", - "liangzhouqu": "凉州区", - "lingtaixian": "灵台县", - "lintanxian": "临潭县", - "lintaoxian": "临洮县", - "linxia": "临夏市", - "linxiahuizu": "临夏回族", - "linxiahuizuzizhizhou": "临夏回族", - "linxiashi": "临夏市", - "linxiaxian": "临夏县", - "linzexian": "临泽县", - "lixian": "礼县", - "longnan": "陇南", - "longnanshi": "陇南", - "longxixian": "陇西县", - "luquxian": "碌曲县", - "maijiqu": "麦积区", - "maquxian": "玛曲县", - "minqinxian": "民勤县", - "minxian": "岷县", - "minyuexian": "民乐县", - "ningxian": "宁县", - "pingchuanqu": "平川区", - "pingliang": "平凉", - "pingliangshi": "平凉", - "qilihequ": "七里河区", - "qinanxian": "秦安县", - "qingchengxian": "庆城县", - "qingshuixian": "清水县", - "qingyang": "庆阳", - "qingyangshi": "庆阳", - "qinzhouqu": "秦州区", - "shandanxian": "山丹县", - "shixiaqu": "市辖区", - "subeimengguzuzizhixian": "肃北蒙古族自治县", - "sunanyuguzuzizhixian": "肃南裕固族自治县", - "suzhouqu": "肃州区", - "tianshui": "天水", - "tianshuishi": "天水", - "tianzhuzangzuzizhixian": "天祝藏族自治县", - "tongweixian": "通渭县", - "weiyuanxian": "渭源县", - "wenxian": "文县", - "wudouqu": "武都区", - "wushanxian": "武山县", - "wuwei": "武威", - "wuweishi": "武威", - "xiahexian": "夏河县", - "xifengqu": "西峰区", - "xiguqu": "西固区", - "xihexian": "西和县", - "yongchangxian": "永昌县", - "yongdengxian": "永登县", - "yongjingxian": "永靖县", - "yumen": "玉门市", - "yumenshi": "玉门市", - "yuzhongxian": "榆中县", - "zhangjiachuanhuizuzizhixian": "张家川回族自治县", - "zhangxian": "漳县", - "zhangye": "张掖", - "zhangyeshi": "张掖", - "zhengningxian": "正宁县", - "zhenyuanxian": "镇原县", - "zhouquxian": "舟曲县", - "zhuanglangxian": "庄浪县", - "zhuonixian": "卓尼县" - }, - "guangdong": { - "baiyunqu": "白云区", - "baoanqu": "宝安区", - "boluoxian": "博罗县", - "chanchengqu": "禅城区", - "chaoanqu": "潮安区", - "chaonanqu": "潮南区", - "chaoyangqu": "潮阳区", - "chaozhou": "潮州", - "chaozhoushi": "潮州", - "chenghaiqu": "澄海区", - "chengqu": "城区", - "chikanqu": "赤坎区", - "conghuaqu": "从化区", - "dabuxian": "大埔县", - "deqingxian": "德庆县", - "dianbaiqu": "电白区", - "dinghuqu": "鼎湖区", - "dongguan": "东莞", - "dongguanshi": "东莞", - "dongyuanxian": "东源县", - "doumenqu": "斗门区", - "duanzhouqu": "端州区", - "enping": "恩平市", - "enpingshi": "恩平市", - "fengkaixian": "封开县", - "fengshunxian": "丰顺县", - "foshan": "佛山", - "foshanshi": "佛山", - "fugangxian": "佛冈县", - "futianqu": "福田区", - "gaomingqu": "高明区", - "gaoyaoqu": "高要区", - "gaozhou": "高州市", - "gaozhoushi": "高州市", - "guangmingqu": "光明区", - "guangningxian": "广宁县", - "guangzhou": "广州", - "guangzhoushi": "广州", - "haifengxian": "海丰县", - "haizhuqu": "海珠区", - "haojiangqu": "濠江区", - "hepingxian": "和平县", - "heshan": "鹤山市", - "heshanshi": "鹤山市", - "heyuan": "河源", - "heyuanshi": "河源", - "huadouqu": "花都区", - "huaijixian": "怀集县", - "huangpuqu": "黄埔区", - "huazhou": "化州市", - "huazhoushi": "化州市", - "huichengqu": "惠城区", - "huidongxian": "惠东县", - "huilaixian": "惠来县", - "huiyangqu": "惠阳区", - "huizhou": "惠州", - "huizhoushi": "惠州", - "jiangchengqu": "江城区", - "jianghaiqu": "江海区", - "jiangmen": "江门", - "jiangmenshi": "江门", - "jiaolingxian": "蕉岭县", - "jiedongqu": "揭东区", - "jiexixian": "揭西县", - "jieyang": "揭阳", - "jieyangshi": "揭阳", - "jinpingqu": "金平区", - "jinwanqu": "金湾区", - "kaiping": "开平市", - "kaipingshi": "开平市", - "lechang": "乐昌市", - "lechangshi": "乐昌市", - "leizhou": "雷州市", - "leizhoushi": "雷州市", - "lianjiang": "廉江市", - "lianjiangshi": "廉江市", - "liannanyaozuzizhixian": "连南瑶族自治县", - "lianpingxian": "连平县", - "lianshanzhuangzuyaozuzizhixian": "连山壮族瑶族自治县", - "lianzhou": "连州市", - "lianzhoushi": "连州市", - "liwanqu": "荔湾区", - "longchuanxian": "龙川县", - "longgangqu": "龙岗区", - "longhuaqu": "龙华区", - "longhuqu": "龙湖区", - "longmenxian": "龙门县", - "lufeng": "陆丰市", - "lufengshi": "陆丰市", - "luhexian": "陆河县", - "luoding": "罗定市", - "luodingshi": "罗定市", - "luohuqu": "罗湖区", - "maoming": "茂名", - "maomingshi": "茂名", - "maonanqu": "茂南区", - "mazhangqu": "麻章区", - "meijiangqu": "梅江区", - "meixianqu": "梅县区", - "meizhou": "梅州", - "meizhoushi": "梅州", - "nanaoxian": "南澳县", - "nanhaiqu": "南海区", - "nanshanqu": "南山区", - "nanshaqu": "南沙区", - "nanxiong": "南雄市", - "nanxiongshi": "南雄市", - "panyuqu": "番禺区", - "pengjiangqu": "蓬江区", - "pingshanqu": "坪山区", - "pingyuanxian": "平远县", - "potouqu": "坡头区", - "puning": "普宁市", - "puningshi": "普宁市", - "qingchengqu": "清城区", - "qingxinqu": "清新区", - "qingyuan": "清远", - "qingyuanshi": "清远", - "qujiangqu": "曲江区", - "raopingxian": "饶平县", - "renhuaxian": "仁化县", - "rongchengqu": "榕城区", - "ruyuanyaozuzizhixian": "乳源瑶族自治县", - "sanshuiqu": "三水区", - "shantou": "汕头", - "shantoushi": "汕头", - "shanwei": "汕尾", - "shanweishi": "汕尾", - "shaoguan": "韶关", - "shaoguanshi": "韶关", - "shenzhen": "深圳", - "shenzhenshi": "深圳", - "shixiaqu": "市辖区", - "shixingxian": "始兴县", - "shundequ": "顺德区", - "sihui": "四会市", - "sihuishi": "四会市", - "suixixian": "遂溪县", - "taishan": "台山市", - "taishanshi": "台山市", - "tianhequ": "天河区", - "wengyuanxian": "翁源县", - "wuchuan": "吴川市", - "wuchuanshi": "吴川市", - "wuhuaxian": "五华县", - "wujiangqu": "武江区", - "xiangqiaoqu": "湘桥区", - "xiangzhouqu": "香洲区", - "xiashanqu": "霞山区", - "xinfengxian": "新丰县", - "xingning": "兴宁市", - "xingningshi": "兴宁市", - "xinhuiqu": "新会区", - "xinxingxian": "新兴县", - "xinyi": "信宜市", - "xinyishi": "信宜市", - "xuwenxian": "徐闻县", - "yangchun": "阳春市", - "yangchunshi": "阳春市", - "yangdongqu": "阳东区", - "yangjiang": "阳江", - "yangjiangshi": "阳江", - "yangshanxian": "阳山县", - "yangxixian": "阳西县", - "yantianqu": "盐田区", - "yingde": "英德市", - "yingdeshi": "英德市", - "yuanchengqu": "源城区", - "yuexiuqu": "越秀区", - "yunanqu": "云安区", - "yunanxian": "郁南县", - "yunchengqu": "云城区", - "yunfu": "云浮", - "yunfushi": "云浮", - "zengchengqu": "增城区", - "zhanjiang": "湛江", - "zhanjiangshi": "湛江", - "zhaoqing": "肇庆", - "zhaoqingshi": "肇庆", - "zhenjiangqu": "浈江区", - "zhongshan": "中山", - "zhongshanshi": "中山", - "zhuhai": "珠海", - "zhuhaishi": "珠海", - "zijinxian": "紫金县" - }, - "guangxi": { - "babuqu": "八步区", - "baise": "百色", - "baiseshi": "百色", - "bamayaozuzizhixian": "巴马瑶族自治县", - "beihai": "北海", - "beihaishi": "北海", - "beiliu": "北流市", - "beiliushi": "北流市", - "binyangxian": "宾阳县", - "bobaixian": "博白县", - "cangwuxian": "苍梧县", - "cenxi": "岑溪市", - "cenxishi": "岑溪市", - "chengzhongqu": "城中区", - "chongzuo": "崇左", - "chongzuoshi": "崇左", - "dahuayaozuzizhixian": "大化瑶族自治县", - "daxinxian": "大新县", - "debaoxian": "德保县", - "diecaiqu": "叠彩区", - "donglanxian": "东兰县", - "dongxing": "东兴市", - "dongxingshi": "东兴市", - "douanyaozuzizhixian": "都安瑶族自治县", - "fangchenggang": "防城港", - "fangchenggangshi": "防城港", - "fangchengqu": "防城区", - "fengshanxian": "凤山县", - "fuchuanyaozuzizhixian": "富川瑶族自治县", - "fumianqu": "福绵区", - "fusuixian": "扶绥县", - "gangbeiqu": "港北区", - "gangkouqu": "港口区", - "gangnanqu": "港南区", - "gongchengyaozuzizhixian": "恭城瑶族自治县", - "guanyangxian": "灌阳县", - "guigang": "贵港", - "guigangshi": "贵港", - "guilin": "桂林", - "guilinshi": "桂林", - "guiping": "桂平市", - "guipingshi": "桂平市", - "haichengqu": "海城区", - "hechi": "河池", - "hechishi": "河池", - "hengxian": "横县", - "hengzhou": "横州市", - "hengzhoushi": "横州市", - "hepuxian": "合浦县", - "heshan": "合山市", - "heshanshi": "合山市", - "hezhou": "贺州", - "hezhoushi": "贺州", - "huanjiangmaonanzuzizhixian": "环江毛南族自治县", - "jiangnanqu": "江南区", - "jiangzhouqu": "江州区", - "jinchengjiangqu": "金城江区", - "jingxi": "靖西市", - "jingxishi": "靖西市", - "jinxiuyaozuzizhixian": "金秀瑶族自治县", - "laibin": "来宾", - "laibinshi": "来宾", - "leyexian": "乐业县", - "liangqingqu": "良庆区", - "lingchuanxian": "灵川县", - "lingshanxian": "灵山县", - "linguiqu": "临桂区", - "lingyunxian": "凌云县", - "lipu": "荔浦市", - "lipushi": "荔浦市", - "liubeiqu": "柳北区", - "liuchengxian": "柳城县", - "liujiangqu": "柳江区", - "liunanqu": "柳南区", - "liuzhou": "柳州", - "liuzhoushi": "柳州", - "longanxian": "隆安县", - "longlingezuzizhixian": "隆林各族自治县", - "longshenggezuzizhixian": "龙胜各族自治县", - "longweiqu": "龙圩区", - "longzhouxian": "龙州县", - "luchuanxian": "陆川县", - "luochengmulaozuzizhixian": "罗城仫佬族自治县", - "luzhaixian": "鹿寨县", - "mashanxian": "马山县", - "mengshanxian": "蒙山县", - "nandanxian": "南丹县", - "nanning": "南宁", - "nanningshi": "南宁", - "napoxian": "那坡县", - "ningmingxian": "宁明县", - "pingguiqu": "平桂区", - "pingguo": "平果市", - "pingguoshi": "平果市", - "pinglexian": "平乐县", - "pingnanxian": "平南县", - "pingxiang": "凭祥市", - "pingxiangshi": "凭祥市", - "pubeixian": "浦北县", - "qinbeiqu": "钦北区", - "qingxiuqu": "青秀区", - "qinnanqu": "钦南区", - "qinzhou": "钦州", - "qinzhoushi": "钦州", - "qixingqu": "七星区", - "quanzhouxian": "全州县", - "ronganxian": "融安县", - "rongshuimiaozuzizhixian": "融水苗族自治县", - "rongxian": "容县", - "sanjiangdongzuzizhixian": "三江侗族自治县", - "shanglinxian": "上林县", - "shangsixian": "上思县", - "shixiaqu": "市辖区", - "tantangqu": "覃塘区", - "tengxian": "藤县", - "tiandengxian": "天等县", - "tiandongxian": "田东县", - "tianexian": "天峨县", - "tianlinxian": "田林县", - "tianyangqu": "田阳区", - "tieshangangqu": "铁山港区", - "wanxiuqu": "万秀区", - "wumingqu": "武鸣区", - "wuxuanxian": "武宣县", - "wuzhou": "梧州", - "wuzhoushi": "梧州", - "xiangshanqu": "象山区", - "xiangzhouxian": "象州县", - "xilinxian": "西林县", - "xinchengxian": "忻城县", - "xinganxian": "兴安县", - "xingbinqu": "兴宾区", - "xingningqu": "兴宁区", - "xingyexian": "兴业县", - "xiufengqu": "秀峰区", - "xixiangtangqu": "西乡塘区", - "yangshuoxian": "阳朔县", - "yanshanqu": "雁山区", - "yinhaiqu": "银海区", - "yizhouqu": "宜州区", - "yongfuxian": "永福县", - "yongningqu": "邕宁区", - "youjiangqu": "右江区", - "yufengqu": "鱼峰区", - "yulin": "玉林", - "yulinshi": "玉林", - "yuzhouqu": "玉州区", - "zhangzhouqu": "长洲区", - "zhaopingxian": "昭平县", - "zhongshanxian": "钟山县", - "ziyuanxian": "资源县" - }, - "guizhou": { - "anlongxian": "安龙县", - "anshun": "安顺", - "anshunshi": "安顺", - "baiyunqu": "白云区", - "bijiangqu": "碧江区", - "bijie": "毕节", - "bijieshi": "毕节", - "bozhouqu": "播州区", - "cehengxian": "册亨县", - "cengongxian": "岑巩县", - "chishui": "赤水市", - "chishuishi": "赤水市", - "congjiangxian": "从江县", - "dafangxian": "大方县", - "danzhaixian": "丹寨县", - "daozhengelaozumiaozuzizhixian": "道真仡佬族苗族自治县", - "dejiangxian": "德江县", - "douyun": "都匀市", - "douyunshi": "都匀市", - "dushanxian": "独山县", - "fenggangxian": "凤冈县", - "fuquan": "福泉市", - "fuquanshi": "福泉市", - "guanlingbuyizumiaozuzizhixian": "关岭布依族苗族自治县", - "guanshanhuqu": "观山湖区", - "guidingxian": "贵定县", - "guiyang": "贵阳", - "guiyangshi": "贵阳", - "hezhangxian": "赫章县", - "honghuagangqu": "红花岗区", - "huangpingxian": "黄平县", - "huaxiqu": "花溪区", - "huichuanqu": "汇川区", - "huishuixian": "惠水县", - "jiangkouxian": "江口县", - "jianhexian": "剑河县", - "jinpingxian": "锦屏县", - "jinshaxian": "金沙县", - "kaili": "凯里市", - "kailishi": "凯里市", - "kaiyangxian": "开阳县", - "leishanxian": "雷山县", - "liboxian": "荔波县", - "lipingxian": "黎平县", - "liupanshui": "六盘水", - "liupanshuishi": "六盘水", - "liuzhitequ": "六枝特区", - "longlixian": "龙里县", - "luodianxian": "罗甸县", - "majiangxian": "麻江县", - "meitanxian": "湄潭县", - "nanmingqu": "南明区", - "nayongxian": "纳雍县", - "panzhou": "盘州市", - "panzhoushi": "盘州市", - "pingbaqu": "平坝区", - "pingtangxian": "平塘县", - "puanxian": "普安县", - "pudingxian": "普定县", - "qiandongnanmiaozudongzu": "黔东南苗族侗族", - "qiandongnanmiaozudongzuzizhizhou": "黔东南苗族侗族", - "qiannanbuyizumiaozu": "黔南布依族苗族", - "qiannanbuyizumiaozuzizhizhou": "黔南布依族苗族", - "qianxi": "黔西市", - "qianxinanbuyizumiaozu": "黔西南布依族苗族", - "qianxinanbuyizumiaozuzizhizhou": "黔西南布依族苗族", - "qianxishi": "黔西市", - "qianxixian": "黔西县", - "qinglongxian": "晴隆县", - "qingzhen": "清镇市", - "qingzhenshi": "清镇市", - "qixingguanqu": "七星关区", - "renhuai": "仁怀市", - "renhuaishi": "仁怀市", - "rongjiangxian": "榕江县", - "sandoushuizuzizhixian": "三都水族自治县", - "sansuixian": "三穗县", - "shibingxian": "施秉县", - "shiqianxian": "石阡县", - "shixiaqu": "市辖区", - "shuichengqu": "水城区", - "sinanxian": "思南县", - "songtaomiaozuzizhixian": "松桃苗族自治县", - "suiyangxian": "绥阳县", - "taijiangxian": "台江县", - "tianzhuxian": "天柱县", - "tongren": "铜仁", - "tongrenshi": "铜仁", - "tongzixian": "桐梓县", - "wangmoxian": "望谟县", - "wanshanqu": "万山区", - "weiningyizuhuizumiaozuzizhixian": "威宁彝族回族苗族自治县", - "wenganxian": "瓮安县", - "wuchuangelaozumiaozuzizhixian": "务川仡佬族苗族自治县", - "wudangqu": "乌当区", - "xifengxian": "息烽县", - "xingren": "兴仁市", - "xingrenshi": "兴仁市", - "xingyi": "兴义市", - "xingyishi": "兴义市", - "xishuixian": "习水县", - "xiuwenxian": "修文县", - "xixiuqu": "西秀区", - "yanhetujiazuzizhixian": "沿河土家族自治县", - "yinjiangtujiazumiaozuzizhixian": "印江土家族苗族自治县", - "yunyanqu": "云岩区", - "yupingdongzuzizhixian": "玉屏侗族自治县", - "yuqingxian": "余庆县", - "zhangshunxian": "长顺县", - "zhenfengxian": "贞丰县", - "zhenganxian": "正安县", - "zhenningbuyizumiaozuzizhixian": "镇宁布依族苗族自治县", - "zhenyuanxian": "镇远县", - "zhijinxian": "织金县", - "zhongshanqu": "钟山区", - "ziyunmiaozubuyizuzizhixian": "紫云苗族布依族自治县", - "zunyi": "遵义", - "zunyishi": "遵义" - }, - "hainan": { - "baishalizuzizhixian": "白沙黎族自治县", - "baotinglizumiaozuzizhixian": "保亭黎族苗族自治县", - "changjianglizuzizhixian": "昌江黎族自治县", - "chengmaixian": "澄迈县", - "danzhou": "儋州", - "danzhoushi": "儋州", - "dinganxian": "定安县", - "dongfang": "东方市", - "dongfangshi": "东方市", - "haikou": "海口", - "haikoushi": "海口", - "hainanshengzizhiquzhixiaxianjixingzhengquhua": "海南省-自治区直辖县级行政区划", - "haitangqu": "海棠区", - "jiyangqu": "吉阳区", - "ledonglizuzizhixian": "乐东黎族自治县", - "lingaoxian": "临高县", - "lingshuilizuzizhixian": "陵水黎族自治县", - "longhuaqu": "龙华区", - "meilanqu": "美兰区", - "nanshaqundao": "南沙群岛", - "qionghai": "琼海市", - "qionghaishi": "琼海市", - "qiongshanqu": "琼山区", - "qiongzhonglizumiaozuzizhixian": "琼中黎族苗族自治县", - "sansha": "三沙", - "sanshashi": "三沙", - "sanya": "三亚", - "sanyashi": "三亚", - "shixiaqu": "市辖区", - "tianyaqu": "天涯区", - "tunchangxian": "屯昌县", - "wanning": "万宁市", - "wanningshi": "万宁市", - "wenchang": "文昌市", - "wenchangshi": "文昌市", - "wuzhishan": "五指山市", - "wuzhishanshi": "五指山市", - "xishaqundao": "西沙群岛", - "xiuyingqu": "秀英区", - "yazhouqu": "崖州区", - "zhongshaqundaodedaojiaojiqihaiyu": "中沙群岛的岛礁及其海域" - }, - "hebei": { - "anciqu": "安次区", - "anguo": "安国市", - "anguoshi": "安国市", - "anpingxian": "安平县", - "anxinxian": "安新县", - "baixiangxian": "柏乡县", - "baoding": "保定", - "baodingbaigouxincheng": "保定白沟新城", - "baodinggaoxinjishuchanyekaifaqu": "保定高新技术产业开发区", - "baodingshi": "保定", - "bazhou": "霸州市", - "bazhoushi": "霸州市", - "beidaihequ": "北戴河区", - "beidaihexinqu": "北戴河新区", - "boyexian": "博野县", - "cangxian": "沧县", - "cangzhou": "沧州", - "cangzhoubohaixinqu": "沧州渤海新区", - "cangzhougaoxinjishuchanyekaifaqu": "沧州高新技术产业开发区", - "cangzhoushi": "沧州", - "caofeidianqu": "曹妃甸区", - "changanqu": "长安区", - "changlixian": "昌黎县", - "chenganxian": "成安县", - "chengde": "承德", - "chengdegaoxinjishuchanyekaifaqu": "承德高新技术产业开发区", - "chengdeshi": "承德", - "chengdexian": "承德县", - "chichengxian": "赤城县", - "chongliqu": "崇礼区", - "cixian": "磁县", - "congtaiqu": "丛台区", - "dachanghuizuzizhixian": "大厂回族自治县", - "dachengxian": "大城县", - "damingxian": "大名县", - "dingxingxian": "定兴县", - "dingzhou": "定州市", - "dingzhoushi": "定州市", - "dongguangxian": "东光县", - "feixiangqu": "肥乡区", - "fengfengkuangqu": "峰峰矿区", - "fengnanqu": "丰南区", - "fengningmanzuzizhixian": "丰宁满族自治县", - "fengrunqu": "丰润区", - "fuchengxian": "阜城县", - "funingqu": "抚宁区", - "fupingxian": "阜平县", - "fuxingqu": "复兴区", - "gaobeidian": "高碑店市", - "gaobeidianshi": "高碑店市", - "gaochengqu": "藁城区", - "gaoyangxian": "高阳县", - "gaoyixian": "高邑县", - "guangpingxian": "广平县", - "guangyangqu": "广阳区", - "guangzongxian": "广宗县", - "guantaoxian": "馆陶县", - "guanxian": "固安县", - "guchengxian": "故城县", - "guyequ": "古冶区", - "guyuanxian": "沽源县", - "haigangqu": "海港区", - "haixingxian": "海兴县", - "handan": "邯郸", - "handanjinanxinqu": "邯郸冀南新区", - "handanjingjijishukaifaqu": "邯郸经济技术开发区", - "handanshi": "邯郸", - "hanshanqu": "邯山区", - "hebeicangzhoujingjikaifaqu": "河北沧州经济开发区", - "hebeihengshuigaoxinjishuchanyekaifaqu": "河北衡水高新技术产业开发区", - "hebeitangshanhaigangjingjikaifaqu": "河北唐山海港经济开发区", - "hebeitangshanlutaijingjikaifaqu": "河北唐山芦台经济开发区", - "hebeixingtaijingjikaifaqu": "河北邢台经济开发区", - "hejian": "河间市", - "hejianshi": "河间市", - "hengshui": "衡水", - "hengshuibinhuxinqu": "衡水滨湖新区", - "hengshuishi": "衡水", - "huaianxian": "怀安县", - "huailaixian": "怀来县", - "huanghua": "黄骅市", - "huanghuashi": "黄骅市", - "jingxian": "景县", - "jingxingkuangqu": "井陉矿区", - "jingxingxian": "井陉县", - "jingxiuqu": "竞秀区", - "jinzhou": "晋州市", - "jinzhoushi": "晋州市", - "jizexian": "鸡泽县", - "jizhouqu": "冀州区", - "juluxian": "巨鹿县", - "kaipingqu": "开平区", - "kangbaoxian": "康保县", - "kuanchengmanzuzizhixian": "宽城满族自治县", - "laishuixian": "涞水县", - "laiyuanxian": "涞源县", - "langfang": "廊坊", - "langfangjingjijishukaifaqu": "廊坊经济技术开发区", - "langfangshi": "廊坊", - "laotingxian": "乐亭县", - "lianchiqu": "莲池区", - "linchengxian": "临城县", - "lingshouxian": "灵寿县", - "linxixian": "临西县", - "linzhangxian": "临漳县", - "lixian": "蠡县", - "longhuaxian": "隆化县", - "longyaoxian": "隆尧县", - "luanchengqu": "栾城区", - "luannanxian": "滦南县", - "luanpingxian": "滦平县", - "luanzhou": "滦州市", - "luanzhoushi": "滦州市", - "lubeiqu": "路北区", - "lulongxian": "卢龙县", - "lunanqu": "路南区", - "luquanqu": "鹿泉区", - "manchengqu": "满城区", - "mengcunhuizuzizhixian": "孟村回族自治县", - "nangong": "南宫市", - "nangongshi": "南宫市", - "nanhequ": "南和区", - "nanpixian": "南皮县", - "neiqiuxian": "内丘县", - "ningjinxian": "宁晋县", - "pingquan": "平泉市", - "pingquanshi": "平泉市", - "pingshanxian": "平山县", - "pingxiangxian": "平乡县", - "potou": "泊头市", - "potoushi": "泊头市", - "qianan": "迁安市", - "qiananshi": "迁安市", - "qianxixian": "迁西县", - "qiaodongqu": "桥东区", - "qiaoxiqu": "桥西区", - "qinghexian": "清河县", - "qinglongmanzuzizhixian": "青龙满族自治县", - "qingxian": "青县", - "qingyuanqu": "清苑区", - "qinhuangdao": "秦皇岛", - "qinhuangdaoshi": "秦皇岛", - "qinhuangdaoshijingjijishukaifaqu": "秦皇岛市经济技术开发区", - "qiuxian": "邱县", - "quyangxian": "曲阳县", - "quzhouxian": "曲周县", - "raoyangxian": "饶阳县", - "renqiu": "任丘市", - "renqiushi": "任丘市", - "renzequ": "任泽区", - "rongchengxian": "容城县", - "sanhe": "三河市", - "sanheshi": "三河市", - "shahe": "沙河市", - "shaheshi": "沙河市", - "shangyixian": "尚义县", - "shanhaiguanqu": "山海关区", - "shenzexian": "深泽县", - "shenzhou": "深州市", - "shenzhoushi": "深州市", - "shexian": "涉县", - "shijiazhuang": "石家庄", - "shijiazhuanggaoxinjishuchanyekaifaqu": "石家庄高新技术产业开发区", - "shijiazhuangshi": "石家庄", - "shijiazhuangxunhuanhuagongyuanqu": "石家庄循环化工园区", - "shixiaqu": "市辖区", - "shuangluanqu": "双滦区", - "shuangqiaoqu": "双桥区", - "shunpingxian": "顺平县", - "suningxian": "肃宁县", - "tangshan": "唐山", - "tangshangaoxinjishuchanyekaifaqu": "唐山高新技术产业开发区", - "tangshanshi": "唐山", - "tangshanshihanguguanliqu": "唐山市汉沽管理区", - "tangxian": "唐县", - "taochengqu": "桃城区", - "wangdouxian": "望都县", - "wanquanqu": "万全区", - "weichangmanzumengguzuzizhixian": "围场满族蒙古族自治县", - "weixian": "魏县", - "wenanxian": "文安县", - "wuan": "武安市", - "wuanshi": "武安市", - "wujixian": "无极县", - "wuqiangxian": "武强县", - "wuqiaoxian": "吴桥县", - "wuyixian": "武邑县", - "xiahuayuanqu": "下花园区", - "xiangdouqu": "襄都区", - "xianghexian": "香河县", - "xianxian": "献县", - "xindouqu": "信都区", - "xinglongxian": "兴隆县", - "xingtai": "邢台", - "xingtaishi": "邢台", - "xingtangxian": "行唐县", - "xinhexian": "新河县", - "xinhuaqu": "新华区", - "xinji": "辛集市", - "xinjishi": "辛集市", - "xinle": "新乐市", - "xinleshi": "新乐市", - "xiongxian": "雄县", - "xuanhuaqu": "宣化区", - "xushuiqu": "徐水区", - "yangyuanxian": "阳原县", - "yanshanxian": "盐山县", - "yingshouyingzikuangqu": "鹰手营子矿区", - "yixian": "易县", - "yongnianqu": "永年区", - "yongqingxian": "永清县", - "yuanshixian": "元氏县", - "yuhuaqu": "裕华区", - "yunhequ": "运河区", - "yutianxian": "玉田县", - "yuxian": "蔚县", - "zanhuangxian": "赞皇县", - "zaoqiangxian": "枣强县", - "zhangbeixian": "张北县", - "zhangjiakou": "张家口", - "zhangjiakoujingjikaifaqu": "张家口经济开发区", - "zhangjiakoushi": "张家口", - "zhangjiakoushichabeiguanliqu": "张家口市察北管理区", - "zhangjiakoushisaibeiguanliqu": "张家口市塞北管理区", - "zhaoxian": "赵县", - "zhengdingxian": "正定县", - "zhuoluxian": "涿鹿县", - "zhuozhou": "涿州市", - "zhuozhoushi": "涿州市", - "zunhua": "遵化市", - "zunhuashi": "遵化市" - }, - "heilongjiang": { - "achengqu": "阿城区", - "aihuiqu": "爱辉区", - "aiminqu": "爱民区", - "anda": "安达市", - "andashi": "安达市", - "angangxiqu": "昂昂溪区", - "baiquanxian": "拜泉县", - "baoqingxian": "宝清县", - "baoshanqu": "宝山区", - "bayanxian": "巴彦县", - "bei": "北林区", - "beian": "北安市", - "beianshi": "北安市", - "beilinqu": "北林区", - "binxian": "宾县", - "bolixian": "勃利县", - "chengzihequ": "城子河区", - "daoliqu": "道里区", - "daowaiqu": "道外区", - "daqing": "大庆", - "daqinggaoxinjishuchanyekaifaqu": "大庆高新技术产业开发区", - "daqingshanxian": "大箐山县", - "daqingshi": "大庆", - "datongqu": "大同区", - "daxinganling": "大兴安岭", - "daxinganlingdiqu": "大兴安岭", - "didaoqu": "滴道区", - "donganqu": "东安区", - "dongfengqu": "东风区", - "dongning": "东宁市", - "dongningshi": "东宁市", - "dongshanqu": "东山区", - "duerbotemengguzuzizhixian": "杜尔伯特蒙古族自治县", - "fangzhengxian": "方正县", - "fenglinxian": "丰林县", - "fujin": "富锦市", - "fujinshi": "富锦市", - "fulaerjiqu": "富拉尔基区", - "fuyuan": "抚远市", - "fuyuanshi": "抚远市", - "fuyuxian": "富裕县", - "gannanxian": "甘南县", - "gongnongqu": "工农区", - "haerbin": "哈尔滨", - "haerbinshi": "哈尔滨", - "hailin": "海林市", - "hailinshi": "海林市", - "hailun": "海伦市", - "hailunshi": "海伦市", - "hegang": "鹤岗", - "hegangshi": "鹤岗", - "heihe": "黑河", - "heiheshi": "黑河", - "hengshanqu": "恒山区", - "honggangqu": "红岗区", - "huachuanxian": "桦川县", - "huananxian": "桦南县", - "hulanqu": "呼兰区", - "hulin": "虎林市", - "hulinshi": "虎林市", - "humaxian": "呼玛县", - "huzhongqu": "呼中区", - "jiagedaqiqu": "加格达奇区", - "jiamusi": "佳木斯", - "jiamusishi": "佳木斯", - "jianhuaqu": "建华区", - "jianshanqu": "尖山区", - "jiaoqu": "郊区", - "jiayinxian": "嘉荫县", - "jidongxian": "鸡东县", - "jiguanqu": "鸡冠区", - "jin": "金林区", - "jinlinqu": "金林区", - "jixi": "鸡西", - "jixianxian": "集贤县", - "jixishi": "鸡西", - "kedongxian": "克东县", - "keshanxian": "克山县", - "lanxixian": "兰西县", - "lindianxian": "林甸县", - "lingdongqu": "岭东区", - "linkouxian": "林口县", - "lishuqu": "梨树区", - "longfengqu": "龙凤区", - "longjiangxian": "龙江县", - "longshaqu": "龙沙区", - "luobeixian": "萝北县", - "mashanqu": "麻山区", - "meilisidawoerzuqu": "梅里斯达斡尔族区", - "mingshuixian": "明水县", - "mishan": "密山市", - "mishanshi": "密山市", - "mohe": "漠河市", - "moheshi": "漠河市", - "mudanjiang": "牡丹江", - "mudanjiangjingjijishukaifaqu": "牡丹江经济技术开发区", - "mudanjiangshi": "牡丹江", - "mulanxian": "木兰县", - "muleng": "穆棱市", - "mulengshi": "穆棱市", - "nanchaxian": "南岔县", - "nangangqu": "南岗区", - "nanshanqu": "南山区", - "nehe": "讷河市", - "neheshi": "讷河市", - "nenjiang": "嫩江市", - "nenjiangshi": "嫩江市", - "nianzishanqu": "碾子山区", - "ningan": "宁安市", - "ninganshi": "宁安市", - "pingfangqu": "平房区", - "qianjinqu": "前进区", - "qiezihequ": "茄子河区", - "qinganxian": "庆安县", - "qinggangxian": "青冈县", - "qiqihaer": "齐齐哈尔", - "qiqihaershi": "齐齐哈尔", - "qitaihe": "七台河", - "qitaiheshi": "七台河", - "ranghuluqu": "让胡路区", - "raohexian": "饶河县", - "saertuqu": "萨尔图区", - "shangzhi": "尚志市", - "shangzhishi": "尚志市", - "shixiaqu": "市辖区", - "shuangchengqu": "双城区", - "shuangyashan": "双鸭山", - "shuangyashanshi": "双鸭山", - "sifangtaiqu": "四方台区", - "songbeiqu": "松北区", - "songlingqu": "松岭区", - "suibinxian": "绥滨县", - "suifenhe": "绥芬河市", - "suifenheshi": "绥芬河市", - "suihua": "绥化", - "suihuashi": "绥化", - "suilengxian": "绥棱县", - "sunwuxian": "孙吴县", - "tahexian": "塔河县", - "tailaixian": "泰来县", - "tangwangxian": "汤旺县", - "tangyuanxian": "汤原县", - "taoshanqu": "桃山区", - "tiefengqu": "铁锋区", - "tieli": "铁力市", - "tielishi": "铁力市", - "tonghexian": "通河县", - "tongjiang": "同江市", - "tongjiangshi": "同江市", - "wangkuixian": "望奎县", - "wuchang": "五常市", - "wuchangshi": "五常市", - "wucuiqu": "乌翠区", - "wudalianchi": "五大连池市", - "wudalianchishi": "五大连池市", - "xiangfangqu": "香坊区", - "xiangyangqu": "向阳区", - "xianqu": "西安区", - "xin": "新林区", - "xinganqu": "兴安区", - "xingshanqu": "兴山区", - "xinlinqu": "新林区", - "xinxingqu": "新兴区", - "xunkexian": "逊克县", - "yangmingqu": "阳明区", - "yanshouxian": "延寿县", - "yianxian": "依安县", - "yichun": "伊春", - "yichunshi": "伊春", - "yilanxian": "依兰县", - "yimeiqu": "伊美区", - "youhaoqu": "友好区", - "youyixian": "友谊县", - "zhaodong": "肇东市", - "zhaodongshi": "肇东市", - "zhaoyuanxian": "肇源县", - "zhaozhouxian": "肇州县" - }, - "henan": { - "anyang": "安阳", - "anyanggaoxinjishuchanyekaifaqu": "安阳高新技术产业开发区", - "anyangshi": "安阳", - "anyangxian": "安阳县", - "baofengxian": "宝丰县", - "beiguanqu": "北关区", - "biyangxian": "泌阳县", - "boaixian": "博爱县", - "chanhehuizuqu": "瀍河回族区", - "chuanhuiqu": "川汇区", - "danchengxian": "郸城县", - "dengfeng": "登封市", - "dengfengshi": "登封市", - "dengzhou": "邓州市", - "dengzhoushi": "邓州市", - "erqiqu": "二七区", - "fangchengxian": "方城县", - "fanxian": "范县", - "fengqiuxian": "封丘县", - "fengquanqu": "凤泉区", - "fugouxian": "扶沟县", - "gongyi": "巩义市", - "gongyishi": "巩义市", - "guanchenghuizuqu": "管城回族区", - "guangshanxian": "光山县", - "gulouqu": "鼓楼区", - "gushixian": "固始县", - "hebi": "鹤壁", - "hebijingjijishukaifaqu": "鹤壁经济技术开发区", - "hebishi": "鹤壁", - "henanpuyanggongyeyuanqu": "河南濮阳工业园区", - "henansanmenxiajingjikaifaqu": "河南三门峡经济开发区", - "henanshangqiujingjikaifaqu": "河南商丘经济开发区", - "henanshengshengzhixiaxianjixingzhengquhua": "河南省-省直辖县级行政区划", - "henanzhoukoujingjikaifaqu": "河南周口经济开发区", - "henanzhumadianjingjikaifaqu": "河南驻马店经济开发区", - "heshanqu": "鹤山区", - "hongqiqu": "红旗区", - "huaibinxian": "淮滨县", - "huaiyangqu": "淮阳区", - "hualongqu": "华龙区", - "huangchuanxian": "潢川县", - "huaxian": "滑县", - "hubinqu": "湖滨区", - "huijiqu": "惠济区", - "huixian": "辉县市", - "huixianshi": "辉县市", - "huojiaxian": "获嘉县", - "jiananqu": "建安区", - "jianxiqu": "涧西区", - "jiaozuo": "焦作", - "jiaozuochengxiangyitihuashifanqu": "焦作城乡一体化示范区", - "jiaozuoshi": "焦作", - "jiaxian": "郏县", - "jiefangqu": "解放区", - "jiliqu": "吉利区", - "jinshuiqu": "金水区", - "jiyuan": "济源市", - "jiyuanshi": "济源市", - "junxian": "浚县", - "kaifeng": "开封", - "kaifengshi": "开封", - "lankaoxian": "兰考县", - "laochengqu": "老城区", - "liangyuanqu": "梁园区", - "lingbao": "灵宝市", - "lingbaoshi": "灵宝市", - "linyingxian": "临颍县", - "linzhou": "林州市", - "linzhoushi": "林州市", - "longanqu": "龙安区", - "longtingqu": "龙亭区", - "luanchuanxian": "栾川县", - "luolongqu": "洛龙区", - "luoningxian": "洛宁县", - "luoshanxian": "罗山县", - "luoyang": "洛阳", - "luoyanggaoxinjishuchanyekaifaqu": "洛阳高新技术产业开发区", - "luoyangshi": "洛阳", - "lushanxian": "鲁山县", - "lushixian": "卢氏县", - "luyixian": "鹿邑县", - "macunqu": "马村区", - "mengjinqu": "孟津区", - "mengjinxian": "孟津县", - "mengzhou": "孟州市", - "mengzhoushi": "孟州市", - "mianchixian": "渑池县", - "minquanxian": "民权县", - "muyequ": "牧野区", - "nanyang": "南阳", - "nanyanggaoxinjishuchanyekaifaqu": "南阳高新技术产业开发区", - "nanyangshi": "南阳", - "nanyangshichengxiangyitihuashifanqu": "南阳市城乡一体化示范区", - "nanyuexian": "南乐县", - "nanzhaoxian": "南召县", - "neihuangxian": "内黄县", - "neixiangxian": "内乡县", - "ninglingxian": "宁陵县", - "pingdingshan": "平顶山", - "pingdingshangaoxinjishuchanyekaifaqu": "平顶山高新技术产业开发区", - "pingdingshanshi": "平顶山", - "pingdingshanshichengxiangyitihuashifanqu": "平顶山市城乡一体化示范区", - "pingqiaoqu": "平桥区", - "pingyuxian": "平舆县", - "puyang": "濮阳", - "puyangjingjijishukaifaqu": "濮阳经济技术开发区", - "puyangshi": "濮阳", - "puyangxian": "濮阳县", - "qibinqu": "淇滨区", - "qingfengxian": "清丰县", - "qinyang": "沁阳市", - "qinyangshi": "沁阳市", - "qixian": "杞县", - "queshanxian": "确山县", - "runanxian": "汝南县", - "ruyangxian": "汝阳县", - "ruzhou": "汝州市", - "ruzhoushi": "汝州市", - "sanmenxia": "三门峡", - "sanmenxiashi": "三门峡", - "shanchengqu": "山城区", - "shangcaixian": "上蔡县", - "shangchengxian": "商城县", - "shangjiequ": "上街区", - "shangqiu": "商丘", - "shangqiushi": "商丘", - "shangshuixian": "商水县", - "shanyangqu": "山阳区", - "shanzhouqu": "陕州区", - "shenqiuxian": "沈丘县", - "sheqixian": "社旗县", - "shihequ": "浉河区", - "shilongqu": "石龙区", - "shixiaqu": "市辖区", - "shunhehuizuqu": "顺河回族区", - "songxian": "嵩县", - "suipingxian": "遂平县", - "suixian": "睢县", - "suiyangqu": "睢阳区", - "tahe": "漯河", - "tahejingjijishukaifaqu": "漯河经济技术开发区", - "taheshi": "漯河", - "taikangxian": "太康县", - "taiqianxian": "台前县", - "tanghexian": "唐河县", - "tangyinxian": "汤阴县", - "tongbaixian": "桐柏县", - "tongxuxian": "通许县", - "wanchengqu": "宛城区", - "weibinqu": "卫滨区", - "weidongqu": "卫东区", - "weidouqu": "魏都区", - "weihui": "卫辉市", - "weihuishi": "卫辉市", - "weishixian": "尉氏县", - "wenfengqu": "文峰区", - "wenxian": "温县", - "wolongqu": "卧龙区", - "wugang": "舞钢市", - "wugangshi": "舞钢市", - "wuyangxian": "舞阳县", - "wuzhixian": "武陟县", - "xiangcheng": "项城市", - "xiangchengshi": "项城市", - "xiangchengxian": "襄城县", - "xiangfuqu": "祥符区", - "xiayixian": "夏邑县", - "xichuanxian": "淅川县", - "xigongqu": "西工区", - "xihuaxian": "西华县", - "xinanxian": "新安县", - "xincaixian": "新蔡县", - "xingyang": "荥阳市", - "xingyangshi": "荥阳市", - "xinhuaqu": "新华区", - "xinmi": "新密市", - "xinmishi": "新密市", - "xinxian": "新县", - "xinxiang": "新乡", - "xinxianggaoxinjishuchanyekaifaqu": "新乡高新技术产业开发区", - "xinxiangjingjijishukaifaqu": "新乡经济技术开发区", - "xinxiangshi": "新乡", - "xinxiangshipingyuanchengxiangyitihuashifanqu": "新乡市平原城乡一体化示范区", - "xinxiangxian": "新乡县", - "xinyang": "信阳", - "xinyanggaoxinjishuchanyekaifaqu": "信阳高新技术产业开发区", - "xinyangshi": "信阳", - "xinyexian": "新野县", - "xinzheng": "新郑市", - "xinzhengshi": "新郑市", - "xipingxian": "西平县", - "xiuwuxian": "修武县", - "xixian": "息县", - "xixiaxian": "西峡县", - "xuchang": "许昌", - "xuchangjingjijishukaifaqu": "许昌经济技术开发区", - "xuchangshi": "许昌", - "yanchengqu": "郾城区", - "yanjinxian": "延津县", - "yanlingxian": "鄢陵县", - "yanshi": "偃师市", - "yanshiqu": "偃师区", - "yanshishi": "偃师市", - "yexian": "叶县", - "yichengqu": "驿城区", - "yichuanxian": "伊川县", - "yima": "义马市", - "yimashi": "义马市", - "yindouqu": "殷都区", - "yiyangxian": "宜阳县", - "yongcheng": "永城市", - "yongchengshi": "永城市", - "yuanhuiqu": "源汇区", - "yuanyangxian": "原阳县", - "yuchengxian": "虞城县", - "yudongzonghewuliuchanyejujiqu": "豫东综合物流产业聚集区", - "yuwangtaiqu": "禹王台区", - "yuzhou": "禹州市", - "yuzhoushi": "禹州市", - "zhangge": "长葛市", - "zhanggeshi": "长葛市", - "zhangyuan": "长垣市", - "zhangyuanshi": "长垣市", - "zhanhequ": "湛河区", - "zhaolingqu": "召陵区", - "zhechengxian": "柘城县", - "zhengyangxian": "正阳县", - "zhengzhou": "郑州", - "zhengzhougaoxinjishuchanyekaifaqu": "郑州高新技术产业开发区", - "zhengzhouhangkonggangjingjizongheshiyanqu": "郑州航空港经济综合实验区", - "zhengzhoujingjijishukaifaqu": "郑州经济技术开发区", - "zhengzhoushi": "郑州", - "zhenpingxian": "镇平县", - "zhongmuxian": "中牟县", - "zhongyuanqu": "中原区", - "zhongzhanqu": "中站区", - "zhoukou": "周口", - "zhoukoushi": "周口", - "zhumadian": "驻马店", - "zhumadianshi": "驻马店" - }, - "hubei": { - "anlu": "安陆市", - "anlushi": "安陆市", - "badongxian": "巴东县", - "baokangxian": "保康县", - "caidianqu": "蔡甸区", - "cengdouqu": "曾都区", - "chibi": "赤壁市", - "chibishi": "赤壁市", - "chongyangxian": "崇阳县", - "dangyang": "当阳市", - "dangyangshi": "当阳市", - "danjiangkou": "丹江口市", - "danjiangkoushi": "丹江口市", - "dawuxian": "大悟县", - "daye": "大冶市", - "dayeshi": "大冶市", - "dianjunqu": "点军区", - "dongbaoqu": "东宝区", - "dongxihuqu": "东西湖区", - "duodaoqu": "掇刀区", - "echengqu": "鄂城区", - "enshi": "恩施市", - "enshishi": "恩施市", - "enshitujiazumiaozu": "恩施土家族苗族", - "enshitujiazumiaozuzizhizhou": "恩施土家族苗族", - "ezhou": "鄂州", - "ezhoushi": "鄂州", - "fanchengqu": "樊城区", - "fangxian": "房县", - "gonganxian": "公安县", - "guangshui": "广水市", - "guangshuishi": "广水市", - "guchengxian": "谷城县", - "hanchuan": "汉川市", - "hanchuanshi": "汉川市", - "hannanqu": "汉南区", - "hanyangqu": "汉阳区", - "hefengxian": "鹤峰县", - "honganxian": "红安县", - "honghu": "洪湖市", - "honghushi": "洪湖市", - "hongshanqu": "洪山区", - "huang": "黄石", - "huanggang": "黄冈", - "huanggangshi": "黄冈", - "huangmeixian": "黄梅县", - "huangpiqu": "黄陂区", - "huangshi": "黄石", - "huangshigangqu": "黄石港区", - "huangshishi": "黄石", - "huangzhouqu": "黄州区", - "huarongqu": "华容区", - "hubeishengzizhiquzhixiaxianjixingzhengquhua": "湖北省-自治区直辖县级行政区划", - "jianganqu": "江岸区", - "jianghanqu": "江汉区", - "jianglingxian": "江陵县", - "jiangxiaqu": "江夏区", - "jianli": "监利市", - "jianlishi": "监利市", - "jianshixian": "建始县", - "jiayuxian": "嘉鱼县", - "jingmen": "荆门", - "jingmenshi": "荆门", - "jingshan": "京山市", - "jingshanshi": "京山市", - "jingzhou": "荆州", - "jingzhoujingjijishukaifaqu": "荆州经济技术开发区", - "jingzhouqu": "荆州区", - "jingzhoushi": "荆州", - "laifengxian": "来凤县", - "laohekou": "老河口市", - "laohekoushi": "老河口市", - "liangzihuqu": "梁子湖区", - "lichuan": "利川市", - "lichuanshi": "利川市", - "longganhuguanliqu": "龙感湖管理区", - "luotianxian": "罗田县", - "macheng": "麻城市", - "machengshi": "麻城市", - "maojianqu": "茅箭区", - "nanzhangxian": "南漳县", - "qianjiang": "潜江市", - "qianjiangshi": "潜江市", - "qiaokouqu": "硚口区", - "qichunxian": "蕲春县", - "qingshanqu": "青山区", - "shashiqu": "沙市区", - "shayangxian": "沙洋县", - "shennongjia": "神农架林区", - "shennongjialinqu": "神农架林区", - "shishou": "石首市", - "shishoushi": "石首市", - "shixiaqu": "市辖区", - "shiyan": "十堰", - "shiyanshi": "十堰", - "songzi": "松滋市", - "songzishi": "松滋市", - "suixian": "随县", - "suizhou": "随州", - "suizhoushi": "随州", - "tianmen": "天门市", - "tianmenshi": "天门市", - "tieshanqu": "铁山区", - "tongchengxian": "通城县", - "tongshanxian": "通山县", - "tuanfengxian": "团风县", - "wuchangqu": "武昌区", - "wufengtujiazuzizhixian": "五峰土家族自治县", - "wuhan": "武汉", - "wuhanshi": "武汉", - "wujiagangqu": "伍家岗区", - "wuxue": "武穴市", - "wuxueshi": "武穴市", - "xialuqu": "下陆区", - "xiananqu": "咸安区", - "xianfengxian": "咸丰县", - "xiangchengqu": "襄城区", - "xiangyang": "襄阳", - "xiangyangshi": "襄阳", - "xiangzhouqu": "襄州区", - "xianning": "咸宁", - "xianningshi": "咸宁", - "xiantao": "仙桃市", - "xiantaoshi": "仙桃市", - "xiaochangxian": "孝昌县", - "xiaogan": "孝感", - "xiaoganshi": "孝感", - "xiaonanqu": "孝南区", - "xiaotingqu": "猇亭区", - "xilingqu": "西陵区", - "xingshanxian": "兴山县", - "xinzhouqu": "新洲区", - "xisaishanqu": "西塞山区", - "xishuixian": "浠水县", - "xuanenxian": "宣恩县", - "yangxinxian": "阳新县", - "yichang": "宜昌", - "yichangshi": "宜昌", - "yicheng": "宜城市", - "yichengshi": "宜城市", - "yidu": "宜都市", - "yidushi": "宜都市", - "yilingqu": "夷陵区", - "yingcheng": "应城市", - "yingchengshi": "应城市", - "yingshanxian": "英山县", - "yuananxian": "远安县", - "yunmengxian": "云梦县", - "yunxixian": "郧西县", - "yunyangqu": "郧阳区", - "zaoyang": "枣阳市", - "zaoyangshi": "枣阳市", - "zhangwanqu": "张湾区", - "zhangyangtujiazuzizhixian": "长阳土家族自治县", - "zhijiang": "枝江市", - "zhijiangshi": "枝江市", - "zhongxiang": "钟祥市", - "zhongxiangshi": "钟祥市", - "zhushanxian": "竹山县", - "zhuxixian": "竹溪县", - "ziguixian": "秭归县" - }, - "hunan": { - "anhuaxian": "安化县", - "anrenxian": "安仁县", - "anxiangxian": "安乡县", - "baojingxian": "保靖县", - "beihuqu": "北湖区", - "beitaqu": "北塔区", - "chalingxian": "茶陵县", - "changde": "常德", - "changdeshi": "常德", - "changdeshixidongtingguanliqu": "常德市西洞庭管理区", - "changning": "常宁市", - "changningshi": "常宁市", - "changsha": "长沙", - "changshashi": "长沙", - "changshaxian": "长沙县", - "chengbumiaozuzizhixian": "城步苗族自治县", - "chenxixian": "辰溪县", - "chenzhou": "郴州", - "chenzhoushi": "郴州", - "cilixian": "慈利县", - "daoxian": "道县", - "daxiangqu": "大祥区", - "dingchengqu": "鼎城区", - "donganxian": "东安县", - "dongkouxian": "洞口县", - "fenghuangxian": "凤凰县", - "furongqu": "芙蓉区", - "guidongxian": "桂东县", - "guiyangxian": "桂阳县", - "guzhangxian": "古丈县", - "hanshouxian": "汉寿县", - "hechengqu": "鹤城区", - "hengdongxian": "衡东县", - "hengnanxian": "衡南县", - "hengshanxian": "衡山县", - "hengyang": "衡阳", - "hengyangshi": "衡阳", - "hengyangxian": "衡阳县", - "hengyangzonghebaoshuiqu": "衡阳综合保税区", - "heshanqu": "赫山区", - "hetangqu": "荷塘区", - "hongjiang": "洪江市", - "hongjiangshi": "洪江市", - "huaihua": "怀化", - "huaihuashi": "怀化", - "huaihuashihongjiangguanliqu": "怀化市洪江管理区", - "huarongxian": "华容县", - "huayuanxian": "花垣县", - "huitongxian": "会同县", - "hunanhengyanggaoxinjishuchanyeyuanqu": "湖南衡阳高新技术产业园区", - "hunanhengyangsongmujingjikaifaqu": "湖南衡阳松木经济开发区", - "hunanxiangtangaoxinjishuchanyeyuanqu": "湖南湘潭高新技术产业园区", - "hunanyiyanggaoxinjishuchanyeyuanqu": "湖南益阳高新技术产业园区", - "jiahexian": "嘉禾县", - "jianghuayaozuzizhixian": "江华瑶族自治县", - "jiangyongxian": "江永县", - "jingzhoumiaozudongzuzizhixian": "靖州苗族侗族自治县", - "jinshi": "津市市", - "jinshishi": "津市市", - "jishou": "吉首市", - "jishoushi": "吉首市", - "junshanqu": "君山区", - "kaifuqu": "开福区", - "lanshanxian": "蓝山县", - "leiyang": "耒阳市", - "leiyangshi": "耒阳市", - "lengshuijiang": "冷水江市", - "lengshuijiangshi": "冷水江市", - "lengshuitanqu": "冷水滩区", - "lianyuan": "涟源市", - "lianyuanshi": "涟源市", - "liling": "醴陵市", - "lilingshi": "醴陵市", - "linglingqu": "零陵区", - "linlixian": "临澧县", - "linwuxian": "临武县", - "linxiang": "临湘市", - "linxiangshi": "临湘市", - "liuyang": "浏阳市", - "liuyangshi": "浏阳市", - "lixian": "澧县", - "longhuixian": "隆回县", - "longshanxian": "龙山县", - "loudi": "娄底", - "loudishi": "娄底", - "louxingqu": "娄星区", - "lukouqu": "渌口区", - "lusongqu": "芦淞区", - "luxixian": "泸溪县", - "mayangmiaozuzizhixian": "麻阳苗族自治县", - "miluo": "汨罗市", - "miluoshi": "汨罗市", - "nanxian": "南县", - "nanyuequ": "南岳区", - "ningxiang": "宁乡市", - "ningxiangshi": "宁乡市", - "ningyuanxian": "宁远县", - "pingjiangxian": "平江县", - "qidongxian": "祁东县", - "qiyang": "祁阳市", - "qiyangshi": "祁阳市", - "qiyangxian": "祁阳县", - "ruchengxian": "汝城县", - "sangzhixian": "桑植县", - "shaodong": "邵东市", - "shaodongshi": "邵东市", - "shaoshan": "韶山市", - "shaoshanshi": "韶山市", - "shaoyang": "邵阳", - "shaoyangshi": "邵阳", - "shaoyangxian": "邵阳县", - "shifengqu": "石峰区", - "shiguqu": "石鼓区", - "shimenxian": "石门县", - "shixiaqu": "市辖区", - "shuangfengxian": "双峰县", - "shuangpaixian": "双牌县", - "shuangqingqu": "双清区", - "suiningxian": "绥宁县", - "suxianqu": "苏仙区", - "taojiangxian": "桃江县", - "taoyuanxian": "桃源县", - "tianxinqu": "天心区", - "tianyuanqu": "天元区", - "tongdaodongzuzizhixian": "通道侗族自治县", - "wangchengqu": "望城区", - "wugang": "武冈市", - "wugangshi": "武冈市", - "wulingqu": "武陵区", - "wulingyuanqu": "武陵源区", - "xiangtan": "湘潭", - "xiangtanjiuhuashifanqu": "湘潭九华示范区", - "xiangtanshi": "湘潭", - "xiangtanxian": "湘潭县", - "xiangtanzhaoshanshifanqu": "湘潭昭山示范区", - "xiangxiang": "湘乡市", - "xiangxiangshi": "湘乡市", - "xiangxitujiazumiaozu": "湘西土家族苗族", - "xiangxitujiazumiaozuzizhizhou": "湘西土家族苗族", - "xiangyinxian": "湘阴县", - "xinhuangdongzuzizhixian": "新晃侗族自治县", - "xinhuaxian": "新化县", - "xinningxian": "新宁县", - "xinshaoxian": "新邵县", - "xintianxian": "新田县", - "xupuxian": "溆浦县", - "yanfengqu": "雁峰区", - "yanlingxian": "炎陵县", - "yiyang": "益阳", - "yiyangshi": "益阳", - "yiyangshidatonghuguanliqu": "益阳市大通湖管理区", - "yizhangxian": "宜章县", - "yongdingqu": "永定区", - "yongshunxian": "永顺县", - "yongxingxian": "永兴县", - "yongzhou": "永州", - "yongzhoujingjijishukaifaqu": "永州经济技术开发区", - "yongzhoushi": "永州", - "yongzhoushihuilongweiguanliqu": "永州市回龙圩管理区", - "youxian": "攸县", - "yuanjiang": "沅江市", - "yuanjiangshi": "沅江市", - "yuanlingxian": "沅陵县", - "yueluqu": "岳麓区", - "yuetangqu": "岳塘区", - "yueyang": "岳阳", - "yueyanglouqu": "岳阳楼区", - "yueyangshi": "岳阳", - "yueyangshiquyuanguanliqu": "岳阳市屈原管理区", - "yueyangxian": "岳阳县", - "yuhuaqu": "雨花区", - "yuhuqu": "雨湖区", - "yunlongshifanqu": "云龙示范区", - "yunxiqu": "云溪区", - "zhangjiajie": "张家界", - "zhangjiajieshi": "张家界", - "zhengxiangqu": "蒸湘区", - "zhijiangdongzuzizhixian": "芷江侗族自治县", - "zhongfangxian": "中方县", - "zhuhuiqu": "珠晖区", - "zhuzhou": "株洲", - "zhuzhoushi": "株洲", - "zixing": "资兴市", - "zixingshi": "资兴市", - "ziyangqu": "资阳区" - }, - "jiangsu": { - "baoyingxian": "宝应县", - "binhaixian": "滨海县", - "binhuqu": "滨湖区", - "changshu": "常熟市", - "changshushi": "常熟市", - "changzhou": "常州", - "changzhoushi": "常州", - "chongchuanqu": "崇川区", - "dafengqu": "大丰区", - "dantuqu": "丹徒区", - "danyang": "丹阳市", - "danyangshi": "丹阳市", - "donghaixian": "东海县", - "dongtai": "东台市", - "dongtaishi": "东台市", - "fengxian": "丰县", - "funingxian": "阜宁县", - "ganyuqu": "赣榆区", - "gaochunqu": "高淳区", - "gaogangqu": "高港区", - "gaoyou": "高邮市", - "gaoyoushi": "高邮市", - "guanglingqu": "广陵区", - "guannanxian": "灌南县", - "guanyunxian": "灌云县", - "gulouqu": "鼓楼区", - "gusuqu": "姑苏区", - "haian": "海安市", - "haianshi": "海安市", - "hailingqu": "海陵区", - "haimenqu": "海门区", - "haizhouqu": "海州区", - "hanjiangqu": "邗江区", - "hongzequ": "洪泽区", - "huaian": "淮安", - "huaianjingjijishukaifaqu": "淮安经济技术开发区", - "huaianqu": "淮安区", - "huaianshi": "淮安", - "huaiyinqu": "淮阴区", - "huishanqu": "惠山区", - "huqiuqu": "虎丘区", - "jiangduqu": "江都区", - "jiangningqu": "江宁区", - "jiangyanqu": "姜堰区", - "jiangyin": "江阴市", - "jiangyinshi": "江阴市", - "jianhuxian": "建湖县", - "jianyequ": "建邺区", - "jiawangqu": "贾汪区", - "jingjiang": "靖江市", - "jingjiangshi": "靖江市", - "jingkouqu": "京口区", - "jinhuxian": "金湖县", - "jintanqu": "金坛区", - "jurong": "句容市", - "jurongshi": "句容市", - "kunshan": "昆山市", - "kunshanshi": "昆山市", - "liangxiqu": "梁溪区", - "lianshuixian": "涟水县", - "lianyungang": "连云港", - "lianyunganggaoxinjishuchanyekaifaqu": "连云港高新技术产业开发区", - "lianyungangjingjijishukaifaqu": "连云港经济技术开发区", - "lianyungangshi": "连云港", - "lianyunqu": "连云区", - "lishuiqu": "溧水区", - "liuhequ": "六合区", - "liyang": "溧阳市", - "liyangshi": "溧阳市", - "nanjing": "南京", - "nanjingshi": "南京", - "nantong": "南通", - "nantongjingjijishukaifaqu": "南通经济技术开发区", - "nantongshi": "南通", - "peixian": "沛县", - "pizhou": "邳州市", - "pizhoushi": "邳州市", - "pukouqu": "浦口区", - "qidong": "启东市", - "qidongshi": "启东市", - "qingjiangpuqu": "清江浦区", - "qinhuaiqu": "秦淮区", - "qixiaqu": "栖霞区", - "quanshanqu": "泉山区", - "rudongxian": "如东县", - "rugao": "如皋市", - "rugaoshi": "如皋市", - "runzhouqu": "润州区", - "sheyangxian": "射阳县", - "shixiaqu": "市辖区", - "shuyangxian": "沭阳县", - "sihongxian": "泗洪县", - "siyangxian": "泗阳县", - "suchengqu": "宿城区", - "suiningxian": "睢宁县", - "suqian": "宿迁", - "suqianjingjijishukaifaqu": "宿迁经济技术开发区", - "suqianshi": "宿迁", - "suyuqu": "宿豫区", - "suzhou": "苏州", - "suzhougongyeyuanqu": "苏州工业园区", - "suzhoushi": "苏州", - "taicang": "太仓市", - "taicangshi": "太仓市", - "taixing": "泰兴市", - "taixingshi": "泰兴市", - "taizhou": "泰州", - "taizhoushi": "泰州", - "taizhouyiyaogaoxinjishuchanyekaifaqu": "泰州医药高新技术产业开发区", - "tianningqu": "天宁区", - "tinghuqu": "亭湖区", - "tongshanqu": "铜山区", - "tongzhouqu": "通州区", - "wujiangqu": "吴江区", - "wujinqu": "武进区", - "wuxi": "无锡", - "wuxishi": "无锡", - "wuzhongqu": "吴中区", - "xiangchengqu": "相城区", - "xiangshuixian": "响水县", - "xinbeiqu": "新北区", - "xinghua": "兴化市", - "xinghuashi": "兴化市", - "xinwuqu": "新吴区", - "xinyi": "新沂市", - "xinyishi": "新沂市", - "xishanqu": "锡山区", - "xuanwuqu": "玄武区", - "xuyixian": "盱眙县", - "xuzhou": "徐州", - "xuzhoujingjijishukaifaqu": "徐州经济技术开发区", - "xuzhoushi": "徐州", - "yancheng": "盐城", - "yanchengjingjijishukaifaqu": "盐城经济技术开发区", - "yanchengshi": "盐城", - "yandouqu": "盐都区", - "yangzhong": "扬中市", - "yangzhongshi": "扬中市", - "yangzhou": "扬州", - "yangzhoujingjijishukaifaqu": "扬州经济技术开发区", - "yangzhoushi": "扬州", - "yixing": "宜兴市", - "yixingshi": "宜兴市", - "yizheng": "仪征市", - "yizhengshi": "仪征市", - "yuhuataiqu": "雨花台区", - "yunlongqu": "云龙区", - "zhangjiagang": "张家港市", - "zhangjiagangshi": "张家港市", - "zhenjiang": "镇江", - "zhenjiangshi": "镇江", - "zhenjiangxinqu": "镇江新区", - "zhonglouqu": "钟楼区" - }, - "jiangxi": { - "anfuxian": "安福县", - "anyixian": "安义县", - "anyuanqu": "安源区", - "anyuanxian": "安远县", - "chaisangqu": "柴桑区", - "changjiangqu": "昌江区", - "chongrenxian": "崇仁县", - "chongyixian": "崇义县", - "dayuxian": "大余县", - "deanxian": "德安县", - "dexing": "德兴市", - "dexingshi": "德兴市", - "dingnanxian": "定南县", - "donghuqu": "东湖区", - "dongxiangqu": "东乡区", - "douchangxian": "都昌县", - "fengcheng": "丰城市", - "fengchengshi": "丰城市", - "fengxinxian": "奉新县", - "fenyixian": "分宜县", - "fuliangxian": "浮梁县", - "fuzhou": "抚州", - "fuzhoushi": "抚州", - "ganxianqu": "赣县区", - "ganzhou": "赣州", - "ganzhoushi": "赣州", - "gaoan": "高安市", - "gaoanshi": "高安市", - "gongqingcheng": "共青城市", - "gongqingchengshi": "共青城市", - "guangchangxian": "广昌县", - "guangfengqu": "广丰区", - "guangxinqu": "广信区", - "guixi": "贵溪市", - "guixishi": "贵溪市", - "hengfengxian": "横峰县", - "honggutanqu": "红谷滩区", - "huichangxian": "会昌县", - "hukouxian": "湖口县", - "jian": "吉安", - "jianshi": "吉安", - "jianxian": "吉安县", - "jinganxian": "靖安县", - "jingdezhen": "景德镇", - "jingdezhenshi": "景德镇", - "jinggangshan": "井冈山市", - "jinggangshanshi": "井冈山市", - "jinxianxian": "进贤县", - "jinxixian": "金溪县", - "jishuixian": "吉水县", - "jiujiang": "九江", - "jiujiangshi": "九江", - "jizhouqu": "吉州区", - "leanxian": "乐安县", - "leping": "乐平市", - "lepingshi": "乐平市", - "lianhuaxian": "莲花县", - "lianxiqu": "濂溪区", - "lichuanxian": "黎川县", - "linchuanqu": "临川区", - "longnan": "龙南市", - "longnanshi": "龙南市", - "lushan": "庐山市", - "lushanshi": "庐山市", - "luxixian": "芦溪县", - "nanchang": "南昌", - "nanchangshi": "南昌", - "nanchangxian": "南昌县", - "nanchengxian": "南城县", - "nanfengxian": "南丰县", - "nankangqu": "南康区", - "ningdouxian": "宁都县", - "pengzexian": "彭泽县", - "pingxiang": "萍乡", - "pingxiangshi": "萍乡", - "poyangxian": "鄱阳县", - "qingshanhuqu": "青山湖区", - "qingyuanqu": "青原区", - "qingyunpuqu": "青云谱区", - "quannanxian": "全南县", - "ruichang": "瑞昌市", - "ruichangshi": "瑞昌市", - "ruijin": "瑞金市", - "ruijinshi": "瑞金市", - "shanggaoxian": "上高县", - "shanglixian": "上栗县", - "shangrao": "上饶", - "shangraoshi": "上饶", - "shangyouxian": "上犹县", - "shichengxian": "石城县", - "shixiaqu": "市辖区", - "suichuanxian": "遂川县", - "taihexian": "泰和县", - "tongguxian": "铜鼓县", - "wananxian": "万安县", - "wannianxian": "万年县", - "wanzaixian": "万载县", - "wuningxian": "武宁县", - "wuyuanxian": "婺源县", - "xiajiangxian": "峡江县", - "xiangdongqu": "湘东区", - "xihuqu": "西湖区", - "xinfengxian": "信丰县", - "xinganxian": "新干县", - "xingguoxian": "兴国县", - "xinjianqu": "新建区", - "xinyu": "新余", - "xinyushi": "新余", - "xinzhouqu": "信州区", - "xiushuixian": "修水县", - "xunwuxian": "寻乌县", - "xunyangqu": "浔阳区", - "yanshanxian": "铅山县", - "yichun": "宜春", - "yichunshi": "宜春", - "yifengxian": "宜丰县", - "yihuangxian": "宜黄县", - "yingtan": "鹰潭", - "yingtanshi": "鹰潭", - "yiyangxian": "弋阳县", - "yongfengxian": "永丰县", - "yongxinxian": "永新县", - "yongxiuxian": "永修县", - "yuanzhouqu": "袁州区", - "yudouxian": "于都县", - "yuehuqu": "月湖区", - "yuganxian": "余干县", - "yujiangqu": "余江区", - "yushanxian": "玉山县", - "yushuiqu": "渝水区", - "zhanggongqu": "章贡区", - "zhangshu": "樟树市", - "zhangshushi": "樟树市", - "zhushanqu": "珠山区", - "zixixian": "资溪县" - }, - "jilin": { - "antuxian": "安图县", - "baicheng": "白城", - "baichengshi": "白城", - "baishan": "白山", - "baishanshi": "白山", - "changchun": "长春", - "changchungaoxinjishuchanyekaifaqu": "长春高新技术产业开发区", - "changchunjingjijishukaifaqu": "长春经济技术开发区", - "changchunjingyuegaoxinjishuchanyekaifaqu": "长春净月高新技术产业开发区", - "changchunqichejingjijishukaifaqu": "长春汽车经济技术开发区", - "changchunshi": "长春", - "changyiqu": "昌邑区", - "chaoyangqu": "朝阳区", - "chuanyingqu": "船营区", - "daan": "大安市", - "daanshi": "大安市", - "dehui": "德惠市", - "dehuishi": "德惠市", - "dongchangqu": "东昌区", - "dongfengxian": "东丰县", - "dongliaoxian": "东辽县", - "dunhua": "敦化市", - "dunhuashi": "敦化市", - "erdaojiangqu": "二道江区", - "erdaoqu": "二道区", - "fengmanqu": "丰满区", - "fusongxian": "抚松县", - "fuyu": "扶余市", - "fuyushi": "扶余市", - "gongzhuling": "公主岭市", - "gongzhulingshi": "公主岭市", - "helong": "和龙市", - "helongshi": "和龙市", - "huadian": "桦甸市", - "huadianshi": "桦甸市", - "huichun": "珲春市", - "huichunshi": "珲春市", - "huinanxian": "辉南县", - "hunjiangqu": "浑江区", - "jian": "集安市", - "jiangyuanqu": "江源区", - "jianshi": "集安市", - "jiaohe": "蛟河市", - "jiaoheshi": "蛟河市", - "jilin": "吉林", - "jilinbaichengjingjikaifaqu": "吉林白城经济开发区", - "jilingaoxinjishuchanyekaifaqu": "吉林高新技术产业开发区", - "jilinjingjikaifaqu": "吉林经济开发区", - "jilinshi": "吉林", - "jilinsongyuanjingjikaifaqu": "吉林松原经济开发区", - "jilinzhongguoxinjiaposhipinqu": "吉林中国新加坡食品区", - "jingyuxian": "靖宇县", - "jiutaiqu": "九台区", - "kuanchengqu": "宽城区", - "liaoyuan": "辽源", - "liaoyuanshi": "辽源", - "linjiang": "临江市", - "linjiangshi": "临江市", - "lishuxian": "梨树县", - "liuhexian": "柳河县", - "longjing": "龙井市", - "longjingshi": "龙井市", - "longshanqu": "龙山区", - "longtanqu": "龙潭区", - "lvyuanqu": "绿园区", - "meihekou": "梅河口市", - "meihekoushi": "梅河口市", - "nanguanqu": "南关区", - "ningjiangqu": "宁江区", - "nonganxian": "农安县", - "panshi": "磐石市", - "panshishi": "磐石市", - "qiananxian": "乾安县", - "qianguoerluosimengguzuzizhixian": "前郭尔罗斯蒙古族自治县", - "shixiaqu": "市辖区", - "shuangliao": "双辽市", - "shuangliaoshi": "双辽市", - "shuangyangqu": "双阳区", - "shulan": "舒兰市", - "shulanshi": "舒兰市", - "siping": "四平", - "sipingshi": "四平", - "songyuan": "松原", - "songyuanshi": "松原", - "taobeiqu": "洮北区", - "taonan": "洮南市", - "taonanshi": "洮南市", - "tiedongqu": "铁东区", - "tiexiqu": "铁西区", - "tonghua": "通化", - "tonghuashi": "通化", - "tonghuaxian": "通化县", - "tongyuxian": "通榆县", - "tumen": "图们市", - "tumenshi": "图们市", - "wangqingxian": "汪清县", - "xianqu": "西安区", - "yanbianchaoxianzu": "延边朝鲜族", - "yanbianchaoxianzuzizhizhou": "延边朝鲜族", - "yanji": "延吉市", - "yanjishi": "延吉市", - "yitongmanzuzizhixian": "伊通满族自治县", - "yongjixian": "永吉县", - "yushu": "榆树市", - "yushushi": "榆树市", - "zhangbaichaoxianzuzizhixian": "长白朝鲜族自治县", - "zhanglingxian": "长岭县", - "zhenlaixian": "镇赉县" - }, - "liaoning": { - "anshan": "鞍山", - "anshanshi": "鞍山", - "baitaqu": "白塔区", - "bayuquanqu": "鲅鱼圈区", - "beipiao": "北票市", - "beipiaoshi": "北票市", - "beizhen": "北镇市", - "beizhenshi": "北镇市", - "benxi": "本溪", - "benximanzuzizhixian": "本溪满族自治县", - "benxishi": "本溪", - "changtuxian": "昌图县", - "dadongqu": "大东区", - "dalian": "大连", - "dalianshi": "大连", - "dandong": "丹东", - "dandongshi": "丹东", - "dashiqiao": "大石桥市", - "dashiqiaoshi": "大石桥市", - "dawaqu": "大洼区", - "dengta": "灯塔市", - "dengtashi": "灯塔市", - "diaobingshan": "调兵山市", - "diaobingshanshi": "调兵山市", - "donggang": "东港市", - "donggangshi": "东港市", - "dongzhouqu": "东洲区", - "fakuxian": "法库县", - "fengcheng": "凤城市", - "fengchengshi": "凤城市", - "fushun": "抚顺", - "fushunshi": "抚顺", - "fushunxian": "抚顺县", - "fuxin": "阜新", - "fuxinmengguzuzizhixian": "阜新蒙古族自治县", - "fuxinshi": "阜新", - "gaizhou": "盖州市", - "gaizhoushi": "盖州市", - "ganjingziqu": "甘井子区", - "gongzhanglingqu": "弓长岭区", - "gutaqu": "古塔区", - "haicheng": "海城市", - "haichengshi": "海城市", - "haizhouqu": "海州区", - "heishanxian": "黑山县", - "hepingqu": "和平区", - "hongweiqu": "宏伟区", - "huangguqu": "皇姑区", - "huanrenmanzuzizhixian": "桓仁满族自治县", - "huludao": "葫芦岛", - "huludaoshi": "葫芦岛", - "hunnanqu": "浑南区", - "jianchangxian": "建昌县", - "jianpingxian": "建平县", - "jinzhou": "锦州", - "jinzhouqu": "金州区", - "jinzhoushi": "锦州", - "kaiyuan": "开原市", - "kaiyuanshi": "开原市", - "kalaqinzuoyimengguzuzizhixian": "喀喇沁左翼蒙古族自治县", - "kangpingxian": "康平县", - "kuandianmanzuzizhixian": "宽甸满族自治县", - "laobianqu": "老边区", - "lianshanqu": "连山区", - "liaoyang": "辽阳", - "liaoyangshi": "辽阳", - "liaoyangxian": "辽阳县", - "liaozhongqu": "辽中区", - "linghai": "凌海市", - "linghaishi": "凌海市", - "linghequ": "凌河区", - "lingyuan": "凌源市", - "lingyuanshi": "凌源市", - "lishanqu": "立山区", - "longchengqu": "龙城区", - "longgangqu": "龙港区", - "lvshunkouqu": "旅顺口区", - "mingshanqu": "明山区", - "nanfenqu": "南芬区", - "nanpiaoqu": "南票区", - "panjin": "盘锦", - "panjinshi": "盘锦", - "panshanxian": "盘山县", - "pingshanqu": "平山区", - "pulandianqu": "普兰店区", - "qianshanqu": "千山区", - "qinghemenqu": "清河门区", - "qinghequ": "清河区", - "qingyuanmanzuzizhixian": "清原满族自治县", - "shahekouqu": "沙河口区", - "shenbeixinqu": "沈北新区", - "shenhequ": "沈河区", - "shenyang": "沈阳", - "shenyangshi": "沈阳", - "shixiaqu": "市辖区", - "shuangtaiziqu": "双台子区", - "shuangtaqu": "双塔区", - "shunchengqu": "顺城区", - "suizhongxian": "绥中县", - "sujiatunqu": "苏家屯区", - "taianxian": "台安县", - "taihequ": "太和区", - "taipingqu": "太平区", - "taizihequ": "太子河区", - "tiedongqu": "铁东区", - "tieling": "铁岭", - "tielingshi": "铁岭", - "tielingxian": "铁岭县", - "tiexiqu": "铁西区", - "wafangdian": "瓦房店市", - "wafangdianshi": "瓦房店市", - "wanghuaqu": "望花区", - "wenshengqu": "文圣区", - "xifengxian": "西丰县", - "xigangqu": "西岗区", - "xihequ": "细河区", - "xihuqu": "溪湖区", - "xinbinmanzuzizhixian": "新宾满族自治县", - "xinfuqu": "新抚区", - "xingcheng": "兴城市", - "xingchengshi": "兴城市", - "xinglongtaiqu": "兴隆台区", - "xinmin": "新民市", - "xinminshi": "新民市", - "xinqiuqu": "新邱区", - "xishiqu": "西市区", - "xiuyanmanzuzizhixian": "岫岩满族自治县", - "yingkou": "营口", - "yingkoushi": "营口", - "yinzhouqu": "银州区", - "yixian": "义县", - "yuanbaoqu": "元宝区", - "yuhongqu": "于洪区", - "zhanghaixian": "长海县", - "zhangwuxian": "彰武县", - "zhanqianqu": "站前区", - "zhaoyang": "朝阳", - "zhaoyangshi": "朝阳", - "zhaoyangxian": "朝阳县", - "zhenanqu": "振安区", - "zhenxingqu": "振兴区", - "zhongshanqu": "中山区", - "zhuanghe": "庄河市", - "zhuangheshi": "庄河市" - }, - "neimenggu": { - "abagaqi": "阿巴嘎旗", - "aershan": "阿尔山市", - "aershanshi": "阿尔山市", - "alashan": "阿拉善", - "alashanmeng": "阿拉善", - "alashanyouqi": "阿拉善右旗", - "alashanzuoqi": "阿拉善左旗", - "alukeerqinqi": "阿鲁科尔沁旗", - "aohanqi": "敖汉旗", - "arongqi": "阿荣旗", - "baiyunebokuangqu": "白云鄂博矿区", - "balinyouqi": "巴林右旗", - "balinzuoqi": "巴林左旗", - "baotou": "包头", - "baotoushi": "包头", - "baotouxitugaoxinjishuchanyekaifaqu": "包头稀土高新技术产业开发区", - "bayannaoer": "巴彦淖尔", - "bayannaoershi": "巴彦淖尔", - "chahaeryouyihouqi": "察哈尔右翼后旗", - "chahaeryouyiqianqi": "察哈尔右翼前旗", - "chahaeryouyizhongqi": "察哈尔右翼中旗", - "chenbaerhuqi": "陈巴尔虎旗", - "chifeng": "赤峰", - "chifengshi": "赤峰", - "daerhanmaominganlianheqi": "达尔罕茂明安联合旗", - "dalateqi": "达拉特旗", - "dengkouxian": "磴口县", - "donghequ": "东河区", - "dongshengqu": "东胜区", - "dongwuzhumuqinqi": "东乌珠穆沁旗", - "duolunxian": "多伦县", - "eerduosi": "鄂尔多斯", - "eerduosishi": "鄂尔多斯", - "eerguna": "额尔古纳市", - "eergunashi": "额尔古纳市", - "ejinaqi": "额济纳旗", - "elunchunzizhiqi": "鄂伦春自治旗", - "erlianhaote": "二连浩特市", - "erlianhaoteshi": "二连浩特市", - "etuokeqi": "鄂托克旗", - "etuokeqianqi": "鄂托克前旗", - "ewenkezuzizhiqi": "鄂温克族自治旗", - "fengzhen": "丰镇市", - "fengzhenshi": "丰镇市", - "genhe": "根河市", - "genheshi": "根河市", - "guyangxian": "固阳县", - "haibowanqu": "海勃湾区", - "hailaerqu": "海拉尔区", - "hainanqu": "海南区", - "hangjinhouqi": "杭锦后旗", - "hangjinqi": "杭锦旗", - "helingeerxian": "和林格尔县", - "hongshanqu": "红山区", - "huadexian": "化德县", - "huhehaote": "呼和浩特", - "huhehaotejingjijishukaifaqu": "呼和浩特经济技术开发区", - "huhehaoteshi": "呼和浩特", - "huiminqu": "回民区", - "hulunbeier": "呼伦贝尔", - "hulunbeiershi": "呼伦贝尔", - "huolinguolei": "霍林郭勒市", - "huolinguoleishi": "霍林郭勒市", - "jiningqu": "集宁区", - "jiuyuanqu": "九原区", - "kailuxian": "开鲁县", - "kalaqinqi": "喀喇沁旗", - "kangbashenqu": "康巴什区", - "keerqinqu": "科尔沁区", - "keerqinyouyiqianqi": "科尔沁右翼前旗", - "keerqinyouyizhongqi": "科尔沁右翼中旗", - "keerqinzuoyihouqi": "科尔沁左翼后旗", - "keerqinzuoyizhongqi": "科尔沁左翼中旗", - "keshenketengqi": "克什克腾旗", - "kulunqi": "库伦旗", - "kundoulunqu": "昆都仑区", - "liangchengxian": "凉城县", - "linhequ": "临河区", - "linxixian": "林西县", - "manzhouli": "满洲里市", - "manzhoulishi": "满洲里市", - "molidawadawoerzuzizhiqi": "莫力达瓦达斡尔族自治旗", - "naimanqi": "奈曼旗", - "neimenggualashangaoxinjishuchanyekaifaqu": "内蒙古阿拉善高新技术产业开发区", - "ningchengxian": "宁城县", - "qingshanqu": "青山区", - "qingshuihexian": "清水河县", - "saihanqu": "赛罕区", - "shangdouxian": "商都县", - "shiguaiqu": "石拐区", - "shixiaqu": "市辖区", - "siziwangqi": "四子王旗", - "songshanqu": "松山区", - "suniteyouqi": "苏尼特右旗", - "sunitezuoqi": "苏尼特左旗", - "taipusiqi": "太仆寺旗", - "tongliao": "通辽", - "tongliaojingjijishukaifaqu": "通辽经济技术开发区", - "tongliaoshi": "通辽", - "tumoteyouqi": "土默特右旗", - "tumotezuoqi": "土默特左旗", - "tuoketuoxian": "托克托县", - "tuquanxian": "突泉县", - "wengniuteqi": "翁牛特旗", - "wuchuanxian": "武川县", - "wudaqu": "乌达区", - "wuhai": "乌海", - "wuhaishi": "乌海", - "wulagaiguanweihui": "乌拉盖管委会", - "wulanchabu": "乌兰察布", - "wulanchabushi": "乌兰察布", - "wulanhaote": "乌兰浩特市", - "wulanhaoteshi": "乌兰浩特市", - "wulatehouqi": "乌拉特后旗", - "wulateqianqi": "乌拉特前旗", - "wulatezhongqi": "乌拉特中旗", - "wushenqi": "乌审旗", - "wuyuanxian": "五原县", - "xianghuangqi": "镶黄旗", - "xilinguolei": "锡林郭勒", - "xilinguoleimeng": "锡林郭勒", - "xilinhaote": "锡林浩特市", - "xilinhaoteshi": "锡林浩特市", - "xinbaerhuyouqi": "新巴尔虎右旗", - "xinbaerhuzuoqi": "新巴尔虎左旗", - "xinchengqu": "新城区", - "xingan": "兴安", - "xinganmeng": "兴安", - "xinghexian": "兴和县", - "xiwuzhumuqinqi": "西乌珠穆沁旗", - "yakeshi": "牙克石市", - "yakeshishi": "牙克石市", - "yijinhuoluoqi": "伊金霍洛旗", - "yuanbaoshanqu": "元宝山区", - "yuquanqu": "玉泉区", - "zhalainuoerqu": "扎赉诺尔区", - "zhalaiteqi": "扎赉特旗", - "zhalantun": "扎兰屯市", - "zhalantunshi": "扎兰屯市", - "zhaluteqi": "扎鲁特旗", - "zhenglanqi": "正蓝旗", - "zhengxiangbaiqi": "正镶白旗", - "zhungeerqi": "准格尔旗", - "zhuozixian": "卓资县" - }, - "ningxia": { - "dawukouqu": "大武口区", - "guyuan": "固原", - "guyuanshi": "固原", - "haiyuanxian": "海原县", - "helanxian": "贺兰县", - "hongsibaoqu": "红寺堡区", - "huinongqu": "惠农区", - "jinfengqu": "金凤区", - "jingyuanxian": "泾源县", - "lingwu": "灵武市", - "lingwushi": "灵武市", - "litongqu": "利通区", - "longdexian": "隆德县", - "pengyangxian": "彭阳县", - "pingluoxian": "平罗县", - "qingtongxia": "青铜峡市", - "qingtongxiashi": "青铜峡市", - "shapotouqu": "沙坡头区", - "shixiaqu": "市辖区", - "shizuishan": "石嘴山", - "shizuishanshi": "石嘴山", - "tongxinxian": "同心县", - "wuzhong": "吴忠", - "wuzhongshi": "吴忠", - "xijixian": "西吉县", - "xingqingqu": "兴庆区", - "xixiaqu": "西夏区", - "yanchixian": "盐池县", - "yinchuan": "银川", - "yinchuanshi": "银川", - "yongningxian": "永宁县", - "yuanzhouqu": "原州区", - "zhongningxian": "中宁县", - "zhongwei": "中卫", - "zhongweishi": "中卫" - }, - "qinghai": { - "banmaxian": "班玛县", - "chengbeiqu": "城北区", - "chengdongqu": "城东区", - "chengduoxian": "称多县", - "chengxiqu": "城西区", - "chengzhongqu": "城中区", - "dachaidanxingzhengweiyuanhui": "大柴旦行政委员会", - "darixian": "达日县", - "datonghuizutuzuzizhixian": "大通回族土族自治县", - "delingha": "德令哈市", - "delinghashi": "德令哈市", - "doulanxian": "都兰县", - "gandexian": "甘德县", - "gangchaxian": "刚察县", - "geermu": "格尔木市", - "geermushi": "格尔木市", - "gonghexian": "共和县", - "guidexian": "贵德县", - "guinanxian": "贵南县", - "guoluozangzu": "果洛藏族", - "guoluozangzuzizhizhou": "果洛藏族", - "haibeizangzu": "海北藏族", - "haibeizangzuzizhizhou": "海北藏族", - "haidong": "海东", - "haidongshi": "海东", - "hainanzangzu": "海南藏族", - "hainanzangzuzizhizhou": "海南藏族", - "haiximengguzuzangzu": "海西蒙古族藏族", - "haiximengguzuzangzuzizhizhou": "海西蒙古族藏族", - "haiyanxian": "海晏县", - "henanmengguzuzizhixian": "河南蒙古族自治县", - "hualonghuizuzizhixian": "化隆回族自治县", - "huangnanzangzu": "黄南藏族", - "huangnanzangzuzizhizhou": "黄南藏族", - "huangyuanxian": "湟源县", - "huangzhongqu": "湟中区", - "huzhutuzuzizhixian": "互助土族自治县", - "jianzhaxian": "尖扎县", - "jiuzhixian": "久治县", - "leduqu": "乐都区", - "maduoxian": "玛多县", - "mangya": "茫崖市", - "mangyashi": "茫崖市", - "maqinxian": "玛沁县", - "menyuanhuizuzizhixian": "门源回族自治县", - "minhehuizutuzuzizhixian": "民和回族土族自治县", - "nangqianxian": "囊谦县", - "pinganqu": "平安区", - "qilianxian": "祁连县", - "qumalaixian": "曲麻莱县", - "shixiaqu": "市辖区", - "tianjunxian": "天峻县", - "tongdexian": "同德县", - "tongren": "同仁市", - "tongrenshi": "同仁市", - "wulanxian": "乌兰县", - "xinghaixian": "兴海县", - "xining": "西宁", - "xiningshi": "西宁", - "xunhuasalazuzizhixian": "循化撒拉族自治县", - "yushu": "玉树市", - "yushushi": "玉树市", - "yushuzangzu": "玉树藏族", - "yushuzangzuzizhizhou": "玉树藏族", - "zaduoxian": "杂多县", - "zekuxian": "泽库县", - "zhiduoxian": "治多县" - }, - "shandong": { - "anqiu": "安丘市", - "anqiushi": "安丘市", - "binchengqu": "滨城区", - "binzhou": "滨州", - "binzhoushi": "滨州", - "boshanqu": "博山区", - "boxingxian": "博兴县", - "caoxian": "曹县", - "changlexian": "昌乐县", - "changyi": "昌邑市", - "changyishi": "昌邑市", - "chengwuxian": "成武县", - "chengyangqu": "城阳区", - "chipingqu": "茌平区", - "daiyuequ": "岱岳区", - "danxian": "单县", - "dechengqu": "德城区", - "dezhou": "德州", - "dezhoujingjijishukaifaqu": "德州经济技术开发区", - "dezhoushi": "德州", - "dezhouyunhejingjikaifaqu": "德州运河经济开发区", - "dingtaoqu": "定陶区", - "dongchangfuqu": "东昌府区", - "dongexian": "东阿县", - "donggangqu": "东港区", - "dongmingxian": "东明县", - "dongpingxian": "东平县", - "dongying": "东营", - "dongyinggangjingjikaifaqu": "东营港经济开发区", - "dongyingjingjijishukaifaqu": "东营经济技术开发区", - "dongyingqu": "东营区", - "dongyingshi": "东营", - "fangziqu": "坊子区", - "feicheng": "肥城市", - "feichengshi": "肥城市", - "feixian": "费县", - "fushanqu": "福山区", - "gangchengqu": "钢城区", - "gaomi": "高密市", - "gaomishi": "高密市", - "gaoqingxian": "高青县", - "gaotangxian": "高唐县", - "guangraoxian": "广饶县", - "guanxian": "冠县", - "haiyang": "海阳市", - "haiyangshi": "海阳市", - "hantingqu": "寒亭区", - "hedongqu": "河东区", - "hekouqu": "河口区", - "heze": "菏泽", - "hezegaoxinjishukaifaqu": "菏泽高新技术开发区", - "hezejingjijishukaifaqu": "菏泽经济技术开发区", - "hezeshi": "菏泽", - "huaiyinqu": "槐荫区", - "huancuiqu": "环翠区", - "huangdaoqu": "黄岛区", - "huantaixian": "桓台县", - "huiminxian": "惠民县", - "jiaozhou": "胶州市", - "jiaozhoushi": "胶州市", - "jiaxiangxian": "嘉祥县", - "jimoqu": "即墨区", - "jinan": "济南", - "jinangaoxinjishuchanyekaifaqu": "济南高新技术产业开发区", - "jinanshi": "济南", - "jining": "济宁", - "jininggaoxinjishuchanyekaifaqu": "济宁高新技术产业开发区", - "jiningshi": "济宁", - "jinxiangxian": "金乡县", - "jiyangqu": "济阳区", - "juanchengxian": "鄄城县", - "junanxian": "莒南县", - "juxian": "莒县", - "juyexian": "巨野县", - "kenliqu": "垦利区", - "kuiwenqu": "奎文区", - "laishanqu": "莱山区", - "laiwuqu": "莱芜区", - "laixi": "莱西市", - "laixishi": "莱西市", - "laiyang": "莱阳市", - "laiyangshi": "莱阳市", - "laizhou": "莱州市", - "laizhoushi": "莱州市", - "lanlingxian": "兰陵县", - "lanshanqu": "岚山区", - "laoshanqu": "崂山区", - "leling": "乐陵市", - "lelingshi": "乐陵市", - "liangshanxian": "梁山县", - "liaocheng": "聊城", - "liaochengshi": "聊城", - "licangqu": "李沧区", - "lichengqu": "历城区", - "lijinxian": "利津县", - "lingchengqu": "陵城区", - "linqing": "临清市", - "linqingshi": "临清市", - "linquxian": "临朐县", - "linshuxian": "临沭县", - "linyi": "临沂", - "linyigaoxinjishuchanyekaifaqu": "临沂高新技术产业开发区", - "linyishi": "临沂", - "linyixian": "临邑县", - "linziqu": "临淄区", - "lixiaqu": "历下区", - "longkou": "龙口市", - "longkoushi": "龙口市", - "luozhuangqu": "罗庄区", - "mengyinxian": "蒙阴县", - "mudanqu": "牡丹区", - "mupingqu": "牟平区", - "ningjinxian": "宁津县", - "ningyangxian": "宁阳县", - "penglaiqu": "蓬莱区", - "pingdu": "平度市", - "pingdushi": "平度市", - "pingyinxian": "平阴县", - "pingyixian": "平邑县", - "pingyuanxian": "平原县", - "qihexian": "齐河县", - "qingdao": "青岛", - "qingdaogaoxinjishuchanyekaifaqu": "青岛高新技术产业开发区", - "qingdaoshi": "青岛", - "qingyunxian": "庆云县", - "qingzhou": "青州市", - "qingzhoushi": "青州市", - "qixia": "栖霞市", - "qixiashi": "栖霞市", - "qufu": "曲阜市", - "qufushi": "曲阜市", - "renchengqu": "任城区", - "rizhao": "日照", - "rizhaojingjijishukaifaqu": "日照经济技术开发区", - "rizhaoshi": "日照", - "rongcheng": "荣成市", - "rongchengshi": "荣成市", - "rushan": "乳山市", - "rushanshi": "乳山市", - "shanghexian": "商河县", - "shantingqu": "山亭区", - "shenxian": "莘县", - "shibeiqu": "市北区", - "shinanqu": "市南区", - "shixiaqu": "市辖区", - "shizhongqu": "市中区", - "shouguang": "寿光市", - "shouguangshi": "寿光市", - "sishuixian": "泗水县", - "taian": "泰安", - "taianshi": "泰安", - "taierzhuangqu": "台儿庄区", - "taishanqu": "泰山区", - "tanchengxian": "郯城县", - "tengzhou": "滕州市", - "tengzhoushi": "滕州市", - "tianqiaoqu": "天桥区", - "weichengqu": "潍城区", - "weifang": "潍坊", - "weifangbinhaijingjijishukaifaqu": "潍坊滨海经济技术开发区", - "weifangshi": "潍坊", - "weihai": "威海", - "weihaihuojugaojishuchanyekaifaqu": "威海火炬高技术产业开发区", - "weihaijingjijishukaifaqu": "威海经济技术开发区", - "weihailingangjingjijishukaifaqu": "威海临港经济技术开发区", - "weihaishi": "威海", - "weishanxian": "微山县", - "wendengqu": "文登区", - "wenshangxian": "汶上县", - "wuchengxian": "武城县", - "wudixian": "无棣县", - "wulianxian": "五莲县", - "xiajinxian": "夏津县", - "xintai": "新泰市", - "xintaishi": "新泰市", - "xuechengqu": "薛城区", - "yangguxian": "阳谷县", - "yangxinxian": "阳信县", - "yantai": "烟台", - "yantaigaoxinjishuchanyekaifaqu": "烟台高新技术产业开发区", - "yantaijingjijishukaifaqu": "烟台经济技术开发区", - "yantaishi": "烟台", - "yanzhouqu": "兖州区", - "yichengqu": "峄城区", - "yinanxian": "沂南县", - "yishuixian": "沂水县", - "yiyuanxian": "沂源县", - "yucheng": "禹城市", - "yuchengshi": "禹城市", - "yunchengxian": "郓城县", - "yutaixian": "鱼台县", - "zaozhuang": "枣庄", - "zaozhuangshi": "枣庄", - "zhangdianqu": "张店区", - "zhangqingqu": "长清区", - "zhangqiuqu": "章丘区", - "zhanhuaqu": "沾化区", - "zhaoyuan": "招远市", - "zhaoyuanshi": "招远市", - "zhifuqu": "芝罘区", - "zhoucunqu": "周村区", - "zhucheng": "诸城市", - "zhuchengshi": "诸城市", - "zibo": "淄博", - "ziboshi": "淄博", - "zichuanqu": "淄川区", - "zoucheng": "邹城市", - "zouchengshi": "邹城市", - "zouping": "邹平市", - "zoupingshi": "邹平市" - }, - "shanghai": { - "baoshanqu": "宝山区", - "chongmingqu": "崇明区", - "fengxianqu": "奉贤区", - "hongkouqu": "虹口区", - "huangpuqu": "黄浦区", - "jiadingqu": "嘉定区", - "jinganqu": "静安区", - "jinshanqu": "金山区", - "minxingqu": "闵行区", - "pudongxinqu": "浦东新区", - "putuoqu": "普陀区", - "qingpuqu": "青浦区", - "songjiangqu": "松江区", - "xuhuiqu": "徐汇区", - "yangpuqu": "杨浦区", - "zhangningqu": "长宁区" - }, - "shanxi": { - "ankang": "安康", - "ankangshi": "安康", - "ansaiqu": "安塞区", - "anzexian": "安泽县", - "baihexian": "白河县", - "baishuixian": "白水县", - "baodexian": "保德县", - "baoji": "宝鸡", - "baojishi": "宝鸡", - "baotaqu": "宝塔区", - "baqiaoqu": "灞桥区", - "bei": "碑林区", - "beilinqu": "碑林区", - "binzhou": "彬州市", - "binzhoushi": "彬州市", - "changanqu": "长安区", - "chencangqu": "陈仓区", - "chengchengxian": "澄城县", - "chengguxian": "城固县", - "chengqu": "城区", - "chunhuaxian": "淳化县", - "daixian": "代县", - "dalixian": "大荔县", - "danfengxian": "丹凤县", - "daningxian": "大宁县", - "datong": "大同", - "datongshi": "大同", - "dingbianxian": "定边县", - "dingxiangxian": "定襄县", - "fangshanxian": "方山县", - "fanzhixian": "繁峙县", - "fengxian": "凤县", - "fengxiangqu": "凤翔区", - "fengxiangxian": "凤翔县", - "fenxixian": "汾西县", - "fenyang": "汾阳市", - "fenyangshi": "汾阳市", - "fufengxian": "扶风县", - "fuguxian": "府谷县", - "fupingxian": "富平县", - "fushanxian": "浮山县", - "fuxian": "富县", - "ganquanxian": "甘泉县", - "gaolingqu": "高陵区", - "gaoping": "高平市", - "gaopingshi": "高平市", - "guanglingxian": "广灵县", - "gujiao": "古交市", - "gujiaoshi": "古交市", - "guxian": "古县", - "hanbinqu": "汉滨区", - "hancheng": "韩城市", - "hanchengshi": "韩城市", - "hantaiqu": "汉台区", - "hanyinxian": "汉阴县", - "hanzhong": "汉中", - "hanzhongshi": "汉中", - "hejin": "河津市", - "hejinshi": "河津市", - "hengshanqu": "横山区", - "hequxian": "河曲县", - "heshunxian": "和顺县", - "heyangxian": "合阳县", - "hongdongxian": "洪洞县", - "houma": "侯马市", - "houmashi": "侯马市", - "huairen": "怀仁市", - "huairenshi": "怀仁市", - "huanglingxian": "黄陵县", - "huanglongxian": "黄龙县", - "huayin": "华阴市", - "huayinshi": "华阴市", - "huazhouqu": "华州区", - "huguanxian": "壶关县", - "hunyuanxian": "浑源县", - "huozhou": "霍州市", - "huozhoushi": "霍州市", - "huyiqu": "鄠邑区", - "jiancaopingqu": "尖草坪区", - "jiangxian": "绛县", - "jiaochengxian": "交城县", - "jiaokouxian": "交口县", - "jiaoqu": "郊区", - "jiaxian": "佳县", - "jiexiu": "介休市", - "jiexiushi": "介休市", - "jincheng": "晋城", - "jinchengshi": "晋城", - "jingbianxian": "靖边县", - "jinglexian": "静乐县", - "jingyangxian": "泾阳县", - "jintaiqu": "金台区", - "jinyuanqu": "晋源区", - "jinzhong": "晋中", - "jinzhongshi": "晋中", - "jishanxian": "稷山县", - "jixian": "吉县", - "kelanxian": "岢岚县", - "kuangqu": "矿区", - "langaoxian": "岚皋县", - "lantianxian": "蓝田县", - "lanxian": "岚县", - "lianhuqu": "莲湖区", - "lichengxian": "黎城县", - "linfen": "临汾", - "linfenshi": "临汾", - "lingchuanxian": "陵川县", - "lingqiuxian": "灵丘县", - "lingshixian": "灵石县", - "lintongqu": "临潼区", - "linweiqu": "临渭区", - "linxian": "临县", - "linyixian": "临猗县", - "linyouxian": "麟游县", - "liquanxian": "礼泉县", - "lishiqu": "离石区", - "liubaxian": "留坝县", - "liulinxian": "柳林县", - "longxian": "陇县", - "loufanxian": "娄烦县", - "luchengqu": "潞城区", - "luochuanxian": "洛川县", - "luonanxian": "洛南县", - "luzhouqu": "潞州区", - "lveyangxian": "略阳县", - "lvliang": "吕梁", - "lvliangshi": "吕梁", - "meixian": "眉县", - "mianxian": "勉县", - "mizhixian": "米脂县", - "nanzhengqu": "南郑区", - "ningqiangxian": "宁强县", - "ningshanxian": "宁陕县", - "ningwuxian": "宁武县", - "pianguanxian": "偏关县", - "pingchengqu": "平城区", - "pingdingxian": "平定县", - "pinglixian": "平利县", - "pingluqu": "平鲁区", - "pingluxian": "平陆县", - "pingshunxian": "平顺县", - "pingyaoxian": "平遥县", - "puchengxian": "蒲城县", - "puxian": "蒲县", - "qianxian": "乾县", - "qianyangxian": "千阳县", - "qindouqu": "秦都区", - "qingjianxian": "清涧县", - "qingxuxian": "清徐县", - "qinshuixian": "沁水县", - "qinxian": "沁县", - "qinyuanxian": "沁源县", - "qishanxian": "岐山县", - "qixian": "祁县", - "quwoxian": "曲沃县", - "ruichengxian": "芮城县", - "sanyuanxian": "三原县", - "shangdangqu": "上党区", - "shangluo": "商洛", - "shangluoshi": "商洛", - "shangnanxian": "商南县", - "shangzhouqu": "商州区", - "shanxidatongjingjikaifaqu": "山西大同经济开发区", - "shanxishuozhoujingjikaifaqu": "山西朔州经济开发区", - "shanxizhangzhigaoxinjishuchanyeyuanqu": "山西长治高新技术产业园区", - "shanxizhuanxingzonghegaigeshifanqu": "山西转型综合改革示范区", - "shanyangxian": "山阳县", - "shanyinxian": "山阴县", - "shenchixian": "神池县", - "shenmu": "神木市", - "shenmushi": "神木市", - "shilouxian": "石楼县", - "shiquanxian": "石泉县", - "shixiaqu": "市辖区", - "shouyangxian": "寿阳县", - "shuochengqu": "朔城区", - "shuozhou": "朔州", - "shuozhoushi": "朔州", - "suidexian": "绥德县", - "taibaixian": "太白县", - "taiguqu": "太谷区", - "taiyuan": "太原", - "taiyuanshi": "太原", - "tianzhenxian": "天镇县", - "tongchuan": "铜川", - "tongchuanshi": "铜川", - "tongguanxian": "潼关县", - "tunliuqu": "屯留区", - "wanbo": "万柏林区", - "wanbolinqu": "万柏林区", - "wangyiqu": "王益区", - "wanrongxian": "万荣县", - "weibinqu": "渭滨区", - "weichengqu": "渭城区", - "weinan": "渭南", - "weinanshi": "渭南", - "weiyangqu": "未央区", - "wenshuixian": "文水县", - "wenxixian": "闻喜县", - "wubuxian": "吴堡县", - "wugongxian": "武功县", - "wuqixian": "吴起县", - "wutaishanfengjingmingshengqu": "五台山风景名胜区", - "wutaixian": "五台县", - "wuxiangxian": "武乡县", - "wuzhaixian": "五寨县", - "xian": "西安", - "xiangfenxian": "襄汾县", - "xiangningxian": "乡宁县", - "xiangyuanxian": "襄垣县", - "xianshi": "西安", - "xianyang": "咸阳", - "xianyangshi": "咸阳", - "xiaodianqu": "小店区", - "xiaoyi": "孝义市", - "xiaoyishi": "孝义市", - "xiaxian": "夏县", - "xinchengqu": "新城区", - "xinfuqu": "忻府区", - "xinghualingqu": "杏花岭区", - "xingping": "兴平市", - "xingpingshi": "兴平市", - "xingxian": "兴县", - "xinjiangxian": "新绛县", - "xinrongqu": "新荣区", - "xinzhou": "忻州", - "xinzhoushi": "忻州", - "xixian": "隰县", - "xixiangxian": "西乡县", - "xiyangxian": "昔阳县", - "xunyang": "旬阳市", - "xunyangshi": "旬阳市", - "xunyangxian": "旬阳县", - "xunyixian": "旬邑县", - "yanan": "延安", - "yananshi": "延安", - "yanchangxian": "延长县", - "yanchuanxian": "延川县", - "yangchengxian": "阳城县", - "yanggaoxian": "阳高县", - "yanglingqu": "杨陵区", - "yangquan": "阳泉", - "yangquanshi": "阳泉", - "yangquxian": "阳曲县", - "yangxian": "洋县", - "yanhuqu": "盐湖区", - "yanliangqu": "阎良区", - "yantaqu": "雁塔区", - "yaodouqu": "尧都区", - "yaozhouqu": "耀州区", - "yichengxian": "翼城县", - "yichuanxian": "宜川县", - "yijunxian": "宜君县", - "yingxian": "应县", - "yingzequ": "迎泽区", - "yintaiqu": "印台区", - "yonghexian": "永和县", - "yongji": "永济市", - "yongjishi": "永济市", - "yongshouxian": "永寿县", - "youyuxian": "右玉县", - "yuanping": "原平市", - "yuanpingshi": "原平市", - "yuanquxian": "垣曲县", - "yuciqu": "榆次区", - "yulin": "榆林", - "yulinshi": "榆林", - "yuncheng": "运城", - "yunchengshi": "运城", - "yungangqu": "云冈区", - "yunzhouqu": "云州区", - "yushexian": "榆社县", - "yuxian": "盂县", - "yuyangqu": "榆阳区", - "zezhouxian": "泽州县", - "zhangwuxian": "长武县", - "zhangzhi": "长治", - "zhangzhishi": "长治", - "zhangzixian": "长子县", - "zhashuixian": "柞水县", - "zhenanxian": "镇安县", - "zhenbaxian": "镇巴县", - "zhenpingxian": "镇坪县", - "zhidanxian": "志丹县", - "zhongyangxian": "中阳县", - "zhouzhixian": "周至县", - "ziyangxian": "紫阳县", - "zizhang": "子长市", - "zizhangshi": "子长市", - "zizhouxian": "子洲县", - "zuoquanxian": "左权县", - "zuoyunxian": "左云县" - }, - "sichuan": { - "abaxian": "阿坝县", - "abazangzuqiangzu": "阿坝藏族羌族", - "abazangzuqiangzuzizhizhou": "阿坝藏族羌族", - "anjuqu": "安居区", - "anyuexian": "安岳县", - "anzhouqu": "安州区", - "baiyuxian": "白玉县", - "baoxingxian": "宝兴县", - "batangxian": "巴塘县", - "bazhong": "巴中", - "bazhongjingjikaifaqu": "巴中经济开发区", - "bazhongshi": "巴中", - "bazhouqu": "巴州区", - "beichuanqiangzuzizhixian": "北川羌族自治县", - "butuoxian": "布拖县", - "cangxixian": "苍溪县", - "chaotianqu": "朝天区", - "chengdu": "成都", - "chengdushi": "成都", - "chenghuaqu": "成华区", - "chongzhou": "崇州市", - "chongzhoushi": "崇州市", - "chuanshanqu": "船山区", - "cuipingqu": "翠屏区", - "daanqu": "大安区", - "dachuanqu": "达川区", - "danbaxian": "丹巴县", - "danlengxian": "丹棱县", - "daochengxian": "稻城县", - "daofuxian": "道孚县", - "dayingxian": "大英县", - "dayixian": "大邑县", - "dazhou": "达州", - "dazhoujingjikaifaqu": "达州经济开发区", - "dazhoushi": "达州", - "dazhuxian": "大竹县", - "dechangxian": "德昌县", - "degexian": "德格县", - "derongxian": "得荣县", - "deyang": "德阳", - "deyangshi": "德阳", - "dongpoqu": "东坡区", - "dongqu": "东区", - "dongxingqu": "东兴区", - "dujiangyan": "都江堰市", - "dujiangyanshi": "都江堰市", - "ebianyizuzizhixian": "峨边彝族自治县", - "emeishan": "峨眉山市", - "emeishanshi": "峨眉山市", - "enyangqu": "恩阳区", - "fuchengqu": "涪城区", - "fushunxian": "富顺县", - "ganluoxian": "甘洛县", - "ganzixian": "甘孜县", - "ganzizangzu": "甘孜藏族", - "ganzizangzuzizhizhou": "甘孜藏族", - "gaopingqu": "高坪区", - "gaoxian": "高县", - "gongjingqu": "贡井区", - "gongxian": "珙县", - "guangan": "广安", - "guanganqu": "广安区", - "guanganshi": "广安", - "guanghan": "广汉市", - "guanghanshi": "广汉市", - "guangyuan": "广元", - "guangyuanshi": "广元", - "gulinxian": "古蔺县", - "hanyuanxian": "汉源县", - "heishuixian": "黑水县", - "hejiangxian": "合江县", - "hongyaxian": "洪雅县", - "hongyuanxian": "红原县", - "huaying": "华蓥市", - "huayingshi": "华蓥市", - "huidongxian": "会东县", - "huili": "会理市", - "huilishi": "会理市", - "huilixian": "会理县", - "jiajiangxian": "夹江县", - "jialingqu": "嘉陵区", - "jianganxian": "江安县", - "jiangexian": "剑阁县", - "jiangyangqu": "江阳区", - "jiangyou": "江油市", - "jiangyoushi": "江油市", - "jianyang": "简阳市", - "jianyangshi": "简阳市", - "jinchuanxian": "金川县", - "jingyangqu": "旌阳区", - "jingyanxian": "井研县", - "jinjiangqu": "锦江区", - "jinkouhequ": "金口河区", - "jinniuqu": "金牛区", - "jintangxian": "金堂县", - "jinyangxian": "金阳县", - "jiulongxian": "九龙县", - "jiuzhaigouxian": "九寨沟县", - "kaijiangxian": "开江县", - "kangding": "康定市", - "kangdingshi": "康定市", - "langzhong": "阆中市", - "langzhongshi": "阆中市", - "leiboxian": "雷波县", - "leshan": "乐山", - "leshanshi": "乐山", - "lezhixian": "乐至县", - "liangshanyizu": "凉山彝族", - "liangshanyizuzizhizhou": "凉山彝族", - "linshuixian": "邻水县", - "litangxian": "理塘县", - "lixian": "理县", - "lizhouqu": "利州区", - "longchang": "隆昌市", - "longchangshi": "隆昌市", - "longmatanqu": "龙马潭区", - "longquanyiqu": "龙泉驿区", - "ludingxian": "泸定县", - "luhuoxian": "炉霍县", - "luojiangqu": "罗江区", - "lushanxian": "芦山县", - "luxian": "泸县", - "luzhou": "泸州", - "luzhoushi": "泸州", - "mabianyizuzizhixian": "马边彝族自治县", - "maerkang": "马尔康市", - "maerkangshi": "马尔康市", - "maoxian": "茂县", - "meiguxian": "美姑县", - "meishan": "眉山", - "meishanshi": "眉山", - "mianningxian": "冕宁县", - "mianyang": "绵阳", - "mianyangshi": "绵阳", - "mianzhu": "绵竹市", - "mianzhushi": "绵竹市", - "mingshanqu": "名山区", - "miyixian": "米易县", - "muchuanxian": "沐川县", - "mulizangzuzizhixian": "木里藏族自治县", - "nanbuxian": "南部县", - "nanchong": "南充", - "nanchongshi": "南充", - "nanjiangxian": "南江县", - "nanxiqu": "南溪区", - "naxiqu": "纳溪区", - "neijiang": "内江", - "neijiangjingjikaifaqu": "内江经济开发区", - "neijiangshi": "内江", - "ningnanxian": "宁南县", - "panzhihua": "攀枝花", - "panzhihuashi": "攀枝花", - "penganxian": "蓬安县", - "pengshanqu": "彭山区", - "pengxixian": "蓬溪县", - "pengzhou": "彭州市", - "pengzhoushi": "彭州市", - "pidouqu": "郫都区", - "pingchangxian": "平昌县", - "pingshanxian": "屏山县", - "pingwuxian": "平武县", - "pugexian": "普格县", - "pujiangxian": "蒲江县", - "qianfengqu": "前锋区", - "qianweixian": "犍为县", - "qingbaijiangqu": "青白江区", - "qingchuanxian": "青川县", - "qingshenxian": "青神县", - "qingyangqu": "青羊区", - "qionglai": "邛崃市", - "qionglaishi": "邛崃市", - "quxian": "渠县", - "rangtangxian": "壤塘县", - "renhequ": "仁和区", - "renshouxian": "仁寿县", - "rongxian": "荣县", - "ruoergaixian": "若尔盖县", - "santaixian": "三台县", - "sedaxian": "色达县", - "shawanqu": "沙湾区", - "shehong": "射洪市", - "shehongshi": "射洪市", - "shenfang": "什邡市", - "shenfangshi": "什邡市", - "shimianxian": "石棉县", - "shiquxian": "石渠县", - "shixiaqu": "市辖区", - "shizhongqu": "市中区", - "shuangliuqu": "双流区", - "shunqingqu": "顺庆区", - "songpanxian": "松潘县", - "suining": "遂宁", - "suiningshi": "遂宁", - "tianquanxian": "天全县", - "tongchuanqu": "通川区", - "tongjiangxian": "通江县", - "wangcangxian": "旺苍县", - "wanyuan": "万源市", - "wanyuanshi": "万源市", - "weiyuanxian": "威远县", - "wenchuanxian": "汶川县", - "wenjiangqu": "温江区", - "wuhouqu": "武侯区", - "wushengxian": "武胜县", - "wutongqiaoqu": "五通桥区", - "xiangchengxian": "乡城县", - "xiaojinxian": "小金县", - "xichang": "西昌市", - "xichangshi": "西昌市", - "xichongxian": "西充县", - "xidexian": "喜德县", - "xindouqu": "新都区", - "xingjingxian": "荥经县", - "xingwenxian": "兴文县", - "xinjinqu": "新津区", - "xinlongxian": "新龙县", - "xiqu": "西区", - "xuanhanxian": "宣汉县", - "xuyongxian": "叙永县", - "xuzhouqu": "叙州区", - "yaan": "雅安", - "yaanshi": "雅安", - "yajiangxian": "雅江县", - "yanbianxian": "盐边县", - "yanjiangqu": "雁江区", - "yantanqu": "沿滩区", - "yantingxian": "盐亭县", - "yanyuanxian": "盐源县", - "yibin": "宜宾", - "yibinshi": "宜宾", - "yilongxian": "仪陇县", - "yingshanxian": "营山县", - "youxianqu": "游仙区", - "yuchengqu": "雨城区", - "yuechixian": "岳池县", - "yuexixian": "越西县", - "yunlianxian": "筠连县", - "zhangningxian": "长宁县", - "zhaohuaqu": "昭化区", - "zhaojuexian": "昭觉县", - "zhongjiangxian": "中江县", - "zigong": "自贡", - "zigongshi": "自贡", - "ziliujingqu": "自流井区", - "zitongxian": "梓潼县", - "ziyang": "资阳", - "ziyangshi": "资阳", - "zizhongxian": "资中县" - }, - "tianjin": { - "bao": "宝坻区", - "baodiqu": "宝坻区", - "beichenqu": "北辰区", - "binhaixinqu": "滨海新区", - "dongliqu": "东丽区", - "hebeiqu": "河北区", - "hedongqu": "河东区", - "hepingqu": "和平区", - "hexiqu": "河西区", - "hongqiaoqu": "红桥区", - "jinghaiqu": "静海区", - "jinnanqu": "津南区", - "jizhouqu": "蓟州区", - "nankaiqu": "南开区", - "ninghequ": "宁河区", - "wuqingqu": "武清区", - "xiqingqu": "西青区" - }, - "xianggang": { - "beiqu": "北区", - "dabuqu": "大埔区", - "dongqu": "东区", - "guantangqu": "观塘区", - "huangdaxianqu": "黄大仙区", - "jiulongchengqu": "九龙城区", - "kuiqingqu": "葵青区", - "lidaoqu": "离岛区", - "nanqu": "南区", - "quanwanqu": "荃湾区", - "shatianqu": "沙田区", - "shenshuibuqu": "深水埗区", - "tunmenqu": "屯门区", - "wanzaiqu": "湾仔区", - "xigongqu": "西贡区", - "youjianwangqu": "油尖旺区", - "yuanlangqu": "元朗区", - "zhongxiqu": "中西区" - }, - "xinjiang": { - "aheqixian": "阿合奇县", - "akesu": "阿克苏", - "akesudiqu": "阿克苏", - "akesushi": "阿克苏市", - "aketaoxian": "阿克陶县", - "alaer": "阿拉尔市", - "alaershi": "阿拉尔市", - "alashankou": "阿拉山口市", - "alashankoushi": "阿拉山口市", - "aleitai": "阿勒泰", - "aleitaidiqu": "阿勒泰", - "aleitaishi": "阿勒泰市", - "atushen": "阿图什市", - "atushenshi": "阿图什市", - "awatixian": "阿瓦提县", - "bachuxian": "巴楚县", - "baichengxian": "拜城县", - "baijiantanqu": "白碱滩区", - "balikunhasakezizhixian": "巴里坤哈萨克自治县", - "bayinguolengmenggu": "巴音郭楞蒙古", - "bayinguolengmengguzizhizhou": "巴音郭楞蒙古", - "beitun": "北屯市", - "beitunshi": "北屯市", - "boertalamenggu": "博尔塔拉蒙古", - "boertalamengguzizhizhou": "博尔塔拉蒙古", - "bohuxian": "博湖县", - "bole": "博乐市", - "boleshi": "博乐市", - "buerjinxian": "布尔津县", - "celeixian": "策勒县", - "chabuchaerxibozizhixian": "察布查尔锡伯自治县", - "changji": "昌吉市", - "changjihuizu": "昌吉回族", - "changjihuizuzizhizhou": "昌吉回族", - "changjishi": "昌吉市", - "dabanchengqu": "达坂城区", - "dushanziqu": "独山子区", - "eminxian": "额敏县", - "fuhaixian": "福海县", - "fukang": "阜康市", - "fukangshi": "阜康市", - "fuyunxian": "富蕴县", - "gaochangqu": "高昌区", - "gashixian": "伽师县", - "gongliuxian": "巩留县", - "habahexian": "哈巴河县", - "hami": "哈密", - "hamishi": "哈密", - "hebukesaiermengguzizhixian": "和布克赛尔蒙古自治县", - "hejingxian": "和静县", - "heshuoxian": "和硕县", - "hetian": "和田", - "hetiandiqu": "和田", - "hetianshi": "和田市", - "hetianxian": "和田县", - "huochengxian": "霍城县", - "huoerguosi": "霍尔果斯市", - "huoerguosishi": "霍尔果斯市", - "hutubixian": "呼图壁县", - "huyanghe": "胡杨河市", - "huyangheshi": "胡杨河市", - "jimunaixian": "吉木乃县", - "jimusaerxian": "吉木萨尔县", - "jinghexian": "精河县", - "ka": "喀什", - "kashi": "喀什", - "kashidiqu": "喀什", - "kashishi": "喀什市", - "kekedala": "可克达拉市", - "kekedalashi": "可克达拉市", - "kelamayi": "克拉玛依", - "kelamayiqu": "克拉玛依区", - "kelamayishi": "克拉玛依", - "kepingxian": "柯坪县", - "kezileisukeerkezi": "克孜勒苏柯尔克孜", - "kezileisukeerkezizizhizhou": "克孜勒苏柯尔克孜", - "kuche": "库车市", - "kucheshi": "库车市", - "kuerlei": "库尔勒市", - "kuerleijingjijishukaifaqu": "库尔勒经济技术开发区", - "kuerleishi": "库尔勒市", - "kuitun": "奎屯市", - "kuitunshi": "奎屯市", - "kunyu": "昆玉市", - "kunyushi": "昆玉市", - "luntaixian": "轮台县", - "luopuxian": "洛浦县", - "maigaitixian": "麦盖提县", - "manasixian": "玛纳斯县", - "midongqu": "米东区", - "minfengxian": "民丰县", - "moyuxian": "墨玉县", - "muleihasakezizhixian": "木垒哈萨克自治县", - "nileikexian": "尼勒克县", - "pishanxian": "皮山县", - "qiemoxian": "且末县", - "qinghexian": "青河县", - "qitaixian": "奇台县", - "ruoqiangxian": "若羌县", - "shachexian": "莎车县", - "shanshanxian": "鄯善县", - "shawan": "沙湾市", - "shawanshi": "沙湾市", - "shawanxian": "沙湾县", - "shayaxian": "沙雅县", - "shayibakequ": "沙依巴克区", - "shihezi": "石河子市", - "shihezishi": "石河子市", - "shixiaqu": "市辖区", - "shuanghe": "双河市", - "shuangheshi": "双河市", - "shufuxian": "疏附县", - "shuimogouqu": "水磨沟区", - "shulexian": "疏勒县", - "tacheng": "塔城", - "tachengdiqu": "塔城", - "tachengshi": "塔城市", - "tashenkuergantajikezizhixian": "塔什库尔干塔吉克自治县", - "tekesixian": "特克斯县", - "tianshanqu": "天山区", - "tiemenguan": "铁门关市", - "tiemenguanshi": "铁门关市", - "toutunhequ": "头屯河区", - "tulufan": "吐鲁番", - "tulufanshi": "吐鲁番", - "tumushuke": "图木舒克市", - "tumushukeshi": "图木舒克市", - "tuokexunxian": "托克逊县", - "tuolixian": "托里县", - "wenquanxian": "温泉县", - "wensuxian": "温宿县", - "wuerhequ": "乌尔禾区", - "wujiaqu": "五家渠市", - "wujiaqushi": "五家渠市", - "wulumuqi": "乌鲁木齐", - "wulumuqishi": "乌鲁木齐", - "wulumuqixian": "乌鲁木齐县", - "wuqiaxian": "乌恰县", - "wushenxian": "乌什县", - "wusu": "乌苏市", - "wusushi": "乌苏市", - "xinhexian": "新和县", - "xinjiangweiwuerzizhiquzizhiquzhixiaxianjixingzhengquhua": "新疆维吾尔自治区-自治区直辖县级行政区划", - "xinshiqu": "新市区", - "xinxing": "新星市", - "xinxingshi": "新星市", - "xinyuanxian": "新源县", - "yanqihuizuzizhixian": "焉耆回族自治县", - "yechengxian": "叶城县", - "yilihasake": "伊犁哈萨克", - "yilihasakezizhizhou": "伊犁哈萨克", - "yingjishaxian": "英吉沙县", - "yining": "伊宁市", - "yiningshi": "伊宁市", - "yiningxian": "伊宁县", - "yiwuxian": "伊吾县", - "yizhouqu": "伊州区", - "yuepuhuxian": "岳普湖县", - "yulixian": "尉犁县", - "yuminxian": "裕民县", - "yutianxian": "于田县", - "zepuxian": "泽普县", - "zhaosuxian": "昭苏县" - }, - "xizang": { - "ali": "阿里", - "alidiqu": "阿里", - "anduoxian": "安多县", - "angrenxian": "昂仁县", - "bailangxian": "白朗县", - "bangexian": "班戈县", - "baqingxian": "巴青县", - "basuxian": "八宿县", - "bayiqu": "巴宜区", - "bianbaxian": "边坝县", - "biruxian": "比如县", - "bomixian": "波密县", - "changdou": "昌都", - "changdu": "昌都", - "changdushi": "昌都", - "chayaxian": "察雅县", - "chayuxian": "察隅县", - "chengguanqu": "城关区", - "cuomeixian": "措美县", - "cuonaxian": "错那县", - "cuoqinxian": "措勤县", - "dangxiongxian": "当雄县", - "dazigongyeyuanqu": "达孜工业园区", - "daziqu": "达孜区", - "dingjiexian": "定结县", - "dingqingxian": "丁青县", - "dingrixian": "定日县", - "duilongdeqingqu": "堆龙德庆区", - "gaerxian": "噶尔县", - "gaizexian": "改则县", - "gangbaxian": "岗巴县", - "geermuzangqinggongyeyuanqu": "格尔木藏青工业园区", - "gejixian": "革吉县", - "gongbujiangdaxian": "工布江达县", - "gonggaxian": "贡嘎县", - "gongjuexian": "贡觉县", - "jiachaxian": "加查县", - "jialixian": "嘉黎县", - "jiangdaxian": "江达县", - "jiangzixian": "江孜县", - "jilongxian": "吉隆县", - "kangmaxian": "康马县", - "karuoqu": "卡若区", - "langqiazixian": "浪卡子县", - "langxian": "朗县", - "lasa": "拉萨", - "lasajingjijishukaifaqu": "拉萨经济技术开发区", - "lasashi": "拉萨", - "lazixian": "拉孜县", - "leiwuqixian": "类乌齐县", - "linzhi": "林芝", - "linzhishi": "林芝", - "linzhouxian": "林周县", - "longzixian": "隆子县", - "luolongxian": "洛隆县", - "luozhaxian": "洛扎县", - "mangkangxian": "芒康县", - "milinxian": "米林县", - "motuoxian": "墨脱县", - "mozhugongkaxian": "墨竹工卡县", - "naidongqu": "乃东区", - "nanmulinxian": "南木林县", - "naqu": "那曲", - "naqushi": "那曲", - "nielamuxian": "聂拉木县", - "nierongxian": "聂荣县", - "nimaxian": "尼玛县", - "nimuxian": "尼木县", - "pulanxian": "普兰县", - "qiongjiexian": "琼结县", - "qushuixian": "曲水县", - "qusongxian": "曲松县", - "renbuxian": "仁布县", - "rikaze": "日喀则", - "rikazeshi": "日喀则", - "rituxian": "日土县", - "sagaxian": "萨嘎县", - "sajiaxian": "萨迦县", - "sangrixian": "桑日县", - "sangzhuziqu": "桑珠孜区", - "seniqu": "色尼区", - "shannan": "山南", - "shannanshi": "山南", - "shenzhaxian": "申扎县", - "shixiaqu": "市辖区", - "shuanghuxian": "双湖县", - "suoxian": "索县", - "xietongmenxian": "谢通门县", - "xizangwenhualvyouchuangyiyuanqu": "西藏文化旅游创意园区", - "yadongxian": "亚东县", - "zhadaxian": "札达县", - "zhanangxian": "扎囊县", - "zhongbaxian": "仲巴县", - "zuogongxian": "左贡县" - }, - "yunnan": { - "anning": "安宁市", - "anningshi": "安宁市", - "baoshan": "保山", - "baoshanshi": "保山", - "binchuanxian": "宾川县", - "cangyuanwazuzizhixian": "沧源佤族自治县", - "changningxian": "昌宁县", - "chenggongqu": "呈贡区", - "chengjiang": "澄江市", - "chengjiangshi": "澄江市", - "chuxiong": "楚雄市", - "chuxiongshi": "楚雄市", - "chuxiongyizu": "楚雄彝族", - "chuxiongyizuzizhizhou": "楚雄彝族", - "daguanxian": "大关县", - "dali": "大理市", - "dalibaizu": "大理白族", - "dalibaizuzizhizhou": "大理白族", - "dalishi": "大理市", - "dayaoxian": "大姚县", - "dehongdaizujingpozu": "德宏傣族景颇族", - "dehongdaizujingpozuzizhizhou": "德宏傣族景颇族", - "deqinxian": "德钦县", - "diqingzangzu": "迪庆藏族", - "diqingzangzuzizhizhou": "迪庆藏族", - "dongchuanqu": "东川区", - "eryuanxian": "洱源县", - "eshanyizuzizhixian": "峨山彝族自治县", - "fengqingxian": "凤庆县", - "fugongxian": "福贡县", - "fuminxian": "富民县", - "funingxian": "富宁县", - "fuyuanxian": "富源县", - "gejiu": "个旧市", - "gejiushi": "个旧市", - "gengmadaizuwazuzizhixian": "耿马傣族佤族自治县", - "gongshandulongzunuzuzizhixian": "贡山独龙族怒族自治县", - "guanduqu": "官渡区", - "guangnanxian": "广南县", - "guchengqu": "古城区", - "hekouyaozuzizhixian": "河口瑶族自治县", - "heqingxian": "鹤庆县", - "honghehanizuyizu": "红河哈尼族彝族", - "honghehanizuyizuzizhizhou": "红河哈尼族彝族", - "honghexian": "红河县", - "hongtaqu": "红塔区", - "huaningxian": "华宁县", - "huapingxian": "华坪县", - "huizexian": "会泽县", - "jianchuanxian": "剑川县", - "jiangchenghanizuyizuzizhixian": "江城哈尼族彝族自治县", - "jiangchuanqu": "江川区", - "jianshuixian": "建水县", - "jingdongyizuzizhixian": "景东彝族自治县", - "jinggudaizuyizuzizhixian": "景谷傣族彝族自治县", - "jinghong": "景洪市", - "jinghongshi": "景洪市", - "jinningqu": "晋宁区", - "jinpingmiaozuyaozudaizuzizhixian": "金平苗族瑶族傣族自治县", - "kaiyuan": "开远市", - "kaiyuanshi": "开远市", - "kunming": "昆明", - "kunmingshi": "昆明", - "lancanglahuzuzizhixian": "澜沧拉祜族自治县", - "lanpingbaizupumizuzizhixian": "兰坪白族普米族自治县", - "lianghexian": "梁河县", - "lijiang": "丽江", - "lijiangshi": "丽江", - "lincang": "临沧", - "lincangshi": "临沧", - "linxiangqu": "临翔区", - "longchuanxian": "陇川县", - "longlingxian": "龙陵县", - "longyangqu": "隆阳区", - "ludianxian": "鲁甸县", - "lufeng": "禄丰市", - "lufengshi": "禄丰市", - "lufengxian": "禄丰县", - "luliangxian": "陆良县", - "luopingxian": "罗平县", - "luquanyizumiaozuzizhixian": "禄劝彝族苗族自治县", - "lushui": "泸水市", - "lushuishi": "泸水市", - "luxixian": "泸西县", - "lvchunxian": "绿春县", - "maguanxian": "马关县", - "malipoxian": "麻栗坡县", - "malongqu": "马龙区", - "mang": "芒市", - "mangshi": "芒市", - "menghaixian": "勐海县", - "menglaxian": "勐腊县", - "mengliandaizulahuzuwazuzizhixian": "孟连傣族拉祜族佤族自治县", - "mengzi": "蒙自市", - "mengzishi": "蒙自市", - "miduxian": "弥渡县", - "mile": "弥勒市", - "mileshi": "弥勒市", - "mojianghanizuzizhixian": "墨江哈尼族自治县", - "moudingxian": "牟定县", - "nanhuaxian": "南华县", - "nanjianyizuzizhixian": "南涧彝族自治县", - "ningerhanizuyizuzizhixian": "宁洱哈尼族彝族自治县", - "ninglangyizuzizhixian": "宁蒗彝族自治县", - "nujianglisuzu": "怒江傈僳族", - "nujianglisuzuzizhizhou": "怒江傈僳族", - "panlongqu": "盘龙区", - "pingbianmiaozuzizhixian": "屏边苗族自治县", - "puer": "普洱", - "puershi": "普洱", - "qi": "麒麟区", - "qiaojiaxian": "巧家县", - "qilinqu": "麒麟区", - "qiubeixian": "丘北县", - "qujing": "曲靖", - "qujingshi": "曲靖", - "ruili": "瑞丽市", - "ruilishi": "瑞丽市", - "shidianxian": "施甸县", - "shilinyizuzizhixian": "石林彝族自治县", - "shipingxian": "石屏县", - "shixiaqu": "市辖区", - "shizongxian": "师宗县", - "shuangbaixian": "双柏县", - "shuangjianglahuzuwazubulangzudaizuzizhixian": "双江拉祜族佤族布朗族傣族自治县", - "shuifu": "水富市", - "shuifushi": "水富市", - "simaoqu": "思茅区", - "songmingxian": "嵩明县", - "suijiangxian": "绥江县", - "tengchong": "腾冲市", - "tengchongshi": "腾冲市", - "tonghaixian": "通海县", - "weishanyizuhuizuzizhixian": "巍山彝族回族自治县", - "weixilisuzuzizhixian": "维西傈僳族自治县", - "weixinxian": "威信县", - "wenshan": "文山市", - "wenshanshi": "文山市", - "wenshanzhuangzumiaozu": "文山壮族苗族", - "wenshanzhuangzumiaozuzizhizhou": "文山壮族苗族", - "wudingxian": "武定县", - "wuhuaqu": "五华区", - "xianggelila": "香格里拉市", - "xianggelilashi": "香格里拉市", - "xiangyunxian": "祥云县", - "xichouxian": "西畴县", - "ximengwazuzizhixian": "西盟佤族自治县", - "xinpingyizudaizuzizhixian": "新平彝族傣族自治县", - "xishanqu": "西山区", - "xishuangbannadaizu": "西双版纳傣族", - "xishuangbannadaizuzizhizhou": "西双版纳傣族", - "xuanwei": "宣威市", - "xuanweishi": "宣威市", - "xundianhuizuyizuzizhixian": "寻甸回族彝族自治县", - "yangbiyizuzizhixian": "漾濞彝族自治县", - "yanjinxian": "盐津县", - "yanshanxian": "砚山县", - "yaoanxian": "姚安县", - "yiliangxian": "宜良县", - "yimenxian": "易门县", - "yingjiangxian": "盈江县", - "yongdexian": "永德县", - "yongpingxian": "永平县", - "yongrenxian": "永仁县", - "yongshanxian": "永善县", - "yongshengxian": "永胜县", - "yuanjianghanizuyizudaizuzizhixian": "元江哈尼族彝族傣族自治县", - "yuanmouxian": "元谋县", - "yuanyangxian": "元阳县", - "yulongnaxizuzizhixian": "玉龙纳西族自治县", - "yunlongxian": "云龙县", - "yunxian": "云县", - "yuxi": "玉溪", - "yuxishi": "玉溪", - "zhanyiqu": "沾益区", - "zhaotong": "昭通", - "zhaotongshi": "昭通", - "zhaoyangqu": "昭阳区", - "zhenkangxian": "镇康县", - "zhenxiongxian": "镇雄县", - "zhenyuanyizuhanizulahuzuzizhixian": "镇沅彝族哈尼族拉祜族自治县" - }, - "zhejiang": { - "anjixian": "安吉县", - "beilunqu": "北仑区", - "binjiangqu": "滨江区", - "cangnanxian": "苍南县", - "changshanxian": "常山县", - "changxingxian": "长兴县", - "chunanxian": "淳安县", - "cixi": "慈溪市", - "cixishi": "慈溪市", - "daishanxian": "岱山县", - "deqingxian": "德清县", - "dinghaiqu": "定海区", - "dongtouqu": "洞头区", - "dongyang": "东阳市", - "dongyangshi": "东阳市", - "fenghuaqu": "奉化区", - "fuyangqu": "富阳区", - "gongshuqu": "拱墅区", - "haining": "海宁市", - "hainingshi": "海宁市", - "haishuqu": "海曙区", - "haiyanxian": "海盐县", - "hangzhou": "杭州", - "hangzhoushi": "杭州", - "huangyanqu": "黄岩区", - "huzhou": "湖州", - "huzhoushi": "湖州", - "jiande": "建德市", - "jiandeshi": "建德市", - "jiangbeiqu": "江北区", - "jiangganqu": "江干区", - "jiangshan": "江山市", - "jiangshanshi": "江山市", - "jiaojiangqu": "椒江区", - "jiashanxian": "嘉善县", - "jiaxing": "嘉兴", - "jiaxingshi": "嘉兴", - "jindongqu": "金东区", - "jingningshezuzizhixian": "景宁畲族自治县", - "jinhua": "金华", - "jinhuashi": "金华", - "jinyunxian": "缙云县", - "kaihuaxian": "开化县", - "kechengqu": "柯城区", - "keqiaoqu": "柯桥区", - "lanxi": "兰溪市", - "lanxishi": "兰溪市", - "liandouqu": "莲都区", - "linanqu": "临安区", - "linhai": "临海市", - "linhaishi": "临海市", - "linpingqu": "临平区", - "lishui": "丽水", - "lishuishi": "丽水", - "longgang": "龙港市", - "longgangshi": "龙港市", - "longquan": "龙泉市", - "longquanshi": "龙泉市", - "longwanqu": "龙湾区", - "longyouxian": "龙游县", - "luchengqu": "鹿城区", - "luqiaoqu": "路桥区", - "nanhuqu": "南湖区", - "nanxunqu": "南浔区", - "ningbo": "宁波", - "ningboshi": "宁波", - "ninghaixian": "宁海县", - "ouhaiqu": "瓯海区", - "pananxian": "磐安县", - "pinghu": "平湖市", - "pinghushi": "平湖市", - "pingyangxian": "平阳县", - "pujiangxian": "浦江县", - "putuoqu": "普陀区", - "qiantangqu": "钱塘区", - "qingtianxian": "青田县", - "qingyuanxian": "庆元县", - "qujiangqu": "衢江区", - "quzhou": "衢州", - "quzhoushi": "衢州", - "ruian": "瑞安市", - "ruianshi": "瑞安市", - "sanmenxian": "三门县", - "shangchengqu": "上城区", - "shangyuqu": "上虞区", - "shaoxing": "绍兴", - "shaoxingshi": "绍兴", - "shengsixian": "嵊泗县", - "shengzhou": "嵊州市", - "shengzhoushi": "嵊州市", - "shixiaqu": "市辖区", - "songyangxian": "松阳县", - "suichangxian": "遂昌县", - "taishunxian": "泰顺县", - "taizhou": "台州", - "taizhoushi": "台州", - "tiantaixian": "天台县", - "tongluxian": "桐庐县", - "tongxiang": "桐乡市", - "tongxiangshi": "桐乡市", - "wenchengxian": "文成县", - "wenling": "温岭市", - "wenlingshi": "温岭市", - "wenzhou": "温州", - "wenzhoujingjijishukaifaqu": "温州经济技术开发区", - "wenzhoushi": "温州", - "wuchengqu": "婺城区", - "wuxingqu": "吴兴区", - "wuyixian": "武义县", - "xiachengqu": "下城区", - "xiangshanxian": "象山县", - "xianjuxian": "仙居县", - "xiaoshanqu": "萧山区", - "xihuqu": "西湖区", - "xinchangxian": "新昌县", - "xiuzhouqu": "秀洲区", - "yinzhouqu": "鄞州区", - "yiwu": "义乌市", - "yiwushi": "义乌市", - "yongjiaxian": "永嘉县", - "yongkang": "永康市", - "yongkangshi": "永康市", - "yuechengqu": "越城区", - "yueqing": "乐清市", - "yueqingshi": "乐清市", - "yuhangqu": "余杭区", - "yuhuan": "玉环市", - "yuhuanshi": "玉环市", - "yunhexian": "云和县", - "yuyao": "余姚市", - "yuyaoshi": "余姚市", - "zhenhaiqu": "镇海区", - "zhoushan": "舟山", - "zhoushanshi": "舟山", - "zhuji": "诸暨市", - "zhujishi": "诸暨市" - } - }, - "cityNameByKey": { - "abagaqi": "阿巴嘎旗", - "abaxian": "阿坝县", - "abazangzuqiangzu": "阿坝藏族羌族", - "abazangzuqiangzuzizhizhou": "阿坝藏族羌族", - "achengqu": "阿城区", - "aershan": "阿尔山市", - "aershanshi": "阿尔山市", - "aheqixian": "阿合奇县", - "aihuiqu": "爱辉区", - "aiminqu": "爱民区", - "akesaihasakezuzizhixian": "阿克塞哈萨克族自治县", - "akesudiqu": "阿克苏", - "akesushi": "阿克苏市", - "aketaoxian": "阿克陶县", - "alaer": "阿拉尔市", - "alaershi": "阿拉尔市", - "alashan": "阿拉善", - "alashankou": "阿拉山口市", - "alashankoushi": "阿拉山口市", - "alashanmeng": "阿拉善", - "alashanyouqi": "阿拉善右旗", - "alashanzuoqi": "阿拉善左旗", - "aleitaidiqu": "阿勒泰", - "aleitaishi": "阿勒泰市", - "ali": "阿里", - "alidiqu": "阿里", - "alukeerqinqi": "阿鲁科尔沁旗", - "anciqu": "安次区", - "anda": "安达市", - "andashi": "安达市", - "andingqu": "安定区", - "anduoxian": "安多县", - "anfuxian": "安福县", - "angangxiqu": "昂昂溪区", - "angrenxian": "昂仁县", - "anguo": "安国市", - "anguoshi": "安国市", - "anhuaxian": "安化县", - "anhuianqingjingjikaifaqu": "安徽安庆经济开发区", - "anhuiwuhusanshanjingjikaifaqu": "安徽芜湖三山经济开发区", - "anjixian": "安吉县", - "anjuqu": "安居区", - "ankang": "安康", - "ankangshi": "安康", - "anlongxian": "安龙县", - "anlu": "安陆市", - "anlushi": "安陆市", - "anning": "安宁市", - "anningqu": "安宁区", - "anningshi": "安宁市", - "anpingxian": "安平县", - "anqing": "安庆", - "anqingshi": "安庆", - "anqiu": "安丘市", - "anqiushi": "安丘市", - "anrenxian": "安仁县", - "ansaiqu": "安塞区", - "anshan": "鞍山", - "anshanshi": "鞍山", - "anshun": "安顺", - "anshunshi": "安顺", - "antuxian": "安图县", - "anxiangxian": "安乡县", - "anxinxian": "安新县", - "anxixian": "安溪县", - "anyang": "安阳", - "anyanggaoxinjishuchanyekaifaqu": "安阳高新技术产业开发区", - "anyangshi": "安阳", - "anyangxian": "安阳县", - "anyixian": "安义县", - "anyuanqu": "安源区", - "anyuanxian": "安远县", - "anyuexian": "安岳县", - "anzexian": "安泽县", - "anzhouqu": "安州区", - "aohanqi": "敖汉旗", - "arongqi": "阿荣旗", - "atushen": "阿图什市", - "atushenshi": "阿图什市", - "awatixian": "阿瓦提县", - "babuqu": "八步区", - "bachuxian": "巴楚县", - "badongxian": "巴东县", - "bagongshanqu": "八公山区", - "baicheng": "白城", - "baichengshi": "白城", - "baichengxian": "拜城县", - "baihexian": "白河县", - "baijiantanqu": "白碱滩区", - "bailangxian": "白朗县", - "baiquanxian": "拜泉县", - "baise": "百色", - "baiseshi": "百色", - "baishalizuzizhixian": "白沙黎族自治县", - "baishan": "白山", - "baishanshi": "白山", - "baishuixian": "白水县", - "baitaqu": "白塔区", - "baixiangxian": "柏乡县", - "baiyin": "白银", - "baiyinqu": "白银区", - "baiyinshi": "白银", - "baiyunebokuangqu": "白云鄂博矿区", - "baiyunqu": "白云区", - "baiyuxian": "白玉县", - "balikunhasakezizhixian": "巴里坤哈萨克自治县", - "balinyouqi": "巴林右旗", - "balinzuoqi": "巴林左旗", - "bamayaozuzizhixian": "巴马瑶族自治县", - "bananqu": "巴南区", - "bangexian": "班戈县", - "bangshanqu": "蚌山区", - "banmaxian": "班玛县", - "bao": "宝坻区", - "baoanqu": "宝安区", - "baodexian": "保德县", - "baoding": "保定", - "baodingbaigouxincheng": "保定白沟新城", - "baodinggaoxinjishuchanyekaifaqu": "保定高新技术产业开发区", - "baodingshi": "保定", - "baodiqu": "宝坻区", - "baofengxian": "宝丰县", - "baohequ": "包河区", - "baoji": "宝鸡", - "baojingxian": "保靖县", - "baojishi": "宝鸡", - "baokangxian": "保康县", - "baoqingxian": "宝清县", - "baoshan": "保山", - "baoshanqu": "宝山区", - "baoshanshi": "保山", - "baotaqu": "宝塔区", - "baotinglizumiaozuzizhixian": "保亭黎族苗族自治县", - "baotou": "包头", - "baotoushi": "包头", - "baotouxitugaoxinjishuchanyekaifaqu": "包头稀土高新技术产业开发区", - "baoxingxian": "宝兴县", - "baoyingxian": "宝应县", - "baqiaoqu": "灞桥区", - "baqingxian": "巴青县", - "basuxian": "八宿县", - "batangxian": "巴塘县", - "bayannaoer": "巴彦淖尔", - "bayannaoershi": "巴彦淖尔", - "bayanxian": "巴彦县", - "bayinguolengmenggu": "巴音郭楞蒙古", - "bayinguolengmengguzizhizhou": "巴音郭楞蒙古", - "bayiqu": "巴宜区", - "bayuquanqu": "鲅鱼圈区", - "bazhong": "巴中", - "bazhongjingjikaifaqu": "巴中经济开发区", - "bazhongshi": "巴中", - "bazhou": "霸州市", - "bazhouqu": "巴州区", - "bazhoushi": "霸州市", - "beian": "北安市", - "beianshi": "北安市", - "beibeiqu": "北碚区", - "beichenqu": "北辰区", - "beichuanqiangzuzizhixian": "北川羌族自治县", - "beidaihequ": "北戴河区", - "beidaihexinqu": "北戴河新区", - "beiguanqu": "北关区", - "beihai": "北海", - "beihaishi": "北海", - "beihuqu": "北湖区", - "beiliu": "北流市", - "beiliushi": "北流市", - "beilunqu": "北仑区", - "beipiao": "北票市", - "beipiaoshi": "北票市", - "beiqu": "北区", - "beitaqu": "北塔区", - "beitun": "北屯市", - "beitunshi": "北屯市", - "beizhen": "北镇市", - "beizhenshi": "北镇市", - "bengbu": "蚌埠", - "bengbushi": "蚌埠", - "bengbushigaoxinjishukaifaqu": "蚌埠市高新技术开发区", - "bengbushijingjikaifaqu": "蚌埠市经济开发区", - "benxi": "本溪", - "benximanzuzizhixian": "本溪满族自治县", - "benxishi": "本溪", - "bianbaxian": "边坝县", - "bijiangqu": "碧江区", - "bijie": "毕节", - "bijieshi": "毕节", - "binchengqu": "滨城区", - "binchuanxian": "宾川县", - "binhaixian": "滨海县", - "binhaixinqu": "滨海新区", - "binhuqu": "滨湖区", - "binjiangqu": "滨江区", - "binxian": "宾县", - "binyangxian": "宾阳县", - "biruxian": "比如县", - "bishanqu": "璧山区", - "biyangxian": "泌阳县", - "boaixian": "博爱县", - "bobaixian": "博白县", - "boertalamenggu": "博尔塔拉蒙古", - "boertalamengguzizhizhou": "博尔塔拉蒙古", - "bohuxian": "博湖县", - "bole": "博乐市", - "boleshi": "博乐市", - "bolixian": "勃利县", - "boluoxian": "博罗县", - "bomixian": "波密县", - "boshanqu": "博山区", - "bowangqu": "博望区", - "boxingxian": "博兴县", - "boyexian": "博野县", - "bozhou": "亳州", - "bozhouqu": "播州区", - "bozhoushi": "亳州", - "buerjinxian": "布尔津县", - "butuoxian": "布拖县", - "caidianqu": "蔡甸区", - "cangnanxian": "苍南县", - "cangshanqu": "仓山区", - "cangwuxian": "苍梧县", - "cangxian": "沧县", - "cangxixian": "苍溪县", - "cangyuanwazuzizhixian": "沧源佤族自治县", - "cangzhou": "沧州", - "cangzhoubohaixinqu": "沧州渤海新区", - "cangzhougaoxinjishuchanyekaifaqu": "沧州高新技术产业开发区", - "cangzhoushi": "沧州", - "caofeidianqu": "曹妃甸区", - "caoxian": "曹县", - "cehengxian": "册亨县", - "celeixian": "策勒县", - "cengdouqu": "曾都区", - "cengongxian": "岑巩县", - "cenxi": "岑溪市", - "cenxishi": "岑溪市", - "chabuchaerxibozizhixian": "察布查尔锡伯自治县", - "chahaeryouyihouqi": "察哈尔右翼后旗", - "chahaeryouyiqianqi": "察哈尔右翼前旗", - "chahaeryouyizhongqi": "察哈尔右翼中旗", - "chaisangqu": "柴桑区", - "chalingxian": "茶陵县", - "chanchengqu": "禅城区", - "changanqu": "长安区", - "changchun": "长春", - "changchungaoxinjishuchanyekaifaqu": "长春高新技术产业开发区", - "changchunjingjijishukaifaqu": "长春经济技术开发区", - "changchunjingyuegaoxinjishuchanyekaifaqu": "长春净月高新技术产业开发区", - "changchunqichejingjijishukaifaqu": "长春汽车经济技术开发区", - "changchunshi": "长春", - "changde": "常德", - "changdeshi": "常德", - "changdeshixidongtingguanliqu": "常德市西洞庭管理区", - "changdou": "昌都", - "changdu": "昌都", - "changdushi": "昌都", - "changji": "昌吉市", - "changjianglizuzizhixian": "昌江黎族自治县", - "changjiangqu": "昌江区", - "changjihuizu": "昌吉回族", - "changjihuizuzizhizhou": "昌吉回族", - "changjishi": "昌吉市", - "changlequ": "长乐区", - "changlexian": "昌乐县", - "changlixian": "昌黎县", - "changning": "常宁市", - "changningshi": "常宁市", - "changningxian": "昌宁县", - "changpingqu": "昌平区", - "changsha": "长沙", - "changshanxian": "常山县", - "changshashi": "长沙", - "changshaxian": "长沙县", - "changshouqu": "长寿区", - "changshu": "常熟市", - "changshushi": "常熟市", - "changtingxian": "长汀县", - "changtuxian": "昌图县", - "changxingxian": "长兴县", - "changyi": "昌邑市", - "changyiqu": "昌邑区", - "changyishi": "昌邑市", - "changzhou": "常州", - "changzhoushi": "常州", - "chanhehuizuqu": "瀍河回族区", - "chaoanqu": "潮安区", - "chaohu": "巢湖市", - "chaohushi": "巢湖市", - "chaonanqu": "潮南区", - "chaotianqu": "朝天区", - "chaozhou": "潮州", - "chaozhoushi": "潮州", - "chayaxian": "察雅县", - "chayuxian": "察隅县", - "chenbaerhuqi": "陈巴尔虎旗", - "chencangqu": "陈仓区", - "chenganxian": "成安县", - "chengbeiqu": "城北区", - "chengbumiaozuzizhixian": "城步苗族自治县", - "chengchengxian": "澄城县", - "chengde": "承德", - "chengdegaoxinjishuchanyekaifaqu": "承德高新技术产业开发区", - "chengdeshi": "承德", - "chengdexian": "承德县", - "chengdongqu": "城东区", - "chengdu": "成都", - "chengduoxian": "称多县", - "chengdushi": "成都", - "chenggongqu": "呈贡区", - "chengguanqu": "城关区", - "chengguxian": "城固县", - "chenghaiqu": "澄海区", - "chenghuaqu": "成华区", - "chengjiang": "澄江市", - "chengjiangshi": "澄江市", - "chengkouxian": "城口县", - "chengmaixian": "澄迈县", - "chengqu": "城区", - "chengwuxian": "成武县", - "chengxian": "成县", - "chengxiangqu": "城厢区", - "chengxiqu": "城西区", - "chengyangqu": "城阳区", - "chengzhongqu": "城中区", - "chengzihequ": "城子河区", - "chenxixian": "辰溪县", - "chenzhou": "郴州", - "chenzhoushi": "郴州", - "chibi": "赤壁市", - "chibishi": "赤壁市", - "chichengxian": "赤城县", - "chifeng": "赤峰", - "chifengshi": "赤峰", - "chikanqu": "赤坎区", - "chipingqu": "茌平区", - "chishui": "赤水市", - "chishuishi": "赤水市", - "chizhou": "池州", - "chizhoushi": "池州", - "chongchuanqu": "崇川区", - "chongliqu": "崇礼区", - "chongmingqu": "崇明区", - "chongrenxian": "崇仁县", - "chongxinxian": "崇信县", - "chongyangxian": "崇阳县", - "chongyixian": "崇义县", - "chongzhou": "崇州市", - "chongzhoushi": "崇州市", - "chongzuo": "崇左", - "chongzuoshi": "崇左", - "chuanhuiqu": "川汇区", - "chuanshanqu": "船山区", - "chuanyingqu": "船营区", - "chunanxian": "淳安县", - "chunhuaxian": "淳化县", - "chuxiong": "楚雄市", - "chuxiongshi": "楚雄市", - "chuxiongyizu": "楚雄彝族", - "chuxiongyizuzizhizhou": "楚雄彝族", - "chuzhou": "滁州", - "chuzhoujingjijishukaifaqu": "滁州经济技术开发区", - "chuzhoushi": "滁州", - "cilixian": "慈利县", - "cixi": "慈溪市", - "cixian": "磁县", - "cixishi": "慈溪市", - "conghuaqu": "从化区", - "congjiangxian": "从江县", - "congtaiqu": "丛台区", - "cuipingqu": "翠屏区", - "cuomeixian": "措美县", - "cuonaxian": "错那县", - "cuoqinxian": "措勤县", - "daan": "大安市", - "daanqu": "大安区", - "daanshi": "大安市", - "dabanchengqu": "达坂城区", - "dabuqu": "大埔区", - "dabuxian": "大埔县", - "dachaidanxingzhengweiyuanhui": "大柴旦行政委员会", - "dachanghuizuzizhixian": "大厂回族自治县", - "dachengxian": "大城县", - "dachuanqu": "达川区", - "dadongqu": "大东区", - "dadukouqu": "大渡口区", - "daerhanmaominganlianheqi": "达尔罕茂明安联合旗", - "dafangxian": "大方县", - "dafengqu": "大丰区", - "daguanqu": "大观区", - "daguanxian": "大关县", - "dahuayaozuzizhixian": "大化瑶族自治县", - "daishanxian": "岱山县", - "daixian": "代县", - "daiyuequ": "岱岳区", - "dalateqi": "达拉特旗", - "dali": "大理市", - "dalian": "大连", - "dalianshi": "大连", - "dalibaizu": "大理白族", - "dalibaizuzizhizhou": "大理白族", - "dalishi": "大理市", - "dalixian": "大荔县", - "damingxian": "大名县", - "danbaxian": "丹巴县", - "danchengxian": "郸城县", - "dandong": "丹东", - "dandongshi": "丹东", - "danfengxian": "丹凤县", - "dangchangxian": "宕昌县", - "dangshanxian": "砀山县", - "dangtuxian": "当涂县", - "dangxiongxian": "当雄县", - "dangyang": "当阳市", - "dangyangshi": "当阳市", - "daningxian": "大宁县", - "danjiangkou": "丹江口市", - "danjiangkoushi": "丹江口市", - "danlengxian": "丹棱县", - "dantuqu": "丹徒区", - "danxian": "单县", - "danyang": "丹阳市", - "danyangshi": "丹阳市", - "danzhaixian": "丹寨县", - "danzhou": "儋州", - "danzhoushi": "儋州", - "daochengxian": "稻城县", - "daofuxian": "道孚县", - "daoliqu": "道里区", - "daowaiqu": "道外区", - "daoxian": "道县", - "daozhengelaozumiaozuzizhixian": "道真仡佬族苗族自治县", - "daqing": "大庆", - "daqinggaoxinjishuchanyekaifaqu": "大庆高新技术产业开发区", - "daqingshanxian": "大箐山县", - "daqingshi": "大庆", - "darixian": "达日县", - "dashiqiao": "大石桥市", - "dashiqiaoshi": "大石桥市", - "datangqu": "大堂区", - "datianxian": "大田县", - "datong": "大同", - "datonghuizutuzuzizhixian": "大通回族土族自治县", - "datongshi": "大同", - "dawaqu": "大洼区", - "dawukouqu": "大武口区", - "dawuxian": "大悟县", - "daxiangqu": "大祥区", - "daxinganling": "大兴安岭", - "daxinganlingdiqu": "大兴安岭", - "daxingqu": "大兴区", - "daxinxian": "大新县", - "dayaoxian": "大姚县", - "daye": "大冶市", - "dayeshi": "大冶市", - "dayingxian": "大英县", - "dayixian": "大邑县", - "dayuxian": "大余县", - "dazhou": "达州", - "dazhoujingjikaifaqu": "达州经济开发区", - "dazhoushi": "达州", - "dazhuxian": "大竹县", - "dazigongyeyuanqu": "达孜工业园区", - "daziqu": "达孜区", - "dazuqu": "大足区", - "deanxian": "德安县", - "debaoxian": "德保县", - "dechangxian": "德昌县", - "dechengqu": "德城区", - "degexian": "德格县", - "dehongdaizujingpozu": "德宏傣族景颇族", - "dehongdaizujingpozuzizhizhou": "德宏傣族景颇族", - "dehuaxian": "德化县", - "dehui": "德惠市", - "dehuishi": "德惠市", - "dejiangxian": "德江县", - "delingha": "德令哈市", - "delinghashi": "德令哈市", - "dengfeng": "登封市", - "dengfengshi": "登封市", - "dengkouxian": "磴口县", - "dengta": "灯塔市", - "dengtashi": "灯塔市", - "dengzhou": "邓州市", - "dengzhoushi": "邓州市", - "deqinxian": "德钦县", - "derongxian": "得荣县", - "dexing": "德兴市", - "dexingshi": "德兴市", - "deyang": "德阳", - "deyangshi": "德阳", - "dezhou": "德州", - "dezhoujingjijishukaifaqu": "德州经济技术开发区", - "dezhoushi": "德州", - "dezhouyunhejingjikaifaqu": "德州运河经济开发区", - "dianbaiqu": "电白区", - "dianjiangxian": "垫江县", - "dianjunqu": "点军区", - "diaobingshan": "调兵山市", - "diaobingshanshi": "调兵山市", - "didaoqu": "滴道区", - "diebuxian": "迭部县", - "diecaiqu": "叠彩区", - "dinganxian": "定安县", - "dingbianxian": "定边县", - "dingchengqu": "鼎城区", - "dinghaiqu": "定海区", - "dinghuqu": "鼎湖区", - "dingjiexian": "定结县", - "dingnanxian": "定南县", - "dingqingxian": "丁青县", - "dingrixian": "定日县", - "dingtaoqu": "定陶区", - "dingxi": "定西", - "dingxiangxian": "定襄县", - "dingxingxian": "定兴县", - "dingxishi": "定西", - "dingyuanxian": "定远县", - "dingzhou": "定州市", - "dingzhoushi": "定州市", - "diqingzangzu": "迪庆藏族", - "diqingzangzuzizhizhou": "迪庆藏族", - "donganqu": "东安区", - "donganxian": "东安县", - "dongbaoqu": "东宝区", - "dongchangfuqu": "东昌府区", - "dongchangqu": "东昌区", - "dongchengqu": "东城区", - "dongchuanqu": "东川区", - "dongexian": "东阿县", - "dongfang": "东方市", - "dongfangshi": "东方市", - "dongfengqu": "东风区", - "dongfengxian": "东丰县", - "donggang": "东港市", - "donggangqu": "东港区", - "donggangshi": "东港市", - "dongguan": "东莞", - "dongguangxian": "东光县", - "dongguanshi": "东莞", - "donghaixian": "东海县", - "donghequ": "东河区", - "donghuqu": "东湖区", - "dongkouxian": "洞口县", - "donglanxian": "东兰县", - "dongliaoxian": "东辽县", - "dongliqu": "东丽区", - "dongmingxian": "东明县", - "dongning": "东宁市", - "dongningshi": "东宁市", - "dongpingxian": "东平县", - "dongpoqu": "东坡区", - "dongqu": "东区", - "dongshanqu": "东山区", - "dongshanxian": "东山县", - "dongshengqu": "东胜区", - "dongtai": "东台市", - "dongtaishi": "东台市", - "dongtouqu": "洞头区", - "dongwuzhumuqinqi": "东乌珠穆沁旗", - "dongxiangqu": "东乡区", - "dongxiangzuzizhixian": "东乡族自治县", - "dongxihuqu": "东西湖区", - "dongxing": "东兴市", - "dongxingqu": "东兴区", - "dongxingshi": "东兴市", - "dongyang": "东阳市", - "dongyangshi": "东阳市", - "dongying": "东营", - "dongyinggangjingjikaifaqu": "东营港经济开发区", - "dongyingjingjijishukaifaqu": "东营经济技术开发区", - "dongyingqu": "东营区", - "dongyingshi": "东营", - "dongyuanxian": "东源县", - "dongzhixian": "东至县", - "dongzhouqu": "东洲区", - "douanyaozuzizhixian": "都安瑶族自治县", - "douchangxian": "都昌县", - "doulanxian": "都兰县", - "doumenqu": "斗门区", - "douyun": "都匀市", - "douyunshi": "都匀市", - "duanzhouqu": "端州区", - "duerbotemengguzuzizhixian": "杜尔伯特蒙古族自治县", - "duilongdeqingqu": "堆龙德庆区", - "dujiangyan": "都江堰市", - "dujiangyanshi": "都江堰市", - "dujiqu": "杜集区", - "dunhua": "敦化市", - "dunhuang": "敦煌市", - "dunhuangshi": "敦煌市", - "dunhuashi": "敦化市", - "duodaoqu": "掇刀区", - "duolunxian": "多伦县", - "dushanxian": "独山县", - "dushanziqu": "独山子区", - "ebianyizuzizhixian": "峨边彝族自治县", - "echengqu": "鄂城区", - "eerduosi": "鄂尔多斯", - "eerduosishi": "鄂尔多斯", - "eerguna": "额尔古纳市", - "eergunashi": "额尔古纳市", - "ejinaqi": "额济纳旗", - "elunchunzizhiqi": "鄂伦春自治旗", - "emeishan": "峨眉山市", - "emeishanshi": "峨眉山市", - "eminxian": "额敏县", - "enping": "恩平市", - "enpingshi": "恩平市", - "enshi": "恩施市", - "enshishi": "恩施市", - "enshitujiazumiaozu": "恩施土家族苗族", - "enshitujiazumiaozuzizhizhou": "恩施土家族苗族", - "enyangqu": "恩阳区", - "erdaojiangqu": "二道江区", - "erdaoqu": "二道区", - "erlianhaote": "二连浩特市", - "erlianhaoteshi": "二连浩特市", - "erqiqu": "二七区", - "eryuanxian": "洱源县", - "eshanyizuzizhixian": "峨山彝族自治县", - "etuokeqi": "鄂托克旗", - "etuokeqianqi": "鄂托克前旗", - "ewenkezuzizhiqi": "鄂温克族自治旗", - "ezhou": "鄂州", - "ezhoushi": "鄂州", - "fakuxian": "法库县", - "fanchangqu": "繁昌区", - "fanchengqu": "樊城区", - "fangchenggang": "防城港", - "fangchenggangshi": "防城港", - "fangchengqu": "防城区", - "fangchengxian": "方城县", - "fangshanqu": "房山区", - "fangshanxian": "方山县", - "fangxian": "房县", - "fangzhengxian": "方正县", - "fangziqu": "坊子区", - "fanxian": "范县", - "fanzhixian": "繁峙县", - "feicheng": "肥城市", - "feichengshi": "肥城市", - "feidongxian": "肥东县", - "feixian": "费县", - "feixiangqu": "肥乡区", - "feixixian": "肥西县", - "fengdouxian": "丰都县", - "fengfengkuangqu": "峰峰矿区", - "fenggangxian": "凤冈县", - "fenghuangxian": "凤凰县", - "fenghuaqu": "奉化区", - "fengjiexian": "奉节县", - "fengkaixian": "封开县", - "fenglinxian": "丰林县", - "fengmanqu": "丰满区", - "fengnanqu": "丰南区", - "fengningmanzuzizhixian": "丰宁满族自治县", - "fengqingxian": "凤庆县", - "fengqiuxian": "封丘县", - "fengquanqu": "凤泉区", - "fengrunqu": "丰润区", - "fengshanxian": "凤山县", - "fengshuntangqu": "风顺堂区", - "fengshunxian": "丰顺县", - "fengtaiqu": "丰台区", - "fengtaixian": "凤台县", - "fengxiangqu": "凤翔区", - "fengxiangxian": "凤翔县", - "fengxianqu": "奉贤区", - "fengxinxian": "奉新县", - "fengyangxian": "凤阳县", - "fengzequ": "丰泽区", - "fengzhen": "丰镇市", - "fengzhenshi": "丰镇市", - "fenxixian": "汾西县", - "fenyang": "汾阳市", - "fenyangshi": "汾阳市", - "fenyixian": "分宜县", - "foshan": "佛山", - "foshanshi": "佛山", - "fuan": "福安市", - "fuanshi": "福安市", - "fuchengqu": "涪城区", - "fuchengxian": "阜城县", - "fuchuanyaozuzizhixian": "富川瑶族自治县", - "fuding": "福鼎市", - "fudingshi": "福鼎市", - "fufengxian": "扶风县", - "fugangxian": "佛冈县", - "fugongxian": "福贡县", - "fugouxian": "扶沟县", - "fuguxian": "府谷县", - "fuhaixian": "福海县", - "fujin": "富锦市", - "fujinshi": "富锦市", - "fukang": "阜康市", - "fukangshi": "阜康市", - "fulaerjiqu": "富拉尔基区", - "fuliangxian": "浮梁县", - "fulingqu": "涪陵区", - "fumianqu": "福绵区", - "fuminxian": "富民县", - "funanxian": "阜南县", - "funingqu": "抚宁区", - "fuqing": "福清市", - "fuqingshi": "福清市", - "fuquan": "福泉市", - "fuquanshi": "福泉市", - "furongqu": "芙蓉区", - "fushanqu": "福山区", - "fushanxian": "浮山县", - "fushun": "抚顺", - "fushunshi": "抚顺", - "fusongxian": "抚松县", - "fusuixian": "扶绥县", - "futianqu": "福田区", - "fuxian": "富县", - "fuxin": "阜新", - "fuxingqu": "复兴区", - "fuxinmengguzuzizhixian": "阜新蒙古族自治县", - "fuxinshi": "阜新", - "fuyang": "阜阳", - "fuyanghefeixiandaichanyeyuanqu": "阜阳合肥现代产业园区", - "fuyangjingjijishukaifaqu": "阜阳经济技术开发区", - "fuyangqu": "富阳区", - "fuyangshi": "阜阳", - "fuyu": "扶余市", - "fuyuan": "抚远市", - "fuyuanshi": "抚远市", - "fuyuanxian": "富源县", - "fuyunxian": "富蕴县", - "fuyushi": "扶余市", - "fuyuxian": "富裕县", - "gaerxian": "噶尔县", - "gaizexian": "改则县", - "gaizhou": "盖州市", - "gaizhoushi": "盖州市", - "gandexian": "甘德县", - "gangbaxian": "岗巴县", - "gangbeiqu": "港北区", - "gangchaxian": "刚察县", - "gangchengqu": "钢城区", - "gangkouqu": "港口区", - "gangnanqu": "港南区", - "ganguxian": "甘谷县", - "ganjingziqu": "甘井子区", - "ganluoxian": "甘洛县", - "gannanxian": "甘南县", - "gannanzangzu": "甘南藏族", - "gannanzangzuzizhizhou": "甘南藏族", - "ganquanxian": "甘泉县", - "ganxianqu": "赣县区", - "ganyuqu": "赣榆区", - "ganzhou": "赣州", - "ganzhouqu": "甘州区", - "ganzhoushi": "赣州", - "ganzixian": "甘孜县", - "ganzizangzu": "甘孜藏族", - "ganzizangzuzizhizhou": "甘孜藏族", - "gaoan": "高安市", - "gaoanshi": "高安市", - "gaobeidian": "高碑店市", - "gaobeidianshi": "高碑店市", - "gaochangqu": "高昌区", - "gaochengqu": "藁城区", - "gaochunqu": "高淳区", - "gaogangqu": "高港区", - "gaolanxian": "皋兰县", - "gaolingqu": "高陵区", - "gaomi": "高密市", - "gaomingqu": "高明区", - "gaomishi": "高密市", - "gaoping": "高平市", - "gaopingqu": "高坪区", - "gaopingshi": "高平市", - "gaoqingxian": "高青县", - "gaotaixian": "高台县", - "gaotangxian": "高唐县", - "gaoxian": "高县", - "gaoyangxian": "高阳县", - "gaoyaoqu": "高要区", - "gaoyixian": "高邑县", - "gaoyou": "高邮市", - "gaoyoushi": "高邮市", - "gaozhou": "高州市", - "gaozhoushi": "高州市", - "gashixian": "伽师县", - "geermu": "格尔木市", - "geermushi": "格尔木市", - "geermuzangqinggongyeyuanqu": "格尔木藏青工业园区", - "gejiu": "个旧市", - "gejiushi": "个旧市", - "gejixian": "革吉县", - "gengmadaizuwazuzizhixian": "耿马傣族佤族自治县", - "genhe": "根河市", - "genheshi": "根河市", - "gonganxian": "公安县", - "gongbujiangdaxian": "工布江达县", - "gongchengyaozuzizhixian": "恭城瑶族自治县", - "gonggaxian": "贡嘎县", - "gonghexian": "共和县", - "gongjingqu": "贡井区", - "gongjuexian": "贡觉县", - "gongliuxian": "巩留县", - "gongnongqu": "工农区", - "gongqingcheng": "共青城市", - "gongqingchengshi": "共青城市", - "gongshandulongzunuzuzizhixian": "贡山独龙族怒族自治县", - "gongshuqu": "拱墅区", - "gongxian": "珙县", - "gongyi": "巩义市", - "gongyishi": "巩义市", - "gongzhanglingqu": "弓长岭区", - "gongzhuling": "公主岭市", - "gongzhulingshi": "公主岭市", - "guanchenghuizuqu": "管城回族区", - "guanduqu": "官渡区", - "guangan": "广安", - "guanganqu": "广安区", - "guanganshi": "广安", - "guangchangxian": "广昌县", - "guangde": "广德市", - "guangdeshi": "广德市", - "guangfengqu": "广丰区", - "guanghan": "广汉市", - "guanghanshi": "广汉市", - "guanghexian": "广河县", - "guanglingqu": "广陵区", - "guanglingxian": "广灵县", - "guangmingqu": "光明区", - "guangnanxian": "广南县", - "guangningxian": "广宁县", - "guangpingxian": "广平县", - "guangraoxian": "广饶县", - "guangshanxian": "光山县", - "guangshui": "广水市", - "guangshuishi": "广水市", - "guangxinqu": "广信区", - "guangyangqu": "广阳区", - "guangyuan": "广元", - "guangyuanshi": "广元", - "guangzexian": "光泽县", - "guangzhou": "广州", - "guangzhoushi": "广州", - "guangzongxian": "广宗县", - "guanlingbuyizumiaozuzizhixian": "关岭布依族苗族自治县", - "guannanxian": "灌南县", - "guanshanhuqu": "观山湖区", - "guantangqu": "观塘区", - "guantaoxian": "馆陶县", - "guanyangxian": "灌阳县", - "guanyunxian": "灌云县", - "guazhouxian": "瓜州县", - "guchengqu": "古城区", - "guichiqu": "贵池区", - "guidexian": "贵德县", - "guidingxian": "贵定县", - "guidongxian": "桂东县", - "guigang": "贵港", - "guigangshi": "贵港", - "guilin": "桂林", - "guilinshi": "桂林", - "guinanxian": "贵南县", - "guiping": "桂平市", - "guipingshi": "桂平市", - "guixi": "贵溪市", - "guixishi": "贵溪市", - "guiyang": "贵阳", - "guiyangshi": "贵阳", - "guiyangxian": "桂阳县", - "gujiao": "古交市", - "gujiaoshi": "古交市", - "gulangxian": "古浪县", - "gulinxian": "古蔺县", - "gulouqu": "鼓楼区", - "guoluozangzu": "果洛藏族", - "guoluozangzuzizhizhou": "果洛藏族", - "gushixian": "固始县", - "gusuqu": "姑苏区", - "gutaqu": "古塔区", - "gutianxian": "古田县", - "guxian": "古县", - "guyangxian": "固阳县", - "guyequ": "古冶区", - "guyuan": "固原", - "guyuanshi": "固原", - "guyuanxian": "沽源县", - "guzhangxian": "古丈县", - "guzhenxian": "固镇县", - "habahexian": "哈巴河县", - "haerbin": "哈尔滨", - "haerbinshi": "哈尔滨", - "haian": "海安市", - "haianshi": "海安市", - "haibeizangzu": "海北藏族", - "haibeizangzuzizhizhou": "海北藏族", - "haibowanqu": "海勃湾区", - "haicangqu": "海沧区", - "haicheng": "海城市", - "haichengqu": "海城区", - "haichengshi": "海城市", - "haidianqu": "海淀区", - "haidong": "海东", - "haidongshi": "海东", - "haifengxian": "海丰县", - "haigangqu": "海港区", - "haikou": "海口", - "haikoushi": "海口", - "hailaerqu": "海拉尔区", - "hailin": "海林市", - "hailingqu": "海陵区", - "hailinshi": "海林市", - "hailun": "海伦市", - "hailunshi": "海伦市", - "haimenqu": "海门区", - "hainanqu": "海南区", - "hainanshengzizhiquzhixiaxianjixingzhengquhua": "海南省-自治区直辖县级行政区划", - "hainanzangzu": "海南藏族", - "hainanzangzuzizhizhou": "海南藏族", - "haining": "海宁市", - "hainingshi": "海宁市", - "haishuqu": "海曙区", - "haitangqu": "海棠区", - "haiximengguzuzangzu": "海西蒙古族藏族", - "haiximengguzuzangzuzizhizhou": "海西蒙古族藏族", - "haixingxian": "海兴县", - "haiyang": "海阳市", - "haiyangshi": "海阳市", - "haiyuanxian": "海原县", - "haizhouqu": "海州区", - "haizhuqu": "海珠区", - "hami": "哈密", - "hamishi": "哈密", - "hanbinqu": "汉滨区", - "hancheng": "韩城市", - "hanchengshi": "韩城市", - "hanchuan": "汉川市", - "hanchuanshi": "汉川市", - "handan": "邯郸", - "handanjinanxinqu": "邯郸冀南新区", - "handanjingjijishukaifaqu": "邯郸经济技术开发区", - "handanshi": "邯郸", - "hangjinhouqi": "杭锦后旗", - "hangjinqi": "杭锦旗", - "hangzhou": "杭州", - "hangzhoushi": "杭州", - "hannanqu": "汉南区", - "hanshanqu": "邯山区", - "hanshanxian": "含山县", - "hanshouxian": "汉寿县", - "hantaiqu": "汉台区", - "hantingqu": "寒亭区", - "hanyangqu": "汉阳区", - "hanyinxian": "汉阴县", - "hanyuanxian": "汉源县", - "hanzhong": "汉中", - "hanzhongshi": "汉中", - "haojiangqu": "濠江区", - "hebeicangzhoujingjikaifaqu": "河北沧州经济开发区", - "hebeihengshuigaoxinjishuchanyekaifaqu": "河北衡水高新技术产业开发区", - "hebeiqu": "河北区", - "hebeitangshanhaigangjingjikaifaqu": "河北唐山海港经济开发区", - "hebeitangshanlutaijingjikaifaqu": "河北唐山芦台经济开发区", - "hebeixingtaijingjikaifaqu": "河北邢台经济开发区", - "hebi": "鹤壁", - "hebijingjijishukaifaqu": "鹤壁经济技术开发区", - "hebishi": "鹤壁", - "hebukesaiermengguzizhixian": "和布克赛尔蒙古自治县", - "hechengqu": "鹤城区", - "hechi": "河池", - "hechishi": "河池", - "hechuanqu": "合川区", - "hedongqu": "河东区", - "hefei": "合肥", - "hefeigaoxinjishuchanyekaifaqu": "合肥高新技术产业开发区", - "hefeijingjijishukaifaqu": "合肥经济技术开发区", - "hefeishi": "合肥", - "hefeixinzhangaoxinjishuchanyekaifaqu": "合肥新站高新技术产业开发区", - "hefengxian": "鹤峰县", - "hegang": "鹤岗", - "hegangshi": "鹤岗", - "heihe": "黑河", - "heiheshi": "黑河", - "heishanxian": "黑山县", - "heishuixian": "黑水县", - "hejian": "河间市", - "hejiangxian": "合江县", - "hejianshi": "河间市", - "hejin": "河津市", - "hejingxian": "和静县", - "hejinshi": "河津市", - "hekouqu": "河口区", - "hekouyaozuzizhixian": "河口瑶族自治县", - "helanxian": "贺兰县", - "helingeerxian": "和林格尔县", - "helong": "和龙市", - "helongshi": "和龙市", - "henanmengguzuzizhixian": "河南蒙古族自治县", - "henanpuyanggongyeyuanqu": "河南濮阳工业园区", - "henansanmenxiajingjikaifaqu": "河南三门峡经济开发区", - "henanshangqiujingjikaifaqu": "河南商丘经济开发区", - "henanshengshengzhixiaxianjixingzhengquhua": "河南省-省直辖县级行政区划", - "henanzhoukoujingjikaifaqu": "河南周口经济开发区", - "henanzhumadianjingjikaifaqu": "河南驻马店经济开发区", - "hengdongxian": "衡东县", - "hengfengxian": "横峰县", - "hengnanxian": "衡南县", - "hengshanxian": "衡山县", - "hengshui": "衡水", - "hengshuibinhuxinqu": "衡水滨湖新区", - "hengshuishi": "衡水", - "hengxian": "横县", - "hengyang": "衡阳", - "hengyangshi": "衡阳", - "hengyangxian": "衡阳县", - "hengyangzonghebaoshuiqu": "衡阳综合保税区", - "hengzhou": "横州市", - "hengzhoushi": "横州市", - "hepingqu": "和平区", - "hepingxian": "和平县", - "hepuxian": "合浦县", - "heqingxian": "鹤庆县", - "hequxian": "河曲县", - "heshuixian": "合水县", - "heshunxian": "和顺县", - "heshuoxian": "和硕县", - "hetangqu": "荷塘区", - "hetiandiqu": "和田", - "hetianshi": "和田市", - "hetianxian": "和田县", - "hexian": "和县", - "hexiqu": "河西区", - "heyangxian": "合阳县", - "heyuan": "河源", - "heyuanshi": "河源", - "heze": "菏泽", - "hezegaoxinjishukaifaqu": "菏泽高新技术开发区", - "hezejingjijishukaifaqu": "菏泽经济技术开发区", - "hezeshi": "菏泽", - "hezhangxian": "赫章县", - "hezhengxian": "和政县", - "hezhou": "贺州", - "hezhoushi": "贺州", - "hezuo": "合作市", - "hezuoshi": "合作市", - "honganxian": "红安县", - "hongdongxian": "洪洞县", - "honggangqu": "红岗区", - "hongguqu": "红古区", - "honggutanqu": "红谷滩区", - "honghehanizuyizu": "红河哈尼族彝族", - "honghehanizuyizuzizhizhou": "红河哈尼族彝族", - "honghexian": "红河县", - "honghu": "洪湖市", - "honghuagangqu": "红花岗区", - "honghushi": "洪湖市", - "hongjiang": "洪江市", - "hongjiangshi": "洪江市", - "hongkouqu": "虹口区", - "hongqiaoqu": "红桥区", - "hongqiqu": "红旗区", - "hongsibaoqu": "红寺堡区", - "hongtaqu": "红塔区", - "hongweiqu": "宏伟区", - "hongyaxian": "洪雅县", - "hongyuanxian": "红原县", - "hongzequ": "洪泽区", - "houma": "侯马市", - "houmashi": "侯马市", - "huaanxian": "华安县", - "huachixian": "华池县", - "huachuanxian": "桦川县", - "huadexian": "化德县", - "huadian": "桦甸市", - "huadianshi": "桦甸市", - "huadimatangqu": "花地玛堂区", - "huadouqu": "花都区", - "huaian": "淮安", - "huaianjingjijishukaifaqu": "淮安经济技术开发区", - "huaianqu": "淮安区", - "huaianshi": "淮安", - "huaianxian": "怀安县", - "huaibei": "淮北", - "huaibeishi": "淮北", - "huaibinxian": "淮滨县", - "huaihua": "怀化", - "huaihuashi": "怀化", - "huaihuashihongjiangguanliqu": "怀化市洪江管理区", - "huaijixian": "怀集县", - "huailaixian": "怀来县", - "huainan": "淮南", - "huainanshi": "淮南", - "huainingxian": "怀宁县", - "huairen": "怀仁市", - "huairenshi": "怀仁市", - "huairouqu": "怀柔区", - "huaishangqu": "淮上区", - "huaiyangqu": "淮阳区", - "huaiyuanxian": "怀远县", - "hualonghuizuzizhixian": "化隆回族自治县", - "hualongqu": "华龙区", - "huananxian": "桦南县", - "huancuiqu": "环翠区", - "huang": "黄石", - "huangchuanxian": "潢川县", - "huangdaoqu": "黄岛区", - "huangdaxianqu": "黄大仙区", - "huanggang": "黄冈", - "huanggangshi": "黄冈", - "huangguqu": "皇姑区", - "huanghua": "黄骅市", - "huanghuashi": "黄骅市", - "huanglingxian": "黄陵县", - "huanglongxian": "黄龙县", - "huangmeixian": "黄梅县", - "huangnanzangzu": "黄南藏族", - "huangnanzangzuzizhizhou": "黄南藏族", - "huangpingxian": "黄平县", - "huangpiqu": "黄陂区", - "huangshan": "黄山", - "huangshanqu": "黄山区", - "huangshanshi": "黄山", - "huangshi": "黄石", - "huangshigangqu": "黄石港区", - "huangshishi": "黄石", - "huangyanqu": "黄岩区", - "huangyuanxian": "湟源县", - "huangzhongqu": "湟中区", - "huangzhouqu": "黄州区", - "huaningxian": "华宁县", - "huanjiangmaonanzuzizhixian": "环江毛南族自治县", - "huanrenmanzuzizhixian": "桓仁满族自治县", - "huantaixian": "桓台县", - "huanxian": "环县", - "huapingxian": "华坪县", - "huarongqu": "华容区", - "huarongxian": "华容县", - "huashanqu": "花山区", - "huating": "华亭市", - "huatingshi": "华亭市", - "huawangtangqu": "花王堂区", - "huaxian": "滑县", - "huaxiqu": "花溪区", - "huayin": "华阴市", - "huaying": "华蓥市", - "huayingshi": "华蓥市", - "huayinshi": "华阴市", - "huayuanxian": "花垣县", - "huazhou": "化州市", - "huazhouqu": "华州区", - "huazhoushi": "化州市", - "hubeishengzizhiquzhixiaxianjixingzhengquhua": "湖北省-自治区直辖县级行政区划", - "hubinqu": "湖滨区", - "huguanxian": "壶关县", - "huhehaote": "呼和浩特", - "huhehaotejingjijishukaifaqu": "呼和浩特经济技术开发区", - "huhehaoteshi": "呼和浩特", - "huianxian": "惠安县", - "huichangxian": "会昌县", - "huichengqu": "惠城区", - "huichuanqu": "汇川区", - "huichun": "珲春市", - "huichunshi": "珲春市", - "huijiqu": "惠济区", - "huilaixian": "惠来县", - "huili": "会理市", - "huilishi": "会理市", - "huilixian": "会理县", - "huiminqu": "回民区", - "huiminxian": "惠民县", - "huinanxian": "辉南县", - "huiningxian": "会宁县", - "huinongqu": "惠农区", - "huishanqu": "惠山区", - "huishuixian": "惠水县", - "huitongxian": "会同县", - "huixianshi": "辉县市", - "huiyangqu": "惠阳区", - "huizexian": "会泽县", - "huizhou": "惠州", - "huizhouqu": "徽州区", - "huizhoushi": "惠州", - "hukouxian": "湖口县", - "hulanqu": "呼兰区", - "hulin": "虎林市", - "hulinshi": "虎林市", - "huliqu": "湖里区", - "huludao": "葫芦岛", - "huludaoshi": "葫芦岛", - "hulunbeier": "呼伦贝尔", - "hulunbeiershi": "呼伦贝尔", - "humaxian": "呼玛县", - "hunanhengyanggaoxinjishuchanyeyuanqu": "湖南衡阳高新技术产业园区", - "hunanhengyangsongmujingjikaifaqu": "湖南衡阳松木经济开发区", - "hunanxiangtangaoxinjishuchanyeyuanqu": "湖南湘潭高新技术产业园区", - "hunanyiyanggaoxinjishuchanyeyuanqu": "湖南益阳高新技术产业园区", - "hunjiangqu": "浑江区", - "hunnanqu": "浑南区", - "hunyuanxian": "浑源县", - "huochengxian": "霍城县", - "huoerguosi": "霍尔果斯市", - "huoerguosishi": "霍尔果斯市", - "huojiaxian": "获嘉县", - "huolinguolei": "霍林郭勒市", - "huolinguoleishi": "霍林郭勒市", - "huoqiuxian": "霍邱县", - "huoshanxian": "霍山县", - "huozhou": "霍州市", - "huozhoushi": "霍州市", - "huqiuqu": "虎丘区", - "hutubixian": "呼图壁县", - "huyanghe": "胡杨河市", - "huyangheshi": "胡杨河市", - "huyiqu": "鄠邑区", - "huzhongqu": "呼中区", - "huzhou": "湖州", - "huzhoushi": "湖州", - "huzhutuzuzizhixian": "互助土族自治县", - "jiachaxian": "加查县", - "jiadingqu": "嘉定区", - "jiagedaqiqu": "加格达奇区", - "jiahexian": "嘉禾县", - "jiajiangxian": "夹江县", - "jialingqu": "嘉陵区", - "jialixian": "嘉黎县", - "jiamotangqu": "嘉模堂区", - "jiamusi": "佳木斯", - "jiamusishi": "佳木斯", - "jiananqu": "建安区", - "jiancaopingqu": "尖草坪区", - "jianchangxian": "建昌县", - "jianchuanxian": "剑川县", - "jiande": "建德市", - "jiandeshi": "建德市", - "jianganqu": "江岸区", - "jianganxian": "江安县", - "jiangbeiqu": "江北区", - "jiangchenghanizuyizuzizhixian": "江城哈尼族彝族自治县", - "jiangchengqu": "江城区", - "jiangchuanqu": "江川区", - "jiangdaxian": "江达县", - "jiangduqu": "江都区", - "jiangexian": "剑阁县", - "jiangganqu": "江干区", - "jianghaiqu": "江海区", - "jianghanqu": "江汉区", - "jianghuayaozuzizhixian": "江华瑶族自治县", - "jiangjinqu": "江津区", - "jiangkouxian": "江口县", - "jianglexian": "将乐县", - "jianglingxian": "江陵县", - "jiangmen": "江门", - "jiangmenshi": "江门", - "jiangnanqu": "江南区", - "jiangningqu": "江宁区", - "jiangshan": "江山市", - "jiangshanshi": "江山市", - "jiangxian": "绛县", - "jiangxiaqu": "江夏区", - "jiangyangqu": "江阳区", - "jiangyanqu": "姜堰区", - "jiangyin": "江阴市", - "jiangyinshi": "江阴市", - "jiangyongxian": "江永县", - "jiangyou": "江油市", - "jiangyoushi": "江油市", - "jiangyuanqu": "江源区", - "jiangzhouqu": "江州区", - "jiangzixian": "江孜县", - "jianhexian": "剑河县", - "jianhuaqu": "建华区", - "jianhuxian": "建湖县", - "jianli": "监利市", - "jianlishi": "监利市", - "jianningxian": "建宁县", - "jianou": "建瓯市", - "jianoushi": "建瓯市", - "jianpingxian": "建平县", - "jianshanqu": "尖山区", - "jianshixian": "建始县", - "jianshuixian": "建水县", - "jianxian": "吉安县", - "jianxiqu": "涧西区", - "jianyang": "简阳市", - "jianyangqu": "建阳区", - "jianyangshi": "简阳市", - "jianyequ": "建邺区", - "jianzhaxian": "尖扎县", - "jiaochengqu": "蕉城区", - "jiaochengxian": "交城县", - "jiaohe": "蛟河市", - "jiaoheshi": "蛟河市", - "jiaojiangqu": "椒江区", - "jiaokouxian": "交口县", - "jiaolingxian": "蕉岭县", - "jiaoqu": "郊区", - "jiaozhou": "胶州市", - "jiaozhoushi": "胶州市", - "jiaozuo": "焦作", - "jiaozuochengxiangyitihuashifanqu": "焦作城乡一体化示范区", - "jiaozuoshi": "焦作", - "jiashanxian": "嘉善县", - "jiawangqu": "贾汪区", - "jiaxiangxian": "嘉祥县", - "jiaxing": "嘉兴", - "jiaxingshi": "嘉兴", - "jiayinxian": "嘉荫县", - "jiayuguan": "嘉峪关", - "jiayuguanshi": "嘉峪关", - "jiayuxian": "嘉鱼县", - "jidongxian": "鸡东县", - "jiedongqu": "揭东区", - "jiefangqu": "解放区", - "jieshou": "界首市", - "jieshoushi": "界首市", - "jiexiu": "介休市", - "jiexiushi": "介休市", - "jiexixian": "揭西县", - "jieyang": "揭阳", - "jieyangshi": "揭阳", - "jiguanqu": "鸡冠区", - "jilin": "吉林", - "jilinbaichengjingjikaifaqu": "吉林白城经济开发区", - "jilingaoxinjishuchanyekaifaqu": "吉林高新技术产业开发区", - "jilinjingjikaifaqu": "吉林经济开发区", - "jilinshi": "吉林", - "jilinsongyuanjingjikaifaqu": "吉林松原经济开发区", - "jilinzhongguoxinjiaposhipinqu": "吉林中国新加坡食品区", - "jiliqu": "吉利区", - "jilongxian": "吉隆县", - "jimeiqu": "集美区", - "jimoqu": "即墨区", - "jimunaixian": "吉木乃县", - "jimusaerxian": "吉木萨尔县", - "jin": "金林区", - "jinan": "济南", - "jinangaoxinjishuchanyekaifaqu": "济南高新技术产业开发区", - "jinanshi": "济南", - "jinchang": "金昌", - "jinchangshi": "金昌", - "jincheng": "晋城", - "jinchengjiangqu": "金城江区", - "jinchengshi": "晋城", - "jinchuanqu": "金川区", - "jinchuanxian": "金川县", - "jindongqu": "金东区", - "jinfengqu": "金凤区", - "jinganqu": "静安区", - "jinganxian": "靖安县", - "jingbianxian": "靖边县", - "jingchuanxian": "泾川县", - "jingdexian": "旌德县", - "jingdezhen": "景德镇", - "jingdezhenshi": "景德镇", - "jingdongyizuzizhixian": "景东彝族自治县", - "jinggangshan": "井冈山市", - "jinggangshanshi": "井冈山市", - "jinggudaizuyizuzizhixian": "景谷傣族彝族自治县", - "jinghaiqu": "静海区", - "jinghexian": "精河县", - "jinghong": "景洪市", - "jinghongshi": "景洪市", - "jinghuqu": "镜湖区", - "jingjiang": "靖江市", - "jingjiangshi": "靖江市", - "jingkouqu": "京口区", - "jinglexian": "静乐县", - "jingmen": "荆门", - "jingmenshi": "荆门", - "jingningshezuzizhixian": "景宁畲族自治县", - "jingningxian": "静宁县", - "jingshan": "京山市", - "jingshanshi": "京山市", - "jingtaixian": "景泰县", - "jingxi": "靖西市", - "jingxingkuangqu": "井陉矿区", - "jingxingxian": "井陉县", - "jingxishi": "靖西市", - "jingxiuqu": "竞秀区", - "jingyangqu": "旌阳区", - "jingyangxian": "泾阳县", - "jingyanxian": "井研县", - "jingyuxian": "靖宇县", - "jingzhou": "荆州", - "jingzhoujingjijishukaifaqu": "荆州经济技术开发区", - "jingzhoumiaozudongzuzizhixian": "靖州苗族侗族自治县", - "jingzhouqu": "荆州区", - "jingzhoushi": "荆州", - "jinhua": "金华", - "jinhuashi": "金华", - "jinhuxian": "金湖县", - "jining": "济宁", - "jininggaoxinjishuchanyekaifaqu": "济宁高新技术产业开发区", - "jiningqu": "集宁区", - "jiningshi": "济宁", - "jinjiang": "晋江市", - "jinjiangqu": "锦江区", - "jinjiangshi": "晋江市", - "jinkouhequ": "金口河区", - "jinlinqu": "金林区", - "jinmenxian": "金门县", - "jinnanqu": "津南区", - "jinningqu": "晋宁区", - "jinniuqu": "金牛区", - "jinpingmiaozuyaozudaizuzizhixian": "金平苗族瑶族傣族自治县", - "jinpingqu": "金平区", - "jinpingxian": "锦屏县", - "jinshanqu": "金山区", - "jinshaxian": "金沙县", - "jinshi": "津市市", - "jinshishi": "津市市", - "jinshuiqu": "金水区", - "jintaiqu": "金台区", - "jintangxian": "金堂县", - "jintanqu": "金坛区", - "jintaxian": "金塔县", - "jinwanqu": "金湾区", - "jinxiangxian": "金乡县", - "jinxianxian": "进贤县", - "jinxiuyaozuzizhixian": "金秀瑶族自治县", - "jinxixian": "金溪县", - "jinyangxian": "金阳县", - "jinyuanqu": "晋源区", - "jinyunxian": "缙云县", - "jinzhaixian": "金寨县", - "jinzhong": "晋中", - "jinzhongshi": "晋中", - "jinzhouqu": "金州区", - "jishanxian": "稷山县", - "jishishanbaoanzudongxiangzusalazuzizhixian": "积石山保安族东乡族撒拉族自治县", - "jishou": "吉首市", - "jishoushi": "吉首市", - "jishuixian": "吉水县", - "jiujiang": "九江", - "jiujiangqu": "鸠江区", - "jiujiangshi": "九江", - "jiulongchengqu": "九龙城区", - "jiulongpoqu": "九龙坡区", - "jiulongxian": "九龙县", - "jiuquan": "酒泉", - "jiuquanshi": "酒泉", - "jiutaiqu": "九台区", - "jiuyuanqu": "九原区", - "jiuzhaigouxian": "九寨沟县", - "jiuzhixian": "久治县", - "jixi": "鸡西", - "jixian": "吉县", - "jixianxian": "集贤县", - "jixishi": "鸡西", - "jixixian": "绩溪县", - "jiyuan": "济源市", - "jiyuanshi": "济源市", - "jizexian": "鸡泽县", - "juanchengxian": "鄄城县", - "juluxian": "巨鹿县", - "junanxian": "莒南县", - "junshanqu": "君山区", - "junxian": "浚县", - "jurong": "句容市", - "jurongshi": "句容市", - "juxian": "莒县", - "juyexian": "巨野县", - "ka": "喀什", - "kaifeng": "开封", - "kaifengshi": "开封", - "kaifuqu": "开福区", - "kaihuaxian": "开化县", - "kaijiangxian": "开江县", - "kaili": "凯里市", - "kailishi": "凯里市", - "kailuxian": "开鲁县", - "kaiping": "开平市", - "kaipingqu": "开平区", - "kaipingshi": "开平市", - "kaiyangxian": "开阳县", - "kaizhouqu": "开州区", - "kalaqinqi": "喀喇沁旗", - "kalaqinzuoyimengguzuzizhixian": "喀喇沁左翼蒙古族自治县", - "kangbaoxian": "康保县", - "kangbashenqu": "康巴什区", - "kangding": "康定市", - "kangdingshi": "康定市", - "kanglexian": "康乐县", - "kangmaxian": "康马县", - "kangpingxian": "康平县", - "kangxian": "康县", - "karuoqu": "卡若区", - "kashidiqu": "喀什", - "kashishi": "喀什市", - "kechengqu": "柯城区", - "kedongxian": "克东县", - "keerqinqu": "科尔沁区", - "keerqinyouyiqianqi": "科尔沁右翼前旗", - "keerqinyouyizhongqi": "科尔沁右翼中旗", - "keerqinzuoyihouqi": "科尔沁左翼后旗", - "keerqinzuoyizhongqi": "科尔沁左翼中旗", - "kekedala": "可克达拉市", - "kekedalashi": "可克达拉市", - "kelamayi": "克拉玛依", - "kelamayiqu": "克拉玛依区", - "kelamayishi": "克拉玛依", - "kelanxian": "岢岚县", - "kenliqu": "垦利区", - "kepingxian": "柯坪县", - "keqiaoqu": "柯桥区", - "keshanxian": "克山县", - "keshenketengqi": "克什克腾旗", - "kezileisukeerkezi": "克孜勒苏柯尔克孜", - "kezileisukeerkezizizhizhou": "克孜勒苏柯尔克孜", - "kongdongqu": "崆峒区", - "kuanchengmanzuzizhixian": "宽城满族自治县", - "kuanchengqu": "宽城区", - "kuandianmanzuzizhixian": "宽甸满族自治县", - "kuangqu": "矿区", - "kuche": "库车市", - "kucheshi": "库车市", - "kuerlei": "库尔勒市", - "kuerleijingjijishukaifaqu": "库尔勒经济技术开发区", - "kuerleishi": "库尔勒市", - "kuiqingqu": "葵青区", - "kuitun": "奎屯市", - "kuitunshi": "奎屯市", - "kuiwenqu": "奎文区", - "kulunqi": "库伦旗", - "kundoulunqu": "昆都仑区", - "kunming": "昆明", - "kunmingshi": "昆明", - "kunshan": "昆山市", - "kunshanshi": "昆山市", - "kunyu": "昆玉市", - "kunyushi": "昆玉市", - "laianxian": "来安县", - "laibin": "来宾", - "laibinshi": "来宾", - "laifengxian": "来凤县", - "laishanqu": "莱山区", - "laishuixian": "涞水县", - "laiwuqu": "莱芜区", - "laixi": "莱西市", - "laixishi": "莱西市", - "laiyang": "莱阳市", - "laiyangshi": "莱阳市", - "laiyuanxian": "涞源县", - "laizhou": "莱州市", - "laizhoushi": "莱州市", - "lancanglahuzuzizhixian": "澜沧拉祜族自治县", - "langaoxian": "岚皋县", - "langfang": "廊坊", - "langfangjingjijishukaifaqu": "廊坊经济技术开发区", - "langfangshi": "廊坊", - "langqiazixian": "浪卡子县", - "langxian": "朗县", - "langxixian": "郎溪县", - "langyaqu": "琅琊区", - "langzhong": "阆中市", - "langzhongshi": "阆中市", - "lankaoxian": "兰考县", - "lanlingxian": "兰陵县", - "lanpingbaizupumizuzizhixian": "兰坪白族普米族自治县", - "lanshanxian": "蓝山县", - "lantianxian": "蓝田县", - "lanxi": "兰溪市", - "lanxian": "岚县", - "lanxishi": "兰溪市", - "lanxixian": "兰西县", - "lanzhou": "兰州", - "lanzhoushi": "兰州", - "lanzhouxinqu": "兰州新区", - "laobianqu": "老边区", - "laochengqu": "老城区", - "laohekou": "老河口市", - "laohekoushi": "老河口市", - "laoshanqu": "崂山区", - "laotingxian": "乐亭县", - "lasa": "拉萨", - "lasajingjijishukaifaqu": "拉萨经济技术开发区", - "lasashi": "拉萨", - "lazixian": "拉孜县", - "leanxian": "乐安县", - "lechang": "乐昌市", - "lechangshi": "乐昌市", - "ledonglizuzizhixian": "乐东黎族自治县", - "leduqu": "乐都区", - "leiboxian": "雷波县", - "leishanxian": "雷山县", - "leiwuqixian": "类乌齐县", - "leiyang": "耒阳市", - "leiyangshi": "耒阳市", - "leizhou": "雷州市", - "leizhoushi": "雷州市", - "leling": "乐陵市", - "lelingshi": "乐陵市", - "lengshuijiang": "冷水江市", - "lengshuijiangshi": "冷水江市", - "lengshuitanqu": "冷水滩区", - "leping": "乐平市", - "lepingshi": "乐平市", - "leshan": "乐山", - "leshanshi": "乐山", - "leyexian": "乐业县", - "lezhixian": "乐至县", - "lianchengxian": "连城县", - "lianchiqu": "莲池区", - "liandouqu": "莲都区", - "liangchengxian": "凉城县", - "liangdangxian": "两当县", - "lianghexian": "梁河县", - "liangpingqu": "梁平区", - "liangqingqu": "良庆区", - "liangshanxian": "梁山县", - "liangshanyizu": "凉山彝族", - "liangshanyizuzizhizhou": "凉山彝族", - "liangxiqu": "梁溪区", - "liangyuanqu": "梁园区", - "liangzhouqu": "凉州区", - "liangzihuqu": "梁子湖区", - "lianhuaxian": "莲花县", - "lianhuqu": "莲湖区", - "lianjiang": "廉江市", - "lianjiangshi": "廉江市", - "lianjiangxian": "连江县", - "liannanyaozuzizhixian": "连南瑶族自治县", - "lianpingxian": "连平县", - "lianshanqu": "连山区", - "lianshanzhuangzuyaozuzizhixian": "连山壮族瑶族自治县", - "lianshuixian": "涟水县", - "lianxiqu": "濂溪区", - "lianyuan": "涟源市", - "lianyuanshi": "涟源市", - "lianyungang": "连云港", - "lianyunganggaoxinjishuchanyekaifaqu": "连云港高新技术产业开发区", - "lianyungangjingjijishukaifaqu": "连云港经济技术开发区", - "lianyungangshi": "连云港", - "lianyunqu": "连云区", - "lianzhou": "连州市", - "lianzhoushi": "连州市", - "liaocheng": "聊城", - "liaochengshi": "聊城", - "liaoyang": "辽阳", - "liaoyangshi": "辽阳", - "liaoyangxian": "辽阳县", - "liaoyuan": "辽源", - "liaoyuanshi": "辽源", - "liaozhongqu": "辽中区", - "liboxian": "荔波县", - "licangqu": "李沧区", - "lichengxian": "黎城县", - "lichuan": "利川市", - "lichuanshi": "利川市", - "lichuanxian": "黎川县", - "lidaoqu": "离岛区", - "lieshanqu": "烈山区", - "lijiang": "丽江", - "lijiangshi": "丽江", - "lijinxian": "利津县", - "liling": "醴陵市", - "lilingshi": "醴陵市", - "linanqu": "临安区", - "lincang": "临沧", - "lincangshi": "临沧", - "linchengxian": "临城县", - "linchuanqu": "临川区", - "lindianxian": "林甸县", - "linfen": "临汾", - "linfenshi": "临汾", - "lingaoxian": "临高县", - "lingbao": "灵宝市", - "lingbaoshi": "灵宝市", - "lingbixian": "灵璧县", - "lingchengqu": "陵城区", - "lingdongqu": "岭东区", - "linghai": "凌海市", - "linghaishi": "凌海市", - "linghequ": "凌河区", - "linglingqu": "零陵区", - "lingqiuxian": "灵丘县", - "lingshanxian": "灵山县", - "lingshixian": "灵石县", - "lingshouxian": "灵寿县", - "lingshuilizuzizhixian": "陵水黎族自治县", - "lingtaixian": "灵台县", - "linguiqu": "临桂区", - "lingwu": "灵武市", - "lingwushi": "灵武市", - "lingyuan": "凌源市", - "lingyuanshi": "凌源市", - "lingyunxian": "凌云县", - "linhai": "临海市", - "linhaishi": "临海市", - "linhequ": "临河区", - "linjiang": "临江市", - "linjiangshi": "临江市", - "linkouxian": "林口县", - "linlixian": "临澧县", - "linpingqu": "临平区", - "linqing": "临清市", - "linqingshi": "临清市", - "linquanxian": "临泉县", - "linquxian": "临朐县", - "linshuixian": "邻水县", - "linshuxian": "临沭县", - "lintanxian": "临潭县", - "lintaoxian": "临洮县", - "lintongqu": "临潼区", - "linweiqu": "临渭区", - "linwuxian": "临武县", - "linxia": "临夏市", - "linxiahuizu": "临夏回族", - "linxiahuizuzizhizhou": "临夏回族", - "linxian": "临县", - "linxiang": "临湘市", - "linxiangqu": "临翔区", - "linxiangshi": "临湘市", - "linxiashi": "临夏市", - "linxiaxian": "临夏县", - "linyi": "临沂", - "linyigaoxinjishuchanyekaifaqu": "临沂高新技术产业开发区", - "linyingxian": "临颍县", - "linyishi": "临沂", - "linyouxian": "麟游县", - "linzexian": "临泽县", - "linzhangxian": "临漳县", - "linzhi": "林芝", - "linzhishi": "林芝", - "linzhou": "林州市", - "linzhoushi": "林州市", - "linzhouxian": "林周县", - "linziqu": "临淄区", - "lipingxian": "黎平县", - "lipu": "荔浦市", - "lipushi": "荔浦市", - "liquanxian": "礼泉县", - "lishanqu": "立山区", - "lishiqu": "离石区", - "lishui": "丽水", - "lishuiqu": "溧水区", - "lishuishi": "丽水", - "lishuqu": "梨树区", - "lishuxian": "梨树县", - "litangxian": "理塘县", - "litongqu": "利通区", - "liubaxian": "留坝县", - "liubeiqu": "柳北区", - "liuchengxian": "柳城县", - "liuhequ": "六合区", - "liuhexian": "柳河县", - "liujiangqu": "柳江区", - "liulinxian": "柳林县", - "liunanqu": "柳南区", - "liupanshui": "六盘水", - "liupanshuishi": "六盘水", - "liuyang": "浏阳市", - "liuyangshi": "浏阳市", - "liuzhitequ": "六枝特区", - "liuzhou": "柳州", - "liuzhoushi": "柳州", - "liwanqu": "荔湾区", - "lixiaqu": "历下区", - "lixinxian": "利辛县", - "liyang": "溧阳市", - "liyangshi": "溧阳市", - "lizhouqu": "利州区", - "longanqu": "龙安区", - "longanxian": "隆安县", - "longchang": "隆昌市", - "longchangshi": "隆昌市", - "longchengqu": "龙城区", - "longdexian": "隆德县", - "longfengqu": "龙凤区", - "longgang": "龙港市", - "longgangshi": "龙港市", - "longganhuguanliqu": "龙感湖管理区", - "longhai": "龙海市", - "longhaiqu": "龙海区", - "longhaishi": "龙海市", - "longhuaqu": "龙华区", - "longhuaxian": "隆化县", - "longhuixian": "隆回县", - "longhuqu": "龙湖区", - "longjiangxian": "龙江县", - "longjing": "龙井市", - "longjingshi": "龙井市", - "longkou": "龙口市", - "longkoushi": "龙口市", - "longlingezuzizhixian": "隆林各族自治县", - "longlingxian": "龙陵县", - "longlixian": "龙里县", - "longmatanqu": "龙马潭区", - "longmenxian": "龙门县", - "longquan": "龙泉市", - "longquanshi": "龙泉市", - "longquanyiqu": "龙泉驿区", - "longshanqu": "龙山区", - "longshanxian": "龙山县", - "longshaqu": "龙沙区", - "longshenggezuzizhixian": "龙胜各族自治县", - "longtanqu": "龙潭区", - "longtingqu": "龙亭区", - "longwanqu": "龙湾区", - "longweiqu": "龙圩区", - "longwenqu": "龙文区", - "longxian": "陇县", - "longxixian": "陇西县", - "longyan": "龙岩", - "longyangqu": "隆阳区", - "longyanshi": "龙岩", - "longyaoxian": "隆尧县", - "longyouxian": "龙游县", - "longzhouxian": "龙州县", - "longzihuqu": "龙子湖区", - "longzixian": "隆子县", - "loudi": "娄底", - "loudishi": "娄底", - "loufanxian": "娄烦县", - "louxingqu": "娄星区", - "luan": "六安", - "luanchengqu": "栾城区", - "luanchuanxian": "栾川县", - "luannanxian": "滦南县", - "luanpingxian": "滦平县", - "luanshi": "六安", - "luanzhou": "滦州市", - "luanzhoushi": "滦州市", - "lubeiqu": "路北区", - "luchuanxian": "陆川县", - "ludangtianhaiqu": "路凼填海区", - "ludianxian": "鲁甸县", - "ludingxian": "泸定县", - "lufengxian": "禄丰县", - "luhexian": "陆河县", - "luhuoxian": "炉霍县", - "lujiangxian": "庐江县", - "lukouqu": "渌口区", - "luliangxian": "陆良县", - "lulongxian": "卢龙县", - "lunanqu": "路南区", - "luntaixian": "轮台县", - "luobeixian": "萝北县", - "luochengmulaozuzizhixian": "罗城仫佬族自治县", - "luochuanxian": "洛川县", - "luodianxian": "罗甸县", - "luoding": "罗定市", - "luodingshi": "罗定市", - "luohuqu": "罗湖区", - "luolongqu": "洛龙区", - "luolongxian": "洛隆县", - "luonanxian": "洛南县", - "luoningxian": "洛宁县", - "luopingxian": "罗平县", - "luopuxian": "洛浦县", - "luoshanxian": "罗山县", - "luotianxian": "罗田县", - "luoyang": "洛阳", - "luoyanggaoxinjishuchanyekaifaqu": "洛阳高新技术产业开发区", - "luoyangshi": "洛阳", - "luoyuanxian": "罗源县", - "luozhaxian": "洛扎县", - "luozhuangqu": "罗庄区", - "luqiaoqu": "路桥区", - "luquanqu": "鹿泉区", - "luquanyizumiaozuzizhixian": "禄劝彝族苗族自治县", - "luquxian": "碌曲县", - "lushan": "庐山市", - "lushanshi": "庐山市", - "lushixian": "卢氏县", - "lushui": "泸水市", - "lushuishi": "泸水市", - "lusongqu": "芦淞区", - "luxian": "泸县", - "luyangqu": "庐阳区", - "luyixian": "鹿邑县", - "luzhaixian": "鹿寨县", - "luzhou": "泸州", - "luzhouqu": "潞州区", - "luzhoushi": "泸州", - "lvchunxian": "绿春县", - "lveyangxian": "略阳县", - "lvliang": "吕梁", - "lvliangshi": "吕梁", - "lvshunkouqu": "旅顺口区", - "lvyuanqu": "绿园区", - "maanshan": "马鞍山", - "maanshanshi": "马鞍山", - "mabianyizuzizhixian": "马边彝族自治县", - "macheng": "麻城市", - "machengshi": "麻城市", - "macunqu": "马村区", - "maduoxian": "玛多县", - "maerkang": "马尔康市", - "maerkangshi": "马尔康市", - "maguanxian": "马关县", - "maigaitixian": "麦盖提县", - "maijiqu": "麦积区", - "majiangxian": "麻江县", - "malipoxian": "麻栗坡县", - "malongqu": "马龙区", - "manasixian": "玛纳斯县", - "manchengqu": "满城区", - "mang": "芒市", - "mangkangxian": "芒康县", - "mangshi": "芒市", - "mangya": "茫崖市", - "mangyashi": "茫崖市", - "manzhouli": "满洲里市", - "manzhoulishi": "满洲里市", - "maojianqu": "茅箭区", - "maoming": "茂名", - "maomingshi": "茂名", - "maonanqu": "茂南区", - "maoxian": "茂县", - "maqinxian": "玛沁县", - "maquxian": "玛曲县", - "mashanqu": "麻山区", - "mashanxian": "马山县", - "mayangmiaozuzizhixian": "麻阳苗族自治县", - "mayiqu": "马尾区", - "mazhangqu": "麻章区", - "meiguxian": "美姑县", - "meihekou": "梅河口市", - "meihekoushi": "梅河口市", - "meijiangqu": "梅江区", - "meilanqu": "美兰区", - "meiliequ": "梅列区", - "meilisidawoerzuqu": "梅里斯达斡尔族区", - "meishan": "眉山", - "meishanshi": "眉山", - "meitanxian": "湄潭县", - "meixian": "眉县", - "meixianqu": "梅县区", - "meizhou": "梅州", - "meizhoushi": "梅州", - "mengchengxian": "蒙城县", - "mengcunhuizuzizhixian": "孟村回族自治县", - "menghaixian": "勐海县", - "mengjinqu": "孟津区", - "mengjinxian": "孟津县", - "menglaxian": "勐腊县", - "mengliandaizulahuzuwazuzizhixian": "孟连傣族拉祜族佤族自治县", - "mengshanxian": "蒙山县", - "mengyinxian": "蒙阴县", - "mengzhou": "孟州市", - "mengzhoushi": "孟州市", - "mengzi": "蒙自市", - "mengzishi": "蒙自市", - "mentougouqu": "门头沟区", - "menyuanhuizuzizhixian": "门源回族自治县", - "mianchixian": "渑池县", - "mianningxian": "冕宁县", - "mianxian": "勉县", - "mianyang": "绵阳", - "mianyangshi": "绵阳", - "mianzhu": "绵竹市", - "mianzhushi": "绵竹市", - "midongqu": "米东区", - "miduxian": "弥渡县", - "mile": "弥勒市", - "mileshi": "弥勒市", - "milinxian": "米林县", - "miluo": "汨罗市", - "miluoshi": "汨罗市", - "minfengxian": "民丰县", - "mingguang": "明光市", - "mingguangshi": "明光市", - "mingshuixian": "明水县", - "mingxixian": "明溪县", - "minhehuizutuzuzizhixian": "民和回族土族自治县", - "minhouxian": "闽侯县", - "minqingxian": "闽清县", - "minqinxian": "民勤县", - "minquanxian": "民权县", - "minxian": "岷县", - "minxingqu": "闵行区", - "minyuexian": "民乐县", - "mishan": "密山市", - "mishanshi": "密山市", - "miyixian": "米易县", - "miyunqu": "密云区", - "mizhixian": "米脂县", - "mohe": "漠河市", - "moheshi": "漠河市", - "mojianghanizuzizhixian": "墨江哈尼族自治县", - "molidawadawoerzuzizhiqi": "莫力达瓦达斡尔族自治旗", - "motuoxian": "墨脱县", - "moudingxian": "牟定县", - "moyuxian": "墨玉县", - "mozhugongkaxian": "墨竹工卡县", - "muchuanxian": "沐川县", - "mudanjiang": "牡丹江", - "mudanjiangjingjijishukaifaqu": "牡丹江经济技术开发区", - "mudanjiangshi": "牡丹江", - "mudanqu": "牡丹区", - "mulanxian": "木兰县", - "muleihasakezizhixian": "木垒哈萨克自治县", - "muleng": "穆棱市", - "mulengshi": "穆棱市", - "mulizangzuzizhixian": "木里藏族自治县", - "mupingqu": "牟平区", - "muyequ": "牧野区", - "naidongqu": "乃东区", - "naimanqi": "奈曼旗", - "nanan": "南安市", - "nananqu": "南岸区", - "nananshi": "南安市", - "nanaoxian": "南澳县", - "nanbuxian": "南部县", - "nanchang": "南昌", - "nanchangshi": "南昌", - "nanchangxian": "南昌县", - "nanchaxian": "南岔县", - "nanchengxian": "南城县", - "nanchong": "南充", - "nanchongshi": "南充", - "nanchuanqu": "南川区", - "nandanxian": "南丹县", - "nanfengxian": "南丰县", - "nanfenqu": "南芬区", - "nangangqu": "南岗区", - "nangong": "南宫市", - "nangongshi": "南宫市", - "nangqianxian": "囊谦县", - "nanguanqu": "南关区", - "nanhaiqu": "南海区", - "nanhequ": "南和区", - "nanhuaxian": "南华县", - "nanhuqu": "南湖区", - "nanjiangxian": "南江县", - "nanjianyizuzizhixian": "南涧彝族自治县", - "nanjing": "南京", - "nanjingshi": "南京", - "nanjingxian": "南靖县", - "nankaiqu": "南开区", - "nankangqu": "南康区", - "nanlingxian": "南陵县", - "nanmingqu": "南明区", - "nanmulinxian": "南木林县", - "nanning": "南宁", - "nanningshi": "南宁", - "nanpiaoqu": "南票区", - "nanping": "南平", - "nanpingshi": "南平", - "nanpixian": "南皮县", - "nanqiaoqu": "南谯区", - "nanqu": "南区", - "nanshanqu": "南山区", - "nanshaqu": "南沙区", - "nanshaqundao": "南沙群岛", - "nantong": "南通", - "nantongjingjijishukaifaqu": "南通经济技术开发区", - "nantongshi": "南通", - "nanxian": "南县", - "nanxiong": "南雄市", - "nanxiongshi": "南雄市", - "nanxiqu": "南溪区", - "nanxunqu": "南浔区", - "nanyang": "南阳", - "nanyanggaoxinjishuchanyekaifaqu": "南阳高新技术产业开发区", - "nanyangshi": "南阳", - "nanyangshichengxiangyitihuashifanqu": "南阳市城乡一体化示范区", - "nanyuequ": "南岳区", - "nanyuexian": "南乐县", - "nanzhangxian": "南漳县", - "nanzhaoxian": "南召县", - "nanzhengqu": "南郑区", - "napoxian": "那坡县", - "naqu": "那曲", - "naqushi": "那曲", - "naxiqu": "纳溪区", - "nayongxian": "纳雍县", - "nehe": "讷河市", - "neheshi": "讷河市", - "neihuangxian": "内黄县", - "neijiang": "内江", - "neijiangjingjikaifaqu": "内江经济开发区", - "neijiangshi": "内江", - "neimenggualashangaoxinjishuchanyekaifaqu": "内蒙古阿拉善高新技术产业开发区", - "neiqiuxian": "内丘县", - "neixiangxian": "内乡县", - "nenjiang": "嫩江市", - "nenjiangshi": "嫩江市", - "nianzishanqu": "碾子山区", - "nielamuxian": "聂拉木县", - "nierongxian": "聂荣县", - "nileikexian": "尼勒克县", - "nimaxian": "尼玛县", - "nimuxian": "尼木县", - "ningan": "宁安市", - "ninganshi": "宁安市", - "ningbo": "宁波", - "ningboshi": "宁波", - "ningchengxian": "宁城县", - "ningde": "宁德", - "ningdeshi": "宁德", - "ningdouxian": "宁都县", - "ningerhanizuyizuzizhixian": "宁洱哈尼族彝族自治县", - "ningguo": "宁国市", - "ningguoshi": "宁国市", - "ninghaixian": "宁海县", - "ninghequ": "宁河区", - "ninghuaxian": "宁化县", - "ningjiangqu": "宁江区", - "ninglangyizuzizhixian": "宁蒗彝族自治县", - "ninglingxian": "宁陵县", - "ningmingxian": "宁明县", - "ningnanxian": "宁南县", - "ningqiangxian": "宁强县", - "ningshanxian": "宁陕县", - "ningwuxian": "宁武县", - "ningxian": "宁县", - "ningxiang": "宁乡市", - "ningxiangshi": "宁乡市", - "ningyangxian": "宁阳县", - "ningyuanxian": "宁远县", - "nonganxian": "农安县", - "nujianglisuzu": "怒江傈僳族", - "nujianglisuzuzizhizhou": "怒江傈僳族", - "ouhaiqu": "瓯海区", - "pananxian": "磐安县", - "panjin": "盘锦", - "panjinshi": "盘锦", - "panjiqu": "潘集区", - "panlongqu": "盘龙区", - "panshanxian": "盘山县", - "panshi": "磐石市", - "panshishi": "磐石市", - "panyuqu": "番禺区", - "panzhihua": "攀枝花", - "panzhihuashi": "攀枝花", - "panzhou": "盘州市", - "panzhoushi": "盘州市", - "peixian": "沛县", - "penganxian": "蓬安县", - "pengjiangqu": "蓬江区", - "penglaiqu": "蓬莱区", - "pengshanqu": "彭山区", - "pengshuimiaozutujiazuzizhixian": "彭水苗族土家族自治县", - "pengxixian": "蓬溪县", - "pengyangxian": "彭阳县", - "pengzexian": "彭泽县", - "pengzhou": "彭州市", - "pengzhoushi": "彭州市", - "pianguanxian": "偏关县", - "pidouqu": "郫都区", - "pinganqu": "平安区", - "pingbaqu": "平坝区", - "pingbianmiaozuzizhixian": "屏边苗族自治县", - "pingchangxian": "平昌县", - "pingchengqu": "平城区", - "pingchuanqu": "平川区", - "pingdingshan": "平顶山", - "pingdingshangaoxinjishuchanyekaifaqu": "平顶山高新技术产业开发区", - "pingdingshanshi": "平顶山", - "pingdingshanshichengxiangyitihuashifanqu": "平顶山市城乡一体化示范区", - "pingdingxian": "平定县", - "pingdu": "平度市", - "pingdushi": "平度市", - "pingfangqu": "平房区", - "pingguiqu": "平桂区", - "pingguo": "平果市", - "pingguoshi": "平果市", - "pingguqu": "平谷区", - "pinghexian": "平和县", - "pinghu": "平湖市", - "pinghushi": "平湖市", - "pingjiangxian": "平江县", - "pinglexian": "平乐县", - "pingliang": "平凉", - "pingliangshi": "平凉", - "pinglixian": "平利县", - "pingluoxian": "平罗县", - "pingluqu": "平鲁区", - "pingluxian": "平陆县", - "pingqiaoqu": "平桥区", - "pingquan": "平泉市", - "pingquanshi": "平泉市", - "pingshunxian": "平顺县", - "pingtangxian": "平塘县", - "pingtanxian": "平潭县", - "pingwuxian": "平武县", - "pingxiangxian": "平乡县", - "pingyangxian": "平阳县", - "pingyaoxian": "平遥县", - "pingyinxian": "平阴县", - "pingyixian": "平邑县", - "pingyuxian": "平舆县", - "pishanxian": "皮山县", - "pizhou": "邳州市", - "pizhoushi": "邳州市", - "potou": "泊头市", - "potouqu": "坡头区", - "potoushi": "泊头市", - "poyangxian": "鄱阳县", - "puanxian": "普安县", - "pubeixian": "浦北县", - "pudingxian": "普定县", - "pudongxinqu": "浦东新区", - "puer": "普洱", - "puershi": "普洱", - "pugexian": "普格县", - "pukouqu": "浦口区", - "pulandianqu": "普兰店区", - "pulanxian": "普兰县", - "puning": "普宁市", - "puningshi": "普宁市", - "putian": "莆田", - "putianshi": "莆田", - "putuoqu": "普陀区", - "puxian": "蒲县", - "puyang": "濮阳", - "puyangjingjijishukaifaqu": "濮阳经济技术开发区", - "puyangshi": "濮阳", - "puyangxian": "濮阳县", - "qi": "麒麟区", - "qianan": "迁安市", - "qiananshi": "迁安市", - "qiananxian": "乾安县", - "qiandongnanmiaozudongzu": "黔东南苗族侗族", - "qiandongnanmiaozudongzuzizhizhou": "黔东南苗族侗族", - "qianfengqu": "前锋区", - "qianguoerluosimengguzuzizhixian": "前郭尔罗斯蒙古族自治县", - "qianjiang": "潜江市", - "qianjiangqu": "黔江区", - "qianjiangshi": "潜江市", - "qianjinqu": "前进区", - "qiannanbuyizumiaozu": "黔南布依族苗族", - "qiannanbuyizumiaozuzizhizhou": "黔南布依族苗族", - "qianshan": "潜山市", - "qianshanqu": "千山区", - "qianshanshi": "潜山市", - "qiantangqu": "钱塘区", - "qianweixian": "犍为县", - "qianxi": "黔西市", - "qianxian": "乾县", - "qianxinanbuyizumiaozu": "黔西南布依族苗族", - "qianxinanbuyizumiaozuzizhizhou": "黔西南布依族苗族", - "qianxishi": "黔西市", - "qianyangxian": "千阳县", - "qiaochengqu": "谯城区", - "qiaodongqu": "桥东区", - "qiaojiaxian": "巧家县", - "qiaokouqu": "硚口区", - "qiaoxiqu": "桥西区", - "qibinqu": "淇滨区", - "qichunxian": "蕲春县", - "qidong": "启东市", - "qidongshi": "启东市", - "qidongxian": "祁东县", - "qiemoxian": "且末县", - "qiezihequ": "茄子河区", - "qihexian": "齐河县", - "qijiangqu": "綦江区", - "qilianxian": "祁连县", - "qilihequ": "七里河区", - "qilinqu": "麒麟区", - "qimenxian": "祁门县", - "qinanxian": "秦安县", - "qinbeiqu": "钦北区", - "qindouqu": "秦都区", - "qinganxian": "庆安县", - "qingbaijiangqu": "青白江区", - "qingchengqu": "清城区", - "qingchengxian": "庆城县", - "qingchuanxian": "青川县", - "qingdao": "青岛", - "qingdaogaoxinjishuchanyekaifaqu": "青岛高新技术产业开发区", - "qingdaoshi": "青岛", - "qingfengxian": "清丰县", - "qinggangxian": "青冈县", - "qinghemenqu": "清河门区", - "qinghequ": "清河区", - "qingjiangpuqu": "清江浦区", - "qingjianxian": "清涧县", - "qingliuxian": "清流县", - "qinglongmanzuzizhixian": "青龙满族自治县", - "qinglongxian": "晴隆县", - "qingpuqu": "青浦区", - "qingshanhuqu": "青山湖区", - "qingshanqu": "青山区", - "qingshenxian": "青神县", - "qingshuihexian": "清水河县", - "qingshuixian": "清水县", - "qingtianxian": "青田县", - "qingtongxia": "青铜峡市", - "qingtongxiashi": "青铜峡市", - "qingxian": "青县", - "qingxinqu": "清新区", - "qingxiuqu": "青秀区", - "qingxuxian": "清徐县", - "qingyang": "庆阳", - "qingyangqu": "青羊区", - "qingyangshi": "庆阳", - "qingyangxian": "青阳县", - "qingyuan": "清远", - "qingyuanmanzuzizhixian": "清原满族自治县", - "qingyuanshi": "清远", - "qingyuanxian": "庆元县", - "qingyunpuqu": "青云谱区", - "qingyunxian": "庆云县", - "qingzhen": "清镇市", - "qingzhenshi": "清镇市", - "qingzhou": "青州市", - "qingzhoushi": "青州市", - "qinhuaiqu": "秦淮区", - "qinhuangdao": "秦皇岛", - "qinhuangdaoshi": "秦皇岛", - "qinhuangdaoshijingjijishukaifaqu": "秦皇岛市经济技术开发区", - "qinnanqu": "钦南区", - "qinshuixian": "沁水县", - "qinxian": "沁县", - "qinyang": "沁阳市", - "qinyangshi": "沁阳市", - "qinyuanxian": "沁源县", - "qinzhou": "钦州", - "qinzhouqu": "秦州区", - "qinzhoushi": "钦州", - "qionghai": "琼海市", - "qionghaishi": "琼海市", - "qiongjiexian": "琼结县", - "qionglai": "邛崃市", - "qionglaishi": "邛崃市", - "qiongshanqu": "琼山区", - "qiongzhonglizumiaozuzizhixian": "琼中黎族苗族自治县", - "qiqihaer": "齐齐哈尔", - "qiqihaershi": "齐齐哈尔", - "qishanxian": "岐山县", - "qitaihe": "七台河", - "qitaiheshi": "七台河", - "qitaixian": "奇台县", - "qiubeixian": "丘北县", - "qiuxian": "邱县", - "qixia": "栖霞市", - "qixiaqu": "栖霞区", - "qixiashi": "栖霞市", - "qixingguanqu": "七星关区", - "qixingqu": "七星区", - "qiyang": "祁阳市", - "qiyangshi": "祁阳市", - "qiyangxian": "祁阳县", - "quangangqu": "泉港区", - "quanjiaoxian": "全椒县", - "quannanxian": "全南县", - "quanshanqu": "泉山区", - "quanwanqu": "荃湾区", - "quanzhou": "泉州", - "quanzhoushi": "泉州", - "quanzhouxian": "全州县", - "queshanxian": "确山县", - "qufu": "曲阜市", - "qufushi": "曲阜市", - "qujing": "曲靖", - "qujingshi": "曲靖", - "qumalaixian": "曲麻莱县", - "qushuixian": "曲水县", - "qusongxian": "曲松县", - "quwoxian": "曲沃县", - "quxian": "渠县", - "quyangxian": "曲阳县", - "quzhou": "衢州", - "quzhoushi": "衢州", - "quzhouxian": "曲周县", - "ranghuluqu": "让胡路区", - "rangtangxian": "壤塘县", - "raohexian": "饶河县", - "raopingxian": "饶平县", - "raoyangxian": "饶阳县", - "renbuxian": "仁布县", - "renchengqu": "任城区", - "renhequ": "仁和区", - "renhuai": "仁怀市", - "renhuaishi": "仁怀市", - "renhuaxian": "仁化县", - "renqiu": "任丘市", - "renqiushi": "任丘市", - "renshouxian": "仁寿县", - "renzequ": "任泽区", - "rikaze": "日喀则", - "rikazeshi": "日喀则", - "rituxian": "日土县", - "rizhao": "日照", - "rizhaojingjijishukaifaqu": "日照经济技术开发区", - "rizhaoshi": "日照", - "ronganxian": "融安县", - "rongchangqu": "荣昌区", - "rongcheng": "荣成市", - "rongchengqu": "榕城区", - "rongchengshi": "荣成市", - "rongchengxian": "容城县", - "rongjiangxian": "榕江县", - "rongshuimiaozuzizhixian": "融水苗族自治县", - "ruchengxian": "汝城县", - "rudongxian": "如东县", - "rugao": "如皋市", - "rugaoshi": "如皋市", - "ruian": "瑞安市", - "ruianshi": "瑞安市", - "ruichang": "瑞昌市", - "ruichangshi": "瑞昌市", - "ruichengxian": "芮城县", - "ruijin": "瑞金市", - "ruijinshi": "瑞金市", - "ruili": "瑞丽市", - "ruilishi": "瑞丽市", - "runanxian": "汝南县", - "runzhouqu": "润州区", - "ruoergaixian": "若尔盖县", - "ruoqiangxian": "若羌县", - "rushan": "乳山市", - "rushanshi": "乳山市", - "ruyangxian": "汝阳县", - "ruyuanyaozuzizhixian": "乳源瑶族自治县", - "ruzhou": "汝州市", - "ruzhoushi": "汝州市", - "saertuqu": "萨尔图区", - "sagaxian": "萨嘎县", - "saihanqu": "赛罕区", - "sajiaxian": "萨迦县", - "sandoushuizuzizhixian": "三都水族自治县", - "sangrixian": "桑日县", - "sangzhixian": "桑植县", - "sangzhuziqu": "桑珠孜区", - "sanhe": "三河市", - "sanheshi": "三河市", - "sanjiangdongzuzizhixian": "三江侗族自治县", - "sanmenxia": "三门峡", - "sanmenxian": "三门县", - "sanmenxiashi": "三门峡", - "sanming": "三明", - "sanmingshi": "三明", - "sansha": "三沙", - "sanshashi": "三沙", - "sanshuiqu": "三水区", - "sansuixian": "三穗县", - "santaixian": "三台县", - "sanya": "三亚", - "sanyashi": "三亚", - "sanyuanqu": "三元区", - "sanyuanxian": "三原县", - "sedaxian": "色达县", - "seniqu": "色尼区", - "shachexian": "莎车县", - "shahe": "沙河市", - "shahekouqu": "沙河口区", - "shaheshi": "沙河市", - "shanchengqu": "山城区", - "shandanxian": "山丹县", - "shangcaixian": "上蔡县", - "shangchengqu": "上城区", - "shangchengxian": "商城县", - "shangdangqu": "上党区", - "shangdouxian": "商都县", - "shanggaoxian": "上高县", - "shanghangxian": "上杭县", - "shanghexian": "商河县", - "shangjiequ": "上街区", - "shanglinxian": "上林县", - "shanglixian": "上栗县", - "shangluo": "商洛", - "shangluoshi": "商洛", - "shangnanxian": "商南县", - "shangqiu": "商丘", - "shangqiushi": "商丘", - "shangrao": "上饶", - "shangraoshi": "上饶", - "shangshuixian": "商水县", - "shangsixian": "上思县", - "shangyixian": "尚义县", - "shangyouxian": "上犹县", - "shangyuqu": "上虞区", - "shangzhi": "尚志市", - "shangzhishi": "尚志市", - "shangzhouqu": "商州区", - "shanhaiguanqu": "山海关区", - "shannan": "山南", - "shannanshi": "山南", - "shanshanxian": "鄯善县", - "shantingqu": "山亭区", - "shantou": "汕头", - "shantoushi": "汕头", - "shanwei": "汕尾", - "shanweishi": "汕尾", - "shanxidatongjingjikaifaqu": "山西大同经济开发区", - "shanxishuozhoujingjikaifaqu": "山西朔州经济开发区", - "shanxizhangzhigaoxinjishuchanyeyuanqu": "山西长治高新技术产业园区", - "shanxizhuanxingzonghegaigeshifanqu": "山西转型综合改革示范区", - "shanyangqu": "山阳区", - "shanyangxian": "山阳县", - "shanyinxian": "山阴县", - "shanzhouqu": "陕州区", - "shaodong": "邵东市", - "shaodongshi": "邵东市", - "shaoguan": "韶关", - "shaoguanshi": "韶关", - "shaoshan": "韶山市", - "shaoshanshi": "韶山市", - "shaowu": "邵武市", - "shaowushi": "邵武市", - "shaoxing": "绍兴", - "shaoxingshi": "绍兴", - "shaoyang": "邵阳", - "shaoyangshi": "邵阳", - "shaoyangxian": "邵阳县", - "shapingbaqu": "沙坪坝区", - "shapotouqu": "沙坡头区", - "shashiqu": "沙市区", - "shatianqu": "沙田区", - "shawan": "沙湾市", - "shawanqu": "沙湾区", - "shawanshi": "沙湾市", - "shawanxian": "沙湾县", - "shaxian": "沙县", - "shaxianqu": "沙县区", - "shayangxian": "沙洋县", - "shayaxian": "沙雅县", - "shayibakequ": "沙依巴克区", - "shehong": "射洪市", - "shehongshi": "射洪市", - "shenbeixinqu": "沈北新区", - "shenchixian": "神池县", - "shenfang": "什邡市", - "shenfangshi": "什邡市", - "shengfangjigetangqu": "圣方济各堂区", - "shengsixian": "嵊泗县", - "shengzhou": "嵊州市", - "shengzhoushi": "嵊州市", - "shenhequ": "沈河区", - "shenmu": "神木市", - "shenmushi": "神木市", - "shennongjia": "神农架林区", - "shennongjialinqu": "神农架林区", - "shenqiuxian": "沈丘县", - "shenshuibuqu": "深水埗区", - "shenxian": "莘县", - "shenyang": "沈阳", - "shenyangshi": "沈阳", - "shenzexian": "深泽县", - "shenzhaxian": "申扎县", - "shenzhen": "深圳", - "shenzhenshi": "深圳", - "shenzhou": "深州市", - "shenzhoushi": "深州市", - "sheqixian": "社旗县", - "sheyangxian": "射阳县", - "shibeiqu": "市北区", - "shibingxian": "施秉县", - "shichengxian": "石城县", - "shidianxian": "施甸县", - "shifengqu": "石峰区", - "shiguaiqu": "石拐区", - "shiguqu": "石鼓区", - "shihequ": "浉河区", - "shihezi": "石河子市", - "shihezishi": "石河子市", - "shijiazhuang": "石家庄", - "shijiazhuanggaoxinjishuchanyekaifaqu": "石家庄高新技术产业开发区", - "shijiazhuangshi": "石家庄", - "shijiazhuangxunhuanhuagongyuanqu": "石家庄循环化工园区", - "shijingshanqu": "石景山区", - "shilinyizuzizhixian": "石林彝族自治县", - "shilongqu": "石龙区", - "shilouxian": "石楼县", - "shimenxian": "石门县", - "shimianxian": "石棉县", - "shinanqu": "市南区", - "shipingxian": "石屏县", - "shiqianxian": "石阡县", - "shiquanxian": "石泉县", - "shiquxian": "石渠县", - "shishi": "石狮市", - "shishishi": "石狮市", - "shishou": "石首市", - "shishoushi": "石首市", - "shitaixian": "石台县", - "shixiaqu": "市辖区", - "shixingxian": "始兴县", - "shiyan": "十堰", - "shiyanshi": "十堰", - "shizhongqu": "市中区", - "shizhutujiazuzizhixian": "石柱土家族自治县", - "shizongxian": "师宗县", - "shizuishan": "石嘴山", - "shizuishanshi": "石嘴山", - "shouguang": "寿光市", - "shouguangshi": "寿光市", - "shouningxian": "寿宁县", - "shouxian": "寿县", - "shouyangxian": "寿阳县", - "shuangbaixian": "双柏县", - "shuangchengqu": "双城区", - "shuangfengxian": "双峰县", - "shuanghe": "双河市", - "shuangheshi": "双河市", - "shuanghuxian": "双湖县", - "shuangjianglahuzuwazubulangzudaizuzizhixian": "双江拉祜族佤族布朗族傣族自治县", - "shuangliao": "双辽市", - "shuangliaoshi": "双辽市", - "shuangliuqu": "双流区", - "shuangluanqu": "双滦区", - "shuangpaixian": "双牌县", - "shuangqiaoqu": "双桥区", - "shuangqingqu": "双清区", - "shuangtaiziqu": "双台子区", - "shuangtaqu": "双塔区", - "shuangyangqu": "双阳区", - "shuangyashan": "双鸭山", - "shuangyashanshi": "双鸭山", - "shuchengxian": "舒城县", - "shufuxian": "疏附县", - "shuichengqu": "水城区", - "shuifu": "水富市", - "shuifushi": "水富市", - "shuimogouqu": "水磨沟区", - "shulan": "舒兰市", - "shulanshi": "舒兰市", - "shulexian": "疏勒县", - "shunchangxian": "顺昌县", - "shunchengqu": "顺城区", - "shundequ": "顺德区", - "shunhehuizuqu": "顺河回族区", - "shunpingxian": "顺平县", - "shunqingqu": "顺庆区", - "shunyiqu": "顺义区", - "shuochengqu": "朔城区", - "shuozhou": "朔州", - "shuozhoushi": "朔州", - "shushanqu": "蜀山区", - "shuyangxian": "沭阳县", - "sifangtaiqu": "四方台区", - "sihongxian": "泗洪县", - "sihui": "四会市", - "sihuishi": "四会市", - "simaoqu": "思茅区", - "simingqu": "思明区", - "sinanxian": "思南县", - "siping": "四平", - "sipingshi": "四平", - "sishuixian": "泗水县", - "sixian": "泗县", - "siyangxian": "泗阳县", - "siziwangqi": "四子王旗", - "songbeiqu": "松北区", - "songjiangqu": "松江区", - "songlingqu": "松岭区", - "songmingxian": "嵩明县", - "songpanxian": "松潘县", - "songshanqu": "松山区", - "songtaomiaozuzizhixian": "松桃苗族自治县", - "songxian": "嵩县", - "songxixian": "松溪县", - "songyangxian": "松阳县", - "songyuan": "松原", - "songyuanshi": "松原", - "songzi": "松滋市", - "songzishi": "松滋市", - "subeimengguzuzizhixian": "肃北蒙古族自治县", - "suchengqu": "宿城区", - "suibinxian": "绥滨县", - "suichangxian": "遂昌县", - "suichuanxian": "遂川县", - "suidexian": "绥德县", - "suifenhe": "绥芬河市", - "suifenheshi": "绥芬河市", - "suihua": "绥化", - "suihuashi": "绥化", - "suijiangxian": "绥江县", - "suilengxian": "绥棱县", - "suining": "遂宁", - "suiningshi": "遂宁", - "suipingxian": "遂平县", - "suiyangqu": "睢阳区", - "suiyangxian": "绥阳县", - "suizhongxian": "绥中县", - "suizhou": "随州", - "suizhoushi": "随州", - "sujiatunqu": "苏家屯区", - "sunanyuguzuzizhixian": "肃南裕固族自治县", - "suningxian": "肃宁县", - "suniteyouqi": "苏尼特右旗", - "sunitezuoqi": "苏尼特左旗", - "sunwuxian": "孙吴县", - "suoxian": "索县", - "suqian": "宿迁", - "suqianjingjijishukaifaqu": "宿迁经济技术开发区", - "suqianshi": "宿迁", - "susongxian": "宿松县", - "suxianqu": "苏仙区", - "suyuqu": "宿豫区", - "suzhougongyeyuanqu": "苏州工业园区", - "suzhoujingjijishukaifaqu": "宿州经济技术开发区", - "suzhoumaanshanxiandaichanyeyuanqu": "宿州马鞍山现代产业园区", - "suzhouqu": "肃州区", - "tachengdiqu": "塔城", - "tachengshi": "塔城市", - "tahe": "漯河", - "tahejingjijishukaifaqu": "漯河经济技术开发区", - "taheshi": "漯河", - "tahexian": "塔河县", - "taian": "泰安", - "taianshi": "泰安", - "taianxian": "台安县", - "taibaixian": "太白县", - "taicang": "太仓市", - "taicangshi": "太仓市", - "taierzhuangqu": "台儿庄区", - "taiguqu": "太谷区", - "taihequ": "太和区", - "taihuxian": "太湖县", - "taijiangqu": "台江区", - "taijiangxian": "台江县", - "taikangxian": "太康县", - "tailaixian": "泰来县", - "tainingxian": "泰宁县", - "taipingqu": "太平区", - "taipusiqi": "太仆寺旗", - "taiqianxian": "台前县", - "taishan": "台山市", - "taishanqu": "泰山区", - "taishanshi": "台山市", - "taishunxian": "泰顺县", - "taixing": "泰兴市", - "taixingshi": "泰兴市", - "taiyuan": "太原", - "taiyuanshi": "太原", - "taizhouyiyaogaoxinjishuchanyekaifaqu": "泰州医药高新技术产业开发区", - "taizihequ": "太子河区", - "tanchengxian": "郯城县", - "tanghexian": "唐河县", - "tangshan": "唐山", - "tangshangaoxinjishuchanyekaifaqu": "唐山高新技术产业开发区", - "tangshanshi": "唐山", - "tangshanshihanguguanliqu": "唐山市汉沽管理区", - "tangwangxian": "汤旺县", - "tangxian": "唐县", - "tangyinxian": "汤阴县", - "tangyuanxian": "汤原县", - "tantangqu": "覃塘区", - "taobeiqu": "洮北区", - "taochengqu": "桃城区", - "taojiangxian": "桃江县", - "taonan": "洮南市", - "taonanshi": "洮南市", - "taoshanqu": "桃山区", - "taoyuanxian": "桃源县", - "tashenkuergantajikezizhixian": "塔什库尔干塔吉克自治县", - "tekesixian": "特克斯县", - "tengchong": "腾冲市", - "tengchongshi": "腾冲市", - "tengxian": "藤县", - "tengzhou": "滕州市", - "tengzhoushi": "滕州市", - "tiandengxian": "天等县", - "tiandongxian": "田东县", - "tianexian": "天峨县", - "tianhequ": "天河区", - "tianjiaanqu": "田家庵区", - "tianjunxian": "天峻县", - "tianlinxian": "田林县", - "tianmen": "天门市", - "tianmenshi": "天门市", - "tianningqu": "天宁区", - "tianqiaoqu": "天桥区", - "tianquanxian": "天全县", - "tianshanqu": "天山区", - "tianshui": "天水", - "tianshuishi": "天水", - "tiantaixian": "天台县", - "tianxinqu": "天心区", - "tianyangqu": "田阳区", - "tianyaqu": "天涯区", - "tianyuanqu": "天元区", - "tianzhang": "天长市", - "tianzhangshi": "天长市", - "tianzhenxian": "天镇县", - "tianzhuxian": "天柱县", - "tianzhuzangzuzizhixian": "天祝藏族自治县", - "tiedongqu": "铁东区", - "tiefengqu": "铁锋区", - "tieli": "铁力市", - "tieling": "铁岭", - "tielingshi": "铁岭", - "tielingxian": "铁岭县", - "tielishi": "铁力市", - "tiemenguan": "铁门关市", - "tiemenguanshi": "铁门关市", - "tieshangangqu": "铁山港区", - "tieshanqu": "铁山区", - "tiexiqu": "铁西区", - "tinghuqu": "亭湖区", - "tonganqu": "同安区", - "tongbaixian": "桐柏县", - "tongcheng": "桐城市", - "tongchengshi": "桐城市", - "tongchengxian": "通城县", - "tongchuan": "铜川", - "tongchuanqu": "通川区", - "tongchuanshi": "铜川", - "tongdaodongzuzizhixian": "通道侗族自治县", - "tongdexian": "同德县", - "tongguanqu": "铜官区", - "tongguanxian": "潼关县", - "tongguxian": "铜鼓县", - "tonghaixian": "通海县", - "tonghexian": "通河县", - "tonghua": "通化", - "tonghuashi": "通化", - "tonghuaxian": "通化县", - "tongjiang": "同江市", - "tongjiangshi": "同江市", - "tongjiangxian": "通江县", - "tongliangqu": "铜梁区", - "tongliao": "通辽", - "tongliaojingjijishukaifaqu": "通辽经济技术开发区", - "tongliaoshi": "通辽", - "tongling": "铜陵", - "tonglingshi": "铜陵", - "tongluxian": "桐庐县", - "tongnanqu": "潼南区", - "tongshanqu": "铜山区", - "tongshanxian": "通山县", - "tongweixian": "通渭县", - "tongxiang": "桐乡市", - "tongxiangshi": "桐乡市", - "tongxinxian": "同心县", - "tongxuxian": "通许县", - "tongyuxian": "通榆县", - "tongzhouqu": "通州区", - "tongzixian": "桐梓县", - "toutunhequ": "头屯河区", - "tuanfengxian": "团风县", - "tulufan": "吐鲁番", - "tulufanshi": "吐鲁番", - "tumen": "图们市", - "tumenshi": "图们市", - "tumoteyouqi": "土默特右旗", - "tumotezuoqi": "土默特左旗", - "tumushuke": "图木舒克市", - "tumushukeshi": "图木舒克市", - "tunchangxian": "屯昌县", - "tunliuqu": "屯留区", - "tunmenqu": "屯门区", - "tunxiqu": "屯溪区", - "tuoketuoxian": "托克托县", - "tuokexunxian": "托克逊县", - "tuolixian": "托里县", - "tuquanxian": "突泉县", - "wafangdian": "瓦房店市", - "wafangdianshi": "瓦房店市", - "wananxian": "万安县", - "wanbo": "万柏林区", - "wanbolinqu": "万柏林区", - "wanchengqu": "宛城区", - "wangcangxian": "旺苍县", - "wangchengqu": "望城区", - "wangdetangqu": "望德堂区", - "wangdouxian": "望都县", - "wanghuaqu": "望花区", - "wangjiangxian": "望江县", - "wangkuixian": "望奎县", - "wangmoxian": "望谟县", - "wangqingxian": "汪清县", - "wangyiqu": "王益区", - "wannianxian": "万年县", - "wanning": "万宁市", - "wanningshi": "万宁市", - "wanquanqu": "万全区", - "wanrongxian": "万荣县", - "wanshanqu": "万山区", - "wanxiuqu": "万秀区", - "wanyuan": "万源市", - "wanyuanshi": "万源市", - "wanzaiqu": "湾仔区", - "wanzaixian": "万载县", - "wanzhiqu": "湾沚区", - "wanzhouqu": "万州区", - "weichangmanzumengguzuzizhixian": "围场满族蒙古族自治县", - "weidongqu": "卫东区", - "weidouqu": "魏都区", - "weifang": "潍坊", - "weifangbinhaijingjijishukaifaqu": "潍坊滨海经济技术开发区", - "weifangshi": "潍坊", - "weihai": "威海", - "weihaihuojugaojishuchanyekaifaqu": "威海火炬高技术产业开发区", - "weihaijingjijishukaifaqu": "威海经济技术开发区", - "weihailingangjingjijishukaifaqu": "威海临港经济技术开发区", - "weihaishi": "威海", - "weihui": "卫辉市", - "weihuishi": "卫辉市", - "weinan": "渭南", - "weinanshi": "渭南", - "weiningyizuhuizumiaozuzizhixian": "威宁彝族回族苗族自治县", - "weishanxian": "微山县", - "weishanyizuhuizuzizhixian": "巍山彝族回族自治县", - "weishixian": "尉氏县", - "weixilisuzuzizhixian": "维西傈僳族自治县", - "weixinxian": "威信县", - "weiyangqu": "未央区", - "wenanxian": "文安县", - "wenchang": "文昌市", - "wenchangshi": "文昌市", - "wenchengxian": "文成县", - "wenchuanxian": "汶川县", - "wendengqu": "文登区", - "wenfengqu": "文峰区", - "wenganxian": "瓮安县", - "wengniuteqi": "翁牛特旗", - "wengyuanxian": "翁源县", - "wenjiangqu": "温江区", - "wenling": "温岭市", - "wenlingshi": "温岭市", - "wenquanxian": "温泉县", - "wenshan": "文山市", - "wenshangxian": "汶上县", - "wenshanshi": "文山市", - "wenshanzhuangzumiaozu": "文山壮族苗族", - "wenshanzhuangzumiaozuzizhizhou": "文山壮族苗族", - "wenshengqu": "文圣区", - "wenshuixian": "文水县", - "wensuxian": "温宿县", - "wenxixian": "闻喜县", - "wenzhou": "温州", - "wenzhoujingjijishukaifaqu": "温州经济技术开发区", - "wenzhoushi": "温州", - "wolongqu": "卧龙区", - "woyangxian": "涡阳县", - "wuan": "武安市", - "wuanshi": "武安市", - "wubuxian": "吴堡县", - "wuchang": "五常市", - "wuchangqu": "武昌区", - "wuchangshi": "五常市", - "wuchengqu": "婺城区", - "wuchengxian": "武城县", - "wuchuan": "吴川市", - "wuchuangelaozumiaozuzizhixian": "务川仡佬族苗族自治县", - "wuchuanshi": "吴川市", - "wuchuanxian": "武川县", - "wucuiqu": "乌翠区", - "wudalianchi": "五大连池市", - "wudalianchishi": "五大连池市", - "wudangqu": "乌当区", - "wudaqu": "乌达区", - "wudingxian": "武定县", - "wudixian": "无棣县", - "wudouqu": "武都区", - "wuerhequ": "乌尔禾区", - "wufengtujiazuzizhixian": "五峰土家族自治县", - "wugongxian": "武功县", - "wuhai": "乌海", - "wuhaishi": "乌海", - "wuhan": "武汉", - "wuhanshi": "武汉", - "wuhexian": "五河县", - "wuhouqu": "武侯区", - "wuhu": "芜湖", - "wuhuaqu": "五华区", - "wuhuaxian": "五华县", - "wuhujingjijishukaifaqu": "芜湖经济技术开发区", - "wuhushi": "芜湖", - "wujiagangqu": "伍家岗区", - "wujiaqu": "五家渠市", - "wujiaqushi": "五家渠市", - "wujinqu": "武进区", - "wujixian": "无极县", - "wulagaiguanweihui": "乌拉盖管委会", - "wulanchabu": "乌兰察布", - "wulanchabushi": "乌兰察布", - "wulanhaote": "乌兰浩特市", - "wulanhaoteshi": "乌兰浩特市", - "wulanxian": "乌兰县", - "wulatehouqi": "乌拉特后旗", - "wulateqianqi": "乌拉特前旗", - "wulatezhongqi": "乌拉特中旗", - "wulianxian": "五莲县", - "wulingqu": "武陵区", - "wulingyuanqu": "武陵源区", - "wulongqu": "武隆区", - "wulumuqi": "乌鲁木齐", - "wulumuqishi": "乌鲁木齐", - "wulumuqixian": "乌鲁木齐县", - "wumingqu": "武鸣区", - "wuningxian": "武宁县", - "wupingxian": "武平县", - "wuqiangxian": "武强县", - "wuqiaoxian": "吴桥县", - "wuqiaxian": "乌恰县", - "wuqingqu": "武清区", - "wuqixian": "吴起县", - "wushengxian": "武胜县", - "wushenqi": "乌审旗", - "wushenxian": "乌什县", - "wusu": "乌苏市", - "wusushi": "乌苏市", - "wutaishanfengjingmingshengqu": "五台山风景名胜区", - "wutaixian": "五台县", - "wutongqiaoqu": "五通桥区", - "wuxi": "无锡", - "wuxiangxian": "武乡县", - "wuxingqu": "吴兴区", - "wuxishi": "无锡", - "wuxixian": "巫溪县", - "wuxuanxian": "武宣县", - "wuxue": "武穴市", - "wuxueshi": "武穴市", - "wuyangxian": "舞阳县", - "wuyishan": "武夷山市", - "wuyishanshi": "武夷山市", - "wuzhaixian": "五寨县", - "wuzhishan": "五指山市", - "wuzhishanshi": "五指山市", - "wuzhixian": "武陟县", - "wuzhong": "吴忠", - "wuzhongqu": "吴中区", - "wuzhongshi": "吴忠", - "wuzhou": "梧州", - "wuzhoushi": "梧州", - "xiachengqu": "下城区", - "xiahexian": "夏河县", - "xiahuayuanqu": "下花园区", - "xiajiangxian": "峡江县", - "xiajinxian": "夏津县", - "xialuqu": "下陆区", - "xiamen": "厦门", - "xiamenshi": "厦门", - "xian": "西安", - "xiananqu": "咸安区", - "xianfengxian": "咸丰县", - "xianganqu": "翔安区", - "xiangcheng": "项城市", - "xiangchengshi": "项城市", - "xiangdongqu": "湘东区", - "xiangdouqu": "襄都区", - "xiangfangqu": "香坊区", - "xiangfenxian": "襄汾县", - "xiangfuqu": "祥符区", - "xianggelila": "香格里拉市", - "xianggelilashi": "香格里拉市", - "xianghexian": "香河县", - "xianghuangqi": "镶黄旗", - "xiangningxian": "乡宁县", - "xiangqiaoqu": "湘桥区", - "xiangshanxian": "象山县", - "xiangshuixian": "响水县", - "xiangtan": "湘潭", - "xiangtanjiuhuashifanqu": "湘潭九华示范区", - "xiangtanshi": "湘潭", - "xiangtanxian": "湘潭县", - "xiangtanzhaoshanshifanqu": "湘潭昭山示范区", - "xiangxiang": "湘乡市", - "xiangxiangshi": "湘乡市", - "xiangxitujiazumiaozu": "湘西土家族苗族", - "xiangxitujiazumiaozuzizhizhou": "湘西土家族苗族", - "xiangyang": "襄阳", - "xiangyangqu": "向阳区", - "xiangyangshi": "襄阳", - "xiangyinxian": "湘阴县", - "xiangyuanxian": "襄垣县", - "xiangyunxian": "祥云县", - "xiangzhouxian": "象州县", - "xianjuxian": "仙居县", - "xianning": "咸宁", - "xianningshi": "咸宁", - "xianqu": "西安区", - "xianshi": "西安", - "xiantao": "仙桃市", - "xiantaoshi": "仙桃市", - "xianxian": "献县", - "xianyang": "咸阳", - "xianyangshi": "咸阳", - "xianyouxian": "仙游县", - "xiaochangxian": "孝昌县", - "xiaodianqu": "小店区", - "xiaogan": "孝感", - "xiaoganshi": "孝感", - "xiaojinxian": "小金县", - "xiaonanqu": "孝南区", - "xiaoshanqu": "萧山区", - "xiaotingqu": "猇亭区", - "xiaoxian": "萧县", - "xiaoyi": "孝义市", - "xiaoyishi": "孝义市", - "xiapuxian": "霞浦县", - "xiashanqu": "霞山区", - "xiaxian": "夏县", - "xiayixian": "夏邑县", - "xichang": "西昌市", - "xichangshi": "西昌市", - "xichengqu": "西城区", - "xichongxian": "西充县", - "xichouxian": "西畴县", - "xichuanxian": "淅川县", - "xidexian": "喜德县", - "xiejiajiqu": "谢家集区", - "xietongmenxian": "谢通门县", - "xifengqu": "西峰区", - "xigangqu": "西岗区", - "xiguqu": "西固区", - "xihequ": "细河区", - "xihexian": "西和县", - "xihuaxian": "西华县", - "xijixian": "西吉县", - "xilingqu": "西陵区", - "xilinguolei": "锡林郭勒", - "xilinguoleimeng": "锡林郭勒", - "xilinhaote": "锡林浩特市", - "xilinhaoteshi": "锡林浩特市", - "xilinxian": "西林县", - "ximengwazuzizhixian": "西盟佤族自治县", - "xin": "新林区", - "xinanxian": "新安县", - "xinbaerhuyouqi": "新巴尔虎右旗", - "xinbaerhuzuoqi": "新巴尔虎左旗", - "xinbeiqu": "新北区", - "xinbinmanzuzizhixian": "新宾满族自治县", - "xincaixian": "新蔡县", - "xinchangxian": "新昌县", - "xinchengqu": "新城区", - "xinchengxian": "忻城县", - "xingan": "兴安", - "xinganmeng": "兴安", - "xinganqu": "兴安区", - "xingbinqu": "兴宾区", - "xingcheng": "兴城市", - "xingchengshi": "兴城市", - "xingguoxian": "兴国县", - "xinghaixian": "兴海县", - "xinghexian": "兴和县", - "xinghua": "兴化市", - "xinghualingqu": "杏花岭区", - "xinghuashi": "兴化市", - "xingjingxian": "荥经县", - "xinglongtaiqu": "兴隆台区", - "xinglongxian": "兴隆县", - "xingning": "兴宁市", - "xingningqu": "兴宁区", - "xingningshi": "兴宁市", - "xingping": "兴平市", - "xingpingshi": "兴平市", - "xingqingqu": "兴庆区", - "xingren": "兴仁市", - "xingrenshi": "兴仁市", - "xingshanqu": "兴山区", - "xingshanxian": "兴山县", - "xingtai": "邢台", - "xingtaishi": "邢台", - "xingtangxian": "行唐县", - "xingwenxian": "兴文县", - "xingxian": "兴县", - "xingyang": "荥阳市", - "xingyangshi": "荥阳市", - "xingyexian": "兴业县", - "xingyi": "兴义市", - "xingyishi": "兴义市", - "xinhuangdongzuzizhixian": "新晃侗族自治县", - "xinhuaqu": "新华区", - "xinhuaxian": "新化县", - "xinhuiqu": "新会区", - "xining": "西宁", - "xiningshi": "西宁", - "xinji": "辛集市", - "xinjiangweiwuerzizhiquzizhiquzhixiaxianjixingzhengquhua": "新疆维吾尔自治区-自治区直辖县级行政区划", - "xinjiangxian": "新绛县", - "xinjianqu": "新建区", - "xinjinqu": "新津区", - "xinjishi": "辛集市", - "xinle": "新乐市", - "xinleshi": "新乐市", - "xinlinqu": "新林区", - "xinlongxian": "新龙县", - "xinluoqu": "新罗区", - "xinmi": "新密市", - "xinmin": "新民市", - "xinminshi": "新民市", - "xinmishi": "新密市", - "xinningxian": "新宁县", - "xinpingyizudaizuzizhixian": "新平彝族傣族自治县", - "xinqiuqu": "新邱区", - "xinrongqu": "新荣区", - "xinshaoxian": "新邵县", - "xinshiqu": "新市区", - "xintai": "新泰市", - "xintaishi": "新泰市", - "xintianxian": "新田县", - "xinwuqu": "新吴区", - "xinxian": "新县", - "xinxiang": "新乡", - "xinxianggaoxinjishuchanyekaifaqu": "新乡高新技术产业开发区", - "xinxiangjingjijishukaifaqu": "新乡经济技术开发区", - "xinxiangshi": "新乡", - "xinxiangshipingyuanchengxiangyitihuashifanqu": "新乡市平原城乡一体化示范区", - "xinxiangxian": "新乡县", - "xinxing": "新星市", - "xinxingqu": "新兴区", - "xinxingshi": "新星市", - "xinxingxian": "新兴县", - "xinyang": "信阳", - "xinyanggaoxinjishuchanyekaifaqu": "信阳高新技术产业开发区", - "xinyangshi": "信阳", - "xinyexian": "新野县", - "xinyu": "新余", - "xinyuanxian": "新源县", - "xinyushi": "新余", - "xinzheng": "新郑市", - "xinzhengshi": "新郑市", - "xinzhou": "忻州", - "xinzhoushi": "忻州", - "xiongxian": "雄县", - "xipingxian": "西平县", - "xiqingqu": "西青区", - "xiqu": "西区", - "xisaishanqu": "西塞山区", - "xishaqundao": "西沙群岛", - "xishiqu": "西市区", - "xishuangbannadaizu": "西双版纳傣族", - "xishuangbannadaizuzizhizhou": "西双版纳傣族", - "xiufengqu": "秀峰区", - "xiuningxian": "休宁县", - "xiushantujiazumiaozuzizhixian": "秀山土家族苗族自治县", - "xiushuixian": "修水县", - "xiuwenxian": "修文县", - "xiuwuxian": "修武县", - "xiuyanmanzuzizhixian": "岫岩满族自治县", - "xiuyingqu": "秀英区", - "xiuyuqu": "秀屿区", - "xiuzhouqu": "秀洲区", - "xiwuzhumuqinqi": "西乌珠穆沁旗", - "xixiangtangqu": "西乡塘区", - "xixiangxian": "西乡县", - "xixiaqu": "西夏区", - "xixiaxian": "西峡县", - "xixiuqu": "西秀区", - "xiyangxian": "昔阳县", - "xizangwenhualvyouchuangyiyuanqu": "西藏文化旅游创意园区", - "xuancheng": "宣城", - "xuanchengshi": "宣城", - "xuanchengshijingjikaifaqu": "宣城市经济开发区", - "xuanenxian": "宣恩县", - "xuanhanxian": "宣汉县", - "xuanhuaqu": "宣化区", - "xuanwei": "宣威市", - "xuanweishi": "宣威市", - "xuanwuqu": "玄武区", - "xuanzhouqu": "宣州区", - "xuchang": "许昌", - "xuchangjingjijishukaifaqu": "许昌经济技术开发区", - "xuchangshi": "许昌", - "xuechengqu": "薛城区", - "xuhuiqu": "徐汇区", - "xundianhuizuyizuzizhixian": "寻甸回族彝族自治县", - "xunhuasalazuzizhixian": "循化撒拉族自治县", - "xunkexian": "逊克县", - "xunwuxian": "寻乌县", - "xunyang": "旬阳市", - "xunyangqu": "浔阳区", - "xunyangshi": "旬阳市", - "xunyangxian": "旬阳县", - "xunyixian": "旬邑县", - "xupuxian": "溆浦县", - "xushuiqu": "徐水区", - "xuwenxian": "徐闻县", - "xuyixian": "盱眙县", - "xuyongxian": "叙永县", - "xuzhou": "徐州", - "xuzhoujingjijishukaifaqu": "徐州经济技术开发区", - "xuzhouqu": "叙州区", - "xuzhoushi": "徐州", - "yaan": "雅安", - "yaanshi": "雅安", - "yadongxian": "亚东县", - "yajiangxian": "雅江县", - "yakeshi": "牙克石市", - "yakeshishi": "牙克石市", - "yanan": "延安", - "yananshi": "延安", - "yanbianchaoxianzu": "延边朝鲜族", - "yanbianchaoxianzuzizhizhou": "延边朝鲜族", - "yanbianxian": "盐边县", - "yanchangxian": "延长县", - "yancheng": "盐城", - "yanchengjingjijishukaifaqu": "盐城经济技术开发区", - "yanchengqu": "郾城区", - "yanchengshi": "盐城", - "yanchixian": "盐池县", - "yanchuanxian": "延川县", - "yandouqu": "盐都区", - "yanfengqu": "雁峰区", - "yangbiyizuzizhixian": "漾濞彝族自治县", - "yangchengxian": "阳城县", - "yangchun": "阳春市", - "yangchunshi": "阳春市", - "yangdongqu": "阳东区", - "yanggaoxian": "阳高县", - "yangguxian": "阳谷县", - "yangjiang": "阳江", - "yangjiangshi": "阳江", - "yanglingqu": "杨陵区", - "yangmingqu": "阳明区", - "yangpuqu": "杨浦区", - "yangquan": "阳泉", - "yangquanshi": "阳泉", - "yangquxian": "阳曲县", - "yangshanxian": "阳山县", - "yangshuoxian": "阳朔县", - "yangxian": "洋县", - "yangxixian": "阳西县", - "yangyuanxian": "阳原县", - "yangzhong": "扬中市", - "yangzhongshi": "扬中市", - "yangzhou": "扬州", - "yangzhoujingjijishukaifaqu": "扬州经济技术开发区", - "yangzhoushi": "扬州", - "yanhetujiazuzizhixian": "沿河土家族自治县", - "yanhuqu": "盐湖区", - "yanji": "延吉市", - "yanjiangqu": "雁江区", - "yanjishi": "延吉市", - "yanliangqu": "阎良区", - "yanpingqu": "延平区", - "yanqihuizuzizhixian": "焉耆回族自治县", - "yanqingqu": "延庆区", - "yanshanqu": "雁山区", - "yanshi": "偃师市", - "yanshiqu": "偃师区", - "yanshishi": "偃师市", - "yanshouxian": "延寿县", - "yantai": "烟台", - "yantaigaoxinjishuchanyekaifaqu": "烟台高新技术产业开发区", - "yantaijingjijishukaifaqu": "烟台经济技术开发区", - "yantaishi": "烟台", - "yantanqu": "沿滩区", - "yantaqu": "雁塔区", - "yantianqu": "盐田区", - "yantingxian": "盐亭县", - "yanyuanxian": "盐源县", - "yanzhouqu": "兖州区", - "yaoanxian": "姚安县", - "yaodouqu": "尧都区", - "yaohaiqu": "瑶海区", - "yaozhouqu": "耀州区", - "yazhouqu": "崖州区", - "yechengxian": "叶城县", - "yejiqu": "叶集区", - "yexian": "叶县", - "yianqu": "义安区", - "yianxian": "依安县", - "yibin": "宜宾", - "yibinshi": "宜宾", - "yichang": "宜昌", - "yichangshi": "宜昌", - "yicheng": "宜城市", - "yichengshi": "宜城市", - "yichengxian": "翼城县", - "yidu": "宜都市", - "yidushi": "宜都市", - "yifengxian": "宜丰县", - "yihuangxian": "宜黄县", - "yijiangqu": "弋江区", - "yijinhuoluoqi": "伊金霍洛旗", - "yijunxian": "宜君县", - "yilanxian": "依兰县", - "yilihasake": "伊犁哈萨克", - "yilihasakezizhizhou": "伊犁哈萨克", - "yilingqu": "夷陵区", - "yilongxian": "仪陇县", - "yima": "义马市", - "yimashi": "义马市", - "yimeiqu": "伊美区", - "yimenxian": "易门县", - "yinanxian": "沂南县", - "yinchuan": "银川", - "yinchuanshi": "银川", - "yindouqu": "殷都区", - "yingcheng": "应城市", - "yingchengshi": "应城市", - "yingde": "英德市", - "yingdeshi": "英德市", - "yingdongqu": "颍东区", - "yingjiangqu": "迎江区", - "yingjiangxian": "盈江县", - "yingjishaxian": "英吉沙县", - "yingkou": "营口", - "yingkoushi": "营口", - "yingquanqu": "颍泉区", - "yingshangxian": "颍上县", - "yingshouyingzikuangqu": "鹰手营子矿区", - "yingtan": "鹰潭", - "yingtanshi": "鹰潭", - "yingxian": "应县", - "yingzequ": "迎泽区", - "yingzhouqu": "颍州区", - "yinhaiqu": "银海区", - "yining": "伊宁市", - "yiningshi": "伊宁市", - "yiningxian": "伊宁县", - "yinjiangtujiazumiaozuzizhixian": "印江土家族苗族自治县", - "yintaiqu": "印台区", - "yishuixian": "沂水县", - "yitongmanzuzizhixian": "伊通满族自治县", - "yiwu": "义乌市", - "yiwushi": "义乌市", - "yiwuxian": "伊吾县", - "yixing": "宜兴市", - "yixingshi": "宜兴市", - "yixiuqu": "宜秀区", - "yiyang": "益阳", - "yiyangshi": "益阳", - "yiyangshidatonghuguanliqu": "益阳市大通湖管理区", - "yiyuanxian": "沂源县", - "yizhangxian": "宜章县", - "yizheng": "仪征市", - "yizhengshi": "仪征市", - "yongan": "永安市", - "yonganshi": "永安市", - "yongchangxian": "永昌县", - "yongcheng": "永城市", - "yongchengshi": "永城市", - "yongchuanqu": "永川区", - "yongchunxian": "永春县", - "yongdengxian": "永登县", - "yongdexian": "永德县", - "yongdingqu": "永定区", - "yongfengxian": "永丰县", - "yongfuxian": "永福县", - "yonghexian": "永和县", - "yongji": "永济市", - "yongjiaxian": "永嘉县", - "yongjingxian": "永靖县", - "yongjishi": "永济市", - "yongjixian": "永吉县", - "yongkang": "永康市", - "yongkangshi": "永康市", - "yongnianqu": "永年区", - "yongningqu": "邕宁区", - "yongningxian": "永宁县", - "yongpingxian": "永平县", - "yongqiaoqu": "埇桥区", - "yongqingxian": "永清县", - "yongrenxian": "永仁县", - "yongshanxian": "永善县", - "yongshengxian": "永胜县", - "yongshouxian": "永寿县", - "yongshunxian": "永顺县", - "yongtaixian": "永泰县", - "yongxingxian": "永兴县", - "yongxinxian": "永新县", - "yongxiuxian": "永修县", - "yongzhou": "永州", - "yongzhoujingjijishukaifaqu": "永州经济技术开发区", - "yongzhoushi": "永州", - "yongzhoushihuilongweiguanliqu": "永州市回龙圩管理区", - "youhaoqu": "友好区", - "youjiangqu": "右江区", - "youjianwangqu": "油尖旺区", - "youxian": "攸县", - "youxianqu": "游仙区", - "youxixian": "尤溪县", - "youyangtujiazumiaozuzizhixian": "酉阳土家族苗族自治县", - "youyixian": "友谊县", - "youyuxian": "右玉县", - "yuananxian": "远安县", - "yuanbaoqu": "元宝区", - "yuanbaoshanqu": "元宝山区", - "yuanchengqu": "源城区", - "yuanhuiqu": "源汇区", - "yuanjiang": "沅江市", - "yuanjianghanizuyizudaizuzizhixian": "元江哈尼族彝族傣族自治县", - "yuanjiangshi": "沅江市", - "yuanlangqu": "元朗区", - "yuanlingxian": "沅陵县", - "yuanmouxian": "元谋县", - "yuanping": "原平市", - "yuanpingshi": "原平市", - "yuanqu": "裕安区", - "yuanquxian": "垣曲县", - "yuanshixian": "元氏县", - "yubeiqu": "渝北区", - "yucheng": "禹城市", - "yuchengqu": "雨城区", - "yuchengshi": "禹城市", - "yuchengxian": "虞城县", - "yuciqu": "榆次区", - "yudongzonghewuliuchanyejujiqu": "豫东综合物流产业聚集区", - "yudouxian": "于都县", - "yuechengqu": "越城区", - "yuechixian": "岳池县", - "yuehuqu": "月湖区", - "yueluqu": "岳麓区", - "yuepuhuxian": "岳普湖县", - "yueqing": "乐清市", - "yueqingshi": "乐清市", - "yuetangqu": "岳塘区", - "yuexiuqu": "越秀区", - "yueyang": "岳阳", - "yueyanglouqu": "岳阳楼区", - "yueyangshi": "岳阳", - "yueyangshiquyuanguanliqu": "岳阳市屈原管理区", - "yueyangxian": "岳阳县", - "yufengqu": "鱼峰区", - "yuganxian": "余干县", - "yuhangqu": "余杭区", - "yuhongqu": "于洪区", - "yuhuan": "玉环市", - "yuhuanshi": "玉环市", - "yuhuataiqu": "雨花台区", - "yuhuiqu": "禹会区", - "yuhuqu": "雨湖区", - "yujiangqu": "余江区", - "yulixian": "尉犁县", - "yulongnaxizuzizhixian": "玉龙纳西族自治县", - "yumen": "玉门市", - "yumenshi": "玉门市", - "yuminxian": "裕民县", - "yunanqu": "云安区", - "yunanxian": "郁南县", - "yuncheng": "运城", - "yunchengqu": "云城区", - "yunchengshi": "运城", - "yunchengxian": "郓城县", - "yunfu": "云浮", - "yunfushi": "云浮", - "yungangqu": "云冈区", - "yunhequ": "运河区", - "yunhexian": "云和县", - "yunlianxian": "筠连县", - "yunlongqu": "云龙区", - "yunlongshifanqu": "云龙示范区", - "yunlongxian": "云龙县", - "yunmengxian": "云梦县", - "yunxian": "云县", - "yunxiaoxian": "云霄县", - "yunxiqu": "云溪区", - "yunxixian": "郧西县", - "yunyangqu": "郧阳区", - "yunyangxian": "云阳县", - "yunyanqu": "云岩区", - "yunzhouqu": "云州区", - "yupingdongzuzizhixian": "玉屏侗族自治县", - "yuqingxian": "余庆县", - "yuquanqu": "玉泉区", - "yushanqu": "雨山区", - "yushanxian": "玉山县", - "yushexian": "榆社县", - "yushuiqu": "渝水区", - "yushuzangzu": "玉树藏族", - "yushuzangzuzizhizhou": "玉树藏族", - "yutaixian": "鱼台县", - "yuwangtaiqu": "禹王台区", - "yuxi": "玉溪", - "yuxishi": "玉溪", - "yuyangqu": "榆阳区", - "yuyao": "余姚市", - "yuyaoshi": "余姚市", - "yuzhongqu": "渝中区", - "yuzhongxian": "榆中县", - "yuzhou": "禹州市", - "yuzhouqu": "玉州区", - "yuzhoushi": "禹州市", - "zaduoxian": "杂多县", - "zanhuangxian": "赞皇县", - "zaoqiangxian": "枣强县", - "zaoyang": "枣阳市", - "zaoyangshi": "枣阳市", - "zaozhuang": "枣庄", - "zaozhuangshi": "枣庄", - "zekuxian": "泽库县", - "zengchengqu": "增城区", - "zepuxian": "泽普县", - "zezhouxian": "泽州县", - "zhadaxian": "札达县", - "zhalainuoerqu": "扎赉诺尔区", - "zhalaiteqi": "扎赉特旗", - "zhalantun": "扎兰屯市", - "zhalantunshi": "扎兰屯市", - "zhaluteqi": "扎鲁特旗", - "zhanangxian": "扎囊县", - "zhangbaichaoxianzuzizhixian": "长白朝鲜族自治县", - "zhangbeixian": "张北县", - "zhangdianqu": "张店区", - "zhangfengxian": "长丰县", - "zhangge": "长葛市", - "zhanggeshi": "长葛市", - "zhanggongqu": "章贡区", - "zhanghaixian": "长海县", - "zhangjiachuanhuizuzizhixian": "张家川回族自治县", - "zhangjiagang": "张家港市", - "zhangjiagangshi": "张家港市", - "zhangjiajie": "张家界", - "zhangjiajieshi": "张家界", - "zhangjiakou": "张家口", - "zhangjiakoujingjikaifaqu": "张家口经济开发区", - "zhangjiakoushi": "张家口", - "zhangjiakoushichabeiguanliqu": "张家口市察北管理区", - "zhangjiakoushisaibeiguanliqu": "张家口市塞北管理区", - "zhanglingxian": "长岭县", - "zhangningqu": "长宁区", - "zhangningxian": "长宁县", - "zhangping": "漳平市", - "zhangpingshi": "漳平市", - "zhangpuxian": "漳浦县", - "zhangqingqu": "长清区", - "zhangqiuqu": "章丘区", - "zhangshu": "樟树市", - "zhangshunxian": "长顺县", - "zhangshushi": "樟树市", - "zhangtaiqu": "长泰区", - "zhangtaixian": "长泰县", - "zhangwanqu": "张湾区", - "zhangxian": "漳县", - "zhangyangtujiazuzizhixian": "长阳土家族自治县", - "zhangye": "张掖", - "zhangyeshi": "张掖", - "zhangyuan": "长垣市", - "zhangyuanshi": "长垣市", - "zhangzhi": "长治", - "zhangzhishi": "长治", - "zhangzhou": "漳州", - "zhangzhouqu": "长洲区", - "zhangzhoushi": "漳州", - "zhangzixian": "长子县", - "zhanhequ": "湛河区", - "zhanhuaqu": "沾化区", - "zhanjiang": "湛江", - "zhanjiangshi": "湛江", - "zhanqianqu": "站前区", - "zhanyiqu": "沾益区", - "zhaoanxian": "诏安县", - "zhaodong": "肇东市", - "zhaodongshi": "肇东市", - "zhaohuaqu": "昭化区", - "zhaojuexian": "昭觉县", - "zhaolingqu": "召陵区", - "zhaopingxian": "昭平县", - "zhaoqing": "肇庆", - "zhaoqingshi": "肇庆", - "zhaosuxian": "昭苏县", - "zhaotong": "昭通", - "zhaotongshi": "昭通", - "zhaoxian": "赵县", - "zhaoyang": "朝阳", - "zhaoyangqu": "昭阳区", - "zhaoyangshi": "朝阳", - "zhaoyangxian": "朝阳县", - "zhaoyuan": "招远市", - "zhaoyuanshi": "招远市", - "zhaoyuanxian": "肇源县", - "zhaozhouxian": "肇州县", - "zhashuixian": "柞水县", - "zhechengxian": "柘城县", - "zhenanqu": "振安区", - "zhenanxian": "镇安县", - "zhenbaxian": "镇巴县", - "zhenfengxian": "贞丰县", - "zhenganxian": "正安县", - "zhengdingxian": "正定县", - "zhenghexian": "政和县", - "zhenglanqi": "正蓝旗", - "zhengningxian": "正宁县", - "zhengxiangbaiqi": "正镶白旗", - "zhengxiangqu": "蒸湘区", - "zhengyangxian": "正阳县", - "zhengzhou": "郑州", - "zhengzhougaoxinjishuchanyekaifaqu": "郑州高新技术产业开发区", - "zhengzhouhangkonggangjingjizongheshiyanqu": "郑州航空港经济综合实验区", - "zhengzhoujingjijishukaifaqu": "郑州经济技术开发区", - "zhengzhoushi": "郑州", - "zhenhaiqu": "镇海区", - "zhenjiang": "镇江", - "zhenjiangqu": "浈江区", - "zhenjiangshi": "镇江", - "zhenjiangxinqu": "镇江新区", - "zhenkangxian": "镇康县", - "zhenlaixian": "镇赉县", - "zhenningbuyizumiaozuzizhixian": "镇宁布依族苗族自治县", - "zhenxingqu": "振兴区", - "zhenxiongxian": "镇雄县", - "zhenyuanyizuhanizulahuzuzizhixian": "镇沅彝族哈尼族拉祜族自治县", - "zherongxian": "柘荣县", - "zhidanxian": "志丹县", - "zhiduoxian": "治多县", - "zhifuqu": "芝罘区", - "zhijiang": "枝江市", - "zhijiangdongzuzizhixian": "芷江侗族自治县", - "zhijiangshi": "枝江市", - "zhijinxian": "织金县", - "zhongbaxian": "仲巴县", - "zhongfangxian": "中方县", - "zhongjiangxian": "中江县", - "zhonglouqu": "钟楼区", - "zhongmuxian": "中牟县", - "zhongningxian": "中宁县", - "zhongshan": "中山", - "zhongshanshi": "中山", - "zhongshanxian": "钟山县", - "zhongshaqundaodedaojiaojiqihaiyu": "中沙群岛的岛礁及其海域", - "zhongwei": "中卫", - "zhongweishi": "中卫", - "zhongxian": "忠县", - "zhongxiang": "钟祥市", - "zhongxiangshi": "钟祥市", - "zhongxinsuchugaoxinjishuchanyekaifaqu": "中新苏滁高新技术产业开发区", - "zhongxiqu": "中西区", - "zhongyangxian": "中阳县", - "zhongyuanqu": "中原区", - "zhongzhanqu": "中站区", - "zhoucunqu": "周村区", - "zhoukou": "周口", - "zhoukoushi": "周口", - "zhouningxian": "周宁县", - "zhouquxian": "舟曲县", - "zhoushan": "舟山", - "zhoushanshi": "舟山", - "zhouzhixian": "周至县", - "zhuanghe": "庄河市", - "zhuangheshi": "庄河市", - "zhuanglangxian": "庄浪县", - "zhucheng": "诸城市", - "zhuchengshi": "诸城市", - "zhuhai": "珠海", - "zhuhaishi": "珠海", - "zhuhuiqu": "珠晖区", - "zhuji": "诸暨市", - "zhujishi": "诸暨市", - "zhumadian": "驻马店", - "zhumadianshi": "驻马店", - "zhungeerqi": "准格尔旗", - "zhuoluxian": "涿鹿县", - "zhuonixian": "卓尼县", - "zhuozhou": "涿州市", - "zhuozhoushi": "涿州市", - "zhuozixian": "卓资县", - "zhushanqu": "珠山区", - "zhushanxian": "竹山县", - "zhuxixian": "竹溪县", - "zhuzhou": "株洲", - "zhuzhoushi": "株洲", - "zibo": "淄博", - "ziboshi": "淄博", - "zichuanqu": "淄川区", - "zigong": "自贡", - "zigongshi": "自贡", - "ziguixian": "秭归县", - "zijinxian": "紫金县", - "ziliujingqu": "自流井区", - "zitongxian": "梓潼县", - "zixing": "资兴市", - "zixingshi": "资兴市", - "zixixian": "资溪县", - "ziyang": "资阳", - "ziyangqu": "资阳区", - "ziyangshi": "资阳", - "ziyangxian": "紫阳县", - "ziyuanxian": "资源县", - "ziyunmiaozubuyizuzizhixian": "紫云苗族布依族自治县", - "zizhang": "子长市", - "zizhangshi": "子长市", - "zizhongxian": "资中县", - "zizhouxian": "子洲县", - "zongyangxian": "枞阳县", - "zoucheng": "邹城市", - "zouchengshi": "邹城市", - "zouping": "邹平市", - "zoupingshi": "邹平市", - "zunhua": "遵化市", - "zunhuashi": "遵化市", - "zunyi": "遵义", - "zunyishi": "遵义", - "zuogongxian": "左贡县", - "zuoquanxian": "左权县", - "zuoyunxian": "左云县" - } -} diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts deleted file mode 100644 index 9e8de33..0000000 --- a/electron/services/dbPathService.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { join, basename } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' -import { homedir } from 'os' -import { createDecipheriv } from 'crypto' -import { expandHomePath } from '../utils/pathUtils' - -export interface WxidInfo { - wxid: string - modifiedTime: number - nickname?: string - avatarUrl?: string -} - -export class DbPathService { - private readVarint(buf: Buffer, offset: number): { value: number, length: number } { - let value = 0; - let length = 0; - let shift = 0; - while (offset < buf.length && shift < 32) { - const b = buf[offset++]; - value |= (b & 0x7f) << shift; - length++; - if ((b & 0x80) === 0) break; - shift += 7; - } - return { value, length }; - } - - private extractMmkvString(buf: Buffer, keyName: string): string { - const keyBuf = Buffer.from(keyName, 'utf8'); - const idx = buf.indexOf(keyBuf); - if (idx === -1) return ''; - - try { - let offset = idx + keyBuf.length; - const v1 = this.readVarint(buf, offset); - offset += v1.length; - const v2 = this.readVarint(buf, offset); - offset += v2.length; - - // 合理性检查 - if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) { - return buf.toString('utf8', offset, offset + v2.value); - } - } catch { } - return ''; - } - - private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null { - try { - const configPath = join(rootPath, 'all_users', 'config', 'global_config'); - if (!existsSync(configPath)) return null; - - const fullData = readFileSync(configPath); - if (fullData.length <= 4) return null; - const encryptedData = fullData.subarray(4); - - const key = Buffer.alloc(16, 0); - Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要 - const iv = Buffer.alloc(16, 0); - - const decipher = createDecipheriv('aes-128-cfb', key, iv); - decipher.setAutoPadding(false); - const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]); - - const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name'); - const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name'); - let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url'); - - if (!avatarUrl && decrypted.includes('http')) { - const httpIdx = decrypted.indexOf('http'); - const nullIdx = decrypted.indexOf(0x00, httpIdx); - if (nullIdx !== -1) { - avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx); - } - } - - if (wxid || nickname) { - return { wxid, nickname, avatarUrl }; - } - return null; - } catch (e) { - console.error('解析 global_config 失败:', e); - return null; - } - } - - - /** - * 自动检测微信数据库根目录 - */ - async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> { - try { - const possiblePaths: string[] = [] - const home = homedir() - - if (process.platform === 'darwin') { - // macOS 微信 4.0.5+ 新路径(优先检测) - const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat') - if (existsSync(appSupportBase)) { - try { - const entries = readdirSync(appSupportBase) - for (const entry of entries) { - // 匹配形如 2.0b4.0.9 的版本目录 - if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) { - possiblePaths.push(join(appSupportBase, entry)) - } - } - } catch { } - } - // macOS 旧路径兜底 - possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')) - } else { - // Windows 微信4.x 数据目录 - possiblePaths.push(join(home, 'Documents', 'xwechat_files')) - } - - for (const path of possiblePaths) { - if (!existsSync(path)) continue - - // 检查是否有有效的账号目录,或本身就是账号目录 - const accounts = this.findAccountDirs(path) - if (accounts.length > 0) { - return { success: true, path } - } - - // 如果该目录本身就是账号目录(直接包含 db_storage 等) - if (this.isAccountDir(path)) { - return { success: true, path } - } - } - - return { success: false, error: '未能自动检测到微信数据库目录' } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。 - * - * ## 修复 #996(错误码 -3001:未找到数据库目录) - * - * ### 旧实现的过滤逻辑及缺陷 - * 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断: - * "段数(按下划线切分)必须 ≥ 3,否则跳过" - * 也就是 `wxid_X_<suffix>` 才算合法、`wxid_X` 一律忽略。 - * - * 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录 - * 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。 - * - * ### 修复策略 - * 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或 - * FileStorage/Image[2])一视同仁地收集所有候选; - * 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和 - * `wxid_X_<suffix>` 同时存在时(这是自定义微信号后微信遗留旧空目录 - * 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里 - * 出现两个看起来一样但只有一个能用的混乱选项。 - */ - findAccountDirs(rootPath: string): string[] { - const resolvedRootPath = expandHomePath(rootPath) - const accounts: string[] = [] - - try { - const entries = readdirSync(resolvedRootPath) - - for (const entry of entries) { - const entryPath = join(resolvedRootPath, entry) - let stat: ReturnType<typeof statSync> - try { - stat = statSync(entryPath) - } catch { - continue - } - - if (stat.isDirectory()) { - if (!this.isPotentialAccountName(entry)) continue - - // 检查是否有有效账号目录结构 - if (this.isAccountDir(entryPath)) { - accounts.push(entry) - } - } - } - } catch { } - - return this.dedupeAccountDirs(resolvedRootPath, accounts) - } - - /** - * 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与 - * `wxid_X_<suffix>`),才二选一保留"微信实际在用"的那个目录。 - * - * - 仅有一个候选目录时,原样返回,不做任何处理; - * - 没有匹配到变体对的目录也都保留(互不相关的多账号场景); - * - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。 - */ - private dedupeAccountDirs(rootPath: string, names: string[]): string[] { - if (names.length <= 1) return names.slice() - - const lowered = names.map(n => n.toLowerCase()) - const toSkip = new Set<string>() - - // O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。 - for (let i = 0; i < names.length; i++) { - for (let j = 0; j < names.length; j++) { - if (i === j) continue - // 判定 names[j] 是 names[i] 的"带后缀变体":以 `<i>_` 开头 - if (lowered[j].startsWith(lowered[i] + '_')) { - const baseName = names[i] - const suffixedName = names[j] - if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) { - toSkip.add(baseName) // 留 suffixedName,去掉无后缀的旧目录 - } else { - toSkip.add(suffixedName) // 反之亦然 - } - } - } - } - - return names.filter(n => !toSkip.has(n)) - } - - /** - * 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。 - * - * 优先级(从高到低): - * 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号; - * 2) 都含或都不含 session.db 时,比较修改时间,更新的优先; - * 3) 兜底返回 true,即默认保留带后缀的目录(与微信 4.x 自定义微信号 - * 后真实目录命名一致)。 - */ - private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean { - const basePath = join(rootPath, baseName) - const suffixedPath = join(rootPath, suffixedName) - - const baseHasSession = this.hasSessionDb(basePath) - const suffixedHasSession = this.hasSessionDb(suffixedPath) - if (baseHasSession !== suffixedHasSession) { - return suffixedHasSession - } - - const baseTime = this.getAccountModifiedTime(basePath) - const suffixedTime = this.getAccountModifiedTime(suffixedPath) - if (baseTime !== suffixedTime) { - return suffixedTime >= baseTime - } - - return true - } - - /** - * 浅层检测账号目录下是否存在 session.db("数据是否真实写入"的判据)。 - * - * 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描: - * - db_storage/session/session.db (新版本嵌套布局) - * - db_storage/session.db (部分版本扁平布局) - */ - private hasSessionDb(accountDir: string): boolean { - const candidates = [ - join(accountDir, 'db_storage', 'session', 'session.db'), - join(accountDir, 'db_storage', 'session.db'), - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return true - } - return false - } - - 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 - } - } - - /** - * 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。 - * 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/ - * FileStorage,仅按命名特征判断,结果会暴露给"手动选择 wxid"的弹窗使用。 - * - * ## 修复 #996(错误码 -3001:未找到数据库目录) - * - * 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义 - * 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数 - * 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与 - * `wxid_X_<suffix>` 同时存在的去重场景。 - * - * 排除规则保留: - * - 微信本身的非账号目录(如 `all_users`); - * - 不含下划线的文件夹(不可能是 wxid)。 - */ - scanWxidCandidates(rootPath: string): WxidInfo[] { - const resolvedRootPath = expandHomePath(rootPath) - const wxids: WxidInfo[] = [] - - try { - if (existsSync(resolvedRootPath)) { - const entries = readdirSync(resolvedRootPath) - for (const entry of entries) { - const entryPath = join(resolvedRootPath, entry) - let stat: ReturnType<typeof statSync> - try { stat = statSync(entryPath) } catch { continue } - if (!stat.isDirectory()) continue - const lower = entry.toLowerCase() - if (lower === 'all_users') continue - if (!entry.includes('_')) continue - - wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs }) - } - } - - - if (wxids.length === 0) { - const rootName = basename(resolvedRootPath) - if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') { - const rootStat = statSync(resolvedRootPath) - wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs }) - } - } - } catch { } - - // 修复 #996:对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_<suffix>。 - const dedupedNames = new Set( - this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid)) - ) - const deduped = wxids.filter(w => dedupedNames.has(w.wxid)) - - const sorted = deduped.sort((a, b) => { - if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime - return a.wxid.localeCompare(b.wxid) - }); - - const globalInfo = this.parseGlobalConfig(resolvedRootPath); - if (globalInfo) { - for (const w of sorted) { - if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { - w.nickname = globalInfo.nickname; - w.avatarUrl = globalInfo.avatarUrl; - } - } - } - - return sorted; - } - - - /** - * 扫描 wxid 列表 - */ - scanWxids(rootPath: string): WxidInfo[] { - const resolvedRootPath = expandHomePath(rootPath) - const wxids: WxidInfo[] = [] - - try { - if (this.isAccountDir(resolvedRootPath)) { - const wxid = basename(resolvedRootPath) - const modifiedTime = this.getAccountModifiedTime(resolvedRootPath) - return [{ wxid, modifiedTime }] - } - - const accounts = this.findAccountDirs(resolvedRootPath) - - for (const account of accounts) { - const fullPath = join(resolvedRootPath, account) - const modifiedTime = this.getAccountModifiedTime(fullPath) - wxids.push({ wxid: account, modifiedTime }) - } - } catch { } - - const sorted = wxids.sort((a, b) => { - if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime - return a.wxid.localeCompare(b.wxid) - }); - - const globalInfo = this.parseGlobalConfig(resolvedRootPath); - if (globalInfo) { - for (const w of sorted) { - if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { - w.nickname = globalInfo.nickname; - w.avatarUrl = globalInfo.avatarUrl; - } - } - } - return sorted; - } - - /** - * 获取默认数据库路径 - */ - getDefaultPath(): string { - const home = homedir() - if (process.platform === 'darwin') { - // 优先返回 4.0.5+ 新路径 - const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat') - if (existsSync(appSupportBase)) { - try { - const entries = readdirSync(appSupportBase) - for (const entry of entries) { - if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) { - const candidate = join(appSupportBase, entry) - if (existsSync(candidate)) return candidate - } - } - } catch { } - } - // 旧版本路径兜底 - return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files') - } - return join(home, 'Documents', 'xwechat_files') - } -} - -export const dbPathService = new DbPathService() diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts deleted file mode 100644 index 1d8de7b..0000000 --- a/electron/services/dualReportService.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { parentPort } from 'worker_threads' -import { wcdbService } from './wcdbService' -import { resolveAccountDir } from './accountDirResolver' - - -export interface DualReportMessage { - content: string - isSentByMe: boolean - createTime: number - createTimeStr: string - localType?: number - emojiMd5?: string - emojiCdnUrl?: string -} - -export interface DualReportFirstChat { - createTime: number - createTimeStr: string - content: string - isSentByMe: boolean - senderUsername?: string - localType?: number - emojiMd5?: string - emojiCdnUrl?: string -} - -export interface DualReportStats { - totalMessages: number - totalWords: number - imageCount: number - voiceCount: number - emojiCount: number - myTopEmojiMd5?: string - friendTopEmojiMd5?: string - myTopEmojiUrl?: string - friendTopEmojiUrl?: string - myTopEmojiCount?: number - friendTopEmojiCount?: number -} - -export interface DualReportData { - year: number - selfName: string - selfAvatarUrl?: string - friendUsername: string - friendName: string - friendAvatarUrl?: string - firstChat: DualReportFirstChat | null - firstChatMessages?: DualReportMessage[] - yearFirstChat?: { - createTime: number - createTimeStr: string - content: string - isSentByMe: boolean - friendName: string - firstThreeMessages: DualReportMessage[] - localType?: number - emojiMd5?: string - emojiCdnUrl?: string - } | null - stats: DualReportStats - topPhrases: Array<{ phrase: string; count: number }> - myExclusivePhrases: Array<{ phrase: string; count: number }> - friendExclusivePhrases: Array<{ phrase: string; count: number }> - heatmap?: number[][] - initiative?: { initiated: number; received: number } - response?: { avg: number; fastest: number; count: number } - monthly?: Record<string, number> - streak?: { days: number; startDate: string; endDate: string } -} - -class DualReportService { - private broadcastProgress(status: string, progress: number) { - if (parentPort) { - parentPort.postMessage({ - type: 'dualReport: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})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - 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 accountDir = resolveAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: '无法找到账号目录' } - const ok = await wcdbService.open(accountDir, decryptKey) - if (!ok) return { success: false, error: 'WCDB 打开失败' } - const cleanedWxid = this.cleanAccountDirName(wxid) - return { success: true, cleanedWxid, rawWxid: wxid } - } - - 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 '' - // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 - // 短字符串(如 "123456" 等纯数字)容易被误判为 hex - if (raw.length > 16 && this.looksLikeHex(raw)) { - const bytes = Buffer.from(raw, 'hex') - if (bytes.length > 0) return this.decodeBinaryContent(bytes) - } - // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 - // 短字符串(如 "test", "home" 等)容易被误判为 base64 - if (raw.length > 16 && 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 formatDateTime(milliseconds: number): string { - const dt = new Date(milliseconds) - const month = String(dt.getMonth() + 1).padStart(2, '0') - const day = String(dt.getDate()).padStart(2, '0') - const hour = String(dt.getHours()).padStart(2, '0') - const minute = String(dt.getMinutes()).padStart(2, '0') - return `${month}/${day} ${hour}:${minute}` - } - - private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any { - if (!record) return undefined - for (const key of keys) { - if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) { - return record[key] - } - } - return undefined - } - - private coerceNumber(raw: any): number { - if (raw === undefined || raw === null || raw === '') 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) - const parsed = parseInt(String(raw), 10) - return Number.isFinite(parsed) ? parsed : NaN - } - - private coerceString(raw: any): string { - if (raw === undefined || raw === null) return '' - if (typeof raw === 'string') return raw - if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw) - if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw)) - return String(raw) - } - - private coerceBoolean(raw: any): boolean | undefined { - if (raw === undefined || raw === null || raw === '') return undefined - if (typeof raw === 'boolean') return raw - if (typeof raw === 'number') return raw !== 0 - - const normalized = String(raw).trim().toLowerCase() - if (!normalized) return undefined - - if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true - if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false - return undefined - } - - private normalizeEmojiMd5(raw: string): string | undefined { - if (!raw) return undefined - const trimmed = raw.trim() - if (!trimmed) return undefined - const match = /([a-fA-F0-9]{16,64})/.exec(trimmed) - return match ? match[1].toLowerCase() : undefined - } - - private normalizeEmojiUrl(raw: string): string | undefined { - if (!raw) return undefined - let url = raw.trim().replace(/&/g, '&') - if (!url) return undefined - try { - if (url.includes('%')) { - url = decodeURIComponent(url) - } - } catch { } - return url || undefined - } - - private extractEmojiUrl(content: string | undefined): string | undefined { - if (!content) return undefined - const direct = this.normalizeEmojiUrl(content) - if (direct && /^https?:\/\//i.test(direct)) return direct - - const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content) - || /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content) - if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1]) - - const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content) - || /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content) - return this.normalizeEmojiUrl(tagMatch?.[1] || '') - } - - private extractEmojiMd5(content: string | undefined): string | undefined { - if (!content) return undefined - const direct = this.normalizeEmojiMd5(content) - if (direct && direct.length >= 24) return direct - - const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content) - || /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content) - || /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content) - return this.normalizeEmojiMd5(match?.[1] || '') - } - - private resolveEmojiOwner(item: any, content: string): boolean | undefined { - const sentFlag = this.coerceBoolean(this.getRecordField(item, [ - 'isMe', - 'is_me', - 'isSent', - 'is_sent', - 'isSend', - 'is_send', - 'fromMe', - 'from_me' - ])) - if (sentFlag !== undefined) return sentFlag - - const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase() - if (sideRaw) { - if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true - if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false - } - - const prefixMatch = /^\s*([01])\s*:\s*/.exec(content) - if (prefixMatch) return prefixMatch[1] === '1' - return undefined - } - - private stripEmojiOwnerPrefix(content: string): string { - if (!content) return '' - return content.replace(/^\s*[01]\s*:\s*/, '') - } - - private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } { - const rawContent = this.coerceString(this.getRecordField(item, [ - 'content', - 'xml', - 'message_content', - 'messageContent', - 'msg', - 'payload', - 'raw' - ])) - const content = this.stripEmojiOwnerPrefix(rawContent) - - const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num']) - const parsedCount = this.coerceNumber(countRaw) - const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0 - - const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [ - 'md5', - 'emojiMd5', - 'emoji_md5', - 'emd5' - ]))) - const md5 = directMd5 || this.extractEmojiMd5(content) - - const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [ - 'cdnUrl', - 'cdnurl', - 'emojiUrl', - 'emoji_url', - 'url', - 'thumbUrl', - 'thumburl' - ]))) - const url = directUrl || this.extractEmojiUrl(content) - - return { - isMe: this.resolveEmojiOwner(item, rawContent), - md5, - url, - count - } - } - - private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number { - const raw = this.getRecordField(row, keys) - const parsed = this.coerceNumber(raw) - return Number.isFinite(parsed) ? parsed : fallback - } - - private decodeRowMessageContent(row: Record<string, any>): string { - const messageContent = this.getRecordField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]) - const compressContent = this.getRecordField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]) - return this.decodeMessageContent(messageContent, compressContent) - } - - private async scanEmojiTopFallback( - sessionId: string, - beginTimestamp: number, - endTimestamp: number, - rawWxid: string, - cleanedWxid: string - ): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> { - const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp) - if (!cursorResult.success || !cursorResult.cursor) return {} - - const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>() - try { - let hasMore = true - while (hasMore) { - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !Array.isArray(batch.rows)) break - - for (const row of batch.rows) { - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) - if (localType !== 47) continue - - const rawContent = this.decodeRowMessageContent(row) - const content = this.stripEmojiOwnerPrefix(rawContent) - const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) - const md5 = directMd5 || this.extractEmojiMd5(content) - if (!md5) continue - - const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [ - 'emoji_cdn_url', - 'emojiCdnUrl', - 'cdnurl', - 'cdn_url', - 'emoji_url', - 'emojiUrl', - 'url', - 'thumburl', - 'thumb_url' - ]))) - const url = directUrl || this.extractEmojiUrl(content) - const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid) - const mapKey = `${isMe ? '1' : '0'}:${md5}` - const existing = tallyMap.get(mapKey) - if (existing) { - existing.count += 1 - if (!existing.url && url) existing.url = url - } else { - tallyMap.set(mapKey, { isMe, md5, url, count: 1 }) - } - } - hasMore = batch.hasMore === true - } - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor) - } - - let myTop: { md5: string; url?: string; count: number } | undefined - let friendTop: { md5: string; url?: string; count: number } | undefined - for (const entry of tallyMap.values()) { - if (entry.isMe) { - if (!myTop || entry.count > myTop.count) { - myTop = { md5: entry.md5, url: entry.url, count: entry.count } - } - } else if (!friendTop || entry.count > friendTop.count) { - friendTop = { md5: entry.md5, url: entry.url, count: entry.count } - } - } - - return { my: myTop, friend: friendTop } - } - - private async getDisplayName(username: string, fallback: string): Promise<string> { - const result = await wcdbService.getDisplayNames([username]) - if (result.success && result.map) { - return result.map[username] || fallback - } - return fallback - } - - private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean { - const isSendRaw = row.computed_is_send ?? row.is_send - if (isSendRaw !== undefined && isSendRaw !== null) { - return parseInt(isSendRaw, 10) === 1 - } - const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase() - if (!sender) return false - const rawLower = rawWxid ? rawWxid.toLowerCase() : '' - const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : '' - return !!( - sender === rawLower || - sender === cleanedLower || - (rawLower && rawLower.startsWith(sender + '_')) || - (cleanedLower && cleanedLower.startsWith(sender + '_')) - ) - } - - private async getFirstMessages( - sessionId: string, - limit: number, - beginTimestamp: number, - endTimestamp: number - ): Promise<any[]> { - const safeBegin = Math.max(0, beginTimestamp || 0) - const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000) - const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd) - if (!cursorResult.success || !cursorResult.cursor) return [] - try { - const rows: any[] = [] - let hasMore = true - while (hasMore && rows.length < limit) { - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !batch.rows) break - for (const row of batch.rows) { - rows.push(row) - if (rows.length >= limit) break - } - hasMore = batch.hasMore === true - } - return rows.slice(0, limit) - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor) - } - } - - async generateReportWithConfig(params: { - year: number - friendUsername: string - dbPath: string - decryptKey: string - wxid: string - excludeWords?: string[] - onProgress?: (status: string, progress: number) => void - }): Promise<{ success: boolean; data?: DualReportData; error?: string }> { - try { - const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, 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 reportYear = year <= 0 ? 0 : year - const isAllTime = reportYear === 0 - const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000) - const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000) - - this.reportProgress('加载联系人信息...', 10, onProgress) - const friendName = await this.getDisplayName(friendUsername, friendUsername) - let myName = await this.getDisplayName(rawWxid, rawWxid) - if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) { - myName = await this.getDisplayName(cleanedWxid, rawWxid) - } - const avatarCandidates = Array.from(new Set([ - friendUsername, - rawWxid, - cleanedWxid - ].filter(Boolean) as string[])) - let selfAvatarUrl: string | undefined - let friendAvatarUrl: string | undefined - const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates) - if (avatarResult.success && avatarResult.map) { - selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid] - friendAvatarUrl = avatarResult.map[friendUsername] - } - - this.reportProgress('获取首条聊天记录...', 15, onProgress) - const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0) - let firstChat: DualReportFirstChat | null = null - if (firstRows.length > 0) { - const row = firstRows[0] - const createTime = parseInt(row.create_time || '0', 10) * 1000 - const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) - let emojiMd5: string | undefined - let emojiCdnUrl: string | undefined - if (localType === 47) { - const stripped = this.stripEmojiOwnerPrefix(rawContent) - emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) - emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) - } - - firstChat = { - createTime, - createTimeStr: this.formatDateTime(createTime), - content: String(rawContent || ''), - isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), - senderUsername: row.sender_username || row.sender, - localType, - emojiMd5, - emojiCdnUrl - } - } - const firstChatMessages: DualReportMessage[] = firstRows.map((row) => { - const msgTime = parseInt(row.create_time || '0', 10) * 1000 - const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) - let emojiMd5: string | undefined - let emojiCdnUrl: string | undefined - if (localType === 47) { - const stripped = this.stripEmojiOwnerPrefix(rawContent) - emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) - emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) - } - - return { - content: String(rawContent || ''), - isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), - createTime: msgTime, - createTimeStr: this.formatDateTime(msgTime), - localType, - emojiMd5, - emojiCdnUrl - } - }) - - let yearFirstChat: DualReportData['yearFirstChat'] = null - if (!isAllTime) { - this.reportProgress('获取今年首次聊天...', 20, onProgress) - const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime) - if (firstYearRows.length > 0) { - const firstRow = firstYearRows[0] - const createTime = parseInt(firstRow.create_time || '0', 10) * 1000 - const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => { - const msgTime = parseInt(row.create_time || '0', 10) * 1000 - const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) - let emojiMd5: string | undefined - let emojiCdnUrl: string | undefined - if (localType === 47) { - const stripped = this.stripEmojiOwnerPrefix(rawContent) - emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) - emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) - } - - return { - content: String(rawContent || ''), - isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), - createTime: msgTime, - createTimeStr: this.formatDateTime(msgTime), - localType, - emojiMd5, - emojiCdnUrl - } - }) - const firstRowYear = firstYearRows[0] - const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content) - const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) - let emojiMd5Year: string | undefined - let emojiCdnUrlYear: string | undefined - if (localTypeYear === 47) { - const stripped = this.stripEmojiOwnerPrefix(rawContentYear) - emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) - emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) - } - - yearFirstChat = { - createTime, - createTimeStr: this.formatDateTime(createTime), - content: String(rawContentYear || ''), - isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid), - friendName, - firstThreeMessages, - localType: localTypeYear, - emojiMd5: emojiMd5Year, - emojiCdnUrl: emojiCdnUrlYear - } - } - } - - this.reportProgress('统计聊天数据...', 30, onProgress) - - const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime) - if (!statsResult.success || !statsResult.data) { - return { success: false, error: statsResult.error || '获取双人报告统计失败' } - } - - const cppData = statsResult.data - const counts = cppData.counts || {} - - const stats: DualReportStats = { - totalMessages: counts.total || 0, - totalWords: counts.words || 0, - imageCount: counts.image || 0, - voiceCount: counts.voice || 0, - emojiCount: counts.emoji || 0 - } - - // Process Emojis to find top for me and friend - let myTopEmojiMd5: string | undefined - let myTopEmojiUrl: string | undefined - let myTopCount = -1 - - let friendTopEmojiMd5: string | undefined - let friendTopEmojiUrl: string | undefined - let friendTopCount = -1 - - if (cppData.emojis && Array.isArray(cppData.emojis)) { - for (const item of cppData.emojis) { - const candidate = this.parseEmojiCandidate(item) - if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue - - if (candidate.isMe) { - if (candidate.count > myTopCount) { - myTopCount = candidate.count - myTopEmojiMd5 = candidate.md5 - myTopEmojiUrl = candidate.url - } - } else if (candidate.count > friendTopCount) { - friendTopCount = candidate.count - friendTopEmojiMd5 = candidate.md5 - friendTopEmojiUrl = candidate.url - } - } - } - - const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5) - if (needsEmojiFallback) { - const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid) - - if (!myTopEmojiMd5 && fallback.my?.md5) { - myTopEmojiMd5 = fallback.my.md5 - myTopEmojiUrl = myTopEmojiUrl || fallback.my.url - myTopCount = fallback.my.count - } - if (!friendTopEmojiMd5 && fallback.friend?.md5) { - friendTopEmojiMd5 = fallback.friend.md5 - friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url - friendTopCount = fallback.friend.count - } - } - - const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([ - myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null), - friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null) - ]) - if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url - if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url - - stats.myTopEmojiMd5 = myTopEmojiMd5 - stats.myTopEmojiUrl = myTopEmojiUrl - stats.friendTopEmojiMd5 = friendTopEmojiMd5 - stats.friendTopEmojiUrl = friendTopEmojiUrl - if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount - if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount - - if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount - - const excludeSet = new Set(excludeWords || []) - - const filterPhrases = (list: any[]) => { - return (list || []).filter((p: any) => !excludeSet.has(p.phrase)) - } - - const cleanPhrases = filterPhrases(cppData.phrases) - const cleanMyPhrases = filterPhrases(cppData.myPhrases) - const cleanFriendPhrases = filterPhrases(cppData.friendPhrases) - - const topPhrases = cleanPhrases.map((p: any) => ({ - phrase: p.phrase, - count: p.count - })) - - // 计算专属词汇:一方频繁使用而另一方很少使用的词 - const myPhraseMap = new Map<string, number>() - const friendPhraseMap = new Map<string, number>() - for (const p of cleanMyPhrases) { - myPhraseMap.set(p.phrase, p.count) - } - for (const p of cleanFriendPhrases) { - friendPhraseMap.set(p.phrase, p.count) - } - - // 专属词汇:该方使用占比 >= 75% 且至少出现 2 次 - const myExclusivePhrases: Array<{ phrase: string; count: number }> = [] - const friendExclusivePhrases: Array<{ phrase: string; count: number }> = [] - - for (const [phrase, myCount] of myPhraseMap) { - const friendCount = friendPhraseMap.get(phrase) || 0 - const total = myCount + friendCount - if (myCount >= 2 && total > 0 && myCount / total >= 0.75) { - myExclusivePhrases.push({ phrase, count: myCount }) - } - } - for (const [phrase, friendCount] of friendPhraseMap) { - const myCount = myPhraseMap.get(phrase) || 0 - const total = myCount + friendCount - if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) { - friendExclusivePhrases.push({ phrase, count: friendCount }) - } - } - - // 按频率排序,取前 20 - myExclusivePhrases.sort((a, b) => b.count - a.count) - friendExclusivePhrases.sort((a, b) => b.count - a.count) - if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20 - if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20 - - const reportData: DualReportData = { - year: reportYear, - selfName: myName, - selfAvatarUrl, - friendUsername, - friendName, - friendAvatarUrl, - firstChat, - firstChatMessages, - yearFirstChat, - stats, - topPhrases, - myExclusivePhrases, - friendExclusivePhrases, - heatmap: cppData.heatmap, - initiative: cppData.initiative, - response: cppData.response, - monthly: cppData.monthly, - streak: cppData.streak - } as any - - this.reportProgress('双人报告生成完成', 100, onProgress) - return { success: true, data: reportData } - } catch (e) { - return { success: false, error: String(e) } - } - } -} - -export const dualReportService = new DualReportService() diff --git a/electron/services/exportCardDiagnosticsService.ts b/electron/services/exportCardDiagnosticsService.ts deleted file mode 100644 index 37768a0..0000000 --- a/electron/services/exportCardDiagnosticsService.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { mkdir, writeFile } from 'fs/promises' -import { basename, dirname, extname, join } from 'path' - -export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' -export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error' -export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout' - -export interface ExportCardDiagLogEntry { - id: string - ts: number - source: ExportCardDiagSource - level: ExportCardDiagLevel - message: string - traceId?: string - stepId?: string - stepName?: string - status?: ExportCardDiagStatus - durationMs?: number - data?: Record<string, unknown> -} - -interface ActiveStepState { - key: string - traceId: string - stepId: string - stepName: string - source: ExportCardDiagSource - startedAt: number - lastUpdatedAt: number - message?: string -} - -interface StepStartInput { - traceId: string - stepId: string - stepName: string - source: ExportCardDiagSource - level?: ExportCardDiagLevel - message?: string - data?: Record<string, unknown> -} - -interface StepEndInput { - traceId: string - stepId: string - stepName: string - source: ExportCardDiagSource - status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'> - level?: ExportCardDiagLevel - message?: string - data?: Record<string, unknown> - durationMs?: number -} - -interface LogInput { - ts?: number - source: ExportCardDiagSource - level?: ExportCardDiagLevel - message: string - traceId?: string - stepId?: string - stepName?: string - status?: ExportCardDiagStatus - durationMs?: number - data?: Record<string, unknown> -} - -export interface ExportCardDiagSnapshot { - logs: ExportCardDiagLogEntry[] - activeSteps: Array<{ - traceId: string - stepId: string - stepName: string - source: ExportCardDiagSource - elapsedMs: number - stallMs: number - startedAt: number - lastUpdatedAt: number - message?: string - }> - summary: { - totalLogs: number - activeStepCount: number - errorCount: number - warnCount: number - timeoutCount: number - lastUpdatedAt: number - } -} - -export class ExportCardDiagnosticsService { - private readonly maxLogs = 6000 - private logs: ExportCardDiagLogEntry[] = [] - private activeSteps = new Map<string, ActiveStepState>() - private seq = 0 - - private nextId(ts: number): string { - this.seq += 1 - return `export-card-diag-${ts}-${this.seq}` - } - - private trimLogs() { - if (this.logs.length <= this.maxLogs) return - const drop = this.logs.length - this.maxLogs - this.logs.splice(0, drop) - } - - log(input: LogInput): ExportCardDiagLogEntry { - const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now() - const entry: ExportCardDiagLogEntry = { - id: this.nextId(ts), - ts, - source: input.source, - level: input.level || 'info', - message: input.message, - traceId: input.traceId, - stepId: input.stepId, - stepName: input.stepName, - status: input.status, - durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined, - data: input.data - } - - this.logs.push(entry) - this.trimLogs() - - if (entry.traceId && entry.stepId && entry.stepName) { - const key = `${entry.traceId}::${entry.stepId}` - if (entry.status === 'running') { - const previous = this.activeSteps.get(key) - this.activeSteps.set(key, { - key, - traceId: entry.traceId, - stepId: entry.stepId, - stepName: entry.stepName, - source: entry.source, - startedAt: previous?.startedAt || entry.ts, - lastUpdatedAt: entry.ts, - message: entry.message - }) - } else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') { - this.activeSteps.delete(key) - } - } - - return entry - } - - stepStart(input: StepStartInput): ExportCardDiagLogEntry { - return this.log({ - source: input.source, - level: input.level || 'info', - message: input.message || `${input.stepName} 开始`, - traceId: input.traceId, - stepId: input.stepId, - stepName: input.stepName, - status: 'running', - data: input.data - }) - } - - stepEnd(input: StepEndInput): ExportCardDiagLogEntry { - return this.log({ - source: input.source, - level: input.level || (input.status === 'done' ? 'info' : 'warn'), - message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`, - traceId: input.traceId, - stepId: input.stepId, - stepName: input.stepName, - status: input.status || 'done', - durationMs: input.durationMs, - data: input.data - }) - } - - clear() { - this.logs = [] - this.activeSteps.clear() - } - - snapshot(limit = 1200): ExportCardDiagSnapshot { - const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200 - const logs = this.logs.slice(-capped) - const now = Date.now() - - const activeSteps = Array.from(this.activeSteps.values()) - .map(step => ({ - traceId: step.traceId, - stepId: step.stepId, - stepName: step.stepName, - source: step.source, - startedAt: step.startedAt, - lastUpdatedAt: step.lastUpdatedAt, - elapsedMs: Math.max(0, now - step.startedAt), - stallMs: Math.max(0, now - step.lastUpdatedAt), - message: step.message - })) - .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt) - - let errorCount = 0 - let warnCount = 0 - let timeoutCount = 0 - for (const item of logs) { - if (item.level === 'error') errorCount += 1 - if (item.level === 'warn') warnCount += 1 - if (item.status === 'timeout') timeoutCount += 1 - } - - return { - logs, - activeSteps, - summary: { - totalLogs: this.logs.length, - activeStepCount: activeSteps.length, - errorCount, - warnCount, - timeoutCount, - lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0 - } - } - } - - private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] { - const result: ExportCardDiagLogEntry[] = [] - for (const item of value) { - if (!item || typeof item !== 'object') continue - const row = item as Record<string, unknown> - const tsRaw = row.ts ?? row.timestamp - const tsNum = Number(tsRaw) - const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now() - - const sourceRaw = String(row.source || 'frontend') - const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker' - ? sourceRaw - : 'frontend' - const levelRaw = String(row.level || 'info') - const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error' - ? levelRaw - : 'info' - - const statusRaw = String(row.status || '') - const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout' - ? statusRaw - : undefined - - const durationRaw = Number(row.durationMs) - result.push({ - id: String(row.id || this.nextId(ts)), - ts, - source, - level, - message: String(row.message || ''), - traceId: typeof row.traceId === 'string' ? row.traceId : undefined, - stepId: typeof row.stepId === 'string' ? row.stepId : undefined, - stepName: typeof row.stepName === 'string' ? row.stepName : undefined, - status, - durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined, - data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined - }) - } - return result - } - - private serializeLogEntry(log: ExportCardDiagLogEntry): string { - return JSON.stringify(log) - } - - private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string { - const total = logs.length - let errorCount = 0 - let warnCount = 0 - let timeoutCount = 0 - let frontendCount = 0 - let backendCount = 0 - let mainCount = 0 - let workerCount = 0 - - for (const item of logs) { - if (item.level === 'error') errorCount += 1 - if (item.level === 'warn') warnCount += 1 - if (item.status === 'timeout') timeoutCount += 1 - if (item.source === 'frontend') frontendCount += 1 - if (item.source === 'backend') backendCount += 1 - if (item.source === 'main') mainCount += 1 - if (item.source === 'worker') workerCount += 1 - } - - const lines: string[] = [] - lines.push('WeFlow 导出卡片诊断摘要') - lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`) - lines.push(`日志总数: ${total}`) - lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`) - lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`) - lines.push(`当前活跃步骤: ${activeSteps.length}`) - - if (activeSteps.length > 0) { - lines.push('') - lines.push('活跃步骤:') - for (const step of activeSteps.slice(0, 12)) { - lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`) - } - } - - const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12) - if (latestErrors.length > 0) { - lines.push('') - lines.push('最近异常:') - for (const item of latestErrors) { - lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`) - } - } - - return lines.join('\n') - } - - async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{ - success: boolean - filePath?: string - summaryPath?: string - count?: number - error?: string - }> { - try { - const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : []) - const merged = [...this.logs, ...normalizedFrontend] - .sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id)) - - const lines = merged.map(item => this.serializeLogEntry(item)).join('\n') - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8') - - const ext = extname(filePath) - const baseName = ext ? basename(filePath, ext) : basename(filePath) - const summaryPath = join(dirname(filePath), `${baseName}.txt`) - const snapshot = this.snapshot(1500) - const summaryText = this.buildSummaryText(merged, snapshot.activeSteps) - await writeFile(summaryPath, summaryText, 'utf8') - - return { - success: true, - filePath, - summaryPath, - count: merged.length - } - } catch (error) { - return { - success: false, - error: String(error) - } - } - } -} - -export const exportCardDiagnosticsService = new ExportCardDiagnosticsService() diff --git a/electron/services/exportContentStatsCacheService.ts b/electron/services/exportContentStatsCacheService.ts deleted file mode 100644 index ee8fd5f..0000000 --- a/electron/services/exportContentStatsCacheService.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { join, dirname } from 'path' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { ConfigService } from './config' - -const CACHE_VERSION = 1 -const MAX_SCOPE_ENTRIES = 12 -const MAX_SESSION_ENTRIES_PER_SCOPE = 6000 - -export interface ExportContentSessionStatsEntry { - updatedAt: number - hasAny: boolean - hasVoice: boolean - hasImage: boolean - hasVideo: boolean - hasEmoji: boolean - mediaReady: boolean -} - -export interface ExportContentScopeStatsEntry { - updatedAt: number - sessions: Record<string, ExportContentSessionStatsEntry> -} - -interface ExportContentStatsStore { - version: number - scopes: Record<string, ExportContentScopeStatsEntry> -} - -function toNonNegativeInt(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) return undefined - return Math.max(0, Math.floor(value)) -} - -function toBoolean(value: unknown, fallback = false): boolean { - if (typeof value === 'boolean') return value - return fallback -} - -function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null { - if (!raw || typeof raw !== 'object') return null - const source = raw as Record<string, unknown> - const updatedAt = toNonNegativeInt(source.updatedAt) - if (updatedAt === undefined) return null - return { - updatedAt, - hasAny: toBoolean(source.hasAny, false), - hasVoice: toBoolean(source.hasVoice, false), - hasImage: toBoolean(source.hasImage, false), - hasVideo: toBoolean(source.hasVideo, false), - hasEmoji: toBoolean(source.hasEmoji, false), - mediaReady: toBoolean(source.mediaReady, false) - } -} - -function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null { - if (!raw || typeof raw !== 'object') return null - const source = raw as Record<string, unknown> - const updatedAt = toNonNegativeInt(source.updatedAt) - if (updatedAt === undefined) return null - - const sessionsRaw = source.sessions - if (!sessionsRaw || typeof sessionsRaw !== 'object') { - return { - updatedAt, - sessions: {} - } - } - - const sessions: Record<string, ExportContentSessionStatsEntry> = {} - for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) { - const normalized = normalizeSessionStatsEntry(entryRaw) - if (!normalized) continue - sessions[sessionId] = normalized - } - - return { - updatedAt, - sessions - } -} - -function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry { - return { - updatedAt: scope.updatedAt, - sessions: Object.fromEntries( - Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }]) - ) - } -} - -export class ExportContentStatsCacheService { - private readonly cacheFilePath: string - private store: ExportContentStatsStore = { - version: CACHE_VERSION, - scopes: {} - } - - constructor(cacheBasePath?: string) { - const basePath = cacheBasePath && cacheBasePath.trim().length > 0 - ? cacheBasePath - : ConfigService.getInstance().getCacheBasePath() - this.cacheFilePath = join(basePath, 'export-content-stats.json') - this.ensureCacheDir() - this.load() - } - - private ensureCacheDir(): void { - const dir = dirname(this.cacheFilePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - } - - private load(): void { - if (!existsSync(this.cacheFilePath)) return - try { - const raw = readFileSync(this.cacheFilePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const payload = parsed as Record<string, unknown> - const scopesRaw = payload.scopes - if (!scopesRaw || typeof scopesRaw !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const scopes: Record<string, ExportContentScopeStatsEntry> = {} - for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) { - const normalizedScope = normalizeScopeStatsEntry(scopeRaw) - if (!normalizedScope) continue - scopes[scopeKey] = normalizedScope - } - - this.store = { - version: CACHE_VERSION, - scopes - } - } catch (error) { - console.error('ExportContentStatsCacheService: 载入缓存失败', error) - this.store = { version: CACHE_VERSION, scopes: {} } - } - } - - getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined { - if (!scopeKey) return undefined - const rawScope = this.store.scopes[scopeKey] - if (!rawScope) return undefined - const normalizedScope = normalizeScopeStatsEntry(rawScope) - if (!normalizedScope) { - delete this.store.scopes[scopeKey] - this.persist() - return undefined - } - this.store.scopes[scopeKey] = normalizedScope - return cloneScope(normalizedScope) - } - - setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void { - if (!scopeKey) return - const normalized = normalizeScopeStatsEntry(scope) - if (!normalized) return - this.store.scopes[scopeKey] = normalized - this.trimScope(scopeKey) - this.trimScopes() - this.persist() - } - - deleteSession(scopeKey: string, sessionId: string): void { - if (!scopeKey || !sessionId) return - const scope = this.store.scopes[scopeKey] - if (!scope) return - if (!(sessionId in scope.sessions)) return - delete scope.sessions[sessionId] - if (Object.keys(scope.sessions).length === 0) { - delete this.store.scopes[scopeKey] - } else { - scope.updatedAt = Date.now() - } - this.persist() - } - - clearScope(scopeKey: string): void { - if (!scopeKey) return - if (!this.store.scopes[scopeKey]) return - delete this.store.scopes[scopeKey] - this.persist() - } - - clearAll(): void { - this.store = { version: CACHE_VERSION, scopes: {} } - try { - rmSync(this.cacheFilePath, { force: true }) - } catch (error) { - console.error('ExportContentStatsCacheService: 清理缓存失败', error) - } - } - - private trimScope(scopeKey: string): void { - const scope = this.store.scopes[scopeKey] - if (!scope) return - - const entries = Object.entries(scope.sessions) - if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return - - entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) - scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) - } - - private trimScopes(): void { - const scopeEntries = Object.entries(this.store.scopes) - if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return - - scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) - this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) - } - - private persist(): void { - try { - this.ensureCacheDir() - writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') - } catch (error) { - console.error('ExportContentStatsCacheService: 持久化缓存失败', error) - } - } -} diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css deleted file mode 100644 index c6751fc..0000000 --- a/electron/services/exportHtml.css +++ /dev/null @@ -1,331 +0,0 @@ -:root { - color-scheme: light; - --bg: #f6f7fb; - --card: #ffffff; - --text: #1f2a37; - --muted: #6b7280; - --accent: #4f46e5; - --sent: #dbeafe; - --received: #ffffff; - --border: #e5e7eb; - --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); - --radius: 16px; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; - background: var(--bg); - color: var(--text); -} - -.page { - max-width: 1080px; - margin: 0 auto; - padding: 8px 20px; - height: 100vh; - display: flex; - flex-direction: column; -} - -.header { - background: var(--card); - border-radius: 12px; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); - padding: 12px 20px; - flex-shrink: 0; -} - -.title { - font-size: 16px; - font-weight: 600; - margin: 0; - display: inline; -} - -.meta { - color: var(--muted); - font-size: 13px; - display: inline; - margin-left: 12px; -} - -.meta span { - margin-right: 10px; -} - -.controls { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - flex-wrap: wrap; -} - -.controls input, -.controls button { - border-radius: 8px; - border: 1px solid var(--border); - padding: 6px 10px; - font-size: 13px; - font-family: inherit; -} - -.controls input[type="search"] { - width: 200px; -} - -.controls input[type="datetime-local"] { - width: 200px; -} - -.controls button { - background: var(--accent); - color: #fff; - border: none; - cursor: pointer; - padding: 6px 14px; -} - -.controls button:active { - transform: scale(0.98); -} - -.stats { - font-size: 13px; - color: var(--muted); - margin-left: auto; -} - -.message-list { - display: flex; - flex-direction: column; - gap: 12px; - padding: 4px 0; -} - -.message { - display: flex; - flex-direction: column; - gap: 8px; -} - -.message.hidden { - display: none; -} - -.message-time { - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; -} - -.message-row { - display: flex; - gap: 12px; - align-items: flex-end; -} - -.message.sent .message-row { - flex-direction: row-reverse; -} - -.avatar { - width: 40px; - height: 40px; - border-radius: 12px; - background: #eef2ff; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; - color: #475569; - font-weight: 600; -} - -.avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.bubble { - max-width: min(70%, 720px); - background: var(--received); - border-radius: 18px; - padding: 12px 14px; - border: 1px solid var(--border); - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); -} - -.message.sent .bubble { - background: var(--sent); - border-color: transparent; -} - -.sender-name { - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; -} - -.message-content { - display: flex; - flex-direction: column; - gap: 8px; - font-size: 14px; - line-height: 1.6; -} - -.message-text { - word-break: break-word; -} - -.quoted-message { - border-left: 3px solid rgba(79, 70, 229, 0.35); - background: rgba(79, 70, 229, 0.06); - border-radius: 12px; - padding: 8px 10px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.message.sent .quoted-message { - background: rgba(37, 99, 235, 0.08); - border-left-color: rgba(37, 99, 235, 0.35); -} - -.quoted-sender { - font-size: 12px; - color: #374151; - font-weight: 600; -} - -.quoted-text { - font-size: 13px; - color: #4b5563; - word-break: break-word; -} - -.message-link-card { - color: #2563eb; - text-decoration: underline; - text-underline-offset: 2px; - word-break: break-all; -} - -.message-link-card:hover { - color: #1d4ed8; -} - -.inline-emoji { - width: 22px; - height: 22px; - vertical-align: text-bottom; - margin: 0 2px; -} - -.message-media { - border-radius: 14px; - max-width: 100%; -} - -.previewable { - cursor: zoom-in; -} - -.message-media.image, -.message-media.emoji { - max-height: 260px; - object-fit: contain; - background: #f1f5f9; - padding: 6px; -} - -.message-media.emoji { - max-height: 160px; - width: auto; -} - -.message-media.video { - max-height: 360px; - background: #111827; -} - -.message-media.audio { - width: 260px; -} - -.image-preview { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.7); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - z-index: 999; -} - -.image-preview.active { - opacity: 1; - pointer-events: auto; -} - -.image-preview img { - max-width: min(90vw, 1200px); - max-height: 90vh; - border-radius: 18px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); - background: #0f172a; - transition: transform 0.1s ease; - cursor: zoom-out; -} - -.highlight { - outline: 2px solid var(--accent); - outline-offset: 4px; - border-radius: 18px; - transition: outline-color 0.3s; -} - -.empty { - text-align: center; - color: var(--muted); - padding: 40px; -} - -/* Scroll Container */ -.scroll-container { - flex: 1; - min-height: 0; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); - margin-top: 8px; - margin-bottom: 8px; - padding: 12px; - -webkit-overflow-scrolling: touch; -} - -.scroll-container::-webkit-scrollbar { - width: 6px; -} - -.scroll-container::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; -} - -.load-sentinel { - height: 1px; -} diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts deleted file mode 100644 index 96f4288..0000000 --- a/electron/services/exportHtmlStyles.ts +++ /dev/null @@ -1,333 +0,0 @@ -export const EXPORT_HTML_STYLES = `:root { - color-scheme: light; - --bg: #f6f7fb; - --card: #ffffff; - --text: #1f2a37; - --muted: #6b7280; - --accent: #4f46e5; - --sent: #dbeafe; - --received: #ffffff; - --border: #e5e7eb; - --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); - --radius: 16px; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; - background: var(--bg); - color: var(--text); -} - -.page { - max-width: 1080px; - margin: 0 auto; - padding: 8px 20px; - height: 100vh; - display: flex; - flex-direction: column; -} - -.header { - background: var(--card); - border-radius: 12px; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); - padding: 12px 20px; - flex-shrink: 0; -} - -.title { - font-size: 16px; - font-weight: 600; - margin: 0; - display: inline; -} - -.meta { - color: var(--muted); - font-size: 13px; - display: inline; - margin-left: 12px; -} - -.meta span { - margin-right: 10px; -} - -.controls { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - flex-wrap: wrap; -} - -.controls input, -.controls button { - border-radius: 8px; - border: 1px solid var(--border); - padding: 6px 10px; - font-size: 13px; - font-family: inherit; -} - -.controls input[type="search"] { - width: 200px; -} - -.controls input[type="datetime-local"] { - width: 200px; -} - -.controls button { - background: var(--accent); - color: #fff; - border: none; - cursor: pointer; - padding: 6px 14px; -} - -.controls button:active { - transform: scale(0.98); -} - -.stats { - font-size: 13px; - color: var(--muted); - margin-left: auto; -} - -.message-list { - display: flex; - flex-direction: column; - gap: 12px; - padding: 4px 0; -} - -.message { - display: flex; - flex-direction: column; - gap: 8px; -} - -.message.hidden { - display: none; -} - -.message-time { - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; -} - -.message-row { - display: flex; - gap: 12px; - align-items: flex-end; -} - -.message.sent .message-row { - flex-direction: row-reverse; -} - -.avatar { - width: 40px; - height: 40px; - border-radius: 12px; - background: #eef2ff; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; - color: #475569; - font-weight: 600; -} - -.avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.bubble { - max-width: min(70%, 720px); - background: var(--received); - border-radius: 18px; - padding: 12px 14px; - border: 1px solid var(--border); - box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); -} - -.message.sent .bubble { - background: var(--sent); - border-color: transparent; -} - -.sender-name { - font-size: 12px; - color: var(--muted); - margin-bottom: 6px; -} - -.message-content { - display: flex; - flex-direction: column; - gap: 8px; - font-size: 14px; - line-height: 1.6; -} - -.message-text { - word-break: break-word; -} - -.quoted-message { - border-left: 3px solid rgba(79, 70, 229, 0.35); - background: rgba(79, 70, 229, 0.06); - border-radius: 12px; - padding: 8px 10px; - display: flex; - flex-direction: column; - gap: 4px; -} - -.message.sent .quoted-message { - background: rgba(37, 99, 235, 0.08); - border-left-color: rgba(37, 99, 235, 0.35); -} - -.quoted-sender { - font-size: 12px; - color: #374151; - font-weight: 600; -} - -.quoted-text { - font-size: 13px; - color: #4b5563; - word-break: break-word; -} - -.message-link-card { - color: #2563eb; - text-decoration: underline; - text-underline-offset: 2px; - word-break: break-all; -} - -.message-link-card:hover { - color: #1d4ed8; -} - -.inline-emoji { - width: 22px; - height: 22px; - vertical-align: text-bottom; - margin: 0 2px; -} - -.message-media { - border-radius: 14px; - max-width: 100%; -} - -.previewable { - cursor: zoom-in; -} - -.message-media.image, -.message-media.emoji { - max-height: 260px; - object-fit: contain; - background: #f1f5f9; - padding: 6px; -} - -.message-media.emoji { - max-height: 160px; - width: auto; -} - -.message-media.video { - max-height: 360px; - background: #111827; -} - -.message-media.audio { - width: 260px; -} - -.image-preview { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.7); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - z-index: 999; -} - -.image-preview.active { - opacity: 1; - pointer-events: auto; -} - -.image-preview img { - max-width: min(90vw, 1200px); - max-height: 90vh; - border-radius: 18px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); - background: #0f172a; - transition: transform 0.1s ease; - cursor: zoom-out; -} - -.highlight { - outline: 2px solid var(--accent); - outline-offset: 4px; - border-radius: 18px; - transition: outline-color 0.3s; -} - -.empty { - text-align: center; - color: var(--muted); - padding: 40px; -} - -/* Scroll Container */ -.scroll-container { - flex: 1; - min-height: 0; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); - margin-top: 8px; - margin-bottom: 8px; - padding: 12px; - -webkit-overflow-scrolling: touch; -} - -.scroll-container::-webkit-scrollbar { - width: 6px; -} - -.scroll-container::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; -} - -.load-sentinel { - height: 1px; -} -`; - diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts deleted file mode 100644 index 5ff1049..0000000 --- a/electron/services/exportRecordService.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { app } from 'electron' -import fs from 'fs' -import path from 'path' - -export interface ExportRecord { - exportTime: number - format: string - messageCount: number - sourceLatestMessageTimestamp?: number - outputPath?: string -} - -type RecordStore = Record<string, ExportRecord[]> - -class ExportRecordService { - private filePath: string | null = null - private loaded = false - private store: RecordStore = {} - - private resolveFilePath(): string { - if (this.filePath) return this.filePath - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() - fs.mkdirSync(userDataPath, { recursive: true }) - this.filePath = path.join(userDataPath, 'weflow-export-records.json') - return this.filePath - } - - private ensureLoaded(): void { - if (this.loaded) return - this.loaded = true - const filePath = this.resolveFilePath() - try { - if (!fs.existsSync(filePath)) return - const raw = fs.readFileSync(filePath, 'utf-8') - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - this.store = parsed as RecordStore - } - } catch { - this.store = {} - } - } - - private persist(): void { - try { - const filePath = this.resolveFilePath() - fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8') - } catch { - // ignore persist errors to avoid blocking export flow - } - } - - getLatestRecord(sessionId: string, format: string): ExportRecord | null { - this.ensureLoaded() - const records = this.store[sessionId] - if (!records || records.length === 0) return null - for (let i = records.length - 1; i >= 0; i--) { - const record = records[i] - if (record && record.format === format) return record - } - return null - } - - saveRecord( - sessionId: string, - format: string, - messageCount: number, - extra?: { - sourceLatestMessageTimestamp?: number - outputPath?: string - } - ): void { - this.ensureLoaded() - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return - if (!this.store[normalizedSessionId]) { - this.store[normalizedSessionId] = [] - } - const list = this.store[normalizedSessionId] - list.push({ - exportTime: Date.now(), - format, - messageCount, - sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp, - outputPath: extra?.outputPath - }) - // keep the latest 30 records per session - if (list.length > 30) { - this.store[normalizedSessionId] = list.slice(-30) - } - this.persist() - } -} - -export const exportRecordService = new ExportRecordService() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts deleted file mode 100644 index cd88bb0..0000000 --- a/electron/services/exportService.ts +++ /dev/null @@ -1,10748 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import * as http from 'http' -import * as https from 'https' -import crypto from 'crypto' -import { fileURLToPath } from 'url' -import ExcelJS from 'exceljs' -import { getEmojiPath } from 'wechat-emojis' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { imageDecryptService } from './imageDecryptService' -import { chatService } from './chatService' -import { videoService } from './videoService' -import { voiceTranscribeService } from './voiceTranscribeService' -import { exportRecordService } from './exportRecordService' -import { EXPORT_HTML_STYLES } from './exportHtmlStyles' -import { LRUCache } from '../utils/LRUCache.js' - -// ChatLab 格式类型定义 -interface ChatLabHeader { - version: string - exportedAt: number - generator: string - description?: string -} - -interface ChatLabMeta { - name: string - platform: string - type: 'group' | 'private' - groupId?: string - groupAvatar?: 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 - platformMessageId?: string - replyToMessageId?: string - chatRecords?: any[] // 嵌套的聊天记录 -} - -interface ForwardChatRecordItem { - datatype: number - sourcename: string - sourcetime: string - sourceheadurl?: string - datadesc?: string - datatitle?: string - fileext?: string - datasize?: number - chatRecordTitle?: string - chatRecordDesc?: string - chatRecordList?: ForwardChatRecordItem[] -} - -interface ChatLabExport { - chatlab: ChatLabHeader - meta: ChatLabMeta - members: ChatLabMember[] - messages: ChatLabMessage[] -} - -// 消息类型映射:微信 localType -> ChatLab type -const MESSAGE_TYPE_MAP: Record<number, number> = { - 1: 0, // 文本 -> TEXT - 3: 1, // 图片 -> IMAGE - 34: 2, // 语音 -> VOICE - 43: 3, // 视频 -> VIDEO - 49: 7, // 链接/文件 -> LINK (需要进一步判断) - 34359738417: 7, // 文件消息变体 -> LINK - 103079215153: 7, // 文件消息变体 -> LINK - 25769803825: 7, // 文件消息变体 -> LINK - 47: 5, // 表情包 -> EMOJI - 48: 8, // 位置 -> LOCATION - 42: 27, // 名片 -> CONTACT - 50: 23, // 通话 -> CALL - 10000: 80, // 系统消息 -> SYSTEM -} - -// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。 -const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const -const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES) - -export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' - contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' - dateRange?: { start: number; end: number } | null - senderUsername?: string - fileNameSuffix?: string - fileNamingMode?: 'classic' | 'date-range' - exportMedia?: boolean - exportAvatars?: boolean - exportImages?: boolean - exportVoices?: boolean - exportVideos?: boolean - exportEmojis?: boolean - exportFiles?: boolean - maxFileSizeMb?: number - exportVoiceAsText?: boolean - excelCompactColumns?: boolean - txtColumns?: string[] - sessionLayout?: 'shared' | 'per-session' - exportWriteLayout?: 'A' | 'B' | 'C' - sessionNameWithTypePrefix?: boolean - displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' - exportConcurrency?: number -} - -const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ - { id: 'index', label: '序号' }, - { id: 'time', label: '时间' }, - { id: 'senderRole', label: '发送者身份' }, - { id: 'messageType', label: '消息类型' }, - { id: 'content', label: '内容' }, - { id: 'senderNickname', label: '发送者昵称' }, - { id: 'senderWxid', label: '发送者微信ID' }, - { id: 'senderRemark', label: '发送者备注' } -] - -interface MediaExportItem { - relativePath: string - kind: 'image' | 'voice' | 'emoji' | 'video' | 'file' - posterDataUrl?: string -} - -interface ExportDisplayProfile { - wxid: string - nickname: string - remark: string - alias: string - groupNickname: string - displayName: string -} - -type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' -type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file' -interface FileExportCandidate { - sourcePath: string - matchedBy: 'md5' | 'name' - yearMonth?: string - preferredMonth?: boolean - mtimeMs: number - searchOrder: number -} -interface FileAttachmentSearchRoot { - accountDir: string - msgFileRoot?: string - fileStorageRoot?: string -} - -export interface ExportProgress { - current: number - total: number - currentSession: string - currentSessionId?: string - phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' - phaseProgress?: number - phaseTotal?: number - phaseLabel?: string - collectedMessages?: number - exportedMessages?: number - estimatedTotalMessages?: number - writtenFiles?: number - mediaDoneFiles?: number - mediaCacheHitFiles?: number - mediaCacheMissFiles?: number - mediaCacheFillFiles?: number - mediaDedupReuseFiles?: number - mediaBytesWritten?: number -} - -interface MediaExportTelemetry { - doneFiles: number - cacheHitFiles: number - cacheMissFiles: number - cacheFillFiles: number - dedupReuseFiles: number - bytesWritten: number -} - -interface MediaSourceResolution { - sourcePath: string - cacheHit: boolean - cachePath?: string - fileStat?: { size: number; mtimeMs: number } - dedupeKey?: string -} - -interface ExportTaskControl { - shouldPause?: () => boolean - shouldStop?: () => boolean - recordCreatedFile?: (filePath: string) => void - recordCreatedDir?: (dirPath: string) => void -} - -interface ExportStatsResult { - totalMessages: number - voiceMessages: number - cachedVoiceCount: number - needTranscribeCount: number - mediaMessages: number - estimatedSeconds: number - sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> -} - -interface ExportStatsSessionSnapshot { - totalCount: number - voiceCount: number - imageCount: number - videoCount: number - emojiCount: number - cachedVoiceCount: number - lastTimestamp?: number -} - -interface ExportStatsCacheEntry { - createdAt: number - result: ExportStatsResult - sessions: Record<string, ExportStatsSessionSnapshot> -} - -interface ExportAggregatedSessionMetric { - totalMessages?: number - voiceMessages?: number - imageMessages?: number - videoMessages?: number - emojiMessages?: number - lastTimestamp?: number -} - -interface ExportAggregatedSessionStatsCacheEntry { - createdAt: number - data: Record<string, ExportAggregatedSessionMetric> -} - -// 并发控制:限制同时执行的 Promise 数量 -async function parallelLimit<T, R>( - items: T[], - limit: number, - fn: (item: T, index: number) => Promise<R> -): Promise<R[]> { - const results: R[] = new Array(items.length) - let currentIndex = 0 - - async function runNext(): Promise<void> { - while (currentIndex < items.length) { - const index = currentIndex++ - results[index] = await fn(items[index], index) - } - } - - // 启动 limit 个并发任务 - const workers = Array(Math.min(limit, items.length)) - .fill(null) - .map(() => runNext()) - - await Promise.all(workers) - return results -} - -class ExportService { - private configService: ConfigService - private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null - private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }> - private inlineEmojiCache: LRUCache<string, string> - private htmlStyleCache: string | null = null - private exportStatsCache = new Map<string, ExportStatsCacheEntry>() - private exportAggregatedSessionStatsCache = new Map<string, ExportAggregatedSessionStatsCacheEntry>() - private readonly exportStatsCacheTtlMs = 2 * 60 * 1000 - private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 - private readonly exportStatsCacheMaxEntries = 16 - private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' - private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED' - private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>() - private mediaFileCacheReadyDirs = new Set<string>() - private mediaExportTelemetry: MediaExportTelemetry | null = null - private mediaRunSourceDedupMap = new Map<string, string>() - private mediaRunMissingImageKeys = new Set<string>() - private activeChatImagePipelineCount = 0 - private chatImagePipelineWaiters: Array<() => void> = [] - private mediaFileCacheCleanupPending: Promise<void> | null = null - private mediaFileCacheLastCleanupAt = 0 - private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 - private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 - private readonly mediaFileCacheMaxFiles = 120000 - private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 - private emojiCaptionCache = new Map<string, string | null>() - private emojiCaptionPending = new Map<string, Promise<string | null>>() - private emojiMd5ByCdnCache = new Map<string, string | null>() - private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>() - private emoticonDbPathCache: string | null = null - private emoticonDbPathCacheToken = '' - private readonly emojiCaptionLookupConcurrency = 8 - - constructor() { - this.configService = new ConfigService() - // 限制缓存大小,防止内存泄漏 - this.contactCache = new LRUCache(500) // 最多缓存500个联系人 - this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情 - } - - private createStopError(): Error { - const error = new Error('导出任务已停止') - ;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE - return error - } - - private createPauseError(): Error { - const error = new Error('导出任务已暂停') - ;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE - return error - } - - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void { - this.runtimeConfig = config - imageDecryptService.setRuntimeConfig({ - dbPath: config?.dbPath, - myWxid: config?.myWxid, - imageXorKey: config?.imageXorKey, - imageAesKey: config?.imageAesKey - }) - chatService.setRuntimeConfig({ - dbPath: config?.dbPath, - decryptKey: config?.decryptKey, - myWxid: config?.myWxid, - resourcesPath: config?.resourcesPath, - appPath: config?.appPath, - isPackaged: config?.isPackaged - }) - } - - private getConfiguredDbPath(): string { - return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() - } - - private getConfiguredMyWxid(): string { - return String(this.runtimeConfig?.myWxid || this.configService.getMyWxidCleaned() || '').trim() - } - - private normalizeSessionIds(sessionIds: string[]): string[] { - return Array.from( - new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) - ) - } - - private normalizeTimestampSeconds(value: unknown): number { - const raw = Number(value) - if (!Number.isFinite(raw) || raw <= 0) return 0 - let normalized = Math.floor(raw) - // 兼容毫秒/微秒/纳秒时间戳输入,统一降到秒级。 - while (normalized > 10000000000) { - normalized = Math.floor(normalized / 1000) - } - return normalized - } - - private normalizeExportDateRange(dateRange?: { start: number; end: number } | null): { start: number; end: number } | null { - if (!dateRange) return null - let start = this.normalizeTimestampSeconds(dateRange.start) - let end = this.normalizeTimestampSeconds(dateRange.end) - if (start > 0 && end > 0 && start > end) { - const tmp = start - start = end - end = tmp - } - if (start <= 0 && end <= 0) return null - return { start, end } - } - - private normalizeMaxFileSizeMb(value: unknown): number | undefined { - const raw = Number(value) - if (!Number.isFinite(raw) || raw <= 0) return undefined - return Math.floor(raw) - } - - private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions { - const normalizedDateRange = this.normalizeExportDateRange(options.dateRange) - const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb) - const normalizedWriteLayout = this.resolveExportWriteLayout(options) - return { - ...options, - dateRange: normalizedDateRange, - maxFileSizeMb: normalizedMaxFileSizeMb, - exportWriteLayout: normalizedWriteLayout - } - } - - private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' { - const optionLayout = options?.exportWriteLayout - if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout - const rawWriteLayout = this.configService.get('exportWriteLayout') - return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' - ? rawWriteLayout - : 'B' - } - - private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { - const normalized = this.normalizeExportDateRange(dateRange) - if (!normalized) return 'all' - const start = normalized.start - const end = normalized.end - return `${start}-${end}` - } - - private buildExportStatsCacheKey( - sessionIds: string[], - options: Pick<ExportOptions, 'dateRange' | 'senderUsername'>, - cleanedWxid?: string - ): string { - const normalizedIds = this.normalizeSessionIds(sessionIds).sort() - const senderToken = String(options.senderUsername || '').trim() - const dateToken = this.getExportStatsDateRangeToken(options.dateRange) - const dbPath = this.getConfiguredDbPath() - const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim() - return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` - } - - private cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult { - return { - ...result, - sessions: result.sessions.map((item) => ({ ...item })) - } - } - - private pruneExportStatsCaches(): void { - const now = Date.now() - for (const [key, entry] of this.exportStatsCache.entries()) { - if (now - entry.createdAt > this.exportStatsCacheTtlMs) { - this.exportStatsCache.delete(key) - } - } - for (const [key, entry] of this.exportAggregatedSessionStatsCache.entries()) { - if (now - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { - this.exportAggregatedSessionStatsCache.delete(key) - } - } - } - - private getExportStatsCacheEntry(key: string): ExportStatsCacheEntry | null { - this.pruneExportStatsCaches() - const entry = this.exportStatsCache.get(key) - if (!entry) return null - if (Date.now() - entry.createdAt > this.exportStatsCacheTtlMs) { - this.exportStatsCache.delete(key) - return null - } - return entry - } - - private setExportStatsCacheEntry(key: string, entry: ExportStatsCacheEntry): void { - this.pruneExportStatsCaches() - this.exportStatsCache.set(key, entry) - if (this.exportStatsCache.size <= this.exportStatsCacheMaxEntries) return - const staleKeys = Array.from(this.exportStatsCache.entries()) - .sort((a, b) => a[1].createdAt - b[1].createdAt) - .slice(0, Math.max(0, this.exportStatsCache.size - this.exportStatsCacheMaxEntries)) - .map(([cacheKey]) => cacheKey) - for (const staleKey of staleKeys) { - this.exportStatsCache.delete(staleKey) - } - } - - private getAggregatedSessionStatsCache(key: string): Record<string, ExportAggregatedSessionMetric> | null { - this.pruneExportStatsCaches() - const entry = this.exportAggregatedSessionStatsCache.get(key) - if (!entry) return null - if (Date.now() - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { - this.exportAggregatedSessionStatsCache.delete(key) - return null - } - return entry.data - } - - private setAggregatedSessionStatsCache( - key: string, - data: Record<string, ExportAggregatedSessionMetric> - ): void { - this.pruneExportStatsCaches() - this.exportAggregatedSessionStatsCache.set(key, { - createdAt: Date.now(), - data - }) - if (this.exportAggregatedSessionStatsCache.size <= this.exportStatsCacheMaxEntries) return - const staleKeys = Array.from(this.exportAggregatedSessionStatsCache.entries()) - .sort((a, b) => a[1].createdAt - b[1].createdAt) - .slice(0, Math.max(0, this.exportAggregatedSessionStatsCache.size - this.exportStatsCacheMaxEntries)) - .map(([cacheKey]) => cacheKey) - for (const staleKey of staleKeys) { - this.exportAggregatedSessionStatsCache.delete(staleKey) - } - } - - private isStopError(error: unknown): boolean { - if (!error) return false - if (typeof error === 'string') { - return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止') - } - if (error instanceof Error) { - const code = (error as Error & { code?: string }).code - return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止') - } - return false - } - - private isPauseError(error: unknown): boolean { - if (!error) return false - if (typeof error === 'string') { - return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停') - } - if (error instanceof Error) { - const code = (error as Error & { code?: string }).code - return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停') - } - return false - } - - private throwIfStopRequested(control?: ExportTaskControl): void { - if (control?.shouldStop?.()) { - throw this.createStopError() - } - if (control?.shouldPause?.()) { - throw this.createPauseError() - } - } - - private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set<string>): Promise<void> { - if (dirCache?.has(dirPath)) return - const existed = await this.pathExists(dirPath) - await fs.promises.mkdir(dirPath, { recursive: true }) - dirCache?.add(dirPath) - if (!existed) { - control?.recordCreatedDir?.(dirPath) - } - } - - private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> { - if (!control?.recordCreatedFile) return - if (!await this.pathExists(filePath)) { - control.recordCreatedFile(filePath) - } - } - - private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { - if (typeof value !== 'number' || !Number.isFinite(value)) return fallback - const raw = Math.floor(value) - return Math.max(1, Math.min(raw, max)) - } - - private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): { - emit: (progress: ExportProgress, options?: { force?: boolean }) => void - flush: () => void - } { - if (!onProgress) { - return { - emit: () => { /* noop */ }, - flush: () => { /* noop */ } - } - } - - let pending: ExportProgress | null = null - let lastSentAt = 0 - let lastPhase = '' - let lastSessionId = '' - let lastCollected = 0 - let lastExported = 0 - const MIN_PROGRESS_EMIT_INTERVAL_MS = 400 - const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200 - - const commit = (progress: ExportProgress) => { - onProgress(progress) - pending = null - lastSentAt = Date.now() - lastPhase = String(progress.phase || '') - lastSessionId = String(progress.currentSessionId || '') - lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected - lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported - } - - const emit = (progress: ExportProgress, options?: { force?: boolean }) => { - pending = progress - const force = options?.force === true - const now = Date.now() - const phase = String(progress.phase || '') - const sessionId = String(progress.currentSessionId || '') - const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected - const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported - const collectedDelta = Math.abs(collected - lastCollected) - const exportedDelta = Math.abs(exported - lastExported) - const shouldEmit = force || - phase !== lastPhase || - sessionId !== lastSessionId || - collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || - exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || - (now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS) - - if (shouldEmit && pending) { - commit(pending) - } - } - - const flush = () => { - if (!pending) return - commit(pending) - } - - return { emit, flush } - } - - private async pathExists(filePath: string): Promise<boolean> { - try { - await fs.promises.access(filePath, fs.constants.F_OK) - return true - } catch { - return false - } - } - - private sanitizeExportFileNamePart(value: string): string { - return String(value || '') - .replace(/[<>:"\/\\|?*]/g, '_') - .replace(/\.+$/, '') - .trim() - } - - private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string { - const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || '')) - const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase() - const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_') - return safeExt || 'no-extension' - } - - private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { - return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' - } - - private formatDateTokenBySeconds(seconds?: number): string | null { - const normalizedSeconds = this.normalizeTimestampSeconds(seconds) - if (normalizedSeconds <= 0) return null - const date = new Date(normalizedSeconds * 1000) - if (Number.isNaN(date.getTime())) return null - const y = date.getFullYear() - const m = `${date.getMonth() + 1}`.padStart(2, '0') - const d = `${date.getDate()}`.padStart(2, '0') - return `${y}${m}${d}` - } - - private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string { - const start = this.formatDateTokenBySeconds(dateRange?.start) - const end = this.formatDateTokenBySeconds(dateRange?.end) - if (start && end) { - if (start === end) return start - return start < end ? `${start}-${end}` : `${end}-${start}` - } - if (start) return `${start}-至今` - if (end) return `截至-${end}` - return '全部时间' - } - - private buildSessionExportBaseName( - sessionId: string, - displayName: string, - options: ExportOptions - ): string { - const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session' - const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '') - const namingMode = this.normalizeFileNamingMode(options.fileNamingMode) - const parts = [baseName] - if (suffix) parts.push(suffix) - if (namingMode === 'date-range') { - parts.push(this.buildDateRangeFileNamePart(options.dateRange)) - } - return this.sanitizeExportFileNamePart(parts.join('_')) || 'session' - } - - private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> { - const dir = path.dirname(preferredPath) - const ext = path.extname(preferredPath) - const base = path.basename(preferredPath, ext) - - for (let attempt = 0; attempt < 10000; attempt += 1) { - const candidate = attempt === 0 - ? preferredPath - : path.join(dir, `${base}_${attempt + 1}${ext}`) - - if (reservedPaths.has(candidate)) continue - - const exists = await this.pathExists(candidate) - if (reservedPaths.has(candidate)) continue - if (exists) continue - - reservedPaths.add(candidate) - return candidate - } - - const fallback = path.join(dir, `${base}_${Date.now()}${ext}`) - reservedPaths.add(fallback) - return fallback - } - - private isCloneUnsupportedError(code: string | undefined): boolean { - return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' - } - - private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> { - const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0 - try { - if (cloneFlag) { - await fs.promises.copyFile(sourcePath, destPath, cloneFlag) - } else { - await fs.promises.copyFile(sourcePath, destPath) - } - return { success: true } - } catch (e) { - const code = (e as NodeJS.ErrnoException | undefined)?.code - if (!this.isCloneUnsupportedError(code)) { - return { success: false, code } - } - } - - try { - await fs.promises.copyFile(sourcePath, destPath) - return { success: true } - } catch (e) { - return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code } - } - } - - private getMediaFileCacheRoot(): string { - return path.join(this.configService.getCacheBasePath(), 'export-media-files') - } - - private createEmptyMediaTelemetry(): MediaExportTelemetry { - return { - doneFiles: 0, - cacheHitFiles: 0, - cacheMissFiles: 0, - cacheFillFiles: 0, - dedupReuseFiles: 0, - bytesWritten: 0 - } - } - - private resetMediaRuntimeState(): void { - this.mediaExportTelemetry = this.createEmptyMediaTelemetry() - this.mediaRunSourceDedupMap.clear() - this.mediaRunMissingImageKeys.clear() - } - - private clearMediaRuntimeState(): void { - this.mediaExportTelemetry = null - this.mediaRunSourceDedupMap.clear() - this.mediaRunMissingImageKeys.clear() - } - - private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> { - while (this.activeChatImagePipelineCount >= 2) { - await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve)) - } - this.activeChatImagePipelineCount += 1 - try { - return await fn() - } finally { - this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1) - const next = this.chatImagePipelineWaiters.shift() - if (next) next() - } - } - - private getMediaTelemetrySnapshot(): Partial<ExportProgress> { - const stats = this.mediaExportTelemetry - if (!stats) return {} - return { - mediaDoneFiles: stats.doneFiles, - mediaCacheHitFiles: stats.cacheHitFiles, - mediaCacheMissFiles: stats.cacheMissFiles, - mediaCacheFillFiles: stats.cacheFillFiles, - mediaDedupReuseFiles: stats.dedupReuseFiles, - mediaBytesWritten: stats.bytesWritten - } - } - - private noteMediaTelemetry(delta: Partial<MediaExportTelemetry>): void { - if (!this.mediaExportTelemetry) return - if (Number.isFinite(delta.doneFiles)) { - this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0))) - } - if (Number.isFinite(delta.cacheHitFiles)) { - this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0))) - } - if (Number.isFinite(delta.cacheMissFiles)) { - this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0))) - } - if (Number.isFinite(delta.cacheFillFiles)) { - this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0))) - } - if (Number.isFinite(delta.dedupReuseFiles)) { - this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0))) - } - if (Number.isFinite(delta.bytesWritten)) { - this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0))) - } - } - - private async ensureMediaFileCacheDir(dirPath: string): Promise<void> { - if (this.mediaFileCacheReadyDirs.has(dirPath)) return - await fs.promises.mkdir(dirPath, { recursive: true }) - this.mediaFileCacheReadyDirs.add(dirPath) - } - - private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> { - try { - const stat = await fs.promises.stat(sourcePath) - if (!stat.isFile()) return null - return { - size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, - mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 - } - } catch { - return null - } - } - - private buildMediaFileCachePath( - kind: 'image' | 'video' | 'emoji', - sourcePath: string, - fileStat: { size: number; mtimeMs: number } - ): string { - const normalizedSource = path.resolve(sourcePath) - const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}` - const digest = crypto.createHash('sha1').update(rawKey).digest('hex') - const ext = path.extname(normalizedSource) || '' - return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`) - } - - private async resolveMediaFileCachePath( - kind: 'image' | 'video' | 'emoji', - sourcePath: string - ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> { - const fileStat = await this.getMediaFileStat(sourcePath) - if (!fileStat) return null - const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat) - return { cachePath, fileStat } - } - - private async populateMediaFileCache( - kind: 'image' | 'video' | 'emoji', - sourcePath: string - ): Promise<string | null> { - const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) - if (!resolved) return null - const { cachePath } = resolved - if (await this.pathExists(cachePath)) return cachePath - - const pending = this.mediaFileCachePopulatePending.get(cachePath) - if (pending) return pending - - const task = (async () => { - try { - await this.ensureMediaFileCacheDir(path.dirname(cachePath)) - if (await this.pathExists(cachePath)) return cachePath - - const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` - const copied = await this.copyFileOptimized(sourcePath, tempPath) - if (!copied.success) { - await fs.promises.rm(tempPath, { force: true }).catch(() => { }) - return null - } - await fs.promises.rename(tempPath, cachePath).catch(async (error) => { - const code = (error as NodeJS.ErrnoException | undefined)?.code - if (code === 'EEXIST') { - await fs.promises.rm(tempPath, { force: true }).catch(() => { }) - return - } - await fs.promises.rm(tempPath, { force: true }).catch(() => { }) - throw error - }) - this.noteMediaTelemetry({ cacheFillFiles: 1 }) - return cachePath - } catch { - return null - } finally { - this.mediaFileCachePopulatePending.delete(cachePath) - } - })() - - this.mediaFileCachePopulatePending.set(cachePath, task) - return task - } - - private async resolvePreferredMediaSource( - kind: 'image' | 'video' | 'emoji', - sourcePath: string - ): Promise<MediaSourceResolution> { - const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) - if (!resolved) { - return { - sourcePath, - cacheHit: false - } - } - const dedupeKey = `${kind}\u001f${resolved.cachePath}` - if (await this.pathExists(resolved.cachePath)) { - return { - sourcePath: resolved.cachePath, - cacheHit: true, - cachePath: resolved.cachePath, - fileStat: resolved.fileStat, - dedupeKey - } - } - // 未命中缓存时异步回填,不阻塞当前导出路径 - void this.populateMediaFileCache(kind, sourcePath) - return { - sourcePath, - cacheHit: false, - cachePath: resolved.cachePath, - fileStat: resolved.fileStat, - dedupeKey - } - } - - private isHardlinkFallbackError(code: string | undefined): boolean { - return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP' - } - - private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> { - try { - await fs.promises.link(sourcePath, destPath) - return { success: true, linked: true } - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code - if (code === 'EEXIST') { - return { success: true, linked: true } - } - if (!this.isHardlinkFallbackError(code)) { - return { success: false, code } - } - } - - const copied = await this.copyFileOptimized(sourcePath, destPath) - if (!copied.success) return copied - return { success: true, linked: false } - } - - private async copyMediaWithCacheAndDedup( - kind: 'image' | 'video' | 'emoji', - sourcePath: string, - destPath: string, - control?: ExportTaskControl - ): Promise<{ success: boolean; code?: string }> { - const existedBeforeCopy = await this.pathExists(destPath) - const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) - if (resolved.cacheHit) { - this.noteMediaTelemetry({ cacheHitFiles: 1 }) - } else { - this.noteMediaTelemetry({ cacheMissFiles: 1 }) - } - - const dedupeKey = resolved.dedupeKey - if (dedupeKey) { - const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey) - if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) { - const reused = await this.hardlinkOrCopyFile(reusedPath, destPath) - if (!reused.success) return reused - this.noteMediaTelemetry({ - doneFiles: 1, - dedupReuseFiles: 1, - bytesWritten: resolved.fileStat?.size || 0 - }) - if (!existedBeforeCopy) { - control?.recordCreatedFile?.(destPath) - } - return { success: true } - } - } - - const copied = resolved.cacheHit - ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath) - : await this.copyFileOptimized(resolved.sourcePath, destPath) - if (!copied.success) return copied - - if (dedupeKey) { - this.mediaRunSourceDedupMap.set(dedupeKey, destPath) - } - this.noteMediaTelemetry({ - doneFiles: 1, - bytesWritten: resolved.fileStat?.size || 0 - }) - if (!existedBeforeCopy) { - control?.recordCreatedFile?.(destPath) - } - return { success: true } - } - - private triggerMediaFileCacheCleanup(force = false): void { - const now = Date.now() - if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return - if (this.mediaFileCacheCleanupPending) return - this.mediaFileCacheLastCleanupAt = now - - this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => { - this.mediaFileCacheCleanupPending = null - }) - } - - private async cleanupMediaFileCache(): Promise<void> { - const root = this.getMediaFileCacheRoot() - if (!await this.pathExists(root)) return - const now = Date.now() - const files: Array<{ filePath: string; size: number; mtimeMs: number }> = [] - const dirs: string[] = [] - - const stack = [root] - while (stack.length > 0) { - const current = stack.pop() as string - dirs.push(current) - let entries: fs.Dirent[] - try { - entries = await fs.promises.readdir(current, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const entryPath = path.join(current, entry.name) - if (entry.isDirectory()) { - stack.push(entryPath) - continue - } - if (!entry.isFile()) continue - try { - const stat = await fs.promises.stat(entryPath) - if (!stat.isFile()) continue - files.push({ - filePath: entryPath, - size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, - mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 - }) - } catch { } - } - } - - if (files.length === 0) return - - let totalBytes = files.reduce((sum, item) => sum + item.size, 0) - let totalFiles = files.length - const ttlThreshold = now - this.mediaFileCacheTtlMs - const removalSet = new Set<string>() - - for (const item of files) { - if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) { - removalSet.add(item.filePath) - totalBytes -= item.size - totalFiles -= 1 - } - } - - if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) { - const ordered = files - .filter((item) => !removalSet.has(item.filePath)) - .sort((a, b) => a.mtimeMs - b.mtimeMs) - for (const item of ordered) { - if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break - removalSet.add(item.filePath) - totalBytes -= item.size - totalFiles -= 1 - } - } - - if (removalSet.size === 0) return - - for (const filePath of removalSet) { - await fs.promises.rm(filePath, { force: true }).catch(() => { }) - } - - dirs.sort((a, b) => b.length - a.length) - for (const dirPath of dirs) { - if (dirPath === root) continue - await fs.promises.rmdir(dirPath).catch(() => { }) - } - } - - private isMediaExportEnabled(options: ExportOptions): boolean { - return options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) - } - - private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { - return this.normalizeExportDateRange(dateRange) === null - } - - private shouldUseFastTextCollection(options: ExportOptions): boolean { - // 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用 - return !this.isMediaExportEnabled(options) - } - - private getMediaContentType(options: ExportOptions): MediaContentType | null { - const value = options.contentType - if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') { - return value - } - return null - } - - private isMediaContentBatchExport(options: ExportOptions): boolean { - return this.getMediaContentType(options) !== null - } - - private getTargetMediaLocalTypes(options: ExportOptions): Set<number> { - const mediaContentType = this.getMediaContentType(options) - if (mediaContentType === 'voice') return new Set([34]) - if (mediaContentType === 'image') return new Set([3]) - if (mediaContentType === 'video') return new Set([43]) - if (mediaContentType === 'emoji') return new Set([47]) - if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES) - - const selected = new Set<number>() - if (options.exportImages) selected.add(3) - if (options.exportVoices) selected.add(34) - if (options.exportVideos) selected.add(43) - if (options.exportFiles) { - for (const fileType of FILE_APP_LOCAL_TYPES) { - selected.add(fileType) - } - } - return selected - } - - private isFileAppLocalType(localType: number): boolean { - return FILE_APP_LOCAL_TYPE_SET.has(localType) - } - - private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean { - return Boolean( - targetMediaTypes && - targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length && - FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType)) - ) - } - - private getFileAppMessageHints(message: Record<string, any> | null | undefined): { - xmlType?: string - fileName?: string - fileSize?: number - fileExt?: string - fileMd5?: string - } { - const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined - const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined - const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined - const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0) - const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined - const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim() - const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined - return { xmlType, fileName, fileSize, fileExt, fileMd5 } - } - - private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean { - const hints = this.getFileAppMessageHints(message) - if (hints.xmlType) return hints.xmlType === '6' - return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize) - } - - private isFileAppMessage(msg: { - localType?: unknown - xmlType?: unknown - xml_type?: unknown - content?: unknown - fileName?: unknown - file_name?: unknown - fileSize?: unknown - file_size?: unknown - fileExt?: unknown - file_ext?: unknown - fileMd5?: unknown - file_md5?: unknown - }): boolean { - const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>) - if (xmlType) return xmlType === '6' - if (fileName || fileExt || fileMd5 || fileSize) return true - - const normalized = this.normalizeAppMessageContent(String(msg?.content || '')) - if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) { - return false - } - return this.extractAppMessageType(normalized) === '6' - } - - private extractFileAppMessageMeta(content: string): { - xmlType?: string - fileName?: string - fileSize?: number - fileExt?: string - fileMd5?: string - } | null { - const normalized = this.normalizeAppMessageContent(content || '') - if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) { - return null - } - - const xmlType = this.extractAppMessageType(normalized) - if (!xmlType) return null - - const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title') - const rawFileExt = this.extractXmlValue(normalized, 'fileext') - const rawFileSize = - this.extractXmlValue(normalized, 'totallen') || - this.extractXmlValue(normalized, 'datasize') || - this.extractXmlValue(normalized, 'filesize') - const rawFileMd5 = - this.extractXmlValue(normalized, 'md5') || - this.extractXmlAttribute(normalized, 'appattach', 'md5') || - this.extractLooseHexMd5(normalized) - const fileSize = Number.parseInt(rawFileSize, 10) - const fileMd5 = String(rawFileMd5 || '').trim() - - return { - xmlType, - fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined, - fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined, - fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined, - fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined - } - } - - private resolveCollectMode(options: ExportOptions): MessageCollectMode { - if (this.isMediaContentBatchExport(options)) { - return 'media-fast' - } - return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full' - } - - private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set<number> } { - const mode = this.resolveCollectMode(options) - if (mode === 'media-fast') { - const targetMediaTypes = this.getTargetMediaLocalTypes(options) - if (targetMediaTypes.size > 0) { - return { mode, targetMediaTypes } - } - } - return { mode } - } - - private resolveFastMediaStreamType( - collectMode: MessageCollectMode, - targetMediaTypes: Set<number> | null - ): 'image' | 'video' | null { - if (collectMode !== 'media-fast' || !targetMediaTypes || targetMediaTypes.size !== 1) return null - if (targetMediaTypes.has(3)) return 'image' - if (targetMediaTypes.has(43)) return 'video' - return null - } - - private async collectMessagesByFastMediaStream( - sessionId: string, - cleanedMyWxid: string, - normalizedDateRange: { start: number; end: number } | null, - useCursorTimeRange: boolean, - normalizedSenderUsernameFilter: string, - mediaType: 'image' | 'video', - onCollectProgress?: (payload: { fetched: number }) => void, - control?: ExportTaskControl - ): Promise<{ - success: boolean - rows: any[] - senderUsernames: string[] - firstTime: number | null - lastTime: number | null - error?: string - }> { - const rows: any[] = [] - const senderSet = new Set<string>() - let firstTime: number | null = null - let lastTime: number | null = null - let offset = 0 - let hasMore = true - const PAGE_LIMIT = 480 - - while (hasMore) { - this.throwIfStopRequested(control) - const streamResult = await wcdbService.getMediaStream({ - sessionId, - mediaType, - beginTimestamp: useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0, - endTimestamp: useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0, - limit: PAGE_LIMIT, - offset - }) - if (!streamResult.success) { - return { - success: false, - rows, - senderUsernames: Array.from(senderSet), - firstTime, - lastTime, - error: streamResult.error || '媒体快速流读取失败' - } - } - - const items = Array.isArray(streamResult.items) ? streamResult.items : [] - if (items.length === 0) { - hasMore = false - break - } - - for (const item of items) { - const createTime = this.normalizeRowTimestampSeconds(item?.createTime) - if (normalizedDateRange) { - if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue - if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue - } - - const localTypeRaw = Number(item?.localType || 0) - let localType = Number.isFinite(localTypeRaw) ? Math.floor(localTypeRaw) : 0 - if (localType <= 0) { - localType = mediaType === 'video' ? 43 : 3 - } - const isSend = Number(item?.isSend) === 1 - const senderUsernameRaw = String(item?.senderUsername || '').trim() - const actualSender = isSend ? cleanedMyWxid : (senderUsernameRaw || sessionId) - if (normalizedSenderUsernameFilter && !this.isSameWxid(actualSender, normalizedSenderUsernameFilter)) { - continue - } - senderSet.add(actualSender) - - const localIdRaw = Number(item?.localId || 0) - const localId = Number.isFinite(localIdRaw) ? Math.floor(localIdRaw) : 0 - const serverIdRawToken = this.normalizeUnsignedIntToken(item?.serverId) - const serverIdValue = Number.parseInt(serverIdRawToken, 10) - - const imageMd5 = String(item?.imageMd5 || '').trim().toLowerCase() - const imageDatName = String(item?.imageDatName || '').trim().toLowerCase() - const videoMd5 = String(item?.videoMd5 || '').trim().toLowerCase() - - rows.push({ - localId, - serverId: Number.isFinite(serverIdValue) ? serverIdValue : 0, - serverIdRaw: serverIdRawToken !== '0' ? serverIdRawToken : undefined, - createTime, - localType, - content: String(item?.content || ''), - senderUsername: actualSender, - isSend, - imageMd5: imageMd5 || undefined, - imageDatName: imageDatName || undefined, - videoMd5: videoMd5 || undefined - }) - - if (createTime > 0) { - if (firstTime === null || createTime < firstTime) firstTime = createTime - if (lastTime === null || createTime > lastTime) lastTime = createTime - } - } - - onCollectProgress?.({ fetched: rows.length }) - const nextOffset = Number(streamResult.nextOffset) - const safeNextOffset = Number.isFinite(nextOffset) && nextOffset > offset - ? Math.floor(nextOffset) - : offset + items.length - offset = safeNextOffset - hasMore = Boolean(streamResult.hasMore) && items.length > 0 - } - - return { - success: true, - rows, - senderUsernames: Array.from(senderSet), - firstTime, - lastTime - } - } - - private createCollectProgressReporter( - sessionName: string, - onProgress?: (progress: ExportProgress) => void, - progressCurrent = 5 - ): ((payload: { fetched: number }) => void) | undefined { - if (!onProgress) return undefined - let lastReportAt = 0 - return ({ fetched }) => { - const now = Date.now() - if (now - lastReportAt < 350) return - lastReportAt = now - onProgress({ - current: progressCurrent, - total: 100, - currentSession: sessionName, - phase: 'preparing', - phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`, - collectedMessages: fetched - }) - } - } - - private shouldDecodeMessageContentInFastMode(localType: number): boolean { - // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 - if (localType === 3 || localType === 34 || localType === 42 || localType === 43) { - return false - } - return true - } - - private shouldDecodeMessageContentInMediaMode( - localType: number, - targetMediaTypes: Set<number> | null, - options?: { allowFileProbe?: boolean } - ): boolean { - const allowFileProbe = options?.allowFileProbe === true - if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false - // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 - if (localType === 34) return false - // 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息 - if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true - 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})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - private getIntFromRow(row: Record<string, any>, keys: string[], fallback = 0): number { - for (const key of keys) { - const raw = row?.[key] - if (raw === undefined || raw === null || raw === '') continue - const parsed = Number.parseInt(String(raw), 10) - if (Number.isFinite(parsed)) return parsed - } - return fallback - } - - private parseDateTimeTextToSeconds(value: string): number { - const raw = String(value || '').trim() - if (!raw) return 0 - const compactDigits = this.parseCompactDateTimeDigitsToSeconds(raw) - if (compactDigits > 0) return compactDigits - - // 优先处理带时区信息的格式(例如 2026-04-22T21:33:12Z / +08:00) - if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) { - const parsed = Date.parse(raw) - const seconds = Math.floor(parsed / 1000) - if (Number.isFinite(seconds) && seconds > 0) return seconds - } - - const normalized = raw.replace('T', ' ').replace(/\.\d+$/, '').replace(/\//g, '-') - const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/) - if (!match) return 0 - const year = Number.parseInt(match[1], 10) - const month = Number.parseInt(match[2], 10) - const day = Number.parseInt(match[3], 10) - const hour = Number.parseInt(match[4] || '0', 10) - const minute = Number.parseInt(match[5] || '0', 10) - const second = Number.parseInt(match[6] || '0', 10) - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return 0 - const dt = new Date(year, month - 1, day, hour, minute, second) - const ts = Math.floor(dt.getTime() / 1000) - return Number.isFinite(ts) && ts > 0 ? ts : 0 - } - - private parseCompactDateTimeDigitsToSeconds(value: string): number { - const raw = String(value || '').trim() - if (!/^\d{8}(?:\d{4}(?:\d{2})?)?$/.test(raw)) return 0 - - const year = Number.parseInt(raw.slice(0, 4), 10) - const month = Number.parseInt(raw.slice(4, 6), 10) - const day = Number.parseInt(raw.slice(6, 8), 10) - const hour = raw.length >= 12 ? Number.parseInt(raw.slice(8, 10), 10) : 0 - const minute = raw.length >= 12 ? Number.parseInt(raw.slice(10, 12), 10) : 0 - const second = raw.length >= 14 ? Number.parseInt(raw.slice(12, 14), 10) : 0 - - if (!Number.isFinite(year) || year < 1990 || year > 2200) return 0 - if (!Number.isFinite(month) || month < 1 || month > 12) return 0 - if (!Number.isFinite(day) || day < 1 || day > 31) return 0 - if (!Number.isFinite(hour) || hour < 0 || hour > 23) return 0 - if (!Number.isFinite(minute) || minute < 0 || minute > 59) return 0 - if (!Number.isFinite(second) || second < 0 || second > 59) return 0 - - const dt = new Date(year, month - 1, day, hour, minute, second) - if ( - dt.getFullYear() !== year || - dt.getMonth() !== month - 1 || - dt.getDate() !== day || - dt.getHours() !== hour || - dt.getMinutes() !== minute || - dt.getSeconds() !== second - ) { - return 0 - } - - const ts = Math.floor(dt.getTime() / 1000) - return Number.isFinite(ts) && ts > 0 ? ts : 0 - } - - private normalizeRowTimestampSeconds(value: unknown): number { - if (value === undefined || value === null || value === '') return 0 - const rawText = String(value || '').trim() - if (!rawText) return 0 - - // 纯数字且看起来是年月日时间串时,优先按日期解析,避免误当作毫秒。 - const compactDigits = this.parseCompactDateTimeDigitsToSeconds(rawText) - if (compactDigits > 0) return compactDigits - - const numeric = Number(rawText) - if (Number.isFinite(numeric) && numeric > 0) { - return this.normalizeTimestampSeconds(numeric) - } - - return this.parseDateTimeTextToSeconds(rawText) - } - - private getTimestampSecondsFromRow(row: Record<string, any>): number { - const rawPrimary = this.getRowField(row, [ - 'create_time', 'createTime', 'createtime', - 'msg_create_time', 'msgCreateTime', - 'msg_time', 'msgTime', 'time', - 'WCDB_CT_create_time' - ]) - let primary = this.normalizeRowTimestampSeconds(rawPrimary) - - const rawSortSeq = this.getRowField(row, ['sort_seq', 'sortSeq', 'server_seq', 'serverSeq']) - const sortSeqSeconds = this.normalizeRowTimestampSeconds(rawSortSeq) - - // 对异常小时间戳兜底(例如 parseInt("2026-...") => 2026),优先回退 sort_seq。 - if (primary > 0 && primary < 946684800 && sortSeqSeconds > 946684800) { - return sortSeqSeconds - } - if (primary > 0) return primary - if (sortSeqSeconds > 0) return sortSeqSeconds - return 0 - } - - private getRowField(row: Record<string, any>, keys: string[]): any { - for (const key of keys) { - if (row && Object.prototype.hasOwnProperty.call(row, key)) { - const value = row[key] - if (value !== undefined && value !== null && value !== '') { - return value - } - } - } - return undefined - } - - private normalizeUnsignedIntToken(value: unknown): string { - const raw = String(value ?? '').trim() - if (!raw) return '0' - if (/^\d+$/.test(raw)) { - return raw.replace(/^0+(?=\d)/, '') - } - const num = Number(raw) - if (!Number.isFinite(num) || num <= 0) return '0' - return String(Math.floor(num)) - } - - private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { - const localId = this.normalizeUnsignedIntToken(msg?.localId) - const createTime = this.normalizeUnsignedIntToken(msg?.createTime) - const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId) - return `${localId}:${createTime}:${serverId}` - } - - private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { - const localType = this.normalizeUnsignedIntToken(msg?.localType) - return `${localType}_${this.getStableMessageKey(msg)}` - } - - private getImageMissingRunCacheKey( - sessionId: string, - imageMd5?: unknown, - imageDatName?: unknown - ): string | null { - const normalizedSessionId = String(sessionId || '').trim() - const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase() - const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase() - if (!normalizedSessionId) return null - if (!normalizedImageMd5 && !normalizedImageDatName) return null - - const primaryToken = normalizedImageMd5 || normalizedImageDatName - const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5 - ? normalizedImageDatName - : '' - return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}` - } - - private normalizeEmojiMd5(value: unknown): string | undefined { - const md5 = String(value || '').trim().toLowerCase() - if (!/^[a-f0-9]{32}$/.test(md5)) return undefined - return md5 - } - - private normalizeEmojiCaption(value: unknown): string | null { - const caption = String(value || '').trim() - if (!caption) return null - return caption - } - - private formatEmojiSemanticText(caption?: string | null): string { - const normalizedCaption = this.normalizeEmojiCaption(caption) - if (!normalizedCaption) return '[表情包]' - return `[表情包:${normalizedCaption}]` - } - - private extractLooseHexMd5(content: string): string | undefined { - if (!content) return undefined - const keyedMatch = - /(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) || - /([a-fA-F0-9]{32})/i.exec(content) - return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0]) - } - - private normalizeEmojiCdnUrl(value: unknown): string | undefined { - let url = String(value || '').trim() - if (!url) return undefined - url = url.replace(/&/g, '&') - try { - if (url.includes('%')) { - url = decodeURIComponent(url) - } - } catch { - // keep original URL if decoding fails - } - return url.trim() || undefined - } - - private resolveStrictEmoticonDbPath(): string | null { - const dbPath = this.getConfiguredDbPath() - const rawWxid = this.getConfiguredMyWxid() - const cleanedWxid = this.cleanAccountDirName(rawWxid) - const token = `${dbPath}::${rawWxid}::${cleanedWxid}` - if (token === this.emoticonDbPathCacheToken) { - return this.emoticonDbPathCache - } - this.emoticonDbPathCacheToken = token - this.emoticonDbPathCache = null - - const dbStoragePath = - this.resolveDbStoragePathForExport(dbPath, cleanedWxid) || - this.resolveDbStoragePathForExport(dbPath, rawWxid) - if (!dbStoragePath) return null - - const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db') - if (fs.existsSync(strictPath)) { - this.emoticonDbPathCache = strictPath - return strictPath - } - return null - } - - private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null { - if (!basePath) return null - const normalized = basePath.replace(/[\\/]+$/, '') - if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) { - return normalized - } - const direct = path.join(normalized, 'db_storage') - if (fs.existsSync(direct)) { - return direct - } - if (!wxid) return null - - const viaWxid = path.join(normalized, wxid, 'db_storage') - if (fs.existsSync(viaWxid)) { - return viaWxid - } - - try { - const entries = fs.readdirSync(normalized) - const lowerWxid = wxid.toLowerCase() - const candidates = entries.filter((entry) => { - const entryPath = path.join(normalized, entry) - try { - if (!fs.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 = path.join(normalized, entry, 'db_storage') - if (fs.existsSync(candidate)) { - return candidate - } - } - } catch { - // keep null - } - - return null - } - - private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> { - const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw) - if (!cdnUrl) return null - const emoticonDbPath = this.resolveStrictEmoticonDbPath() - if (!emoticonDbPath) return null - - const candidates = Array.from(new Set([ - cdnUrl, - cdnUrl.replace(/&/g, '&') - ])) - - for (const candidate of candidates) { - const escaped = candidate.replace(/'/g, "''") - const result = await wcdbService.execQuery( - 'message', - emoticonDbPath, - `SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1` - ) - const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null - const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex'])) - if (md5) return md5 - } - - return null - } - - private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> { - const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw) - if (!cdnUrl) return null - - if (this.emojiMd5ByCdnCache.has(cdnUrl)) { - return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null - } - - const pending = this.emojiMd5ByCdnPending.get(cdnUrl) - if (pending) return pending - - const task = (async (): Promise<string | null> => { - try { - return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl) - } catch { - return null - } - })() - - this.emojiMd5ByCdnPending.set(cdnUrl, task) - try { - const md5 = await task - this.emojiMd5ByCdnCache.set(cdnUrl, md5) - return md5 - } finally { - this.emojiMd5ByCdnPending.delete(cdnUrl) - } - } - - private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> { - const md5 = this.normalizeEmojiMd5(md5Raw) - if (!md5) return null - - if (this.emojiCaptionCache.has(md5)) { - return this.emojiCaptionCache.get(md5) ?? null - } - - const pending = this.emojiCaptionPending.get(md5) - if (pending) return pending - - const task = (async (): Promise<string | null> => { - try { - const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5) - if (nativeResult.success) { - const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption) - if (nativeCaption) return nativeCaption - } - } catch { - // ignore and return null - } - return null - })() - - this.emojiCaptionPending.set(md5, task) - try { - const caption = await task - if (caption) { - this.emojiCaptionCache.set(md5, caption) - } else { - this.emojiCaptionCache.delete(md5) - } - return caption - } finally { - this.emojiCaptionPending.delete(md5) - } - } - - private async hydrateEmojiCaptionsForMessages( - sessionId: string, - messages: any[], - control?: ExportTaskControl - ): Promise<void> { - if (!Array.isArray(messages) || messages.length === 0) return - - // 某些环境下游标行缺失 47 的 md5,先按 localId 回填详情再做 caption 查询。 - await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control) - - const unresolvedByUrl = new Map<string, any[]>() - - const uniqueMd5s = new Set<string>() - let scanIndex = 0 - for (const msg of messages) { - if ((scanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (Number(msg?.localType) !== 47) continue - - const content = String(msg?.content || '') - const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5) - || this.extractEmojiMd5(content) - || this.extractLooseHexMd5(content) - const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content)) - if (normalizedCdnUrl) { - msg.emojiCdnUrl = normalizedCdnUrl - } - if (!normalizedMd5) { - if (normalizedCdnUrl) { - const bucket = unresolvedByUrl.get(normalizedCdnUrl) || [] - bucket.push(msg) - unresolvedByUrl.set(normalizedCdnUrl, bucket) - } else { - msg.emojiMd5 = undefined - msg.emojiCaption = undefined - } - continue - } - - msg.emojiMd5 = normalizedMd5 - uniqueMd5s.add(normalizedMd5) - } - - const unresolvedUrls = Array.from(unresolvedByUrl.keys()) - if (unresolvedUrls.length > 0) { - await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => { - if ((index & 0x0f) === 0) { - this.throwIfStopRequested(control) - } - const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url) - if (!resolvedMd5) return - const attached = unresolvedByUrl.get(url) || [] - for (const msg of attached) { - msg.emojiMd5 = resolvedMd5 - uniqueMd5s.add(resolvedMd5) - } - }) - } - - const md5List = Array.from(uniqueMd5s) - if (md5List.length > 0) { - await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => { - if ((index & 0x0f) === 0) { - this.throwIfStopRequested(control) - } - await this.getEmojiCaptionByMd5(md5) - }) - } - - let assignIndex = 0 - for (const msg of messages) { - if ((assignIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (Number(msg?.localType) !== 47) continue - const md5 = this.normalizeEmojiMd5(msg?.emojiMd5) - if (!md5) { - msg.emojiCaption = undefined - continue - } - const caption = this.emojiCaptionCache.get(md5) ?? null - msg.emojiCaption = caption || undefined - } - } - - private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { - const wxid = this.getConfiguredMyWxid() - const dbPath = this.getConfiguredDbPath() - const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() - 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 accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: '无法找到账号目录' } - const ok = await wcdbService.open(accountDir, decryptKey) - 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 [nameResult, avatarResult] = await Promise.all([ - wcdbService.getDisplayNames([username]), - wcdbService.getAvatarUrls([username]) - ]) - - const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username - const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined - - const info = { displayName, avatarUrl } - this.contactCache.set(username, info) - return info - } - - private resolveSessionFilePrefix(sessionId: string, contact?: any): string { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return '私聊_' - if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' - if (normalizedSessionId.startsWith('gh_')) return '公众号_' - - const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type - const localType = Number.parseInt(String(rawLocalType ?? ''), 10) - const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim() - - if (Number.isFinite(localType) && localType === 0 && quanPin) { - return '曾经的好友_' - } - - return '私聊_' - } - - private async getSessionFilePrefix(sessionId: string): Promise<string> { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return '私聊_' - if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' - if (normalizedSessionId.startsWith('gh_')) return '公众号_' - - try { - const contactResult = await wcdbService.getContact(normalizedSessionId) - if (contactResult.success && contactResult.contact) { - return this.resolveSessionFilePrefix(normalizedSessionId, contactResult.contact) - } - } catch { - // ignore and use default private prefix - } - - return '私聊_' - } - - private async preloadContacts( - usernames: Iterable<string>, - cache: Map<string, { success: boolean; contact?: any; error?: string }>, - limit = 8 - ): Promise<void> { - const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) - if (unique.length === 0) return - await parallelLimit(unique, limit, async (username) => { - if (cache.has(username)) return - const result = await wcdbService.getContact(username) - cache.set(username, result) - }) - } - - private async preloadContactInfos( - usernames: Iterable<string>, - limit = 8 - ): Promise<Map<string, { displayName: string; avatarUrl?: string }>> { - const infoMap = new Map<string, { displayName: string; avatarUrl?: string }>() - const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) - if (unique.length === 0) return infoMap - - await parallelLimit(unique, limit, async (username) => { - const info = await this.getContactInfo(username) - infoMap.set(username, info) - }) - - return infoMap - } - - /** - * 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号。 - */ - async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { - try { - const dllResult = await wcdbService.getGroupNicknames(chatroomId) - if (!dllResult.success || !dllResult.nicknames) { - return new Map<string, string>() - } - return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates) - } catch (e) { - console.error('getGroupNicknamesForRoom service error:', e) - return new Map<string, string>() - } - } - - private normalizeGroupNicknameIdentity(value: string): string { - const raw = String(value || '').trim() - if (!raw) return '' - return raw.toLowerCase() - } - - private buildTrustedGroupNicknameMap( - entries: Iterable<[string, string]>, - candidates: string[] = [] - ): Map<string, string> { - const candidateSet = new Set( - this.buildGroupNicknameIdCandidates(candidates) - .map((id) => this.normalizeGroupNicknameIdentity(id)) - .filter(Boolean) - ) - - const buckets = new Map<string, Set<string>>() - for (const [memberIdRaw, nicknameRaw] of entries) { - const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '') - if (!identity) continue - if (candidateSet.size > 0 && !candidateSet.has(identity)) continue - - const nickname = this.normalizeGroupNickname(nicknameRaw || '') - if (!nickname) continue - - const slot = buckets.get(identity) - if (slot) { - slot.add(nickname) - } else { - buckets.set(identity, new Set([nickname])) - } - } - - const trusted = new Map<string, string>() - for (const [identity, nicknameSet] of buckets.entries()) { - if (nicknameSet.size !== 1) continue - trusted.set(identity, Array.from(nicknameSet)[0]) - } - return trusted - } - - private mergeGroupNicknameEntries( - target: Map<string, string>, - entries: Iterable<[string, string]> - ): void { - for (const [memberIdRaw, nicknameRaw] of entries) { - const nickname = this.normalizeGroupNickname(nicknameRaw || '') - if (!nickname) continue - for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) { - if (!alias) continue - if (!target.has(alias)) target.set(alias, nickname) - const lower = alias.toLowerCase() - if (!target.has(lower)) target.set(lower, nickname) - } - } - } - - private decodeExtBuffer(value: unknown): Buffer | null { - if (!value) return null - if (Buffer.isBuffer(value)) return value - if (value instanceof Uint8Array) return Buffer.from(value) - - if (typeof value === 'string') { - const raw = value.trim() - if (!raw) return null - - if (this.looksLikeHex(raw)) { - try { return Buffer.from(raw, 'hex') } catch { } - } - if (this.looksLikeBase64(raw)) { - try { return Buffer.from(raw, 'base64') } catch { } - } - - try { return Buffer.from(raw, 'hex') } catch { } - try { return Buffer.from(raw, 'base64') } catch { } - try { return Buffer.from(raw, 'utf8') } catch { } - return null - } - - return null - } - - private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null { - let value = 0 - let shift = 0 - let pos = offset - while (pos < limit && shift <= 53) { - const byte = buffer[pos] - value += (byte & 0x7f) * Math.pow(2, shift) - pos += 1 - if ((byte & 0x80) === 0) return { value, next: pos } - shift += 7 - } - return null - } - - private isLikelyGroupMemberId(value: string): boolean { - const id = String(value || '').trim() - if (!id) return false - if (id.includes('@chatroom')) return false - if (id.length < 4 || id.length > 80) return false - return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id) - } - - private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> { - const nicknameMap = new Map<string, string>() - if (!buffer || buffer.length === 0) return nicknameMap - - try { - const candidateSet = new Set(this.buildGroupNicknameIdCandidates(candidates).map((id) => id.toLowerCase())) - - for (let i = 0; i < buffer.length - 2; i += 1) { - if (buffer[i] !== 0x0a) continue - - const idLenInfo = this.readVarint(buffer, i + 1) - if (!idLenInfo) continue - const idLen = idLenInfo.value - if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue - - const idStart = idLenInfo.next - const idEnd = idStart + idLen - if (idEnd > buffer.length) continue - - const memberId = buffer.toString('utf8', idStart, idEnd).trim() - if (!this.isLikelyGroupMemberId(memberId)) continue - - const memberIdLower = memberId.toLowerCase() - if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) { - i = idEnd - 1 - continue - } - - const cursor = idEnd - if (cursor >= buffer.length || buffer[cursor] !== 0x12) { - i = idEnd - 1 - continue - } - - const nickLenInfo = this.readVarint(buffer, cursor + 1) - if (!nickLenInfo) { - i = idEnd - 1 - continue - } - const nickLen = nickLenInfo.value - if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) { - i = idEnd - 1 - continue - } - - const nickStart = nickLenInfo.next - const nickEnd = nickStart + nickLen - if (nickEnd > buffer.length) { - i = idEnd - 1 - continue - } - - const rawNick = buffer.toString('utf8', nickStart, nickEnd) - const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim()) - if (!nickname) { - i = nickEnd - 1 - continue - } - - const aliases = this.buildGroupNicknameIdCandidates([memberId]) - for (const alias of aliases) { - if (!alias) continue - if (!nicknameMap.has(alias)) nicknameMap.set(alias, nickname) - const lower = alias.toLowerCase() - if (!nicknameMap.has(lower)) nicknameMap.set(lower, nickname) - } - - i = nickEnd - 1 - } - } catch (e) { - console.error('Failed to parse chat_room.ext_buffer in exportService:', e) - } - - return nicknameMap - } - - /** - * 转换微信消息类型到 ChatLab 类型 - */ - private convertMessageType(localType: number, content: string): number { - const normalized = this.normalizeAppMessageContent(content || '') - if (this.isReadableSystemMessage(localType, normalized)) { - return 80 - } - - const xmlTypeRaw = this.extractAppMessageType(normalized) - const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null - const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>') - - // 特殊处理 type 49 或 XML type - if (looksLikeAppMessage || xmlType) { - const subType = xmlType || 0 - switch (subType) { - case 6: return 4 // 文件 -> FILE - case 19: return 7 // 聊天记录 -> LINK (ChatLab 没有专门的聊天记录类型) - case 33: - case 36: return 24 // 小程序 -> SHARE - case 57: return 25 // 引用回复 -> REPLY - case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型) - case 5: - case 49: return 7 // 链接 -> LINK - default: - if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接 - } - } - return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER - } - - private isReadableSystemMessage(localType: number, content: string): boolean { - if (localType === 10000) return true - const normalized = this.normalizeAppMessageContent(content || '') - return /<sysmsg\b/i.test(this.stripSenderPrefix(normalized)) - } - - /** - * 解码消息内容 - */ - 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 (/^[0-9]+$/.test(raw)) { - return raw - } - // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 - if (raw.length > 16 && this.looksLikeHex(raw)) { - const bytes = Buffer.from(raw, 'hex') - if (bytes.length > 0) return this.decodeBinaryContent(bytes) - } - // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 - // 短字符串(如 "test", "home" 等)容易被误判为 base64 - if (raw.length > 16 && 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 normalizeGroupNickname(value: string): string { - const trimmed = (value || '').trim() - if (!trimmed) return '' - const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '') - if (!cleaned) return '' - if (/^[,"'“”‘’,、]+$/.test(cleaned)) return '' - return cleaned - } - - private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] { - const set = new Set<string>() - for (const rawValue of values) { - const raw = String(rawValue || '').trim() - if (!raw) continue - set.add(raw) - } - return Array.from(set) - } - - private resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string, string>, candidates: Array<string | undefined | null>): string { - const idCandidates = this.buildGroupNicknameIdCandidates(candidates) - if (idCandidates.length === 0) return '' - - let resolved = '' - for (const id of idCandidates) { - const normalizedId = this.normalizeGroupNicknameIdentity(id) - if (!normalizedId) continue - const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '') - if (!candidateNickname) continue - if (!resolved) { - resolved = candidateNickname - continue - } - if (resolved !== candidateNickname) return '' - } - - return resolved - } - - /** - * 根据用户偏好获取显示名称 - */ - private getPreferredDisplayName( - wxid: string, - nickname: string, - remark: string, - groupNickname: string, - preference: 'group-nickname' | 'remark' | 'nickname' = 'remark' - ): string { - switch (preference) { - case 'group-nickname': - return groupNickname || remark || nickname || wxid - case 'remark': - return remark || nickname || wxid - case 'nickname': - return nickname || wxid - default: - return nickname || wxid - } - } - - private async resolveExportDisplayProfile( - wxid: string, - preference: ExportOptions['displayNamePreference'], - getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>, - groupNicknamesMap: Map<string, string>, - fallbackDisplayName = '', - extraGroupNicknameCandidates: Array<string | undefined | null> = [] - ): Promise<ExportDisplayProfile> { - const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown' - const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const } - const contact = contactResult.success ? contactResult.contact : null - const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid) - const remark = String(contact?.remark || '') - const alias = String(contact?.alias || '') - const groupNickname = this.resolveGroupNicknameByCandidates( - groupNicknamesMap, - [ - resolvedWxid, - contact?.username, - contact?.userName, - contact?.encryptUsername, - contact?.encryptUserName, - alias, - ...extraGroupNicknameCandidates - ] - ) || '' - const displayName = this.getPreferredDisplayName( - resolvedWxid, - nickname, - remark, - groupNickname, - preference || 'remark' - ) - - return { - wxid: resolvedWxid, - nickname, - remark, - alias, - groupNickname, - displayName - } - } - - /** - * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 - * @param content 原始消息内容 XML - * @param myWxid 当前用户 wxid - * @param groupNicknamesMap 群昵称映射 - * @param getContactName 联系人名称解析函数 - * @returns "A 转账给 B" 或 null - */ - private async resolveTransferDesc( - content: string, - myWxid: string, - groupNicknamesMap: Map<string, string>, - getContactName: (username: string) => Promise<string> - ): Promise<string | null> { - const normalizedContent = this.normalizeAppMessageContent(content || '') - if (!normalizedContent) return null - - const xmlType = this.extractXmlValue(normalizedContent, 'type') - if (xmlType && xmlType !== '2000') return null - - const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username') - const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username') - if (!payerUsername || !receiverUsername) return null - - const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' - - const resolveName = async (username: string): Promise<string> => { - // 当前用户自己 - if (myWxid && (username === myWxid || username === cleanedMyWxid)) { - const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username, myWxid, cleanedMyWxid]) - if (groupNick) return groupNick - return '我' - } - // 群昵称 - const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) - if (groupNick) return groupNick - // 联系人名称 - return getContactName(username) - } - - const [payerName, receiverName] = await Promise.all([ - resolveName(payerUsername), - resolveName(receiverUsername) - ]) - - return `${payerName} 转账给 ${receiverName}` - } - - private isSameWxid(lhs?: string, rhs?: string): boolean { - const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase())) - if (left.size === 0) return false - const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase()) - return right.some((id) => left.has(id)) - } - - private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' { - const normalizedContent = this.normalizeAppMessageContent(content || '') - if (!normalizedContent) return '[转账]' - - const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype') - // 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定 - // 实测:1=发起侧,3=收款侧 - if (paySubtype === '3') return '[转账收款]' - if (paySubtype === '1') return '[转账]' - - const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username') - const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username') - const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false - const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false - - // 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧 - if (senderWxid) { - if (senderIsReceiver && !senderIsPayer) return '[转账]' - if (senderIsPayer && !senderIsReceiver) return '[转账收款]' - } - - // 兜底:按当前账号角色判断 - if (myWxid) { - if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]' - if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]' - } - - return '[转账]' - } - - private isTransferExportContent(content: string): boolean { - return content.startsWith('[转账]') || content.startsWith('[转账收款]') - } - - private appendTransferDesc(content: string, transferDesc: string): string { - const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]' - return content.replace(prefix, `${prefix} (${transferDesc})`) - } - - 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, - sessionId?: string, - createTime?: number, - myWxid?: string, - senderWxid?: string, - isSend?: boolean, - emojiCaption?: string - ): string | null { - if (!content && localType === 47) { - return this.formatEmojiSemanticText(emojiCaption) - } - if (!content) return null - - const normalizedContent = this.normalizeAppMessageContent(content) - const xmlType = this.extractAppMessageType(normalizedContent) - - switch (localType) { - case 1: // 文本 - return this.stripSenderPrefix(content) - case 3: return '[图片]' - case 34: { - // 语音消息 - 尝试获取转写文字 - const transcriptGetter = (voiceTranscribeService as unknown as { - getCachedTranscript?: (sessionId: string, createTime: number) => string | null | undefined - }).getCachedTranscript - - if (sessionId && createTime && typeof transcriptGetter === 'function') { - const transcript = transcriptGetter(sessionId, createTime) - if (transcript) { - return `[语音消息] ${transcript}` - } - } - return '[语音消息]' // 占位符,导出时会替换为转文字结果 - } - case 42: return '[名片]' - case 43: return '[视频]' - case 47: return this.formatEmojiSemanticText(emojiCaption) - case 48: { - const normalized48 = this.normalizeAppMessageContent(content) - const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') - const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label') - const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude') - const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude') - const locParts: string[] = [] - if (locPoiname) locParts.push(locPoiname) - if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) - if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) - return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' - } - case 49: { - const title = this.extractXmlValue(normalizedContent, 'title') - const type = this.extractAppMessageType(normalizedContent) - const songName = this.extractXmlValue(normalizedContent, 'songname') - - // 转账消息特殊处理 - if (type === '2000') { - const feedesc = this.extractXmlValue(normalizedContent, 'feedesc') - const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo') - const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend) - if (feedesc) { - return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` - } - return transferPrefix - } - - if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') - if (type === '6') return title ? `[文件] ${title}` : '[文件]' - if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) - if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (type === '57') { - const quoteDisplay = this.extractQuotedReplyDisplay(content) - if (quoteDisplay) { - return this.buildQuotedReplyText(quoteDisplay) - } - return title || '[引用消息]' - } - if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' - return title ? `[链接] ${title}` : '[链接]' - } - case 50: return this.parseVoipMessage(content) - case 10000: return this.cleanSystemMessage(content) - case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 - case 244813135921: { - // 引用消息 - const quoteDisplay = this.extractQuotedReplyDisplay(content) - if (quoteDisplay) { - return this.buildQuotedReplyText(quoteDisplay) - } - const title = this.extractXmlValue(content, 'title') - return title || '[引用消息]' - } - default: - // 对于未知的 localType,检查 XML type 来判断消息类型 - if (xmlType) { - const title = this.extractXmlValue(content, 'title') - - // 群公告消息(type 87) - if (xmlType === '87') { - const textAnnouncement = this.extractXmlValue(content, 'textannouncement') - if (textAnnouncement) { - return `[群公告] ${textAnnouncement}` - } - return '[群公告]' - } - - // 转账消息 - if (xmlType === '2000') { - const feedesc = this.extractXmlValue(content, 'feedesc') - const payMemo = this.extractXmlValue(content, 'pay_memo') - const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) - if (feedesc) { - return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` - } - return transferPrefix - } - - // 其他类型 - if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' - if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' - if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) - if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (xmlType === '57') { - const quoteDisplay = this.extractQuotedReplyDisplay(content) - if (quoteDisplay) { - return this.buildQuotedReplyText(quoteDisplay) - } - return title || '[引用消息]' - } - if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]' - if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' - - // 有 title 就返回 title - if (title) return title - } - - // 最后尝试提取文本内容 - return this.stripSenderPrefix(normalizedContent) || null - } - } - - private formatPlainExportContent( - content: string, - localType: number, - options: { exportVoiceAsText?: boolean }, - voiceTranscript?: string, - myWxid?: string, - senderWxid?: string, - isSend?: boolean, - emojiCaption?: string - ): string { - const safeContent = content || '' - const readableSystemText = this.extractReadableSystemMessageText(safeContent) - if (readableSystemText && this.isReadableSystemMessage(localType, safeContent)) { - return readableSystemText - } - - if (localType === 3) return '[图片]' - if (localType === 1) return this.stripSenderPrefix(safeContent) - if (localType === 34) { - if (options.exportVoiceAsText) { - return voiceTranscript || '[语音消息 - 转文字失败]' - } - return '[其他消息]' - } - if (localType === 42) { - const normalized = this.normalizeAppMessageContent(safeContent) - const nickname = - this.extractXmlValue(normalized, 'nickname') || - this.extractXmlValue(normalized, 'displayname') || - this.extractXmlValue(normalized, 'name') - return nickname ? `[名片]${nickname}` : '[名片]' - } - if (localType === 43) { - const normalized = this.normalizeAppMessageContent(safeContent) - const lengthValue = - this.extractXmlValue(normalized, 'playlength') || - this.extractXmlValue(normalized, 'playLength') || - this.extractXmlValue(normalized, 'length') || - this.extractXmlValue(normalized, 'duration') - const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null - return seconds ? `[视频]${seconds}s` : '[视频]' - } - if (localType === 47) { - return this.formatEmojiSemanticText(emojiCaption) - } - if (localType === 48) { - const normalized = this.normalizeAppMessageContent(safeContent) - const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') - const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label') - const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') - const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') - const locParts: string[] = [] - if (locPoiname) locParts.push(locPoiname) - if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) - if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) - return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' - } - if (localType === 50) { - return this.parseVoipMessage(safeContent) - } - if (localType === 10000 || localType === 266287972401) { - return this.cleanSystemMessage(safeContent) - } - - const normalized = this.normalizeAppMessageContent(safeContent) - const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>') - if (localType === 49 || isAppMessage) { - const subTypeRaw = this.extractAppMessageType(normalized) - const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 - const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') - - // 群公告消息(type 87) - if (subType === 87) { - const textAnnouncement = this.extractXmlValue(normalized, 'textannouncement') - if (textAnnouncement) { - return `[群公告]${textAnnouncement}` - } - return '[群公告]' - } - - // 转账消息特殊处理 - if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) { - const feedesc = this.extractXmlValue(normalized, 'feedesc') - const payMemo = this.extractXmlValue(normalized, 'pay_memo') - const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend) - if (feedesc) { - return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}` - } - const amount = this.extractAmountFromText( - [ - title, - this.extractXmlValue(normalized, 'des'), - this.extractXmlValue(normalized, 'money'), - this.extractXmlValue(normalized, 'amount'), - this.extractXmlValue(normalized, 'fee') - ] - .filter(Boolean) - .join(' ') - ) - return amount ? `${transferPrefix}${amount}` : transferPrefix - } - - if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) { - const songName = this.extractXmlValue(normalized, 'songname') || title || '音乐' - return `[音乐]${songName}` - } - if (subType === 6) { - const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件' - return `[文件]${fileName}` - } - if (title.includes('红包') || normalized.includes('hongbao')) { - return `[红包]${title || '微信红包'}` - } - if (subType === 19 || normalized.includes('<recorditem')) { - return this.formatForwardChatRecordContent(normalized) - } - if (subType === 33 || subType === 36) { - const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序' - return `[小程序]${appName}` - } - if (subType === 57) { - const quoteDisplay = this.extractQuotedReplyDisplay(safeContent) - if (quoteDisplay) { - return this.buildQuotedReplyText(quoteDisplay) - } - return title || '[引用消息]' - } - if (title) { - return `[链接]${title}` - } - return '[其他消息]' - } - - return '[其他消息]' - } - - private formatQuotedReferencePreview(content: string, type?: string): string { - const safeContent = content || '' - const referType = Number.parseInt(String(type || ''), 10) - if (!Number.isFinite(referType)) { - const sanitized = this.sanitizeQuotedContent(safeContent) - return sanitized || '[消息]' - } - - if (referType === 49) { - const normalized = this.normalizeAppMessageContent(safeContent) - const title = - this.extractXmlValue(normalized, 'title') || - this.extractXmlValue(normalized, 'filename') || - this.extractXmlValue(normalized, 'appname') - if (title) return this.stripSenderPrefix(title) - - const subTypeRaw = this.extractAppMessageType(normalized) - const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 - if (subType === 6) return '[文件]' - if (subType === 19) return '[聊天记录]' - if (subType === 33 || subType === 36) return '[小程序]' - return '[链接]' - } - - return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]' - } - - private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string { - const normalizedChatUsr = String(chatusr || '').trim() - const normalizedFromUsr = String(fromusr || '').trim() - - if (normalizedChatUsr) { - return normalizedChatUsr - } - - if (normalizedFromUsr.endsWith('@chatroom')) { - return '' - } - - return normalizedFromUsr - } - - private buildQuotedReplyText(display: { - replyText: string - quotedSender?: string - quotedPreview: string - }): string { - const quoteLabel = display.quotedSender - ? `${display.quotedSender}:${display.quotedPreview}` - : display.quotedPreview - if (display.replyText) { - return `${display.replyText}[引用 ${quoteLabel}]` - } - return `[引用 ${quoteLabel}]` - } - - private extractQuotedReplyDisplay(content: string): { - replyText: string - quotedSender?: string - quotedPreview: string - } | null { - try { - const normalized = this.normalizeAppMessageContent(content || '') - const referMsgStart = normalized.indexOf('<refermsg>') - const referMsgEnd = normalized.indexOf('</refermsg>') - if (referMsgStart === -1 || referMsgEnd === -1) { - return null - } - - const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) - const quoteInfo = this.parseQuoteMessage(normalized) - const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') - const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview( - this.extractXmlValue(referMsgXml, 'content'), - this.extractXmlValue(referMsgXml, 'type') - ) - - if (!replyText && !quotedPreview) { - return null - } - - return { - replyText, - quotedSender: quoteInfo.sender || undefined, - quotedPreview: quotedPreview || '[消息]' - } - } catch { - return null - } - } - - private isQuotedReplyMessage(localType: number, content: string): boolean { - if (localType === 244813135921) return true - const normalized = this.normalizeAppMessageContent(content || '') - if (!(localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>'))) { - return false - } - const subType = this.extractAppMessageType(normalized) - return subType === '57' || normalized.includes('<refermsg>') - } - - private async resolveQuotedReplyDisplayWithNames(args: { - content: string - isGroup: boolean - displayNamePreference: ExportOptions['displayNamePreference'] - getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> - groupNicknamesMap: Map<string, string> - cleanedMyWxid: string - rawMyWxid?: string - myDisplayName?: string - }): Promise<{ - replyText: string - quotedSender?: string - quotedPreview: string - } | null> { - const base = this.extractQuotedReplyDisplay(args.content) - if (!base) return null - if (base.quotedSender) return base - - const normalized = this.normalizeAppMessageContent(args.content || '') - const referMsgStart = normalized.indexOf('<refermsg>') - const referMsgEnd = normalized.indexOf('</refermsg>') - if (referMsgStart === -1 || referMsgEnd === -1) { - return base - } - - const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) - const quotedSenderUsername = this.resolveQuotedSenderUsername( - this.extractXmlValue(referMsgXml, 'fromusr'), - this.extractXmlValue(referMsgXml, 'chatusr') - ) - if (!quotedSenderUsername) { - return base - } - - const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) - const fallbackDisplayName = isQuotedSelf - ? (args.myDisplayName || quotedSenderUsername) - : quotedSenderUsername - - const profile = await this.resolveExportDisplayProfile( - quotedSenderUsername, - args.displayNamePreference, - args.getContact, - args.groupNicknamesMap, - fallbackDisplayName, - isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] - ) - - return { - ...base, - quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender - } - } - - private parseDurationSeconds(value: string): number | null { - const numeric = Number(value) - if (!Number.isFinite(numeric) || numeric <= 0) return null - if (numeric >= 1000) return Math.round(numeric / 1000) - return Math.round(numeric) - } - - private extractAmountFromText(text: string): string | null { - if (!text) return null - const match = /([¥¥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text) - return match ? match[1].replace(/\s+/g, '') : null - } - - private stripSenderPrefix(content: string): string { - return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') - } - - private getWeCloneTypeName(localType: number, content: string): string { - if (localType === 1) return 'text' - if (localType === 3) return 'image' - if (localType === 47) return 'sticker' - if (localType === 43) return 'video' - if (localType === 34) return 'voice' - if (localType === 48) return 'location' - const normalized = this.normalizeAppMessageContent(content || '') - const xmlType = this.extractAppMessageType(normalized) - if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) { - if (xmlType === '6') return 'file' - return 'text' - } - return 'text' - } - - private getWeCloneSource(msg: any, typeName: string, mediaItem: MediaExportItem | null): string { - if (mediaItem?.relativePath) { - return mediaItem.relativePath - } - - if (typeName === 'image') { - return msg.imageDatName || '' - } - if (typeName === 'sticker') { - return msg.emojiCdnUrl || '' - } - if (typeName === 'video') { - return '' - } - if (typeName === 'file') { - const xml = msg.content || '' - return this.extractXmlValue(xml, 'filename') || this.extractXmlValue(xml, 'title') || '' - } - return '' - } - - private escapeCsvCell(value: unknown): string { - if (value === null || value === undefined) return '' - const text = String(value) - if (/[",\r\n]/.test(text)) { - return `"${text.replace(/"/g, '""')}"` - } - return text - } - - private formatIsoTimestamp(timestamp: number): string { - return new Date(timestamp * 1000).toISOString() - } - - /** - * 从撤回消息内容中提取撤回者的 wxid - * 撤回消息 XML 格式通常包含 <session> 或 <newmsgid> 等字段 - * 以及撤回者的 wxid 在某些字段中 - * @returns { isRevoke: true, isSelfRevoke: true } - 是自己撤回的消息 - * @returns { isRevoke: true, revokerWxid: string } - 是别人撤回的消息,提取到撤回者 - * @returns { isRevoke: false } - 不是撤回消息 - */ - private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } { - if (!content) return { isRevoke: false } - - // 检查是否是撤回消息 - if (!content.includes('revokemsg') && !content.includes('撤回')) { - return { isRevoke: false } - } - - // 检查是否是 "你撤回了" - 自己撤回 - if (content.includes('你撤回')) { - return { isRevoke: true, isSelfRevoke: true } - } - - // 尝试从 <session> 标签提取(格式: wxid_xxx) - const sessionMatch = /<session>([^<]+)<\/session>/i.exec(content) - if (sessionMatch) { - const session = sessionMatch[1].trim() - // 如果 session 是 wxid 格式,返回它 - if (session.startsWith('wxid_') || /^[a-zA-Z][a-zA-Z0-9_-]+$/.test(session)) { - return { isRevoke: true, revokerWxid: session } - } - } - - // 尝试从 <fromusername> 提取 - const fromUserMatch = /<fromusername>([^<]+)<\/fromusername>/i.exec(content) - if (fromUserMatch) { - return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() } - } - - // 是撤回消息但无法提取撤回者 - return { isRevoke: true } - } - - 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(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim() - } - return '' - } - - private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { - const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i') - const match = tagRegex.exec(xml) - return match ? match[1] : '' - } - - private cleanSystemMessage(content: string): string { - if (!content) return '[系统消息]' - - // 先尝试提取特定的系统消息内容 - // 1. 提取 sysmsg 中的文本内容 - const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content) - if (sysmsgTextMatch) { - content = sysmsgTextMatch[1] - } - - // 2. 提取 revokemsg 撤回消息 - const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content) - if (revokeMatch) { - return revokeMatch[1].trim() - } - - // 3. 提取 pat 拍一拍消息(sysmsg 内的 template 格式) - const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content) - if (patMatch) { - // 移除模板变量占位符 - return patMatch[1] - .replace(/\$\{([^}]+)\}/g, (_, varName) => { - const varMatch = new RegExp(`<${varName}><!\\\[CDATA\\\[([^\]]*)\\\]\\\]><\/${varName}>`, 'i').exec(content) - return varMatch ? varMatch[1] : '' - }) - .replace(/<[^>]+>/g, '') - .trim() - } - - // 3.5 提取 <title> 内容(适用于 appmsg 格式的拍一拍等消息) - const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) - if (titleMatch) { - const title = titleMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim() - if (title) { - return title - } - } - - // 4. 处理 CDATA 内容 - content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '') - - // 5. 移除所有 XML 标签 - return content - .replace(/<img[^>]*>/gi, '') - .replace(/<\/?[a-zA-Z0-9_:]+[^>]*>/g, '') - .replace(/\s+/g, ' ') - .trim() || '[系统消息]' - } - - private extractReadableSystemMessageText(content: string): string { - if (!content) return '' - const normalized = this.normalizeAppMessageContent(content) - const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(this.stripSenderPrefix(normalized)) - const source = sysmsgMatch?.[1] || normalized - const text = - this.extractXmlValue(source, 'plain') || - this.extractXmlValue(source, 'text') || - '' - return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim() - } - - /** - * 解析通话消息 - * 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg> - * room_type: 0 = 语音通话, 1 = 视频通话 - */ - private parseVoipMessage(content: string): string { - try { - if (!content) return '[通话]' - - // 提取 msg 内容(中文通话状态) - const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content) - const msg = msgMatch?.[1]?.trim() || '' - - // 提取 room_type(0=视频,1=语音) - const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content) - const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 - - // 构建通话类型标签 - let callType: string - if (roomType === 0) { - callType = '视频通话' - } else if (roomType === 1) { - callType = '语音通话' - } else { - callType = '通话' - } - - // 解析通话状态 - if (msg.includes('通话时长')) { - const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) - const duration = durationMatch?.[1] || '' - if (duration) { - return `[${callType}] ${duration}` - } - return `[${callType}] 已接听` - } else if (msg.includes('对方无应答')) { - return `[${callType}] 对方无应答` - } else if (msg.includes('已取消')) { - return `[${callType}] 已取消` - } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { - return `[${callType}] 已在其他设备接听` - } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { - return `[${callType}] 对方已拒绝` - } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { - return `[${callType}] 忙线未接听` - } else if (msg.includes('未接听')) { - return `[${callType}] 未接听` - } else if (msg) { - return `[${callType}] ${msg}` - } - - return `[${callType}]` - } catch (e) { - return '[通话]' - } - } - - /** - * 获取消息类型名称 - */ - private getMessageTypeName(localType: number, content?: string): string { - // 检查 XML 中的 type 标签(支持大 localType 的情况) - if (content) { - const normalized = this.normalizeAppMessageContent(content) - if (this.isReadableSystemMessage(localType, normalized)) { - return '系统消息' - } - const xmlType = this.extractAppMessageType(normalized) - - if (xmlType) { - switch (xmlType) { - case '3': return '音乐消息' - case '87': return '群公告' - case '2000': return '转账消息' - case '5': return '链接消息' - case '6': return '文件消息' - case '19': return '聊天记录' - case '33': - case '36': return '小程序消息' - case '57': return '引用消息' - } - } - } - - const typeNames: Record<number, string> = { - 1: '文本消息', - 3: '图片消息', - 34: '语音消息', - 42: '名片消息', - 43: '视频消息', - 47: '动画表情', - 48: '位置消息', - 49: '链接消息', - 50: '通话消息', - 10000: '系统消息', - 244813135921: '引用消息' - } - 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 normalizeTxtColumns(columns?: string[] | null): string[] { - const fallback = ['index', 'time', 'senderRole', 'messageType', 'content'] - const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean)) - const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id)) - return ordered.length > 0 ? ordered : fallback - } - - private sanitizeTxtValue(value: string): string { - return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() - } - - private escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, c => { - switch (c) { - case '&': return '&' - case '<': return '<' - case '>': return '>' - case '"': return '"' - case "'": return ''' - default: return c - } - }) - } - - private escapeAttribute(value: string): string { - return value.replace(/[&<>"'`]/g, c => { - switch (c) { - case '&': return '&' - case '<': return '<' - case '>': return '>' - case '"': return '"' - case "'": return ''' - case '`': return '`' - default: return c - } - }) - } - - private getAvatarFallback(name: string): string { - if (!name) return '?' - return [...name][0] || '?' - } - - private renderMultilineText(value: string): string { - return this.escapeHtml(value).replace(/\r?\n/g, '<br />') - } - - private loadExportHtmlStyles(): string { - if (this.htmlStyleCache !== null) { - return this.htmlStyleCache - } - const candidates = [ - path.join(__dirname, 'exportHtml.css'), - path.join(process.cwd(), 'electron', 'services', 'exportHtml.css') - ] - for (const filePath of candidates) { - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf-8') - if (content.trim().length > 0) { - this.htmlStyleCache = content - return content - } - } catch { - continue - } - } - } - this.htmlStyleCache = EXPORT_HTML_STYLES - return this.htmlStyleCache - } - - /** - * 解析合并转发的聊天记录 (Type 19) - */ - private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined { - try { - const normalized = this.normalizeAppMessageContent(content || '') - const appMsgType = this.extractAppMessageType(normalized) - if (appMsgType !== '19' && !normalized.includes('<recorditem')) { - return undefined - } - - const items: ForwardChatRecordItem[] = [] - const dedupe = new Set<string>() - const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi - let recordItemMatch: RegExpExecArray | null - while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { - const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') - for (const item of parsedItems) { - const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}` - if (!dedupe.has(dedupeKey)) { - dedupe.add(dedupeKey) - items.push(item) - } - } - } - - if (items.length === 0 && normalized.includes('<dataitem')) { - const fallbackItems = this.parseForwardChatRecordContainer(normalized) - for (const item of fallbackItems) { - const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}` - if (!dedupe.has(dedupeKey)) { - dedupe.add(dedupeKey) - items.push(item) - } - } - } - - return items.length > 0 ? items : undefined - } catch (e) { - console.error('ExportService: 解析聊天记录失败:', e) - return undefined - } - } - - private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] { - const source = containerXml || '' - if (!source) return [] - - const segments: string[] = [source] - const decodedContainer = this.decodeHtmlEntities(source) - if (decodedContainer !== source) { - segments.push(decodedContainer) - } - - const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g - let cdataMatch: RegExpExecArray | null - while ((cdataMatch = cdataRegex.exec(source)) !== null) { - const cdataInner = cdataMatch[1] || '' - if (cdataInner) { - segments.push(cdataInner) - const decodedInner = this.decodeHtmlEntities(cdataInner) - if (decodedInner !== cdataInner) { - segments.push(decodedInner) - } - } - } - - const items: ForwardChatRecordItem[] = [] - const seen = new Set<string>() - for (const segment of segments) { - if (!segment) continue - const dataItemRegex = /<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi - let dataItemMatch: RegExpExecArray | null - while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) { - const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '') - if (!parsed) continue - const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}` - if (!seen.has(key)) { - seen.add(key) - items.push(parsed) - } - } - } - - if (items.length > 0) return items - const fallback = this.parseForwardChatRecordDataItem(source, '') - return fallback ? [fallback] : [] - } - - private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null { - const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') - const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0' - const datatype = Number.parseInt(datatypeRaw, 10) - const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename')) - const sourcetime = this.extractXmlValue(body, 'sourcetime') - const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') - const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content')) - const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle')) - const fileext = this.extractXmlValue(body, 'fileext') - const datasizeRaw = this.extractXmlValue(body, 'datasize') - const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0 - const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || '' - const nestedRecordList = - datatype === 17 && nestedRecordXml - ? this.parseForwardChatRecordContainer(nestedRecordXml) - : undefined - const chatRecordTitle = this.decodeHtmlEntities( - (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || '' - ) - const chatRecordDesc = this.decodeHtmlEntities( - (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || '' - ) - - if (!sourcename && !datadesc && !datatitle) return null - - return { - datatype: Number.isFinite(datatype) ? datatype : 0, - sourcename: sourcename || '', - sourcetime: sourcetime || '', - sourceheadurl: sourceheadurl || undefined, - datadesc: datadesc || undefined, - datatitle: datatitle || undefined, - fileext: fileext || undefined, - datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined, - chatRecordTitle: chatRecordTitle || undefined, - chatRecordDesc: chatRecordDesc || undefined, - chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined - } - } - - private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string { - const desc = (item.datadesc || '').trim() - const title = (item.datatitle || '').trim() - if (desc) return desc - if (title) return title - switch (item.datatype) { - case 3: return '[图片]' - case 34: return '[语音消息]' - case 43: return '[视频]' - case 47: return '[表情包]' - case 49: - case 8: return title ? `[文件] ${title}` : '[文件]' - case 17: return item.chatRecordDesc || title || '[聊天记录]' - default: return '[消息]' - } - } - - private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] { - const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : '' - const senderPrefix = record.sourcename ? `${record.sourcename}: ` : '' - if (record.chatRecordList && record.chatRecordList.length > 0) { - const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录' - const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}` - const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1)) - return [header, ...nestedLines] - } - const text = this.formatForwardChatRecordItemText(record) - return [`${indent}${senderPrefix}${text}`] - } - - private formatForwardChatRecordContent(content: string): string { - const normalized = this.normalizeAppMessageContent(content || '') - const forwardName = - this.extractXmlValue(normalized, 'nickname') || - this.extractXmlValue(normalized, 'title') || - this.extractXmlValue(normalized, 'des') || - this.extractXmlValue(normalized, 'displayname') || - '聊天记录' - const records = this.parseChatHistory(normalized) - if (!records || records.length === 0) { - return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]' - } - - const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record)) - return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}` - } - - /** - * 解码 HTML 实体 - */ - private decodeHtmlEntities(text: string): string { - if (!text) return '' - return text - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - } - - private normalizeAppMessageContent(content: string): string { - if (!content) return '' - if (content.includes('<') && content.includes('>')) { - return content - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - } - return content - } - - private extractFinderFeedDesc(content: string): string { - if (!content) return '' - const match = /<finderFeed[\s\S]*?<desc>([\s\S]*?)<\/desc>/i.exec(content) - if (!match) return '' - return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim() - } - - private extractAppMessageType(content: string): string { - if (!content) return '' - const normalized = this.normalizeAppMessageContent(content) - const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(normalized) - if (appmsgMatch) { - const appmsgInner = appmsgMatch[1] - .replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '') - .replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '') - const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner) - if (typeMatch) return typeMatch[1].trim() - } - if (!normalized.includes('<appmsg') && !normalized.includes('<msg>')) { - return '' - } - const fallbackTypeMatch = /<type>(\d+)<\/type>/i.exec(normalized) - return fallbackTypeMatch ? fallbackTypeMatch[1] : '' - } - - 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) - } - - private sanitizeQuotedContent(content: string): string { - if (!content) return '' - let result = content - 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 async resolveQuotedMessagesForExport(messages: any[], sessionId: string): Promise<void> { - const svridsToResolve: Array<{ msg: any; svrid: string }> = [] - - for (const msg of messages) { - if (msg.replyToMessageId && msg.quotedContent === '[消息]') { - svridsToResolve.push({ msg, svrid: msg.replyToMessageId }) - } - } - - if (svridsToResolve.length === 0) return - - const results = await Promise.allSettled( - svridsToResolve.map(({ svrid }) => wcdbService.getMessageByServerId(sessionId, svrid)) - ) - - for (let i = 0; i < results.length; i++) { - const result = results[i] - const { msg } = svridsToResolve[i] - - if (result.status === 'fulfilled' && result.value.success && result.value.row) { - const localType = parseInt(result.value.row.local_type || '0', 10) - const rawMessageContent = result.value.row.message_content - const rawCompressContent = result.value.row.compress_content - const content = chatService['decodeMessageContent'](rawMessageContent, rawCompressContent) - - if (localType === 1) { - msg.quotedContent = chatService['sanitizeQuotedContent'](content) - } else if (localType === 3) { - msg.quotedContent = '[图片]' - } else if (localType === 34) { - msg.quotedContent = '[语音]' - } else if (localType === 43) { - msg.quotedContent = '[视频]' - } else if (localType === 47) { - msg.quotedContent = '[动画表情]' - } else if (localType === 49) { - msg.quotedContent = '[链接]' - } - } - } - } - - private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string; svrid?: string } { - try { - const normalized = this.normalizeAppMessageContent(content || '') - const referMsgStart = normalized.indexOf('<refermsg>') - const referMsgEnd = normalized.indexOf('</refermsg>') - if (referMsgStart === -1 || referMsgEnd === -1) { - return {} - } - - const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) - let sender = this.extractXmlValue(referMsgXml, 'displayname') - if (sender && this.looksLikeWxid(sender)) { - sender = '' - } - - const referContent = this.extractXmlValue(referMsgXml, 'content') - const referType = this.extractXmlValue(referMsgXml, 'type') - const svrid = this.extractXmlValue(referMsgXml, 'svrid') - let displayContent = referContent - - switch (referType) { - case '1': - displayContent = this.extractPreferredQuotedText(referMsgXml) - 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 || undefined, - sender: sender || undefined, - type: referType || undefined - } - } catch { - return {} - } - } - - private extractPreferredQuotedText(referMsgXml: string): string { - if (!referMsgXml) return '' - - const sources = [this.decodeHtmlEntities(referMsgXml)] - const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') - if (rawMsgSource) { - const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) - if (decodedMsgSource) { - sources.push(decodedMsgSource) - } - } - - const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) - const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) - if (partialText) return partialText - - const candidateTags = [ - 'selectedcontent', - 'selectedtext', - 'selectcontent', - 'selecttext', - 'quotecontent', - 'quotetext', - 'partcontent', - 'parttext', - 'excerpt', - 'summary', - 'preview' - ] - - for (const source of sources) { - for (const tag of candidateTags) { - const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) - if (value) return value - } - } - - return fullContent - } - - private extractPartialQuotedText(xml: string, fullContent: string): string { - if (!xml || !fullContent) return '' - - const startChar = this.extractXmlValue(xml, 'start') - const endChar = this.extractXmlValue(xml, 'end') - const startIndexRaw = this.extractXmlValue(xml, 'startindex') - const endIndexRaw = this.extractXmlValue(xml, 'endindex') - const startIndex = Number.parseInt(startIndexRaw, 10) - const endIndex = Number.parseInt(endIndexRaw, 10) - - if (startChar && endChar) { - const startPos = fullContent.indexOf(startChar) - if (startPos !== -1) { - const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) - if (endPos !== -1 && endPos >= startPos) { - const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() - if (sliced) return sliced - } - } - } - - if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { - const chars = Array.from(fullContent) - const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() - if (sliced) return sliced - } - - return '' - } - - private extractChatLabReplyToMessageId(content: string): string | undefined { - try { - const normalized = this.normalizeAppMessageContent(content || '') - const referMsgStart = normalized.indexOf('<refermsg>') - const referMsgEnd = normalized.indexOf('</refermsg>') - if (referMsgStart === -1 || referMsgEnd === -1) { - return undefined - } - - const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) - const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid')) - return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined - } catch { - return undefined - } - } - - private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined { - const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) - return value !== '0' ? value : undefined - } - - private getExportReplyToMessageId(content: string): string | undefined { - return this.extractChatLabReplyToMessageId(content) - } - - private extractArkmeAppMessageMeta(content: string, localType: number): Record<string, any> | null { - if (!content) return null - - const normalized = this.normalizeAppMessageContent(content) - const looksLikeAppMsg = - localType === 49 || - localType === 244813135921 || - normalized.includes('<appmsg') || - normalized.includes('<msg>') - const hasReferMsg = normalized.includes('<refermsg>') - const xmlType = this.extractAppMessageType(normalized) - const isFinder = - xmlType === '51' || - normalized.includes('<finder') || - normalized.includes('finderusername') || - normalized.includes('finderobjectid') - const isMusic = - xmlType === '3' || - normalized.includes('<musicurl') || - normalized.includes('<playurl>') || - normalized.includes('<dataurl>') - - if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null - - let appMsgKind: string | undefined - if (isFinder) { - appMsgKind = 'finder' - } else if (xmlType === '2001') { - appMsgKind = 'red-packet' - } else if (isMusic) { - appMsgKind = 'music' - } else if (xmlType === '33' || xmlType === '36') { - appMsgKind = 'miniapp' - } else if (xmlType === '6') { - appMsgKind = 'file' - } else if (xmlType === '19') { - appMsgKind = 'chat-record' - } else if (xmlType === '2000') { - appMsgKind = 'transfer' - } else if (xmlType === '87') { - appMsgKind = 'announcement' - } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { - appMsgKind = 'quote' - } else if (xmlType === '53') { - appMsgKind = 'solitaire' - } else if (xmlType === '5' || xmlType === '49') { - appMsgKind = 'link' - } else if (looksLikeAppMsg) { - appMsgKind = 'card' - } - - const meta: Record<string, any> = {} - if (xmlType) meta.appMsgType = xmlType - else if (appMsgKind === 'quote') meta.appMsgType = '57' - if (appMsgKind) meta.appMsgKind = appMsgKind - - const appMsgDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') - const appMsgAppName = this.extractXmlValue(normalized, 'appname') - const appMsgSourceName = - this.extractXmlValue(normalized, 'sourcename') || - this.extractXmlValue(normalized, 'sourcedisplayname') - const appMsgSourceUsername = this.extractXmlValue(normalized, 'sourceusername') - const appMsgThumbUrl = - this.extractXmlValue(normalized, 'thumburl') || - this.extractXmlValue(normalized, 'cdnthumburl') || - this.extractXmlValue(normalized, 'cover') || - this.extractXmlValue(normalized, 'coverurl') || - this.extractXmlValue(normalized, 'thumbUrl') || - this.extractXmlValue(normalized, 'coverUrl') - - if (appMsgDesc) meta.appMsgDesc = appMsgDesc - if (appMsgAppName) meta.appMsgAppName = appMsgAppName - if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName - if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername - if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl - - if (appMsgKind === 'quote') { - const quoteInfo = this.parseQuoteMessage(normalized) - if (quoteInfo.content) meta.quotedContent = quoteInfo.content - if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender - if (quoteInfo.type) meta.quotedType = quoteInfo.type - if (quoteInfo.svrid) meta.quotedSvrid = quoteInfo.svrid - } - - if (appMsgKind === 'link') { - const linkCard = this.extractHtmlLinkCard(normalized, localType) - const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl( - this.extractXmlValue(normalized, 'shareurl') || - this.extractXmlValue(normalized, 'shorturl') || - this.extractXmlValue(normalized, 'dataurl') - ) - if (linkCard?.title) meta.linkTitle = linkCard.title - if (linkUrl) meta.linkUrl = linkUrl - if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl - } - - if (isMusic) { - const musicTitle = - this.extractXmlValue(normalized, 'songname') || - this.extractXmlValue(normalized, 'title') - const musicUrl = - this.extractXmlValue(normalized, 'musicurl') || - this.extractXmlValue(normalized, 'playurl') || - this.extractXmlValue(normalized, 'songalbumurl') - const musicDataUrl = - this.extractXmlValue(normalized, 'dataurl') || - this.extractXmlValue(normalized, 'lowurl') - const musicAlbumUrl = this.extractXmlValue(normalized, 'songalbumurl') - const musicCoverUrl = - this.extractXmlValue(normalized, 'thumburl') || - this.extractXmlValue(normalized, 'cdnthumburl') || - this.extractXmlValue(normalized, 'coverurl') || - this.extractXmlValue(normalized, 'cover') - const musicSinger = - this.extractXmlValue(normalized, 'singername') || - this.extractXmlValue(normalized, 'artist') || - this.extractXmlValue(normalized, 'albumartist') - const musicAppName = this.extractXmlValue(normalized, 'appname') - const musicSourceName = this.extractXmlValue(normalized, 'sourcename') - const durationRaw = - this.extractXmlValue(normalized, 'playlength') || - this.extractXmlValue(normalized, 'play_length') || - this.extractXmlValue(normalized, 'duration') - const musicDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null - - if (musicTitle) meta.musicTitle = musicTitle - if (musicUrl) meta.musicUrl = musicUrl - if (musicDataUrl) meta.musicDataUrl = musicDataUrl - if (musicAlbumUrl) meta.musicAlbumUrl = musicAlbumUrl - if (musicCoverUrl) meta.musicCoverUrl = musicCoverUrl - if (musicSinger) meta.musicSinger = musicSinger - if (musicAppName) meta.musicAppName = musicAppName - if (musicSourceName) meta.musicSourceName = musicSourceName - if (musicDuration != null) meta.musicDuration = musicDuration - } - - if (!isFinder) { - return Object.keys(meta).length > 0 ? meta : null - } - - const rawTitle = this.extractXmlValue(normalized, 'title') - const finderFeedDesc = this.extractFinderFeedDesc(normalized) - const finderTitle = (!rawTitle || rawTitle.includes('不支持')) ? finderFeedDesc : rawTitle - const finderDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') - const finderUsername = - this.extractXmlValue(normalized, 'finderusername') || - this.extractXmlValue(normalized, 'finder_username') || - this.extractXmlValue(normalized, 'finderuser') - const finderNickname = - this.extractXmlValue(normalized, 'findernickname') || - this.extractXmlValue(normalized, 'finder_nickname') - const finderCoverUrl = - this.extractXmlValue(normalized, 'thumbUrl') || - this.extractXmlValue(normalized, 'coverUrl') || - this.extractXmlValue(normalized, 'thumburl') || - this.extractXmlValue(normalized, 'coverurl') - const finderAvatar = this.extractXmlValue(normalized, 'avatar') - const durationRaw = this.extractXmlValue(normalized, 'videoPlayDuration') || this.extractXmlValue(normalized, 'duration') - const finderDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null - const finderObjectId = - this.extractXmlValue(normalized, 'finderobjectid') || - this.extractXmlValue(normalized, 'finder_objectid') || - this.extractXmlValue(normalized, 'objectid') || - this.extractXmlValue(normalized, 'object_id') - const finderUrl = - this.extractXmlValue(normalized, 'url') || - this.extractXmlValue(normalized, 'shareurl') - - if (finderTitle) meta.finderTitle = finderTitle - if (finderDesc) meta.finderDesc = finderDesc - if (finderUsername) meta.finderUsername = finderUsername - if (finderNickname) meta.finderNickname = finderNickname - if (finderCoverUrl) meta.finderCoverUrl = finderCoverUrl - if (finderAvatar) meta.finderAvatar = finderAvatar - if (finderDuration != null) meta.finderDuration = finderDuration - if (finderObjectId) meta.finderObjectId = finderObjectId - if (finderUrl) meta.finderUrl = finderUrl - - return Object.keys(meta).length > 0 ? meta : null - } - - private extractArkmeContactCardMeta(content: string, localType: number): Record<string, any> | null { - if (!content || localType !== 42) return null - - const normalized = this.normalizeAppMessageContent(content) - const readAttr = (attrName: string): string => - this.extractXmlAttribute(normalized, 'msg', attrName) || this.extractXmlValue(normalized, attrName) - - const contactCardWxid = - readAttr('username') || - readAttr('encryptusername') || - readAttr('encrypt_user_name') - const contactCardNickname = readAttr('nickname') - const contactCardAlias = readAttr('alias') - const contactCardRemark = readAttr('remark') - const contactCardProvince = readAttr('province') - const contactCardCity = readAttr('city') - const contactCardSignature = readAttr('sign') || readAttr('signature') - const contactCardAvatar = - readAttr('smallheadimgurl') || - readAttr('bigheadimgurl') || - readAttr('headimgurl') || - readAttr('avatar') - const sexRaw = readAttr('sex') - const contactCardGender = sexRaw ? parseInt(sexRaw, 10) : NaN - - const meta: Record<string, any> = { - cardKind: 'contact-card' - } - if (contactCardWxid) meta.contactCardWxid = contactCardWxid - if (contactCardNickname) meta.contactCardNickname = contactCardNickname - if (contactCardAlias) meta.contactCardAlias = contactCardAlias - if (contactCardRemark) meta.contactCardRemark = contactCardRemark - if (contactCardProvince) meta.contactCardProvince = contactCardProvince - if (contactCardCity) meta.contactCardCity = contactCardCity - if (contactCardSignature) meta.contactCardSignature = contactCardSignature - if (contactCardAvatar) meta.contactCardAvatar = contactCardAvatar - if (Number.isFinite(contactCardGender) && contactCardGender >= 0) { - meta.contactCardGender = contactCardGender - } - - return Object.keys(meta).length > 0 ? meta : null - } - - private getInlineEmojiDataUrl(name: string): string | null { - if (!name) return null - const cached = this.inlineEmojiCache.get(name) - if (cached) return cached - const emojiPath = getEmojiPath(name as any) - if (!emojiPath) return null - const baseDir = path.dirname(require.resolve('wechat-emojis')) - const absolutePath = path.join(baseDir, emojiPath) - if (!fs.existsSync(absolutePath)) return null - try { - const buffer = fs.readFileSync(absolutePath) - const dataUrl = `data:image/png;base64,${buffer.toString('base64')}` - this.inlineEmojiCache.set(name, dataUrl) - return dataUrl - } catch { - return null - } - } - - private renderTextWithEmoji(text: string): string { - if (!text) return '' - const parts = text.split(/\[(.*?)\]/g) - const rendered = parts.map((part, index) => { - if (index % 2 === 1) { - const emojiDataUrl = this.getInlineEmojiDataUrl(part) - if (emojiDataUrl) { - // Cache full <img> tag to avoid re-escaping data URL every time - const escapedName = this.escapeAttribute(part) - return `<img class="inline-emoji" src="${emojiDataUrl}" alt="[${escapedName}]" />` - } - return this.escapeHtml(`[${part}]`) - } - return this.escapeHtml(part) - }) - return rendered.join('') - } - - private formatHtmlMessageText( - content: string, - localType: number, - myWxid?: string, - senderWxid?: string, - isSend?: boolean, - emojiCaption?: string - ): string { - if (!content && localType === 47) { - return this.formatEmojiSemanticText(emojiCaption) - } - if (!content) return '' - - const readableSystemText = this.extractReadableSystemMessageText(content) - if (readableSystemText && this.isReadableSystemMessage(localType, content)) { - return readableSystemText - } - - if (localType === 1) { - return this.stripSenderPrefix(content) - } - - if (localType === 34) { - return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || '' - } - - return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption) - } - - private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null { - if (!content) return null - - const normalized = this.normalizeAppMessageContent(content) - const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>') - if (!isAppMessage) return null - - const subType = this.extractAppMessageType(normalized) - if (subType && subType !== '5' && subType !== '49') return null - - const url = [ - this.extractXmlValue(normalized, 'url'), - this.extractXmlValue(normalized, 'shareurlopen'), - this.extractXmlValue(normalized, 'shareurloriginal'), - this.extractXmlValue(normalized, 'shareurl'), - this.extractXmlValue(normalized, 'shorturl'), - this.extractXmlValue(normalized, 'dataurl'), - this.extractXmlValue(normalized, 'lowurl'), - this.extractXmlValue(normalized, 'streamvideoweburl'), - this.extractXmlValue(normalized, 'weburl') - ] - .map(candidate => this.normalizeHtmlLinkUrl(candidate)) - .find(Boolean) || '' - if (!url) return null - - const title = this.stripSenderPrefix( - this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url - ) || url - return { title, url } - } - - private normalizeHtmlLinkUrl(rawUrl: string): string { - const value = (rawUrl || '').trim().replace(/&/gi, '&') - if (!value) return '' - - const parseHttpUrl = (candidate: string): string => { - try { - const parsed = new URL(candidate) - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - return parsed.toString() - } - } catch { - return '' - } - return '' - } - - if (value.startsWith('//')) { - return parseHttpUrl(`https:${value}`) - } - - const direct = parseHttpUrl(value) - if (direct) return direct - - const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) - const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value) - if (!hasScheme && isDomainLike) { - return parseHttpUrl(`https://${value}`) - } - - return '' - } - - private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string { - const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim()) - return normalizedTitle || linkCard.url || '链接' - } - - private formatLinkCardExportText( - content: string, - localType: number, - style: 'markdown' | 'append-url' - ): string | null { - const linkCard = this.extractHtmlLinkCard(content, localType) - if (!linkCard?.url) return null - - const title = this.getLinkCardDisplayTitle(linkCard) - if (style === 'markdown') { - return `[${title}](${linkCard.url})` - } - - const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]' - return `${prefix}\n${linkCard.url}` - } - - private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean { - const linkCard = this.extractHtmlLinkCard(content, localType) - if (!linkCard?.url) return false - - const title = this.getLinkCardDisplayTitle(linkCard) - cell.value = { - text: title, - hyperlink: linkCard.url, - tooltip: linkCard.url - } as any - cell.font = { - ...(cell.font || {}), - color: { argb: 'FF0563C1' }, - underline: true - } - return true - } - - /** - * 导出媒体文件到指定目录 - */ - private async exportMediaForMessage( - msg: any, - sessionId: string, - mediaRootDir: string, - mediaRelativePrefix: string, - options: { - exportImages?: boolean - exportVoices?: boolean - exportVideos?: boolean - exportEmojis?: boolean - exportFiles?: boolean - maxFileSizeMb?: number - exportVoiceAsText?: boolean - includeVideoPoster?: boolean - includeVoiceWithTranscript?: boolean - dirCache?: Set<string> - control?: ExportTaskControl - } - ): Promise<MediaExportItem | null> { - const localType = msg.localType - - // 图片消息 - if (localType === 3 && options.exportImages) { - const result = await this.exportImage( - msg, - sessionId, - mediaRootDir, - mediaRelativePrefix, - options.dirCache, - options.control - ) - if (result) { - } - return result - } - - // 语音消息 - if (localType === 34) { - if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control) - } - if (options.exportVoiceAsText) { - return null - } - } - - // 动画表情 - if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control) - if (result) { - } - return result - } - - if (localType === 43 && options.exportVideos) { - return this.exportVideo( - msg, - sessionId, - mediaRootDir, - mediaRelativePrefix, - options.dirCache, - options.includeVideoPoster === true, - options.control - ) - } - - if (options.exportFiles && this.isFileAppMessage(msg)) { - return this.exportFileAttachment( - msg, - mediaRootDir, - mediaRelativePrefix, - options.maxFileSizeMb, - options.dirCache, - options.control - ) - } - - return null - } - - /** - * 导出图片文件 - */ - private async exportImage( - msg: any, - sessionId: string, - mediaRootDir: string, - mediaRelativePrefix: string, - dirCache?: Set<string>, - control?: ExportTaskControl - ): Promise<MediaExportItem | null> { - try { - const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') - await this.ensureExportDir(imagesDir, control, dirCache) - - const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => { - if (!imageMd5 && !imageDatName) return null - return this.runWithChatImagePipelineLimit(async () => { - const pickResolvedImagePath = (result: any): string | null => { - if (!result?.success) return null - const resolved = String(result.localPath || '').trim() - return resolved || null - } - - const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => { - const cachedResult = await imageDecryptService.resolveCachedImage({ - sessionId, - imageMd5: candidateMd5, - imageDatName: candidateDatName, - createTime: msg.createTime, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - allowCacheIndex: true, - suppressEvents: true - }) - return pickResolvedImagePath(cachedResult) - } - - const cachedPath = await resolveCachedPath(imageMd5, imageDatName) - if (cachedPath) { - return cachedPath - } - - const decryptResult = await imageDecryptService.decryptImage({ - sessionId, - imageMd5, - imageDatName, - createTime: msg.createTime, - force: false, - preferFilePath: true, - hardlinkOnly: true, - allowCacheIndex: true - }) - const decryptedPath = pickResolvedImagePath(decryptResult) - if (decryptedPath) return decryptedPath - - const localId = Number(msg?.localId || 0) - if (Number.isFinite(localId) && localId > 0) { - const fallback = await chatService.getImageData(sessionId, String(localId)) - if (fallback.success && fallback.data) { - const buffer = Buffer.from(fallback.data, 'base64') - const mime = this.detectMimeType(buffer) || 'image/jpeg' - return `data:${mime};base64,${fallback.data}` - } - } - - if (decryptResult.failureKind === 'decrypt_failed') { - console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) - } else { - console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`) - } - - const thumbResult = await imageDecryptService.resolveCachedImage({ - sessionId, - imageMd5, - imageDatName, - createTime: msg.createTime, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - allowCacheIndex: true, - suppressEvents: true - }) - if (thumbResult.success && thumbResult.localPath) { - console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) - return thumbResult.localPath - } - return null - }) - } - - // 使用消息对象中已提取的字段,先尝试快速导出。 - let imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined - let imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined - const initialMissingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName) - if (initialMissingRunCacheKey && this.mediaRunMissingImageKeys.has(initialMissingRunCacheKey)) { - return null - } - let sourcePath = await tryResolveImagePath(imageMd5, imageDatName) - - // 快速流字段存在偏差时,按 localId 强制回填再重试一次,避免“导出进度前进但写入 0”。 - if (!sourcePath) { - const localId = Number(msg?.localId || 0) - if (Number.isFinite(localId) && localId > 0) { - await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([3]), undefined, { force: true }) - imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined - imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined - sourcePath = await tryResolveImagePath(imageMd5, imageDatName) - } - } - - if (!sourcePath) { - const missingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName) - console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`) - if (missingRunCacheKey) { - this.mediaRunMissingImageKeys.add(missingRunCacheKey) - } - return null - } - - // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 - const messageId = String(msg.localId || Date.now()) - const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '') - - // 从 data URL 或 file URL 获取实际路径 - if (sourcePath.startsWith('data:')) { - // 是 data URL,需要保存为文件 - const base64Data = sourcePath.split(',')[1] - const ext = this.getExtFromDataUrl(sourcePath) - const fileName = `${messageId}_${imageKey}${ext}` - const destPath = path.join(imagesDir, fileName) - - const buffer = Buffer.from(base64Data, 'base64') - await this.recordCreatedFileBeforeWrite(destPath, control) - await fs.promises.writeFile(destPath, buffer) - this.noteMediaTelemetry({ - doneFiles: 1, - cacheMissFiles: 1, - bytesWritten: buffer.length - }) - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), - kind: 'image' - } - } else if (sourcePath.startsWith('file://')) { - sourcePath = fileURLToPath(sourcePath) - } - - // 复制文件 - const ext = path.extname(sourcePath) || '.jpg' - const fileName = `${messageId}_${imageKey}${ext}` - const destPath = path.join(imagesDir, fileName) - const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control) - if (!copied.success) { - if (copied.code === 'ENOENT') { - console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) - } else { - console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`) - } - return null - } - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), - kind: 'image' - } - } catch (e) { - console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`) - return null - } - } - - private async preloadMediaLookupCaches( - _sessionId: string, - messages: any[], - options: { exportImages?: boolean; exportVideos?: boolean }, - control?: ExportTaskControl - ): Promise<void> { - if (!Array.isArray(messages) || messages.length === 0) return - - const md5Pattern = /^[a-f0-9]{32}$/i - const imageMd5Set = new Set<string>() - - let scanIndex = 0 - for (const msg of messages) { - if ((scanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - - if (options.exportImages && msg?.localType === 3) { - const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() - if (imageMd5) { - imageMd5Set.add(imageMd5) - } - const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() - if (md5Pattern.test(imageDatName)) { - imageMd5Set.add(imageDatName) - } - } - - } - - const preloadTasks: Array<Promise<void>> = [] - if (imageMd5Set.size > 0) { - preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) - } - if (preloadTasks.length === 0) return - - await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) - this.throwIfStopRequested(control) - } - - /** - * 导出语音文件 - */ - private async preloadVoiceWavCache( - sessionId: string, - messages: any[], - control?: ExportTaskControl - ): Promise<void> { - if (!Array.isArray(messages) || messages.length === 0) return - - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return - - const normalized: Array<{ - localId: number - createTime: number - serverId?: string | number - senderWxid?: string | null - }> = [] - const seen = new Set<string>() - - for (const msg of messages) { - const localIdRaw = Number(msg?.localId) - const createTimeRaw = Number(msg?.createTime) - const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0 - const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0 - if (!localId || !createTime) continue - const dedupeKey = this.getStableMessageKey(msg) - if (seen.has(dedupeKey)) continue - seen.add(dedupeKey) - normalized.push({ - localId, - createTime, - serverId: msg?.serverId, - senderWxid: msg?.senderUsername || null - }) - } - if (normalized.length === 0) return - - const chunkSize = 120 - for (let i = 0; i < normalized.length; i += chunkSize) { - this.throwIfStopRequested(control) - const chunk = normalized.slice(i, i + chunkSize) - await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, { - chunkSize: 48, - decodeConcurrency: 3 - }) - } - } - - /** - * 导出语音文件 - */ - private async exportVoice( - msg: any, - sessionId: string, - mediaRootDir: string, - mediaRelativePrefix: string, - dirCache?: Set<string>, - control?: ExportTaskControl - ): Promise<MediaExportItem | null> { - try { - const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') - await this.ensureExportDir(voicesDir, control, dirCache) - - const msgId = String(msg.localId) - const safeSession = this.cleanAccountDirName(sessionId) - .replace(/[^a-zA-Z0-9_-]/g, '_') - .slice(0, 48) || 'session' - const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_') - const fileName = `voice_${safeSession}_${stableKey || msgId}.wav` - const destPath = path.join(voicesDir, fileName) - - // 如果已存在则跳过 - if (await this.pathExists(destPath)) { - return { - relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), - kind: 'voice' - } - } - - // 调用 chatService 获取语音数据 - const voiceResult = await chatService.getVoiceData( - sessionId, - msgId, - Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined, - msg?.serverId, - msg?.senderUsername || undefined - ) - if (!voiceResult.success || !voiceResult.data) { - return null - } - - // voiceResult.data 是 base64 编码的 wav 数据 - const wavBuffer = Buffer.from(voiceResult.data, 'base64') - await this.recordCreatedFileBeforeWrite(destPath, control) - await fs.promises.writeFile(destPath, wavBuffer) - this.noteMediaTelemetry({ - doneFiles: 1, - bytesWritten: wavBuffer.length - }) - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), - kind: 'voice' - } - } catch (e) { - return null - } - } - - /** - * 转写语音为文字 - */ - private async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise<string> { - try { - const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined) - if (transcript.success && transcript.transcript) { - return `[语音转文字] ${transcript.transcript}` - } - return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]` - } catch (e) { - return `[语音消息 - 转文字失败: ${String(e)}]` - } - } - - /** - * 导出表情文件 - */ - private async exportEmoji( - msg: any, - sessionId: string, - mediaRootDir: string, - mediaRelativePrefix: string, - dirCache?: Set<string>, - control?: ExportTaskControl - ): Promise<MediaExportItem | null> { - try { - const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') - await this.ensureExportDir(emojisDir, control, dirCache) - - // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) - const localPath = await chatService.downloadEmojiFile(msg) - - if (!localPath) { - return null - } - - // 确定目标文件名 - const ext = path.extname(localPath) || '.gif' - const key = msg.emojiMd5 || String(msg.localId) - const fileName = `${key}${ext}` - const destPath = path.join(emojisDir, fileName) - const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control) - if (!copied.success) return null - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), - kind: 'emoji' - } - } catch (e) { - console.error('ExportService: exportEmoji failed', e) - return null - } - } - - /** - * 导出视频文件 - */ - private async exportVideo( - msg: any, - sessionId: string, - mediaRootDir: string, - mediaRelativePrefix: string, - dirCache?: Set<string>, - includePoster = false, - control?: ExportTaskControl - ): Promise<MediaExportItem | null> { - try { - let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase() - const resolveVideoInfo = async (token: string) => { - if (!token) return null - const videoInfo = await videoService.getVideoInfo(token, { includePoster }) - if (!videoInfo.exists || !videoInfo.videoUrl) return null - return videoInfo - } - - let videoInfo = await resolveVideoInfo(videoMd5) - if (!videoInfo) { - const localId = Number(msg?.localId || 0) - if (Number.isFinite(localId) && localId > 0) { - await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([43]), undefined, { force: true }) - videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase() - videoInfo = await resolveVideoInfo(videoMd5) - } - } - if (!videoInfo) return null - - const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') - await this.ensureExportDir(videosDir, control, dirCache) - - const sourcePath = videoInfo.videoUrl - const fileName = path.basename(sourcePath) - const destPath = path.join(videosDir, fileName) - - const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control) - if (!copied.success) return null - - return { - relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), - kind: 'video', - posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined - } - } catch (e) { - return null - } - } - - /** - * 从消息内容提取图片 MD5 - */ - private extractImageMd5(content: string): string | undefined { - if (!content) return undefined - const match = /md5="([^"]+)"/i.exec(content) - return match?.[1] - } - - /** - * 从消息内容提取图片 DAT 文件名 - */ - private extractImageDatName(content: string): string | undefined { - if (!content) return undefined - const candidate = - this.extractXmlValue(content, 'imgname') || - this.extractXmlValue(content, 'cdnmidimgurl') || - this.extractXmlValue(content, 'cdnthumburl') || - this.extractXmlAttribute(content, 'img', 'imgname') || - this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') || - this.extractXmlAttribute(content, 'img', 'cdnthumburl') - return this.normalizeImageDatNameToken(candidate) - } - - private normalizeImageDatNameToken(value: unknown): string | undefined { - let text = String(value ?? '').trim() - if (!text) return undefined - text = text.replace(/&/g, '&') - try { - if (text.includes('%')) text = decodeURIComponent(text) - } catch { } - - const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text) - if (datLike?.[1]) return datLike[1].toLowerCase() - - const base = text - .split(/[?#]/, 1)[0] - .replace(/^.*[\\/]/, '') - .replace(/\.(?:t\.)?dat$/i, '') - .trim() - if (!base) return undefined - - const cdnToken = base.includes('_') ? base.split('_')[0] : base - const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken) - if (exact?.[1]) return exact[1].toLowerCase() - - const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken) - if (preferred32?.[1]) return preferred32[1].toLowerCase() - const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken) - return fallback?.[1]?.toLowerCase() - } - - private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined { - const buffer = this.decodePackedInfoBuffer(raw) - if (!buffer || buffer.length === 0) return undefined - const printable: number[] = [] - for (const byte of buffer) { - printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20) - } - const text = Buffer.from(printable).toString('utf-8') - const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text) - if (datLike?.[1]) return datLike[1].toLowerCase() - const fallback = /([0-9a-fA-F]{16,})/.exec(text) - return fallback?.[1]?.toLowerCase() - } - - private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined { - const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [ - 'image_path', - 'imagePath', - 'image_dat_name', - 'imageDatName', - 'img_path', - 'imgPath', - 'img_name', - 'imgName' - ])) - if (byColumn) return byColumn - - const packedRaw = this.getRowField(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw) - if (byPacked) return byPacked - - return this.extractImageDatName(content || '') - } - - /** - * 从消息内容提取表情 URL - */ - private extractEmojiUrl(content: string): string | undefined { - if (!content) return undefined - // 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"] - const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) - if (attrMatch) { - // 解码 & 等实体 - let url = attrMatch[1].replace(/&/g, '&') - // URL 解码 - try { - if (url.includes('%')) { - url = decodeURIComponent(url) - } - } catch { } - return url - } - // 备用:尝试 XML 标签形式 - const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content) - return tagMatch?.[1] - } - - /** - * 从消息内容提取表情 MD5 - */ - private extractEmojiMd5(content: string): string | undefined { - if (!content) return undefined - const match = - /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) || - /md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) || - /<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content) - return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content) - } - - private extractVideoMd5(content: string): string | undefined { - if (!content) return undefined - const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (attrMatch) { - return attrMatch[1].toLowerCase() - } - const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content) - return tagMatch?.[1]?.toLowerCase() - } - - private decodePackedInfoBuffer(raw: unknown): 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 (!trimmed) return null - const compactHex = trimmed.replace(/\s+/g, '') - if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { - try { - return Buffer.from(compactHex, 'hex') - } catch { } - } - try { - const decoded = Buffer.from(trimmed, 'base64') - if (decoded.length > 0) return decoded - } catch { } - return null - } - if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) { - return Buffer.from((raw as any).data) - } - return null - } - - private normalizeVideoFileToken(value: unknown): string | undefined { - let text = String(value || '').trim().toLowerCase() - if (!text) return undefined - text = text.replace(/^.*[\\/]/, '') - text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') - text = text.replace(/_thumb$/, '') - const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) - if (direct) { - const suffix = /_raw$/i.test(text) ? '_raw' : '' - return `${direct[1].toLowerCase()}${suffix}` - } - const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) - if (preferred32?.[1]) return preferred32[1].toLowerCase() - const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) - return fallback?.[1]?.toLowerCase() - } - - private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined { - const buffer = this.decodePackedInfoBuffer(raw) - if (!buffer || buffer.length === 0) return undefined - const candidates: string[] = [] - let current = '' - for (const byte of buffer) { - const isHex = - (byte >= 0x30 && byte <= 0x39) || - (byte >= 0x41 && byte <= 0x46) || - (byte >= 0x61 && byte <= 0x66) - if (isHex) { - current += String.fromCharCode(byte) - continue - } - if (current.length >= 16) candidates.push(current) - current = '' - } - if (current.length >= 16) candidates.push(current) - if (candidates.length === 0) return undefined - - const exact32 = candidates.find((item) => item.length === 32) - if (exact32) return exact32.toLowerCase() - const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) - return fallback?.toLowerCase() - } - - private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined { - const packedRaw = this.getRowField(row, [ - 'packed_info_data', 'packedInfoData', - 'packed_info_blob', 'packedInfoBlob', - 'packed_info', 'packedInfo', - 'BytesExtra', 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', 'Reserved0', 'WCDB_CT_Reserved0' - ]) - const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw) - if (byPacked) return byPacked - - const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [ - 'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName' - ])) - if (byColumn) return byColumn - - return this.normalizeVideoFileToken(this.extractVideoMd5(content || '')) - } - - private isFileAttachmentAccountDir(dirPath: string): boolean { - if (!dirPath) return false - return fs.existsSync(path.join(dirPath, 'db_storage')) || - fs.existsSync(path.join(dirPath, 'msg', 'file')) || - fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) || - fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) || - fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2')) - } - - private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null { - const cleanedWxid = this.cleanAccountDirName(wxid) - if (!basePath || !cleanedWxid) return null - - const normalized = path.resolve(basePath.replace(/[\\/]+$/, '')) - const parentDir = path.dirname(normalized) - const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage' - ? path.dirname(normalized) - : '' - const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage' - ? path.dirname(parentDir) - : '' - const candidateBases = Array.from(new Set([ - normalized, - parentDir, - path.join(normalized, 'WeChat Files'), - path.join(parentDir, 'WeChat Files'), - dbStorageParent, - fileInsideDbStorageParent - ].filter(Boolean))) - - const lowerWxid = cleanedWxid.toLowerCase() - const tryResolveBase = (candidateBase: string): string | null => { - if (!candidateBase || !fs.existsSync(candidateBase)) return null - if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase - - const direct = path.join(candidateBase, cleanedWxid) - if (this.isFileAttachmentAccountDir(direct)) return direct - - try { - const entries = fs.readdirSync(candidateBase, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isDirectory()) continue - const lowerEntry = entry.name.toLowerCase() - if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { - const entryPath = path.join(candidateBase, entry.name) - if (this.isFileAttachmentAccountDir(entryPath)) { - return entryPath - } - } - } - } catch { - return null - } - - return null - } - - for (const candidateBase of candidateBases) { - const resolved = tryResolveBase(candidateBase) - if (resolved) return resolved - } - - return null - } - - private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] { - const dbPath = this.getConfiguredDbPath() - const rawWxid = this.getConfiguredMyWxid() - const cleanedWxid = this.cleanAccountDirName(rawWxid) - if (!dbPath) return [] - - const normalized = path.resolve(dbPath.replace(/[\\/]+$/, '')) - const accountDirs = new Set<string>() - const maybeAddAccountDir = (candidate: string | null | undefined) => { - if (!candidate) return - const resolved = path.resolve(candidate) - if (this.isFileAttachmentAccountDir(resolved)) { - accountDirs.add(resolved) - } - } - - maybeAddAccountDir(normalized) - maybeAddAccountDir(path.dirname(normalized)) - - const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean))) - for (const wxid of wxidCandidates) { - maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid)) - } - - return Array.from(accountDirs).map((accountDir) => { - const msgFileRoot = path.join(accountDir, 'msg', 'file') - const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File') - return { - accountDir, - msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined, - fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined - } - }).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot)) - } - - private buildPreferredFileYearMonths(createTime?: unknown): string[] { - const raw = Number(createTime) - if (!Number.isFinite(raw) || raw <= 0) return [] - const ts = raw > 1e12 ? raw : raw * 1000 - const date = new Date(ts) - if (Number.isNaN(date.getTime())) return [] - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - return [`${y}-${m}`] - } - - private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> { - const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase() - if (!normalizedExpected) return true - if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true - try { - const hash = crypto.createHash('md5') - await new Promise<void>((resolve, reject) => { - const stream = fs.createReadStream(sourcePath) - stream.on('data', chunk => hash.update(chunk)) - stream.on('end', () => resolve()) - stream.on('error', reject) - }) - return hash.digest('hex').toLowerCase() === normalizedExpected - } catch { - return false - } - } - - private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] { - const normalizedName = String(fileName || '').trim().toLowerCase() - if (!rootDir || !normalizedName) return [] - - const matches: string[] = [] - const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }] - - while (stack.length > 0) { - const current = stack.pop()! - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(current.dir, { withFileTypes: true }) - } catch { - continue - } - - for (const entry of entries) { - const entryPath = path.join(current.dir, entry.name) - if (entry.isFile() && entry.name.toLowerCase() === normalizedName) { - matches.push(entryPath) - continue - } - if (entry.isDirectory() && current.depth < maxDepth) { - stack.push({ dir: entryPath, depth: current.depth + 1 }) - } - } - } - - return matches - } - - private getFileAttachmentLogContext(msg: any): Record<string, unknown> { - return { - localId: msg?.localId, - createTime: msg?.createTime, - localType: msg?.localType, - xmlType: msg?.xmlType, - fileName: msg?.fileName, - fileMd5: msg?.fileMd5 - } - } - - private logFileAttachmentEvent( - level: 'warn' | 'error', - action: string, - msg: any, - extra: Record<string, unknown> = {} - ): void { - const logger = level === 'error' ? console.error : console.warn - logger(`[Export][File] ${action}`, { - ...this.getFileAttachmentLogContext(msg), - ...extra - }) - } - - private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void { - this.logFileAttachmentEvent('warn', action, msg, extra) - this.noteMediaTelemetry({ cacheMissFiles: 1 }) - } - - private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> { - const fileName = String(msg?.fileName || '').trim() - if (!fileName) return [] - - const roots = this.resolveFileAttachmentSearchRoots() - if (roots.length === 0) return [] - - const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() - const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime)) - const candidates: FileExportCandidate[] = [] - const seen = new Set<string>() - let searchOrder = 0 - - const appendCandidate = async (sourcePath: string, yearMonth?: string) => { - if (!sourcePath || !fs.existsSync(sourcePath)) return - - const resolvedPath = path.resolve(sourcePath) - if (seen.has(resolvedPath)) return - - let stat: fs.Stats - try { - stat = await fs.promises.stat(resolvedPath) - } catch { - return - } - if (!stat.isFile()) return - - seen.add(resolvedPath) - const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name' - candidates.push({ - sourcePath: resolvedPath, - matchedBy, - yearMonth, - preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)), - mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0, - searchOrder: searchOrder++ - }) - } - - for (const root of roots) { - if (root.msgFileRoot) { - for (const month of preferredMonths) { - await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) - } - - let monthDirs: string[] = [] - try { - monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name)) - .map(entry => entry.name) - .sort() - } catch { - monthDirs = [] - } - - for (const month of monthDirs) { - await appendCandidate(path.join(root.msgFileRoot, month, fileName), month) - } - await appendCandidate(path.join(root.msgFileRoot, fileName)) - } - - if (root.fileStorageRoot) { - for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) { - await appendCandidate(candidatePath) - } - } - } - - candidates.sort((left, right) => { - if (left.matchedBy !== right.matchedBy) { - return left.matchedBy === 'md5' ? -1 : 1 - } - if (left.preferredMonth !== right.preferredMonth) { - return left.preferredMonth ? -1 : 1 - } - if (left.mtimeMs !== right.mtimeMs) { - return right.mtimeMs - left.mtimeMs - } - return left.searchOrder - right.searchOrder - }) - - return candidates - } - - private async exportFileAttachment( - msg: any, - mediaRootDir: string, - mediaRelativePrefix: string, - maxFileSizeMb?: number, - dirCache?: Set<string>, - control?: ExportTaskControl - ): Promise<MediaExportItem | null> { - try { - const fileNameRaw = String(msg?.fileName || '').trim() - if (!fileNameRaw) return null - - const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw) - const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir) - await this.ensureExportDir(fileDir, control, dirCache) - - const candidates = await this.resolveFileAttachmentCandidates(msg) - if (candidates.length === 0) { - this.recordFileAttachmentMiss(msg, '附件候选未命中', { - searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir) - }) - return null - } - - const maxBytes = Number.isFinite(maxFileSizeMb) - ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) - : 0 - - const selected = candidates[0] - const stat = await fs.promises.stat(selected.sourcePath) - if (!stat.isFile()) { - this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', { - sourcePath: selected.sourcePath - }) - return null - } - if (maxBytes > 0 && stat.size > maxBytes) { - this.recordFileAttachmentMiss(msg, '附件超过大小限制', { - sourcePath: selected.sourcePath, - size: stat.size, - maxBytes - }) - return null - } - - const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() - if (normalizedMd5 && selected.matchedBy !== 'md5') { - this.recordFileAttachmentMiss(msg, '附件哈希校验失败', { - sourcePath: selected.sourcePath, - expectedMd5: normalizedMd5 - }) - return null - } - - const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' - const messageId = String(msg?.localId || Date.now()) - const destFileName = `${messageId}_${safeBaseName}` - const destPath = path.join(fileDir, destFileName) - const existedBeforeCopy = await this.pathExists(destPath) - const copied = await this.copyFileOptimized(selected.sourcePath, destPath) - if (!copied.success) { - this.recordFileAttachmentMiss(msg, '附件复制失败', { - sourcePath: selected.sourcePath, - destPath, - code: copied.code - }) - return null - } - - if (!existedBeforeCopy) { - control?.recordCreatedFile?.(destPath) - } - this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) - return { - relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName), - kind: 'file' - } - } catch (error) { - this.logFileAttachmentEvent('error', '附件导出异常', msg, { - error: error instanceof Error ? error.message : String(error || 'unknown') - }) - this.noteMediaTelemetry({ cacheMissFiles: 1 }) - return null - } - } - - private extractLocationMeta(content: string, localType: number): { - locationLat?: number - locationLng?: number - locationPoiname?: string - locationLabel?: string - } | null { - if (!content || localType !== 48) return null - - const normalized = this.normalizeAppMessageContent(content) - const rawLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') - const rawLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') - const locationPoiname = - this.extractXmlAttribute(normalized, 'location', 'poiname') || - this.extractXmlValue(normalized, 'poiname') || - this.extractXmlValue(normalized, 'poiName') - const locationLabel = - this.extractXmlAttribute(normalized, 'location', 'label') || - this.extractXmlValue(normalized, 'label') - - const meta: { - locationLat?: number - locationLng?: number - locationPoiname?: string - locationLabel?: string - } = {} - - if (rawLat) { - const parsed = parseFloat(rawLat) - if (Number.isFinite(parsed)) meta.locationLat = parsed - } - if (rawLng) { - const parsed = parseFloat(rawLng) - if (Number.isFinite(parsed)) meta.locationLng = parsed - } - if (locationPoiname) meta.locationPoiname = locationPoiname - if (locationLabel) meta.locationLabel = locationLabel - - return Object.keys(meta).length > 0 ? meta : null - } - - /** - * 从 data URL 获取扩展名 - */ - private getExtFromDataUrl(dataUrl: string): string { - if (dataUrl.includes('image/png')) return '.png' - if (dataUrl.includes('image/gif')) return '.gif' - if (dataUrl.includes('image/webp')) return '.webp' - return '.jpg' - } - - private getMediaLayout(outputPath: string, options: ExportOptions): { - exportMediaEnabled: boolean - mediaRootDir: string - mediaRelativePrefix: string - } { - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) - const outputDir = path.dirname(outputPath) - const writeLayout = this.resolveExportWriteLayout(options) - // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories. - if (writeLayout === 'A' && path.basename(outputDir) === 'texts') { - return { - exportMediaEnabled, - mediaRootDir: outputDir, - mediaRelativePrefix: '..' - } - } - const outputBaseName = path.basename(outputPath, path.extname(outputPath)) - const useSharedMediaLayout = options.sessionLayout === 'shared' - const mediaRelativePrefix = useSharedMediaLayout - ? path.posix.join('media', outputBaseName) - : 'media' - return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } - } - - private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] { - if (!this.isMediaExportEnabled(options)) return [] - - return messages.filter((msg) => { - const localType = Number(msg?.localType || 0) - return (localType === 3 && options.exportImages) || - (localType === 47 && options.exportEmojis) || - (localType === 43 && options.exportVideos) || - (localType === 34 && options.exportVoices) || - (options.exportFiles === true && this.isFileAppMessage(msg)) - }) - } - - private getMediaDoneFilesCount(): number { - return this.mediaExportTelemetry?.doneFiles ?? 0 - } - - private formatMediaPhaseLabel(processed: number, total: number, beforeDoneFiles: number): string { - const safeProcessed = Math.max(0, Math.floor(processed || 0)) - const safeTotal = Math.max(0, Math.floor(total || 0)) - const writtenNow = Math.max(0, this.getMediaDoneFilesCount() - Math.max(0, Math.floor(beforeDoneFiles || 0))) - return `导出媒体 ${Math.min(safeProcessed, safeTotal)}/${safeTotal}(已写入 ${writtenNow})` - } - - private buildFileOnlyExportFailure( - options: ExportOptions, - mediaMessages: any[], - beforeDoneFiles: number - ): { success: boolean; error?: string } | null { - if (options.contentType !== 'file') return null - if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null - if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null - - return { - success: false, - error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置' - } - } - - /** - * 下载文件 - */ - private async downloadFile(url: string, destPath: string): Promise<boolean> { - return new Promise((resolve) => { - try { - const protocol = url.startsWith('https') ? https : http - const request = protocol.get(url, { timeout: 30000 }, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location - if (redirectUrl) { - this.downloadFile(redirectUrl, destPath).then(resolve) - return - } - } - if (response.statusCode !== 200) { - resolve(false) - return - } - const fileStream = fs.createWriteStream(destPath) - response.pipe(fileStream) - fileStream.on('finish', () => { - fileStream.close() - resolve(true) - }) - fileStream.on('error', (err) => { - // 确保在错误情况下销毁流,释放文件句柄 - fileStream.destroy() - resolve(false) - }) - response.on('error', (err) => { - // 确保在响应错误时也关闭文件句柄 - fileStream.destroy() - resolve(false) - }) - }) - request.on('error', () => resolve(false)) - request.on('timeout', () => { - request.destroy() - resolve(false) - }) - } catch { - resolve(false) - } - }) - } - - private async collectMessages( - sessionId: string, - cleanedMyWxid: string, - dateRange?: { start: number; end: number } | null, - senderUsernameFilter?: string, - collectMode: MessageCollectMode = 'full', - targetMediaTypes?: Set<number>, - control?: ExportTaskControl, - onCollectProgress?: (payload: { fetched: number }) => void, - allowLiteFallback = true, - allowRangeFallback = true, - useCursorTimeRange = true, - allowModeFallback = true - ): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null; error?: string }> { - const rows: any[] = [] - const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>() - const senderSet = new Set<string>() - let firstTime: number | null = null - let lastTime: number | null = null - const mediaTypeFilter = targetMediaTypes && targetMediaTypes.size > 0 - ? targetMediaTypes - : null - const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter) - - const normalizedDateRange = this.normalizeExportDateRange(dateRange) - const normalizedSenderUsernameFilter = String(senderUsernameFilter || '').trim() - const beginTime = useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0 - const endTime = useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0 - - const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500 - this.throwIfStopRequested(control) - const fastMediaType = this.resolveFastMediaStreamType(collectMode, mediaTypeFilter) - let usedFastMediaStream = false - let usedLiteCursor = false - if (fastMediaType) { - const streamCollected = await this.collectMessagesByFastMediaStream( - sessionId, - cleanedMyWxid, - normalizedDateRange, - useCursorTimeRange, - normalizedSenderUsernameFilter, - fastMediaType, - onCollectProgress, - control - ) - if (streamCollected.success) { - usedFastMediaStream = true - rows.push(...streamCollected.rows) - for (const username of streamCollected.senderUsernames) { - senderSet.add(username) - } - firstTime = streamCollected.firstTime - lastTime = streamCollected.lastTime - } else { - console.warn(`[Export] 媒体快速流读取失败,回退游标链路: session=${sessionId}, type=${fastMediaType}, error=${streamCollected.error || 'unknown'}`) - } - } - - if (!usedFastMediaStream) { - // 媒体导出链路必须优先保证字段完整性,否则会出现“进度前进但无文件写出”。 - // 轻量游标仅用于文本快路径;媒体快路径保留标准游标。 - usedLiteCursor = allowLiteFallback && collectMode === 'text-fast' - let cursor = usedLiteCursor - ? await wcdbService.openMessageCursorLite( - sessionId, - batchSize, - true, - beginTime, - endTime - ) - : await wcdbService.openMessageCursor( - sessionId, - batchSize, - false, - beginTime, - endTime - ) - if (!cursor.success || !cursor.cursor) { - if (usedLiteCursor && allowLiteFallback) { - console.warn(`[Export] 轻量游标打开失败,回退标准游标重试: ${cursor.error || '未知错误'}`) - return this.collectMessages( - sessionId, - cleanedMyWxid, - normalizedDateRange, - senderUsernameFilter, - collectMode, - targetMediaTypes, - control, - onCollectProgress, - false, - allowRangeFallback, - useCursorTimeRange, - allowModeFallback - ) - } - console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`) - return { - rows, - memberSet, - firstTime, - lastTime, - error: cursor.error || '打开消息游标失败' - } - } - - try { - let hasMore = true - let batchCount = 0 - while (hasMore) { - this.throwIfStopRequested(control) - const batch = await wcdbService.fetchMessageBatch(cursor.cursor) - batchCount++ - - if (!batch.success) { - console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`) - break - } - - if (!batch.rows) break - - let rowIndex = 0 - for (const row of batch.rows) { - if ((rowIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const createTime = this.getTimestampSecondsFromRow(row) - if (normalizedDateRange) { - if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue - if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue - } - - const localType = this.getIntFromRow(row, [ - 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' - ], 1) - const rowFileHints = collectMode === 'text-fast' - ? {} - : this.getFileAppMessageHints(row) - const allowFileProbe = collectMode !== 'text-fast' && fileOnlyMediaFilter && this.hasFileAppMessageHints(row) - if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) { - continue - } - const shouldDecodeContent = collectMode === 'full' - || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) - || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe })) - const content = shouldDecodeContent - ? this.decodeMessageContent(row.message_content, row.compress_content) - : '' - const senderUsername = row.sender_username || '' - const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' - const isSend = parseInt(isSendRaw, 10) === 1 - const localId = this.getIntFromRow(row, [ - 'local_id', 'localId', 'LocalId', - 'msg_local_id', 'msgLocalId', 'MsgLocalId', - 'msg_id', 'msgId', 'MsgId', 'id', - 'WCDB_CT_local_id' - ], 0) - const rawServerIdValue = this.getRowField(row, [ - 'server_id', 'serverId', 'ServerId', - 'msg_server_id', 'msgServerId', 'MsgServerId', - 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', - 'WCDB_CT_server_id' - ]) - const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue) - const serverId = this.getIntFromRow(row, [ - 'server_id', 'serverId', 'ServerId', - 'msg_server_id', 'msgServerId', 'MsgServerId', - 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', - 'WCDB_CT_server_id' - ], 0) - - // 确定实际发送者 - let actualSender: string - if (localType === 10000 || localType === 266287972401) { - // 系统消息特殊处理 - const revokeInfo = this.extractRevokerInfo(content) - if (revokeInfo.isRevoke) { - // 撤回消息 - if (revokeInfo.isSelfRevoke) { - // "你撤回了" - 发送者是当前用户 - actualSender = cleanedMyWxid - } else if (revokeInfo.revokerWxid) { - // 提取到了撤回者的 wxid - actualSender = revokeInfo.revokerWxid - } else { - // 无法确定撤回者,使用 sessionId - actualSender = sessionId - } - } else { - // 普通系统消息(如"xxx加入群聊"),发送者是群聊ID - actualSender = sessionId - } - } else { - actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) - } - - if (normalizedSenderUsernameFilter && !this.isSameWxid(actualSender, normalizedSenderUsernameFilter)) { - continue - } - senderSet.add(actualSender) - - if (collectMode === 'text-fast') { - rows.push({ - localId, - serverId, - serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined, - createTime, - localType, - content, - senderUsername: actualSender, - isSend - }) - if (firstTime === null || createTime < firstTime) firstTime = createTime - if (lastTime === null || createTime > lastTime) lastTime = createTime - continue - } - - // 提取媒体相关字段(轻量模式下跳过) - let imageMd5: string | undefined - let imageDatName: string | undefined - let emojiCdnUrl: string | undefined - let emojiMd5: string | undefined - let videoMd5: string | undefined - let locationLat: number | undefined - let locationLng: number | undefined - let locationPoiname: string | undefined - let locationLabel: string | undefined - let chatRecordList: any[] | undefined - let emojiCaption: string | undefined - let xmlType: string | undefined - let fileName: string | undefined - let fileSize: number | undefined - let fileExt: string | undefined - let fileMd5: string | undefined - - if (localType === 48 && content) { - const locationMeta = this.extractLocationMeta(content, localType) - if (locationMeta) { - locationLat = locationMeta.locationLat - locationLng = locationMeta.locationLng - locationPoiname = locationMeta.locationPoiname - locationLabel = locationMeta.locationLabel - } - } - - if (localType === 47) { - emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined - emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined - const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '') - const reserved0Raw = String(row.reserved0 || row.Reserved0 || '') - const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}` - if (content) { - emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content) - emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content)) - } - emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload) - emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload) - } - - if (collectMode === 'full' || collectMode === 'media-fast') { - // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 - imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined - imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined - videoMd5 = this.extractVideoFileNameFromRow(row, content) - xmlType = rowFileHints.xmlType - fileName = rowFileHints.fileName - fileExt = rowFileHints.fileExt - fileSize = rowFileHints.fileSize - fileMd5 = rowFileHints.fileMd5 - - if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) { - const fileMeta = this.extractFileAppMessageMeta(content) - if (fileMeta) { - xmlType = fileMeta.xmlType || xmlType - fileName = fileMeta.fileName || fileName - fileSize = fileMeta.fileSize || fileSize - fileExt = fileMeta.fileExt || fileExt - fileMd5 = fileMeta.fileMd5 || fileMd5 - } - } - - if (localType === 3 && content) { - // 图片消息 - imageMd5 = imageMd5 || this.extractImageMd5(content) - imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content) - } else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('<appmsg'))) { - // 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg - const normalizedContent = this.normalizeAppMessageContent(content) - const xmlType = this.extractAppMessageType(normalizedContent) - if (xmlType === '19') { - chatRecordList = this.parseChatHistory(normalizedContent) - } - } - } - - if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) { - continue - } - - rows.push({ - localId, - serverId, - serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined, - createTime, - localType, - content, - senderUsername: actualSender, - isSend, - imageMd5, - imageDatName, - emojiCdnUrl, - emojiMd5, - emojiCaption, - videoMd5, - xmlType, - fileName, - fileSize, - fileExt, - fileMd5, - locationLat, - locationLng, - locationPoiname, - locationLabel, - chatRecordList - }) - - if (firstTime === null || createTime < firstTime) firstTime = createTime - if (lastTime === null || createTime > lastTime) lastTime = createTime - } - onCollectProgress?.({ fetched: rows.length }) - hasMore = batch.hasMore === true - } - - } catch (err) { - if (this.isStopError(err)) throw err - console.error(`[Export] 收集消息异常:`, err) - } finally { - try { - await wcdbService.closeMessageCursor(cursor.cursor) - } catch (err) { - console.error(`[Export] 关闭游标失败:`, err) - } - } - } - - if (rows.length === 0 && usedLiteCursor && allowLiteFallback) { - console.warn(`[Export] 轻量游标返回 0 条,回退标准游标重试: session=${sessionId}, range=${beginTime}-${endTime}`) - return this.collectMessages( - sessionId, - cleanedMyWxid, - normalizedDateRange, - senderUsernameFilter, - collectMode, - targetMediaTypes, - control, - onCollectProgress, - false, - allowRangeFallback, - useCursorTimeRange, - allowModeFallback - ) - } - - if (rows.length === 0 && collectMode === 'media-fast' && allowModeFallback) { - console.warn(`[Export] media-fast 返回 0 条,回退 full 模式重试: session=${sessionId}`) - return this.collectMessages( - sessionId, - cleanedMyWxid, - normalizedDateRange, - senderUsernameFilter, - 'full', - mediaTypeFilter || undefined, - control, - onCollectProgress, - false, - allowRangeFallback, - useCursorTimeRange, - false - ) - } - - if (rows.length === 0 && allowRangeFallback && normalizedDateRange && useCursorTimeRange) { - console.warn(`[Export] 时间范围游标返回 0 条,回退为全量游标+本地过滤重试: session=${sessionId}, range=${normalizedDateRange.start}-${normalizedDateRange.end}`) - return this.collectMessages( - sessionId, - cleanedMyWxid, - normalizedDateRange, - senderUsernameFilter, - collectMode, - targetMediaTypes, - control, - onCollectProgress, - allowLiteFallback, - false, - false, - allowModeFallback - ) - } - - this.throwIfStopRequested(control) - if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) { - await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control) - } - - this.throwIfStopRequested(control) - if (senderSet.size > 0) { - const usernames = Array.from(senderSet) - const [nameResult, avatarResult] = await Promise.all([ - wcdbService.getDisplayNames(usernames), - wcdbService.getAvatarUrls(usernames) - ]) - - const nameMap = nameResult.success && nameResult.map ? nameResult.map : {} - const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {} - - for (const username of usernames) { - const displayName = nameMap[username] || username - const avatarUrl = avatarMap[username] - memberSet.set(username, { - member: { - platformId: username, - accountName: displayName - }, - avatarUrl - }) - this.contactCache.set(username, { displayName, avatarUrl }) - } - } - - if (rows.length > 1) { - rows.sort((a, b) => { - const timeDelta = (a.createTime || 0) - (b.createTime || 0) - if (timeDelta !== 0) return timeDelta - return (a.localId || 0) - (b.localId || 0) - }) - } - - return { rows, memberSet, firstTime, lastTime } - } - - private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> { - try { - const logResult = await wcdbService.getLogs() - if (!logResult.success || !Array.isArray(logResult.logs)) return undefined - const sid = String(sessionId || '').trim() - const interesting = logResult.logs - .filter((line) => { - const text = String(line || '') - if (sid && text.includes(sid)) return true - return text.includes('QueryMessageBatch') || - text.includes('InitExportCursorHeap') || - text.includes('cursor_init') || - text.includes('fetch_message_batch') || - text.includes('open_message_cursor') - }) - .slice(-8) - if (interesting.length === 0) return undefined - return interesting.join(' | ') - } catch { - return undefined - } - } - - private async buildNoMessagesError( - sessionId: string, - collected: { error?: string }, - fallback = '该会话在指定时间范围内没有消息' - ): Promise<string> { - if (collected.error) return collected.error - const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId) - if (!nativeLogSummary) return fallback - return `${fallback};WCDB日志:${nativeLogSummary}` - } - - private async backfillMediaFieldsFromMessageDetail( - sessionId: string, - rows: any[], - targetMediaTypes: Set<number>, - control?: ExportTaskControl, - options?: { force?: boolean } - ): Promise<void> { - const force = options?.force === true - const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes) - const needsBackfill = rows.filter((msg) => { - if (force) { - return Number(msg?.localId || 0) > 0 - } - const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg)) - if (isFileCandidate) { - return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt - } - if (!targetMediaTypes.has(msg.localType)) return false - if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName - if (msg.localType === 47) return !msg.emojiMd5 - if (msg.localType === 43) return !msg.videoMd5 - return false - }) - if (needsBackfill.length === 0) return - - const DETAIL_CONCURRENCY = 6 - await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const localId = Number(msg.localId || 0) - if (!Number.isFinite(localId) || localId <= 0) return - - try { - const detail = await wcdbService.getMessageById(sessionId, localId) - if (!detail.success || !detail.message) return - - const row = detail.message as any - const rawMessageContent = this.getRowField(row, [ - 'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content' - ]) ?? '' - const rawCompressContent = this.getRowField(row, [ - 'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content' - ]) ?? '' - const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) - const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? '' - const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? '' - const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}` - - if (msg.localType === 3) { - const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase() - const imageDatName = this.extractImageDatNameFromRow(row, content) || '' - if (imageMd5) msg.imageMd5 = imageMd5 - if (imageDatName) msg.imageDatName = imageDatName - return - } - - if (msg.localType === 47) { - const emojiMd5 = - this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || - this.extractEmojiMd5(content) || - this.extractEmojiMd5(supplementalPayload) || - this.extractLooseHexMd5(supplementalPayload) - const emojiCdnUrl = - String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || - this.extractEmojiUrl(content) || - this.extractEmojiUrl(supplementalPayload) - if (emojiMd5) msg.emojiMd5 = emojiMd5 - if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl - return - } - - if (msg.localType === 43) { - const videoMd5 = String(this.extractVideoFileNameFromRow(row, content) || '').trim().toLowerCase() - if (videoMd5) msg.videoMd5 = videoMd5 - return - } - - if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) { - const rowFileHints = this.getFileAppMessageHints(row) - const fileMeta = this.extractFileAppMessageMeta(content) - const mergedFileMeta = { - xmlType: fileMeta?.xmlType || rowFileHints.xmlType, - fileName: fileMeta?.fileName || rowFileHints.fileName, - fileSize: fileMeta?.fileSize || rowFileHints.fileSize, - fileExt: fileMeta?.fileExt || rowFileHints.fileExt, - fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5 - } - if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType - if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName - if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize - if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt - if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5 - } - } catch (error) { - // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 - } - }) - } - - // 补齐群成员,避免只导出发言者导致头像缺失 - private async mergeGroupMembers( - chatroomId: string, - memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>, - includeAvatars: boolean - ): Promise<void> { - const result = await wcdbService.getGroupMembers(chatroomId) - if (!result.success || !result.members || result.members.length === 0) return - - const rawMembers = result.members as Array<{ - username?: string - avatarUrl?: string - nickname?: string - displayName?: string - remark?: string - originalName?: string - }> - const usernames = rawMembers - .map((member) => member.username) - .filter((username): username is string => Boolean(username)) - if (usernames.length === 0) return - - const lookupUsernames = new Set<string>() - for (const username of usernames) { - lookupUsernames.add(username) - const cleaned = this.cleanAccountDirName(username) - if (cleaned && cleaned !== username) { - lookupUsernames.add(cleaned) - } - } - - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(Array.from(lookupUsernames)), - includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> }) - ]) - - for (const member of rawMembers) { - const username = member.username - if (!username) continue - - const cleaned = this.cleanAccountDirName(username) - const displayName = displayNames.success && displayNames.map - ? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username) - : username - const groupNickname = member.nickname || member.displayName || member.remark || member.originalName - const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map - ? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl) - : member.avatarUrl - - const existing = memberSet.get(username) - if (existing) { - if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) { - existing.member.accountName = displayName - } - if (groupNickname && !existing.member.groupNickname) { - existing.member.groupNickname = groupNickname - } - if (!existing.avatarUrl && avatarUrl) { - existing.avatarUrl = avatarUrl - } - memberSet.set(username, existing) - continue - } - - const chatlabMember: ChatLabMember = { - platformId: username, - accountName: displayName - } - if (groupNickname) { - chatlabMember.groupNickname = groupNickname - } - memberSet.set(username, { member: chatlabMember, avatarUrl }) - } - } - - private extractGroupMemberUsername(member: any): string { - if (!member) return '' - if (typeof member === 'string') return member.trim() - return String( - member.username || - member.userName || - member.user_name || - member.encryptUsername || - member.encryptUserName || - member.encrypt_username || - member.originalName || - '' - ).trim() - } - - private extractGroupSenderCountMap(groupStats: any, sessionId: string): Map<string, number> { - const senderCountMap = new Map<string, number>() - if (!groupStats || typeof groupStats !== 'object') return senderCountMap - - const sessions = (groupStats as any).sessions - const sessionStats = sessions && typeof sessions === 'object' - ? (sessions[sessionId] || sessions[String(sessionId)] || null) - : null - const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object') - ? (sessionStats as any).senders - : ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {}) - const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object' - ? (groupStats as any).idMap - : ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object') - ? (sessionStats as any).idMap - : {}) - - for (const [senderKey, rawCount] of Object.entries(senderRaw)) { - const countNumber = Number(rawCount) - if (!Number.isFinite(countNumber) || countNumber <= 0) continue - const count = Math.max(0, Math.floor(countNumber)) - const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : '' - const wxid = (mapped || String(senderKey || '').trim()) - if (!wxid) continue - senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count) - } - - return senderCountMap - } - - private sumSenderCountsByIdentity(senderCountMap: Map<string, number>, wxid: string): number { - const target = String(wxid || '').trim() - if (!target) return 0 - let total = 0 - for (const [senderWxid, count] of senderCountMap.entries()) { - if (!Number.isFinite(count) || count <= 0) continue - if (this.isSameWxid(senderWxid, target)) { - total += count - } - } - return total - } - - private async queryFriendFlagMap(usernames: string[]): Promise<Map<string, boolean>> { - const result = new Map<string, boolean>() - const unique = Array.from( - new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) - ) - if (unique.length === 0) return result - - const query = await wcdbService.getContactFriendFlags(unique) - if (query.success && query.map) { - for (const [username, isFriend] of Object.entries(query.map)) { - const normalized = String(username || '').trim() - if (!normalized) continue - result.set(normalized, Boolean(isFriend)) - } - } - - for (const username of unique) { - if (!result.has(username)) { - result.set(username, false) - } - } - - return result - } - - private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { - if (!avatarUrl) return null - if (avatarUrl.startsWith('data:')) { - const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl) - if (!match) return null - const mime = match[1].toLowerCase() - const data = Buffer.from(match[2], 'base64') - const ext = mime.includes('png') ? '.png' - : mime.includes('gif') ? '.gif' - : mime.includes('webp') ? '.webp' - : '.jpg' - return { data, ext, mime } - } - if (avatarUrl.startsWith('file://')) { - try { - const sourcePath = fileURLToPath(avatarUrl) - const ext = path.extname(sourcePath) || '.jpg' - return { sourcePath, ext } - } catch { - return null - } - } - if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) { - const url = new URL(avatarUrl) - const ext = path.extname(url.pathname) || '.jpg' - return { sourceUrl: avatarUrl, ext } - } - const sourcePath = avatarUrl - const ext = path.extname(sourcePath) || '.jpg' - return { sourcePath, ext } - } - - private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> { - const client = url.startsWith('https:') ? https : http - return new Promise((resolve) => { - const request = client.get(url, (res) => { - const status = res.statusCode || 0 - if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) { - res.resume() - const redirectedUrl = new URL(res.headers.location, url).href - this.downloadToBuffer(redirectedUrl, remainingRedirects - 1) - .then(resolve) - return - } - if (status < 200 || status >= 300) { - res.resume() - resolve(null) - return - } - const chunks: Buffer[] = [] - res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) - res.on('end', () => { - const data = Buffer.concat(chunks) - const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined - resolve({ data, mime }) - }) - }) - request.on('error', () => resolve(null)) - request.setTimeout(15000, () => { - request.destroy() - resolve(null) - }) - }) - } - - private async exportAvatars( - members: Array<{ username: string; avatarUrl?: string }> - ): Promise<Map<string, string>> { - const result = new Map<string, string>() - if (members.length === 0) return result - - // 直接使用 URL,不转换为 base64(与 ciphertalk 保持一致) - for (const member of members) { - if (member.avatarUrl) { - result.set(member.username, member.avatarUrl) - } - } - - return result - } - - /** - * 导出头像为外部文件(仅用于HTML格式) - * 将头像保存到 avatars/ 子目录,返回相对路径 - */ - private async exportAvatarsToFiles( - members: Array<{ username: string; avatarUrl?: string }>, - outputDir: string, - control?: ExportTaskControl - ): Promise<Map<string, string>> { - const result = new Map<string, string>() - if (members.length === 0) return result - - // 创建 avatars 子目录 - const avatarsDir = path.join(outputDir, 'avatars') - await this.ensureExportDir(avatarsDir, control) - - const AVATAR_CONCURRENCY = 8 - await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => { - const fileInfo = this.resolveAvatarFile(member.avatarUrl) - if (!fileInfo) return - try { - let data: Buffer | null = null - let mime = fileInfo.mime - if (fileInfo.data) { - data = fileInfo.data - } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) { - data = await fs.promises.readFile(fileInfo.sourcePath) - } else if (fileInfo.sourceUrl) { - const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl) - if (downloaded) { - data = downloaded.data - mime = downloaded.mime || mime - } - } - if (!data) return - - // 优先使用内容检测出的 MIME 类型 - const detectedMime = this.detectMimeType(data) - const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext) - - // 根据 MIME 类型确定文件扩展名 - const ext = this.getExtensionFromMime(finalMime) - - // 清理用户名作为文件名(移除非法字符,限制长度) - const sanitizedUsername = member.username - .replace(/[<>:"/\\|?*@]/g, '_') - .substring(0, 100) - - const filename = `${sanitizedUsername}${ext}` - const avatarPath = path.join(avatarsDir, filename) - - // 跳过已存在文件 - try { - await fs.promises.access(avatarPath) - } catch { - await this.recordCreatedFileBeforeWrite(avatarPath, control) - await fs.promises.writeFile(avatarPath, data) - } - - // 返回相对路径 - result.set(member.username, `avatars/${filename}`) - } catch { - return - } - }) - - return result - } - - private getExtensionFromMime(mime: string): string { - switch (mime) { - case 'image/png': - return '.png' - case 'image/gif': - return '.gif' - case 'image/webp': - return '.webp' - case 'image/bmp': - return '.bmp' - case 'image/jpeg': - default: - return '.jpg' - } - } - - - private detectMimeType(buffer: Buffer): string | null { - if (buffer.length < 4) return null - - // PNG: 89 50 4E 47 - if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { - return 'image/png' - } - - // JPEG: FF D8 FF - if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { - return 'image/jpeg' - } - - // GIF: 47 49 46 38 - if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) { - return 'image/gif' - } - - // WEBP: RIFF ... WEBP - if (buffer.length >= 12 && - 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 'image/webp' - } - - // BMP: 42 4D - if (buffer[0] === 0x42 && buffer[1] === 0x4D) { - return 'image/bmp' - } - - return null - } - - private inferImageMime(ext: string): string { - switch (ext.toLowerCase()) { - case '.png': - return 'image/png' - case '.gif': - return 'image/gif' - case '.webp': - return 'image/webp' - case '.bmp': - return 'image/bmp' - default: - return 'image/jpeg' - } - } - - private getWeflowHeader(): { version: string; exportedAt: number; generator: string } { - return { - version: '1.0.3', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'WeFlow' - } - } - - /** - * 生成通用的导出元数据 (参考 ChatLab 格式) - */ - private getExportMeta( - sessionId: string, - sessionInfo: { displayName: string }, - isGroup: boolean, - sessionAvatar?: string - ): { chatlab: ChatLabHeader; meta: ChatLabMeta } { - return { - chatlab: { - version: '0.0.2', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'WeFlow' - }, - meta: { - name: sessionInfo.displayName, - platform: 'wechat', - type: isGroup ? 'group' : 'private', - ...(isGroup && { groupId: sessionId }), - ...(sessionAvatar && { groupAvatar: sessionAvatar }) - } - } - } - - /** - * 导出单个会话为 ChatLab 格式(并行优化版本) - */ - async exportSessionToChatLab( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - const allMessages = collected.rows - const totalMessages = allMessages.length - - // 如果没有消息,不创建文件 - if (totalMessages === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - - await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control) - - const voiceMessages = options.exportVoiceAsText - ? allMessages.filter(msg => msg.localType === 34) - : [] - - if (options.exportVoiceAsText && voiceMessages.length > 0) { - await this.ensureVoiceModel(onProgress) - } - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of allMessages) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - senderUsernames.add(cleanedMyWxid) - await this.preloadContacts(senderUsernames, contactCache) - - if (isGroup) { - this.throwIfStopRequested(control) - await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) - } - - // ========== 获取群昵称并更新到 memberSet ========== - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...Array.from(collected.memberSet.keys()), - ...allMessages.map(msg => msg.senderUsername), - cleanedMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - // 将群昵称更新到 memberSet 中 - if (isGroup && groupNicknamesMap.size > 0) { - for (const [username, info] of collected.memberSet) { - // 尝试多种方式查找群昵称(支持大小写) - const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) || '' - if (groupNickname) { - info.member.groupNickname = groupNickname - } - } - } - - const allMessagesInCursorOrder = allMessages - - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - - // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = this.collectMediaMessagesForExport(allMessagesInCursorOrder, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 20, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - // 并行导出媒体,并发数跟随导出设置 - const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) - let mediaExported = 0 - await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 20, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 40, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - // 并行转写语音,限制 4 个并发(转写比较耗资源) - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 40, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - // ========== 阶段3:构建消息列表 ========== - onProgress?.({ - current: 60, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - const chatLabMessages: ChatLabMessage[] = [] - const senderProfileMap = new Map<string, ExportDisplayProfile>() - let messageIndex = 0 - for (const msg of allMessages) { - if ((messageIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { - platformId: msg.senderUsername, - accountName: msg.senderUsername, - groupNickname: undefined - } - - // 如果 memberInfo 中没有群昵称,尝试从 groupNicknamesMap 获取 - const groupNickname = memberInfo.groupNickname - || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '') - || '' - const senderProfile = isGroup - ? await this.resolveExportDisplayProfile( - msg.senderUsername || cleanedMyWxid, - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) - : { - wxid: msg.senderUsername || cleanedMyWxid, - nickname: memberInfo.accountName || msg.senderUsername || '', - remark: '', - alias: '', - groupNickname, - displayName: memberInfo.accountName || msg.senderUsername || '' - } - if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) { - senderProfileMap.set(senderProfile.wxid, senderProfile) - } - - // 确定消息内容 - let content: string | null - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) - if (msg.localType === 34 && options.exportVoiceAsText) { - // 使用预先转写的文字 - content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem && msg.localType !== 47) { - content = mediaItem.relativePath - } else { - content = this.parseMessageContent( - msg.content, - msg.localType, - sessionId, - msg.createTime, - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) - } - if (this.isReadableSystemMessage(msg.localType, msg.content)) { - content = this.extractReadableSystemMessageText(msg.content) || content - } - - // 转账消息:追加 "谁转账给谁" 信息 - if (content && this.isTransferExportContent(content) && msg.content) { - const transferDesc = await this.resolveTransferDesc( - msg.content, - cleanedMyWxid, - groupNicknamesMap, - async (username) => { - const info = await this.getContactInfo(username) - return info.displayName || username - } - ) - if (transferDesc) { - content = this.appendTransferDesc(content, transferDesc) - } - } - - const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown') - if (markdownLinkContent) { - content = markdownLinkContent - } - - const message: ChatLabMessage = { - sender: msg.senderUsername, - accountName: senderProfile.displayName || memberInfo.accountName, - groupNickname: (senderProfile.groupNickname || groupNickname) || undefined, - timestamp: msg.createTime, - type: this.convertMessageType(msg.localType, msg.content), - content: content - } - - const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) - if (platformMessageId !== '0') { - message.platformMessageId = platformMessageId - } - - const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content) - if (replyToMessageId) { - message.replyToMessageId = replyToMessageId - } - - // 如果有聊天记录,添加为嵌套字段 - if (msg.chatRecordList && msg.chatRecordList.length > 0) { - const chatRecords: any[] = [] - - for (const record of msg.chatRecordList) { - // 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS") - let recordTimestamp = msg.createTime - if (record.sourcetime) { - try { - const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/) - if (timeParts) { - const date = new Date( - parseInt(timeParts[1]), - parseInt(timeParts[2]) - 1, - parseInt(timeParts[3]), - parseInt(timeParts[4]), - parseInt(timeParts[5]), - parseInt(timeParts[6]) - ) - recordTimestamp = Math.floor(date.getTime() / 1000) - } - } catch (e) { - console.error('解析聊天记录时间失败:', e) - } - } - - // 转换消息类型 - let recordType = 0 // TEXT - let recordContent = record.datadesc || record.datatitle || '' - - switch (record.datatype) { - case 1: - recordType = 0 // TEXT - break - case 3: - recordType = 1 // IMAGE - recordContent = '[图片]' - break - case 8: - case 49: - recordType = 4 // FILE - recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]' - break - case 34: - recordType = 2 // VOICE - recordContent = '[语音消息]' - break - case 43: - recordType = 3 // VIDEO - recordContent = '[视频]' - break - case 47: - recordType = 5 // EMOJI - recordContent = '[表情包]' - break - default: - recordType = 0 - recordContent = record.datadesc || record.datatitle || '[消息]' - } - - const chatRecord: any = { - sender: record.sourcename || 'unknown', - accountName: record.sourcename || 'unknown', - timestamp: recordTimestamp, - type: recordType, - content: recordContent - } - - // 添加头像(如果启用导出头像) - if (options.exportAvatars && record.sourceheadurl) { - chatRecord.avatar = record.sourceheadurl - } - - chatRecords.push(chatRecord) - - // 添加成员信息到 memberSet - if (record.sourcename && !collected.memberSet.has(record.sourcename)) { - const newMember: ChatLabMember = { - platformId: record.sourcename, - accountName: record.sourcename - } - if (options.exportAvatars && record.sourceheadurl) { - newMember.avatar = record.sourceheadurl - } - collected.memberSet.set(record.sourcename, { - member: newMember, - avatarUrl: record.sourceheadurl - }) - } - } - - message.chatRecords = chatRecords - } - - chatLabMessages.push(message) - if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) { - const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20) - onProgress?.({ - current: exportProgress, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: chatLabMessages.length - }) - } - } - - const avatarMap = options.exportAvatars - ? await this.exportAvatars( - [ - ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ - username, - avatarUrl: info.avatarUrl - })), - { username: sessionId, avatarUrl: sessionInfo.avatarUrl } - ] - ) - : new Map<string, string>() - - const sessionAvatar = avatarMap.get(sessionId) - const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => { - const profile = isGroup - ? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile( - info.member.platformId, - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - info.member.accountName || info.member.platformId, - this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : [] - )) - : null - const member = profile - ? { - ...info.member, - accountName: profile.displayName || info.member.accountName, - groupNickname: profile.groupNickname || info.member.groupNickname - } - : info.member - const avatar = avatarMap.get(info.member.platformId) - return avatar ? { ...member, avatar } : member - })) - - const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar) - - const chatLabExport: ChatLabExport = { - chatlab, - meta, - members, - messages: chatLabMessages - } - - onProgress?.({ - current: 80, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages - }) - - 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) { - this.throwIfStopRequested(control) - lines.push(JSON.stringify({ _type: 'member', ...member })) - } - for (const message of chatLabExport.messages) { - this.throwIfStopRequested(control) - lines.push(JSON.stringify({ _type: 'message', ...message })) - } - this.throwIfStopRequested(control) - await this.recordCreatedFileBeforeWrite(outputPath, control) - await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') - } else { - this.throwIfStopRequested(control) - await this.recordCreatedFileBeforeWrite(outputPath, control) - await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') - } - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - return { success: false, error: String(e) } - } - } - - /** - * 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本 - */ - async exportSessionToDetailedJson( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - const totalMessages = collected.rows.length - - // 如果没有消息,不创建文件 - if (totalMessages === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - - await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) - - // 解析引用消息 - await this.resolveQuotedMessagesForExport(collected.rows, sessionId) - - const voiceMessages = options.exportVoiceAsText - ? collected.rows.filter(msg => msg.localType === 34) - : [] - - if (options.exportVoiceAsText && voiceMessages.length > 0) { - await this.ensureVoiceModel(onProgress) - } - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of collected.rows) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - await this.preloadContacts(senderUsernames, contactCache) - const senderInfoMap = await this.preloadContactInfos([ - ...Array.from(senderUsernames.values()), - cleanedMyWxid - ]) - - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - - // ========== 阶段1:并行导出媒体文件 ========== - const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 15, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) - let mediaExported = 0 - await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 15, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 35, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 35, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - // ========== 预加载群昵称(用于名称显示偏好) ========== - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...Array.from(senderUsernames.values()), - ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - // ========== 阶段3:构建消息列表 ========== - onProgress?.({ - current: 55, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - const allMessages: any[] = [] - const senderProfileMap = new Map<string, { - displayName: string - nickname: string - remark: string - groupNickname: string - }>() - const transferCandidates: Array<{ xml: string; messageRef: any }> = [] - let needSort = false - let lastCreateTime = Number.NEGATIVE_INFINITY - let messageIndex = 0 - for (const msg of collected.rows) { - if ((messageIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' } - const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '') - const source = sourceMatch ? sourceMatch[0] : '' - - let content: string | null - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) - - if (msg.localType === 34 && options.exportVoiceAsText) { - content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem && msg.localType !== 47) { - content = mediaItem.relativePath - } else { - content = this.parseMessageContent( - msg.content, - msg.localType, - undefined, - undefined, - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) - } - if (this.isReadableSystemMessage(msg.localType, msg.content)) { - content = this.extractReadableSystemMessageText(msg.content) || content - } - - const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ - content: msg.content, - isGroup, - displayNamePreference: options.displayNamePreference, - getContact: getContactCached, - groupNicknamesMap, - cleanedMyWxid, - rawMyWxid, - myDisplayName: myInfo.displayName || cleanedMyWxid - }) - // 对于媒体消息,不要让引用信息覆盖媒体路径 - if (quotedReplyDisplay && !mediaItem) { - content = this.buildQuotedReplyText(quotedReplyDisplay) - } - - const appendedLinkContent = quotedReplyDisplay - ? null - : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') - if (appendedLinkContent) { - content = appendedLinkContent - } - - // 获取发送者信息用于名称显示 - const senderWxid = msg.senderUsername - const contact = senderWxid - ? (contactCache.get(senderWxid) ?? { success: false as const }) - : { success: false as const } - const senderNickname = contact.success && contact.contact?.nickName - ? contact.contact.nickName - : (senderInfo.displayName || senderWxid) - const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : '' - const senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) - - // 使用用户偏好的显示名称 - const senderDisplayName = this.getPreferredDisplayName( - senderWxid, - senderNickname, - senderRemark, - senderGroupNickname, - options.displayNamePreference || 'remark' - ) - const existingSenderProfile = senderProfileMap.get(senderWxid) - if (!existingSenderProfile) { - senderProfileMap.set(senderWxid, { - displayName: senderDisplayName, - nickname: senderNickname, - remark: senderRemark, - groupNickname: senderGroupNickname - }) - } - - const msgObj: any = { - localId: allMessages.length + 1, - createTime: msg.createTime, - formattedTime: this.formatTimestamp(msg.createTime), - type: this.getMessageTypeName(msg.localType, msg.content), - localType: msg.localType, - content, - isSend: msg.isSend ? 1 : 0, - senderUsername: msg.senderUsername, - senderDisplayName, - source, - senderAvatarKey: msg.senderUsername - } - - if (msg.localType === 47) { - if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5 - if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl - if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption - } - - const platformMessageId = this.getExportPlatformMessageId(msg) - if (platformMessageId) msgObj.platformMessageId = platformMessageId - - const replyToMessageId = this.getExportReplyToMessageId(msg.content) - if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId - - const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) - if (appMsgMeta) { - if ( - options.format === 'arkme-json' || - (options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link')) - ) { - Object.assign(msgObj, appMsgMeta) - } - } - if (quotedReplyDisplay) { - if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender - if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview - } - - if (options.format === 'arkme-json') { - const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) - if (contactCardMeta) { - Object.assign(msgObj, contactCardMeta) - } - } - - if (content && this.isTransferExportContent(content) && msg.content) { - transferCandidates.push({ xml: msg.content, messageRef: msgObj }) - } - - // 位置消息:附加结构化位置字段 - if (msg.localType === 48) { - if (msg.locationLat != null) msgObj.locationLat = msg.locationLat - if (msg.locationLng != null) msgObj.locationLng = msg.locationLng - if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname - if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel - } - - allMessages.push(msgObj) - if (msg.createTime < lastCreateTime) needSort = true - lastCreateTime = msg.createTime - if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) { - const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15) - onProgress?.({ - current: exportProgress, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: allMessages.length - }) - } - } - - if (transferCandidates.length > 0) { - const transferNameCache = new Map<string, string>() - const transferNamePromiseCache = new Map<string, Promise<string>>() - const resolveDisplayNameByUsername = async (username: string): Promise<string> => { - if (!username) return username - const cachedName = transferNameCache.get(username) - if (cachedName) return cachedName - const pending = transferNamePromiseCache.get(username) - if (pending) return pending - const task = (async () => { - const contactResult = contactCache.get(username) ?? await getContactCached(username) - if (contactResult.success && contactResult.contact) { - return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username - } - return username - })() - transferNamePromiseCache.set(username, task) - const resolved = await task - transferNamePromiseCache.delete(username) - transferNameCache.set(username, resolved) - return resolved - } - - const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8) - await parallelLimit(transferCandidates, transferConcurrency, async (item) => { - this.throwIfStopRequested(control) - const transferDesc = await this.resolveTransferDesc( - item.xml, - cleanedMyWxid, - groupNicknamesMap, - resolveDisplayNameByUsername - ) - if (transferDesc && typeof item.messageRef.content === 'string') { - item.messageRef.content = this.appendTransferDesc(item.messageRef.content, transferDesc) - } - }) - } - - if (needSort) { - allMessages.sort((a, b) => a.createTime - b.createTime) - } - - onProgress?.({ - current: 70, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages - }) - - // 获取会话的昵称和备注信息 - const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId) - const sessionNickname = sessionContact.success && sessionContact.contact?.nickName - ? sessionContact.contact.nickName - : sessionInfo.displayName - const sessionRemark = sessionContact.success && sessionContact.contact?.remark - ? sessionContact.contact.remark - : '' - const sessionGroupNickname = isGroup - ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId]) - : '' - - // 使用用户偏好的显示名称 - const sessionDisplayName = this.getPreferredDisplayName( - sessionId, - sessionNickname, - sessionRemark, - sessionGroupNickname, - options.displayNamePreference || 'remark' - ) - - const weflow = this.getWeflowHeader() - if (options.format === 'arkme-json' && isGroup) { - this.throwIfStopRequested(control) - await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) - } - - const avatarMap = options.exportAvatars - ? await this.exportAvatars( - [ - ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ - username, - avatarUrl: info.avatarUrl - })), - { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, - { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } - ] - ) - : new Map<string, string>() - - const sessionPayload: any = { - wxid: sessionId, - nickname: sessionNickname, - remark: sessionRemark, - displayName: sessionDisplayName, - type: isGroup ? '群聊' : '私聊', - lastTimestamp: collected.lastTime, - messageCount: allMessages.length, - avatar: avatarMap.get(sessionId) - } - - if (options.format === 'arkme-json') { - const senderIdMap = new Map<string, number>() - const senders: Array<{ - senderID: number - wxid: string - displayName: string - nickname: string - remark?: string - groupNickname?: string - avatar?: string - }> = [] - const ensureSenderId = (senderWxidRaw: string): number => { - const senderWxid = String(senderWxidRaw || '').trim() || 'unknown' - const existed = senderIdMap.get(senderWxid) - if (existed) return existed - - const senderID = senders.length + 1 - senderIdMap.set(senderWxid, senderID) - - const profile = senderProfileMap.get(senderWxid) - const senderItem: { - senderID: number - wxid: string - displayName: string - nickname: string - remark?: string - groupNickname?: string - avatar?: string - } = { - senderID, - wxid: senderWxid, - displayName: profile?.displayName || senderWxid, - nickname: profile?.nickname || profile?.displayName || senderWxid - } - if (profile?.remark) senderItem.remark = profile.remark - if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname - const avatar = avatarMap.get(senderWxid) - if (avatar) senderItem.avatar = avatar - - senders.push(senderItem) - return senderID - } - - const compactMessages = allMessages.map((message) => { - this.throwIfStopRequested(control) - const senderID = ensureSenderId(String(message.senderUsername || '')) - const compactMessage: any = { - localId: message.localId, - createTime: message.createTime, - formattedTime: message.formattedTime, - type: message.type, - localType: message.localType, - content: message.content, - isSend: message.isSend, - senderID, - source: message.source - } - if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId - if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId - if (message.locationLat != null) compactMessage.locationLat = message.locationLat - if (message.locationLng != null) compactMessage.locationLng = message.locationLng - if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname - if (message.locationLabel) compactMessage.locationLabel = message.locationLabel - if (message.appMsgType) compactMessage.appMsgType = message.appMsgType - if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind - if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc - if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName - if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName - if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername - if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl - if (message.quotedContent) compactMessage.quotedContent = message.quotedContent - if (message.quotedSender) compactMessage.quotedSender = message.quotedSender - if (message.quotedType) compactMessage.quotedType = message.quotedType - if (message.linkTitle) compactMessage.linkTitle = message.linkTitle - if (message.linkUrl) compactMessage.linkUrl = message.linkUrl - if (message.linkThumb) compactMessage.linkThumb = message.linkThumb - if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5 - if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl - if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption - if (message.finderTitle) compactMessage.finderTitle = message.finderTitle - if (message.finderDesc) compactMessage.finderDesc = message.finderDesc - if (message.finderUsername) compactMessage.finderUsername = message.finderUsername - if (message.finderNickname) compactMessage.finderNickname = message.finderNickname - if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl - if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar - if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration - if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId - if (message.finderUrl) compactMessage.finderUrl = message.finderUrl - if (message.musicTitle) compactMessage.musicTitle = message.musicTitle - if (message.musicUrl) compactMessage.musicUrl = message.musicUrl - if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl - if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl - if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl - if (message.musicSinger) compactMessage.musicSinger = message.musicSinger - if (message.musicAppName) compactMessage.musicAppName = message.musicAppName - if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName - if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration - if (message.cardKind) compactMessage.cardKind = message.cardKind - if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid - if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname - if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias - if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark - if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender - if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince - if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity - if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature - if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar - return compactMessage - }) - - const arkmeSession: any = { - ...sessionPayload - } - let groupMembers: Array<{ - wxid: string - displayName: string - nickname: string - remark: string - alias: string - groupNickname?: string - isFriend: boolean - messageCount: number - avatar?: string - }> | undefined - - if (isGroup) { - const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean) - await this.preloadContacts(memberUsernames, contactCache) - const friendLookupUsernames = this.buildGroupNicknameIdCandidates(memberUsernames) - const friendFlagMap = await this.queryFriendFlagMap(friendLookupUsernames) - const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0) - const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data - ? this.extractGroupSenderCountMap(groupStatsResult.data, sessionId) - : new Map<string, number>() - - groupMembers = [] - for (const memberWxid of memberUsernames) { - this.throwIfStopRequested(control) - const member = collected.memberSet.get(memberWxid)?.member - const contactResult = await getContactCached(memberWxid) - const contact = contactResult.success ? contactResult.contact : null - const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid) - const remark = String(contact?.remark || '') - const alias = String(contact?.alias || '') - const groupNickname = member?.groupNickname || this.resolveGroupNicknameByCandidates( - groupNicknamesMap, - [memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias] - ) || '' - const displayName = this.getPreferredDisplayName( - memberWxid, - nickname, - remark, - groupNickname, - options.displayNamePreference || 'remark' - ) - - const groupMember: { - wxid: string - displayName: string - nickname: string - remark: string - alias: string - groupNickname?: string - isFriend: boolean - messageCount: number - avatar?: string - } = { - wxid: memberWxid, - displayName, - nickname, - remark, - alias, - isFriend: this.buildGroupNicknameIdCandidates([memberWxid]).some((candidate) => friendFlagMap.get(candidate) === true), - messageCount: this.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid) - } - if (groupNickname) groupMember.groupNickname = groupNickname - const avatar = avatarMap.get(memberWxid) - if (avatar) groupMember.avatar = avatar - groupMembers.push(groupMember) - } - groupMembers.sort((a, b) => { - if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount - return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN') - }) - } - - const arkmeExport: any = { - weflow: { - ...weflow, - format: 'arkme-json' - }, - session: arkmeSession, - senders, - messages: compactMessages - } - if (groupMembers) { - arkmeExport.groupMembers = groupMembers - } - - this.throwIfStopRequested(control) - await this.recordCreatedFileBeforeWrite(outputPath, control) - await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') - } else { - const detailedExport: any = { - weflow, - session: sessionPayload, - messages: allMessages - } - - if (options.exportAvatars) { - const avatars: Record<string, string> = {} - for (const [username, relPath] of avatarMap.entries()) { - avatars[username] = relPath - } - if (Object.keys(avatars).length > 0) { - detailedExport.session = { - ...detailedExport.session, - avatar: avatars[sessionId] - } - ; (detailedExport as any).avatars = avatars - } - } - - this.throwIfStopRequested(control) - await this.recordCreatedFileBeforeWrite(outputPath, control) - await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') - } - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - return { success: false, error: String(e) } - } - } - - /** - * 导出单个会话为 Excel 格式(参考 echotrace 格式) - */ - async exportSessionToExcel( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - // 获取会话的备注信息 - const sessionContact = await getContactCached(sessionId) - const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : '' - const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - const totalMessages = collected.rows.length - - // 如果没有消息,不创建文件 - if (totalMessages === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - - await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) - - // 解析引用消息 - await this.resolveQuotedMessagesForExport(collected.rows, sessionId) - - const voiceMessages = options.exportVoiceAsText - ? collected.rows.filter(msg => msg.localType === 34) - : [] - - if (options.exportVoiceAsText && voiceMessages.length > 0) { - await this.ensureVoiceModel(onProgress) - } - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of collected.rows) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - await this.preloadContacts(senderUsernames, contactCache) - - onProgress?.({ - current: 30, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - // 创建 Excel 工作簿 - const workbook = new ExcelJS.Workbook() - workbook.creator = 'WeFlow' - workbook.created = new Date() - - const worksheet = workbook.addWorksheet('聊天记录') - - let currentRow = 1 - - const useCompactColumns = options.excelCompactColumns === true - - // 第一行:会话信息标题 - const titleCell = worksheet.getCell(currentRow, 1) - titleCell.value = '会话信息' - titleCell.font = { name: 'Calibri', bold: true, size: 11 } - titleCell.alignment = { vertical: 'middle', horizontal: 'left' } - worksheet.getRow(currentRow).height = 25 - currentRow++ - - // 第二行:会话详细信息 - worksheet.getCell(currentRow, 1).value = '微信ID' - worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.mergeCells(currentRow, 2, currentRow, 3) - worksheet.getCell(currentRow, 2).value = sessionId - worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 } - - worksheet.getCell(currentRow, 4).value = '昵称' - worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(currentRow, 5).value = sessionNickname - worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 } - - if (isGroup) { - worksheet.getCell(currentRow, 6).value = '备注' - worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.mergeCells(currentRow, 7, currentRow, 8) - worksheet.getCell(currentRow, 7).value = sessionRemark - worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 } - } - worksheet.getRow(currentRow).height = 20 - currentRow++ - - // 第三行:导出元数据 - const { chatlab, meta: exportMeta } = this.getExportMeta(sessionId, sessionInfo, isGroup) - worksheet.getCell(currentRow, 1).value = '导出工具' - worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(currentRow, 2).value = chatlab.generator - worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 } - - worksheet.getCell(currentRow, 3).value = '导出版本' - worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(currentRow, 4).value = chatlab.version - worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 } - - worksheet.getCell(currentRow, 5).value = '平台' - worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(currentRow, 6).value = exportMeta.platform - worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 } - - worksheet.getCell(currentRow, 7).value = '导出时间' - worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(currentRow, 8).value = this.formatTimestamp(chatlab.exportedAt) - worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 } - - worksheet.getRow(currentRow).height = 20 - currentRow++ - - // 表头行 - const includeGroupNicknameColumn = !useCompactColumns && isGroup - const headers = useCompactColumns - ? ['序号', '时间', '发送者身份', '消息类型', '内容'] - : includeGroupNicknameColumn - ? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'] - : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] - const headerRow = worksheet.getRow(currentRow) - headerRow.height = 22 - - headers.forEach((header, index) => { - const cell = headerRow.getCell(index + 1) - cell.value = header - cell.font = { name: 'Calibri', bold: true, size: 11 } - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE8F5E9' } - } - cell.alignment = { vertical: 'middle', horizontal: 'center' } - }) - currentRow++ - - // 设置列宽 - worksheet.getColumn(1).width = 8 // 序号 - worksheet.getColumn(2).width = 20 // 时间 - if (useCompactColumns) { - worksheet.getColumn(3).width = 18 // 发送者身份 - worksheet.getColumn(4).width = 12 // 消息类型 - worksheet.getColumn(5).width = 50 // 内容 - } else { - worksheet.getColumn(3).width = 18 // 发送者昵称 - worksheet.getColumn(4).width = 25 // 发送者微信ID - worksheet.getColumn(5).width = 18 // 发送者备注 - if (includeGroupNicknameColumn) { - worksheet.getColumn(6).width = 18 // 群昵称 - worksheet.getColumn(7).width = 15 // 发送者身份 - worksheet.getColumn(8).width = 12 // 消息类型 - worksheet.getColumn(9).width = 50 // 内容 - } else { - worksheet.getColumn(6).width = 15 // 发送者身份 - worksheet.getColumn(7).width = 12 // 消息类型 - worksheet.getColumn(8).width = 50 // 内容 - } - } - - // 预加载群昵称 (仅群聊且完整列模式) - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid, - rawMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - - // 填充数据 - const sortedMessages = collected.rows - - // 媒体导出设置 - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - - // ========== 并行预处理:媒体文件 ========== - const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 35, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) - let mediaExported = 0 - await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 35, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - // ========== 并行预处理:语音转文字 ========== - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 50, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 50, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - const shouldUseStreamingWriter = totalMessages > 20000 - if (shouldUseStreamingWriter) { - return this.exportSessionToExcelStreaming({ - outputPath, - options, - sessionId, - sessionInfo, - myInfo, - cleanedMyWxid, - rawMyWxid, - isGroup, - sortedMessages, - mediaCache, - voiceTranscriptMap, - getContactCached, - groupNicknamesMap, - onProgress, - control, - totalMessages - }) - } - - onProgress?.({ - current: 65, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - // ========== 写入 Excel 行 ========== - const senderProfileCache = new Map<string, ExportDisplayProfile>() - for (let i = 0; i < totalMessages; i++) { - if ((i & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const msg = sortedMessages[i] - - // 确定发送者信息 - let senderRole: string - let senderWxid: string - let senderNickname: string - let senderRemark: string = '' - let senderGroupNickname: string = '' // 群昵称 - - if (isGroup) { - const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` - let senderProfile = senderProfileCache.get(senderProfileKey) - if (!senderProfile) { - senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) - senderProfileCache.set(senderProfileKey, senderProfile) - } - senderWxid = senderProfile.wxid - senderNickname = senderProfile.nickname - senderRemark = senderProfile.remark - senderGroupNickname = senderProfile.groupNickname - senderRole = senderProfile.displayName - } else if (msg.isSend) { - // 我发送的消息 - senderRole = '我' - senderWxid = cleanedMyWxid - senderNickname = myInfo.displayName || cleanedMyWxid - senderRemark = '' - } else { - // 单聊对方消息 - 用 getContact 获取联系人详情 - senderWxid = sessionId - const contactDetail = await getContactCached(sessionId) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || sessionId - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = sessionInfo.displayName || sessionId - senderRemark = '' - senderRole = senderNickname - } - } - - const row = worksheet.getRow(currentRow) - row.height = 24 - - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) - const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText - const contentValue = shouldUseTranscript - ? this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) - : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) - || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - )) - - // 转账消息:追加 "谁转账给谁" 信息 - let enrichedContentValue = contentValue - if (this.isTransferExportContent(contentValue) && msg.content) { - const transferDesc = await this.resolveTransferDesc( - msg.content, - cleanedMyWxid, - groupNicknamesMap, - async (username) => { - const c = await getContactCached(username) - if (c.success && c.contact) { - return c.contact.remark || c.contact.nickName || c.contact.alias || username - } - return username - } - ) - if (transferDesc) { - enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) - } - } - - const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ - content: msg.content, - isGroup, - displayNamePreference: options.displayNamePreference, - getContact: getContactCached, - groupNicknamesMap, - cleanedMyWxid, - rawMyWxid, - myDisplayName: myInfo.displayName || cleanedMyWxid - }) - if (quotedReplyDisplay) { - enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) - } - - const contentCellIndex = useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8) - const contentCell = worksheet.getCell(currentRow, contentCellIndex) - - worksheet.getCell(currentRow, 1).value = i + 1 - worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) - if (useCompactColumns) { - worksheet.getCell(currentRow, 3).value = senderRole - worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType, msg.content) - } else if (includeGroupNicknameColumn) { - worksheet.getCell(currentRow, 3).value = senderNickname - worksheet.getCell(currentRow, 4).value = senderWxid - worksheet.getCell(currentRow, 5).value = senderRemark - worksheet.getCell(currentRow, 6).value = senderGroupNickname - worksheet.getCell(currentRow, 7).value = senderRole - worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType, msg.content) - } else { - worksheet.getCell(currentRow, 3).value = senderNickname - worksheet.getCell(currentRow, 4).value = senderWxid - worksheet.getCell(currentRow, 5).value = senderRemark - worksheet.getCell(currentRow, 6).value = senderRole - worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType, msg.content) - } - contentCell.value = enrichedContentValue - if (!quotedReplyDisplay) { - this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType) - } - - currentRow++ - - // 每处理 100 条消息报告一次进度 - if ((i + 1) % 100 === 0) { - const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50) - onProgress?.({ - current: progress, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: i + 1 - }) - } - } - - onProgress?.({ - current: 90, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages - }) - - // 写入文件 - this.throwIfStopRequested(control) - await this.recordCreatedFileBeforeWrite(outputPath, control) - await workbook.xlsx.writeFile(outputPath) - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - // 处理文件被占用的错误 - if (e instanceof Error) { - if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { - return { success: false, error: '文件已经打开,请关闭后再导出' } - } - } - - return { success: false, error: String(e) } - } - } - - private async exportSessionToExcelStreaming(params: { - outputPath: string - options: ExportOptions - sessionId: string - sessionInfo: { displayName: string } - myInfo: { displayName: string } - cleanedMyWxid: string - rawMyWxid: string - isGroup: boolean - sortedMessages: any[] - mediaCache: Map<string, MediaExportItem | null> - voiceTranscriptMap: Map<string, string> - getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> - groupNicknamesMap: Map<string, string> - onProgress?: (progress: ExportProgress) => void - control?: ExportTaskControl - totalMessages: number - }): Promise<{ success: boolean; error?: string }> { - const { - outputPath, - options, - sessionId, - sessionInfo, - myInfo, - cleanedMyWxid, - rawMyWxid, - isGroup, - sortedMessages, - mediaCache, - voiceTranscriptMap, - getContactCached, - groupNicknamesMap, - onProgress, - control, - totalMessages - } = params - - try { - const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ - filename: outputPath, - useStyles: true, - useSharedStrings: false - }) - const worksheet = workbook.addWorksheet('聊天记录') - const useCompactColumns = options.excelCompactColumns === true - const includeGroupNicknameColumn = !useCompactColumns && isGroup - const senderProfileCache = new Map<string, ExportDisplayProfile>() - - worksheet.columns = useCompactColumns - ? [ - { width: 8 }, - { width: 20 }, - { width: 18 }, - { width: 12 }, - { width: 50 } - ] - : includeGroupNicknameColumn - ? [ - { width: 8 }, - { width: 20 }, - { width: 18 }, - { width: 25 }, - { width: 18 }, - { width: 18 }, - { width: 15 }, - { width: 12 }, - { width: 50 } - ] - : [ - { width: 8 }, - { width: 20 }, - { width: 18 }, - { width: 25 }, - { width: 18 }, - { width: 15 }, - { width: 12 }, - { width: 50 } - ] - - const appendRow = (values: any[]) => { - const row = worksheet.addRow(values) - row.commit() - } - - appendRow(['会话信息']) - appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId]) - appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))]) - appendRow([]) - appendRow(useCompactColumns - ? ['序号', '时间', '发送者身份', '消息类型', '内容'] - : includeGroupNicknameColumn - ? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'] - : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']) - - for (let i = 0; i < totalMessages; i++) { - if ((i & 0x7f) === 0) this.throwIfStopRequested(control) - const msg = sortedMessages[i] - - let senderRole: string - let senderWxid: string - let senderNickname: string - let senderRemark = '' - let senderGroupNickname = '' - - if (isGroup) { - const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` - let senderProfile = senderProfileCache.get(senderProfileKey) - if (!senderProfile) { - senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) - senderProfileCache.set(senderProfileKey, senderProfile) - } - senderWxid = senderProfile.wxid - senderNickname = senderProfile.nickname - senderRemark = senderProfile.remark - senderGroupNickname = senderProfile.groupNickname - senderRole = senderProfile.displayName - } else if (msg.isSend) { - senderRole = '我' - senderWxid = cleanedMyWxid - senderNickname = myInfo.displayName || cleanedMyWxid - } else { - senderWxid = sessionId - const contactDetail = await getContactCached(sessionId) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || sessionId - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = sessionInfo.displayName || sessionId - senderRole = senderNickname - } - } - - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) - const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText - const contentValue = shouldUseTranscript - ? this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) - : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) - || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - )) - - let enrichedContentValue = contentValue - if (this.isTransferExportContent(contentValue) && msg.content) { - const transferDesc = await this.resolveTransferDesc( - msg.content, - cleanedMyWxid, - groupNicknamesMap, - async (username) => { - const c = await getContactCached(username) - if (c.success && c.contact) { - return c.contact.remark || c.contact.nickName || c.contact.alias || username - } - return username - } - ) - if (transferDesc) { - enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) - } - } - - const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ - content: msg.content, - isGroup, - displayNamePreference: options.displayNamePreference, - getContact: getContactCached, - groupNicknamesMap, - cleanedMyWxid, - rawMyWxid, - myDisplayName: myInfo.displayName || cleanedMyWxid - }) - if (quotedReplyDisplay) { - enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) - } - - const row = worksheet.addRow(useCompactColumns - ? [ - i + 1, - this.formatTimestamp(msg.createTime), - senderRole, - this.getMessageTypeName(msg.localType, msg.content), - enrichedContentValue - ] - : includeGroupNicknameColumn - ? [ - i + 1, - this.formatTimestamp(msg.createTime), - senderNickname, - senderWxid, - senderRemark, - senderGroupNickname, - senderRole, - this.getMessageTypeName(msg.localType, msg.content), - enrichedContentValue - ] - : [ - i + 1, - this.formatTimestamp(msg.createTime), - senderNickname, - senderWxid, - senderRemark, - senderRole, - this.getMessageTypeName(msg.localType, msg.content), - enrichedContentValue - ]) - if (!quotedReplyDisplay) { - this.applyExcelLinkCardCell( - row.getCell(useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)), - msg.content, - msg.localType - ) - } - row.commit() - - if ((i + 1) % 200 === 0) { - onProgress?.({ - current: 65 + Math.floor((i + 1) / totalMessages * 25), - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: i + 1 - }) - } - } - - worksheet.commit() - await workbook.commit() - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - if (e instanceof Error) { - if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { - return { success: false, error: '文件已经打开,请关闭后再导出' } - } - } - return { success: false, error: String(e) } - } - } - - /** - * 确保语音转写模型已下载 - */ - private async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise<boolean> { - try { - const status = await voiceTranscribeService.getModelStatus() - if (status.success && status.exists) { - return true - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: '正在下载 AI 模型', - phase: 'preparing' - }) - - const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => { - if (progress.percent !== undefined) { - onProgress?.({ - current: progress.percent, - total: 100, - currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`, - phase: 'preparing' - }) - } - }) - - return downloadResult.success - } catch (e) { - console.error('Auto download model failed:', e) - return false - } - } - - /** - * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) - */ - async exportSessionToTxt( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - const totalMessages = collected.rows.length - - // 如果没有消息,不创建文件 - if (totalMessages === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - - await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) - - // 解析引用消息 - await this.resolveQuotedMessagesForExport(collected.rows, sessionId) - - const voiceMessages = options.exportVoiceAsText - ? collected.rows.filter(msg => msg.localType === 34) - : [] - - if (options.exportVoiceAsText && voiceMessages.length > 0) { - await this.ensureVoiceModel(onProgress) - } - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of collected.rows) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - await this.preloadContacts(senderUsernames, contactCache) - - // 获取群昵称(用于转账描述等) - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...Array.from(senderUsernames.values()), - ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid, - rawMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - const sortedMessages = collected.rows - - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 25, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) - let mediaExported = 0 - await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 25, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 45, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 45, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - onProgress?.({ - current: 60, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - await this.recordCreatedFileBeforeWrite(outputPath, control) - const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - const writeChunk = async (chunk: string): Promise<void> => { - await new Promise<void>((resolve, _reject) => { - this.throwIfStopRequested(control) - if (!stream.write(chunk)) { - stream.once('drain', resolve) - } else { - resolve() - } - }) - } - const WRITE_BATCH = 120 - let writeBuffer: string[] = [] - const flushWriteBuffer = async (): Promise<void> => { - if (writeBuffer.length === 0) return - await writeChunk(writeBuffer.join('')) - writeBuffer = [] - } - const senderProfileCache = new Map<string, ExportDisplayProfile>() - - for (let i = 0; i < totalMessages; i++) { - if ((i & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const msg = sortedMessages[i] - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) - const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText - const contentValue = shouldUseTranscript - ? this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) - : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) - || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(this.getStableMessageKey(msg)), - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - )) - - // 转账消息:追加 "谁转账给谁" 信息 - let enrichedContentValue = contentValue - if (this.isTransferExportContent(contentValue) && msg.content) { - const transferDesc = await this.resolveTransferDesc( - msg.content, - cleanedMyWxid, - groupNicknamesMap, - async (username) => { - const c = await getContactCached(username) - if (c.success && c.contact) { - return c.contact.remark || c.contact.nickName || c.contact.alias || username - } - return username - } - ) - if (transferDesc) { - enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) - } - } - - const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ - content: msg.content, - isGroup, - displayNamePreference: options.displayNamePreference, - getContact: getContactCached, - groupNicknamesMap, - cleanedMyWxid, - rawMyWxid, - myDisplayName: myInfo.displayName || cleanedMyWxid - }) - if (quotedReplyDisplay) { - enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) - } - - const appendedLinkContent = quotedReplyDisplay - ? null - : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') - if (appendedLinkContent) { - enrichedContentValue = appendedLinkContent - } - - let senderRole: string - let senderWxid: string - let senderNickname: string - let senderRemark = '' - - if (isGroup) { - const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` - let senderProfile = senderProfileCache.get(senderProfileKey) - if (!senderProfile) { - senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) - senderProfileCache.set(senderProfileKey, senderProfile) - } - senderWxid = senderProfile.wxid - senderNickname = senderProfile.nickname - senderRemark = senderProfile.remark - senderRole = senderProfile.displayName - } else if (msg.isSend) { - senderRole = '我' - senderWxid = cleanedMyWxid - senderNickname = myInfo.displayName || cleanedMyWxid - } else { - senderWxid = sessionId - const contactDetail = await getContactCached(sessionId) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || sessionId - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = sessionInfo.displayName || sessionId - senderRole = senderNickname - } - } - - writeBuffer.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'\n${enrichedContentValue}\n\n`) - if (writeBuffer.length >= WRITE_BATCH) { - await flushWriteBuffer() - } - - if ((i + 1) % 200 === 0) { - const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) - onProgress?.({ - current: progress, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: i + 1 - }) - } - } - - await flushWriteBuffer() - - onProgress?.({ - current: 92, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages - }) - - this.throwIfStopRequested(control) - await new Promise<void>((resolve, reject) => { - stream.on('error', reject) - stream.end(() => resolve()) - }) - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - return { success: false, error: String(e) } - } - } - - /** - * 导出单个会话为 WeClone CSV 格式 - */ - async exportSessionToWeCloneCsv( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - let totalMessages = collected.rows.length - if (totalMessages === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - - await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of collected.rows) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - await this.preloadContacts(senderUsernames, contactCache) - - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...Array.from(senderUsernames.values()), - ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid, - rawMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - const sortedMessages = collected.rows - .filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || '')) - totalMessages = sortedMessages.length - if (totalMessages === 0) { - return { success: false, error: '该会话在指定时间范围内没有可导出的消息' } - } - - const voiceMessages = options.exportVoiceAsText - ? sortedMessages.filter(msg => msg.localType === 34) - : [] - - if (options.exportVoiceAsText && voiceMessages.length > 0) { - await this.ensureVoiceModel(onProgress) - } - - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 25, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) - let mediaExported = 0 - await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportVideos: options.exportVideos, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 25, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 45, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 45, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - onProgress?.({ - current: 60, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - await this.recordCreatedFileBeforeWrite(outputPath, control) - const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - const writeChunk = async (chunk: string): Promise<void> => { - await new Promise<void>((resolve, _reject) => { - this.throwIfStopRequested(control) - if (!stream.write(chunk)) { - stream.once('drain', resolve) - } else { - resolve() - } - }) - } - const WRITE_BATCH = 160 - let writeBuffer: string[] = [] - const flushWriteBuffer = async (): Promise<void> => { - if (writeBuffer.length === 0) return - await writeChunk(writeBuffer.join('')) - writeBuffer = [] - } - await writeChunk('\uFEFFid,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime\r\n') - const senderProfileCache = new Map<string, ExportDisplayProfile>() - - for (let i = 0; i < totalMessages; i++) { - if ((i & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - const msg = sortedMessages[i] - const mediaKey = this.getMediaCacheKey(msg) - const mediaItem = mediaCache.get(mediaKey) || null - - const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') - let senderWxid = cleanedMyWxid - if (!msg.isSend) { - senderWxid = isGroup && msg.senderUsername - ? msg.senderUsername - : sessionId - } - - let talker = myInfo.displayName || '我' - if (isGroup) { - const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}` - let senderProfile = senderProfileCache.get(senderProfileKey) - if (!senderProfile) { - senderProfile = await this.resolveExportDisplayProfile( - msg.isSend ? cleanedMyWxid : senderWxid, - options.displayNamePreference, - getContactCached, - groupNicknamesMap, - msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, - msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] - ) - senderProfileCache.set(senderProfileKey, senderProfile) - } - talker = senderProfile.displayName - } else if (!msg.isSend) { - const contactDetail = await getContactCached(senderWxid) - const senderNickname = contactDetail.success && contactDetail.contact - ? (contactDetail.contact.nickName || senderWxid) - : senderWxid - const senderRemark = contactDetail.success && contactDetail.contact - ? (contactDetail.contact.remark || '') - : '' - const senderGroupNickname = isGroup - ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) - : '' - talker = this.getPreferredDisplayName( - senderWxid, - senderNickname, - senderRemark, - senderGroupNickname, - options.displayNamePreference || 'remark' - ) - } - - const msgText = msg.localType === 34 && options.exportVoiceAsText - ? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]') - : (this.parseMessageContent( - msg.content, - msg.localType, - sessionId, - msg.createTime, - cleanedMyWxid, - msg.senderUsername, - msg.isSend, - msg.emojiCaption - ) || '') - const src = this.getWeCloneSource(msg, typeName, mediaItem) - const platformMessageId = this.getExportPlatformMessageId(msg) || '' - - const row = [ - i + 1, - platformMessageId, - typeName, - msg.isSend ? 1 : 0, - talker, - msgText, - src, - this.formatIsoTimestamp(msg.createTime) - ] - - writeBuffer.push(`${row.map((value) => this.escapeCsvCell(value)).join(',')}\r\n`) - if (writeBuffer.length >= WRITE_BATCH) { - await flushWriteBuffer() - } - - if ((i + 1) % 200 === 0) { - const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) - onProgress?.({ - current: progress, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: i + 1 - }) - } - } - - await flushWriteBuffer() - - onProgress?.({ - current: 92, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages - }) - - this.throwIfStopRequested(control) - await new Promise<void>((resolve, reject) => { - stream.on('error', reject) - stream.end(() => resolve()) - }) - - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - - return { success: true } - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - return { success: false, error: String(e) } - } - } - - private getVirtualScrollScript(): string { - return ` - class ChunkedRenderer { - constructor(container, data, renderItem) { - this.container = container; - this.data = data; - this.renderItem = renderItem; - this.batchSize = 100; - this.rendered = 0; - this.loading = false; - - this.list = document.createElement('div'); - this.list.className = 'message-list'; - this.container.appendChild(this.list); - - this.sentinel = document.createElement('div'); - this.sentinel.className = 'load-sentinel'; - this.container.appendChild(this.sentinel); - - this.renderBatch(); - - this.observer = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !this.loading) { - this.renderBatch(); - } - }, { root: this.container, rootMargin: '600px' }); - this.observer.observe(this.sentinel); - } - - renderBatch() { - if (this.rendered >= this.data.length) return; - this.loading = true; - const end = Math.min(this.rendered + this.batchSize, this.data.length); - const fragment = document.createDocumentFragment(); - for (let i = this.rendered; i < end; i++) { - const wrapper = document.createElement('div'); - wrapper.innerHTML = this.renderItem(this.data[i], i); - if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild); - } - this.list.appendChild(fragment); - this.rendered = end; - this.loading = false; - } - - setData(newData) { - this.data = newData; - this.rendered = 0; - this.list.innerHTML = ''; - this.container.scrollTop = 0; - if (this.data.length === 0) { - this.list.innerHTML = '<div class="empty">暂无消息</div>'; - return; - } - this.renderBatch(); - } - - scrollToTime(timestamp) { - const idx = this.data.findIndex(item => item.t >= timestamp); - if (idx === -1) return; - // Ensure all messages up to target are rendered - while (this.rendered <= idx) { - this.renderBatch(); - } - const el = this.list.children[idx]; - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('highlight'); - setTimeout(() => el.classList.remove('highlight'), 2500); - } - } - - scrollToIndex(index) { - while (this.rendered <= index) { - this.renderBatch(); - } - const el = this.list.children[index]; - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - } - `; - } - - /** - * 导出单个会话为 HTML 格式 - */ - async exportSessionToHtml( - sessionId: string, - outputPath: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ success: boolean; error?: string }> { - try { - this.throwIfStopRequested(control) - 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 rawMyWxid = this.getConfiguredMyWxid() - const sessionInfo = await this.getContactInfo(sessionId) - const myInfo = await this.getContactInfo(cleanedMyWxid) - const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>() - const getContactCached = async (username: string) => { - if (contactCache.has(username)) { - return contactCache.get(username)! - } - const result = await wcdbService.getContact(username) - contactCache.set(username, result) - return result - } - - onProgress?.({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing' - }) - - if (options.exportVoiceAsText) { - await this.ensureVoiceModel(onProgress) - } - - const collectParams = this.resolveCollectParams(options) - const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - collectParams.mode, - collectParams.targetMediaTypes, - control, - collectProgressReporter - ) - - // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { - return { success: false, error: await this.buildNoMessagesError(sessionId, collected) } - } - const totalMessages = collected.rows.length - - await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) - - const senderUsernames = new Set<string>() - let senderScanIndex = 0 - for (const msg of collected.rows) { - if ((senderScanIndex++ & 0x7f) === 0) { - this.throwIfStopRequested(control) - } - if (msg.senderUsername) senderUsernames.add(msg.senderUsername) - } - senderUsernames.add(sessionId) - await this.preloadContacts(senderUsernames, contactCache) - - const groupNicknameCandidates = isGroup - ? this.buildGroupNicknameIdCandidates([ - ...Array.from(senderUsernames.values()), - ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid, - rawMyWxid - ]) - : [] - const groupNicknamesMap = isGroup - ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) - : new Map<string, string>() - - if (isGroup) { - this.throwIfStopRequested(control) - await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) - } - const sortedMessages = collected.rows - - const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) - const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options) - - const mediaCache = new Map<string, MediaExportItem | null>() - const mediaDirCache = new Set<string>() - const beforeMediaDoneFiles = this.getMediaDoneFilesCount() - - if (mediaMessages.length > 0) { - await this.preloadMediaLookupCaches(sessionId, mediaMessages, { - exportImages: options.exportImages, - exportVideos: options.exportVideos - }, control) - const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) - if (voiceMediaMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) - } - - onProgress?.({ - current: 20, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: 0, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot(), - estimatedTotalMessages: totalMessages - }) - - const MEDIA_CONCURRENCY = 6 - let mediaExported = 0 - await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const mediaKey = this.getMediaCacheKey(msg) - if (!mediaCache.has(mediaKey)) { - const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { - exportImages: options.exportImages, - exportVoices: options.exportVoices, - exportEmojis: options.exportEmojis, - exportFiles: options.exportFiles, - maxFileSizeMb: options.maxFileSizeMb, - exportVoiceAsText: options.exportVoiceAsText, - includeVideoPoster: options.format === 'html', - includeVoiceWithTranscript: true, - exportVideos: options.exportVideos, - dirCache: mediaDirCache, - control - }) - mediaCache.set(mediaKey, mediaItem) - } - mediaExported++ - if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { - onProgress?.({ - current: 20, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-media', - phaseProgress: mediaExported, - phaseTotal: mediaMessages.length, - phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles), - ...this.getMediaTelemetrySnapshot() - }) - } - }) - } - const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles) - if (fileOnlyExportFailure) return fileOnlyExportFailure - - const useVoiceTranscript = options.exportVoiceAsText === true - const voiceMessages = useVoiceTranscript - ? sortedMessages.filter(msg => msg.localType === 34) - : [] - const voiceTranscriptMap = new Map<string, string>() - - if (voiceMessages.length > 0) { - await this.preloadVoiceWavCache(sessionId, voiceMessages, control) - - onProgress?.({ - current: 40, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: 0, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}`, - estimatedTotalMessages: totalMessages - }) - - const VOICE_CONCURRENCY = 4 - let voiceTranscribed = 0 - await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { - this.throwIfStopRequested(control) - const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) - voiceTranscribed++ - onProgress?.({ - current: 40, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'exporting-voice', - phaseProgress: voiceTranscribed, - phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` - }) - }) - } - - const avatarMap = options.exportAvatars - ? await this.exportAvatarsToFiles( - [ - ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ - username, - avatarUrl: info.avatarUrl - })), - { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, - { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } - ], - path.dirname(outputPath), - control - ) - : new Map<string, string>() - - onProgress?.({ - current: 60, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'writing', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: 0 - }) - - // ================= BEGIN STREAM WRITING ================= - const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) - const htmlStyles = this.loadExportHtmlStyles() - await this.recordCreatedFileBeforeWrite(outputPath, control) - const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - - const writePromise = (str: string) => { - return new Promise<void>((resolve, reject) => { - this.throwIfStopRequested(control) - if (!stream.write(str)) { - stream.once('drain', resolve) - } else { - resolve() - } - }) - } - - await writePromise(`<!DOCTYPE html> -<html lang="zh-CN"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 - - - -
-
-

${this.escapeHtml(sessionInfo.displayName)}

-
- ${sortedMessages.length} 条消息 - ${isGroup ? '群聊' : '私聊'} - ${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} -
-
- - - -
- 共 ${sortedMessages.length} 条 -
-
-
- -
- -
- -
- 预览 -
- - - - - - -`); - - return new Promise((resolve, reject) => { - stream.on('error', (err) => { - // 确保在流错误时销毁流,释放文件句柄 - stream.destroy() - reject(err) - }) - - stream.end(() => { - onProgress?.({ - current: 100, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'complete', - estimatedTotalMessages: totalMessages, - collectedMessages: totalMessages, - exportedMessages: totalMessages, - writtenFiles: 1 - }) - resolve({ success: true }) - }) - stream.on('error', reject) - }) - - } catch (e) { - if (this.isStopError(e)) { - return { success: false, error: '导出任务已停止' } - } - if (this.isPauseError(e)) { - return { success: false, error: '导出任务已暂停' } - } - return { success: false, error: String(e) } - } - } - - /** - * 获取导出前的预估统计信息 - */ - async getExportStats( - sessionIds: string[], - options: ExportOptions - ): Promise { - const conn = await this.ensureConnected() - if (!conn.success || !conn.cleanedWxid) { - return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } - } - const normalizedSessionIds = this.normalizeSessionIds(sessionIds) - if (normalizedSessionIds.length === 0) { - return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } - } - const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid) - const cachedStats = this.getExportStatsCacheEntry(cacheKey) - if (cachedStats) { - const cachedResult = this.cloneExportStatsResult(cachedStats.result) - const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] - const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const)) - for (const sessionId of normalizedSessionIds) { - const cachedSession = sessionMap.get(sessionId) - if (cachedSession) orderedSessions.push(cachedSession) - } - if (orderedSessions.length === cachedResult.sessions.length) { - cachedResult.sessions = orderedSessions - } - return cachedResult - } - - const cleanedMyWxid = conn.cleanedWxid - const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] - const sessionSnapshotMap: Record = {} - let totalMessages = 0 - let voiceMessages = 0 - let cachedVoiceCount = 0 - let mediaMessages = 0 - - const hasSenderFilter = Boolean(String(options.senderUsername || '').trim()) - const canUseAggregatedStats = this.isUnboundedDateRange(options.dateRange) && !hasSenderFilter - - // 快速路径:直接复用 ChatService 聚合统计,避免逐会话 collectMessages 扫全量消息。 - if (canUseAggregatedStats) { - try { - let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey) - if (!aggregatedData) { - const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, { - includeRelations: false, - allowStaleCache: true - }) - if (statsResult.success && statsResult.data) { - aggregatedData = statsResult.data as Record - this.setAggregatedSessionStatsCache(cacheKey, aggregatedData) - } - } - if (aggregatedData) { - const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds) - const fastRows = await parallelLimit( - normalizedSessionIds, - 8, - async (sessionId): Promise<{ - sessionId: string - displayName: string - totalCount: number - voiceCount: number - cachedVoiceCount: number - mediaCount: number - }> => { - let displayName = sessionId - try { - const sessionInfo = await this.getContactInfo(sessionId) - displayName = sessionInfo.displayName || sessionId - } catch { - // 预估阶段显示名获取失败不阻塞统计 - } - - const metric = aggregatedData?.[sessionId] - const totalCount = Number.isFinite(metric?.totalMessages) - ? Math.max(0, Math.floor(metric?.totalMessages ?? 0)) - : 0 - const voiceCount = Number.isFinite(metric?.voiceMessages) - ? Math.max(0, Math.floor(metric?.voiceMessages ?? 0)) - : 0 - const imageCount = Number.isFinite(metric?.imageMessages) - ? Math.max(0, Math.floor(metric?.imageMessages ?? 0)) - : 0 - const videoCount = Number.isFinite(metric?.videoMessages) - ? Math.max(0, Math.floor(metric?.videoMessages ?? 0)) - : 0 - const emojiCount = Number.isFinite(metric?.emojiMessages) - ? Math.max(0, Math.floor(metric?.emojiMessages ?? 0)) - : 0 - const lastTimestamp = Number.isFinite(metric?.lastTimestamp) - ? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0)) - : undefined - const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) - const sessionCachedVoiceCount = Math.min( - voiceCount, - Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0 - ) - - sessionSnapshotMap[sessionId] = { - totalCount, - voiceCount, - imageCount, - videoCount, - emojiCount, - cachedVoiceCount: sessionCachedVoiceCount, - lastTimestamp - } - - return { - sessionId, - displayName, - totalCount, - voiceCount, - cachedVoiceCount: sessionCachedVoiceCount, - mediaCount: voiceCount + imageCount + videoCount + emojiCount - } - } - ) - - for (const row of fastRows) { - totalMessages += row.totalCount - voiceMessages += row.voiceCount - cachedVoiceCount += row.cachedVoiceCount - mediaMessages += row.mediaCount - sessionsStats.push({ - sessionId: row.sessionId, - displayName: row.displayName, - totalCount: row.totalCount, - voiceCount: row.voiceCount - }) - } - - const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) - const estimatedSeconds = needTranscribeCount * 2 - const result: ExportStatsResult = { - totalMessages, - voiceMessages, - cachedVoiceCount, - needTranscribeCount, - mediaMessages, - estimatedSeconds, - sessions: sessionsStats - } - this.setExportStatsCacheEntry(cacheKey, { - createdAt: Date.now(), - result: this.cloneExportStatsResult(result), - sessions: { ...sessionSnapshotMap } - }) - return result - } - } catch (error) { - // 聚合统计失败时自动回退到慢路径,保证功能正确。 - } - } - - // 回退路径:保留旧逻辑,支持有时间范围/发送者过滤等需要精确筛选的场景。 - for (const sessionId of normalizedSessionIds) { - const sessionInfo = await this.getContactInfo(sessionId) - const collected = await this.collectMessages( - sessionId, - cleanedMyWxid, - options.dateRange, - options.senderUsername, - 'text-fast' - ) - const msgs = collected.rows - let voiceCount = 0 - let imageCount = 0 - let videoCount = 0 - let emojiCount = 0 - let latestTimestamp = 0 - let cached = 0 - for (const msg of msgs) { - if (msg.createTime > latestTimestamp) { - latestTimestamp = msg.createTime - } - const localType = msg.localType - if (localType === 34) { - voiceCount++ - if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { - cached++ - } - continue - } - if (localType === 3) imageCount++ - if (localType === 43) videoCount++ - if (localType === 47) emojiCount++ - } - const mediaCount = voiceCount + imageCount + videoCount + emojiCount - - totalMessages += msgs.length - voiceMessages += voiceCount - cachedVoiceCount += cached - mediaMessages += mediaCount - sessionSnapshotMap[sessionId] = { - totalCount: msgs.length, - voiceCount, - imageCount, - videoCount, - emojiCount, - cachedVoiceCount: cached, - lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined - } - sessionsStats.push({ - sessionId, - displayName: sessionInfo.displayName, - totalCount: msgs.length, - voiceCount - }) - } - - const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) - // 预估:每条语音转文字约 2 秒 - const estimatedSeconds = needTranscribeCount * 2 - - const result: ExportStatsResult = { - totalMessages, - voiceMessages, - cachedVoiceCount, - needTranscribeCount, - mediaMessages, - estimatedSeconds, - sessions: sessionsStats - } - this.setExportStatsCacheEntry(cacheKey, { - createdAt: Date.now(), - result: this.cloneExportStatsResult(result), - sessions: { ...sessionSnapshotMap } - }) - return result - } - - /** - * 批量导出多个会话 - */ - async exportSessions( - sessionIds: string[], - outputDir: string, - options: ExportOptions, - onProgress?: (progress: ExportProgress) => void, - control?: ExportTaskControl - ): Promise<{ - success: boolean - successCount: number - failCount: number - paused?: boolean - stopped?: boolean - pendingSessionIds?: string[] - successSessionIds?: string[] - failedSessionIds?: string[] - failedSessionErrors?: Record - sessionOutputPaths?: Record - error?: string - }> { - let successCount = 0 - let failCount = 0 - const successSessionIds: string[] = [] - const failedSessionIds: string[] = [] - const failedSessionErrors: Record = {} - const sessionOutputPaths: Record = {} - const progressEmitter = this.createProgressEmitter(onProgress) - let attachMediaTelemetry = false - const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { - const payload = attachMediaTelemetry - ? { ...progress, ...this.getMediaTelemetrySnapshot() } - : progress - progressEmitter.emit(payload, options) - } - - try { - const conn = await this.ensureConnected() - if (!conn.success) { - return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } - } - - this.resetMediaRuntimeState() - const normalizedOptions = this.normalizeExportOptionsForRun(options) - const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions) - ? { ...normalizedOptions, exportVoiceAsText: false } - : normalizedOptions - - const exportMediaEnabled = effectiveOptions.exportMedia === true && - Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles) - attachMediaTelemetry = exportMediaEnabled - if (exportMediaEnabled) { - this.triggerMediaFileCacheCleanup() - } - const writeLayout = this.resolveExportWriteLayout(effectiveOptions) - const exportBaseDir = writeLayout === 'A' - ? path.join(outputDir, 'texts') - : outputDir - const createdTaskDirs = new Set() - const reservedOutputPaths = new Set() - const ensureTaskDir = async (dirPath: string) => { - if (createdTaskDirs.has(dirPath)) return - await this.ensureExportDir(dirPath, control) - createdTaskDirs.add(dirPath) - } - await ensureTaskDir(exportBaseDir) - const sessionLayout = exportMediaEnabled - ? (effectiveOptions.sessionLayout ?? 'per-session') - : 'shared' - let completedCount = 0 - const activeSessionRatios = new Map() - const computeAggregateCurrent = () => { - let activeRatioSum = 0 - for (const ratio of activeSessionRatios.values()) { - activeRatioSum += Math.max(0, Math.min(1, ratio)) - } - return Math.min(sessionIds.length, completedCount + activeRatioSum) - } - const isTextContentBatchExport = effectiveOptions.contentType === 'text' && !exportMediaEnabled - const defaultConcurrency = exportMediaEnabled ? 2 : (isTextContentBatchExport ? 1 : 4) - const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number' - ? Math.floor(effectiveOptions.exportConcurrency) - : defaultConcurrency - const maxSessionConcurrency = isTextContentBatchExport ? 1 : 6 - const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, maxSessionConcurrency)) - const sessionConcurrency = clampedConcurrency - const queue = [...sessionIds] - let pauseRequested = false - let stopRequested = false - const sessionMessageCountHints = new Map() - const sessionLatestTimestampHints = new Map() - const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid) - const cachedStatsEntry = this.getExportStatsCacheEntry(exportStatsCacheKey) - if (cachedStatsEntry?.sessions) { - for (const sessionId of sessionIds) { - const snapshot = cachedStatsEntry.sessions[sessionId] - if (!snapshot) continue - sessionMessageCountHints.set(sessionId, Math.max(0, Math.floor(snapshot.totalCount || 0))) - if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) { - sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp))) - } - } - } - const canUseSessionSnapshotHints = isTextContentBatchExport && - this.isUnboundedDateRange(effectiveOptions.dateRange) && - !String(effectiveOptions.senderUsername || '').trim() - const canFastSkipEmptySessions = false - const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints - const precheckSessionIds = canFastSkipEmptySessions - ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId)) - : [] - if (canFastSkipEmptySessions && precheckSessionIds.length > 0) { - const EMPTY_SESSION_PRECHECK_LIMIT = 1200 - if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) { - let checkedCount = 0 - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: '', - currentSessionId: '', - phase: 'preparing', - phaseProgress: 0, - phaseTotal: precheckSessionIds.length, - phaseLabel: `预检查空会话 0/${precheckSessionIds.length}` - }) - - const PRECHECK_BATCH_SIZE = 160 - for (let i = 0; i < precheckSessionIds.length; i += PRECHECK_BATCH_SIZE) { - if (control?.shouldStop?.()) { - stopRequested = true - break - } - if (control?.shouldPause?.()) { - pauseRequested = true - break - } - - const batchSessionIds = precheckSessionIds.slice(i, i + PRECHECK_BATCH_SIZE) - const countsResult = await wcdbService.getMessageCounts(batchSessionIds) - if (countsResult.success && countsResult.counts) { - for (const batchSessionId of batchSessionIds) { - const count = countsResult.counts[batchSessionId] - if (typeof count === 'number' && Number.isFinite(count) && count >= 0) { - sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count))) - } - } - } - - checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length) - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: '', - currentSessionId: '', - phase: 'preparing', - phaseProgress: checkedCount, - phaseTotal: precheckSessionIds.length, - phaseLabel: `预检查空会话 ${checkedCount}/${precheckSessionIds.length}` - }) - } - } else { - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: '', - currentSessionId: '', - phase: 'preparing', - phaseLabel: `会话较多,已跳过空会话预检查(${precheckSessionIds.length} 个)` - }) - } - } - - if (canUseSessionSnapshotHints && sessionIds.length > 0) { - const missingHintSessionIds = sessionIds.filter((sessionId) => ( - !sessionMessageCountHints.has(sessionId) || !sessionLatestTimestampHints.has(sessionId) - )) - if (missingHintSessionIds.length > 0) { - const sessionSet = new Set(missingHintSessionIds) - const sessionsResult = await chatService.getSessions() - if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { - for (const item of sessionsResult.sessions) { - const username = String(item?.username || '').trim() - if (!username) continue - if (!sessionSet.has(username)) continue - const messageCountHint = Number(item?.messageCountHint) - if ( - !sessionMessageCountHints.has(username) && - Number.isFinite(messageCountHint) && - messageCountHint >= 0 - ) { - sessionMessageCountHints.set(username, Math.floor(messageCountHint)) - } - const lastTimestamp = Number(item?.lastTimestamp) - if ( - !sessionLatestTimestampHints.has(username) && - Number.isFinite(lastTimestamp) && - lastTimestamp > 0 - ) { - sessionLatestTimestampHints.set(username, Math.floor(lastTimestamp)) - } - } - } - } - } - - if (stopRequested) { - return { - success: true, - successCount, - failCount, - stopped: true, - pendingSessionIds: [...queue], - successSessionIds, - failedSessionIds, - failedSessionErrors, - sessionOutputPaths - } - } - if (pauseRequested) { - return { - success: true, - successCount, - failCount, - paused: true, - pendingSessionIds: [...queue], - successSessionIds, - failedSessionIds, - failedSessionErrors, - sessionOutputPaths - } - } - - const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => { - try { - this.throwIfStopRequested(control) - const sessionInfo = await this.getContactInfo(sessionId) - const messageCountHint = sessionMessageCountHints.get(sessionId) - const latestTimestampHint = sessionLatestTimestampHints.get(sessionId) - - const sessionProgress = (progress: ExportProgress) => { - const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100 - const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0 - const ratio = progress.phase === 'complete' - ? 1 - : Math.max(0, Math.min(1, phaseCurrent / phaseTotal)) - activeSessionRatios.set(sessionId, ratio) - emitProgress({ - ...progress, - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId - }, { force: progress.phase === 'complete' }) - } - - sessionProgress({ - current: 0, - total: 100, - currentSession: sessionInfo.displayName, - phase: 'preparing', - phaseLabel: '准备导出' - }) - - const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode) - const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions) - const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false - const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' - const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` - const useSessionFolder = sessionLayout === 'per-session' - const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName - const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir - - if (useSessionFolder) { - await ensureTaskDir(sessionDir) - } - - let ext = '.json' - if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl' - else if (effectiveOptions.format === 'excel') ext = '.xlsx' - else if (effectiveOptions.format === 'txt') ext = '.txt' - else if (effectiveOptions.format === 'weclone') ext = '.csv' - else if (effectiveOptions.format === 'html') ext = '.html' - const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) - const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && - typeof messageCountHint === 'number' && - messageCountHint >= 0 && - typeof latestTimestampHint === 'number' && - latestTimestampHint > 0 && - await this.pathExists(preferredOutputPath) - if (canTrySkipUnchanged) { - const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) - const hasNoDataChange = Boolean( - latestRecord && - latestRecord.messageCount === messageCountHint && - Number(latestRecord.sourceLatestMessageTimestamp || 0) >= latestTimestampHint - ) - if (hasNoDataChange) { - successCount++ - successSessionIds.push(sessionId) - sessionOutputPaths[sessionId] = preferredOutputPath - activeSessionRatios.delete(sessionId) - completedCount++ - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId, - phase: 'complete', - phaseLabel: '无变化,已跳过', - estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)), - exportedMessages: Math.max(0, Math.floor(messageCountHint || 0)) - }, { force: true }) - return 'done' - } - } - - const outputPath = fileNamingMode === 'date-range' - ? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths) - : preferredOutputPath - - let result: { success: boolean; error?: string } - if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else if (effectiveOptions.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else if (effectiveOptions.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else if (effectiveOptions.format === 'weclone') { - result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else if (effectiveOptions.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control) - } else { - result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` } - } - - if (!result.success && this.isStopError(result.error)) { - activeSessionRatios.delete(sessionId) - return 'stopped' - } - if (!result.success && this.isPauseError(result.error)) { - activeSessionRatios.delete(sessionId) - return 'paused' - } - - if (result.success) { - successCount++ - successSessionIds.push(sessionId) - sessionOutputPaths[sessionId] = outputPath - if (typeof messageCountHint === 'number' && messageCountHint >= 0) { - exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, { - sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0 - ? latestTimestampHint - : undefined, - outputPath - }) - } - } else { - failCount++ - failedSessionIds.push(sessionId) - failedSessionErrors[sessionId] = result.error || '导出失败' - console.error(`导出 ${sessionId} 失败:`, result.error) - } - - activeSessionRatios.delete(sessionId) - completedCount++ - emitProgress({ - current: computeAggregateCurrent(), - total: sessionIds.length, - currentSession: sessionInfo.displayName, - currentSessionId: sessionId, - phase: 'complete', - phaseLabel: result.success ? '完成' : '导出失败' - }, { force: true }) - return 'done' - } catch (error) { - if (this.isStopError(error)) { - activeSessionRatios.delete(sessionId) - return 'stopped' - } - if (this.isPauseError(error)) { - activeSessionRatios.delete(sessionId) - return 'paused' - } - throw error - } - } - - if (isTextContentBatchExport) { - // 文本内容批量导出使用串行调度,降低数据库与文件系统抢占,行为更贴近 wxdaochu。 - while (queue.length > 0) { - if (control?.shouldStop?.()) { - stopRequested = true - break - } - if (control?.shouldPause?.()) { - pauseRequested = true - break - } - - const sessionId = queue.shift() - if (!sessionId) break - const runState = await runOne(sessionId) - await new Promise(resolve => setImmediate(resolve)) - if (runState === 'stopped') { - stopRequested = true - queue.unshift(sessionId) - break - } - if (runState === 'paused') { - pauseRequested = true - queue.unshift(sessionId) - break - } - } - } else { - const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => { - while (queue.length > 0) { - if (control?.shouldStop?.()) { - stopRequested = true - break - } - if (control?.shouldPause?.()) { - pauseRequested = true - break - } - - const sessionId = queue.shift() - if (!sessionId) break - const runState = await runOne(sessionId) - if (runState === 'stopped') { - stopRequested = true - queue.unshift(sessionId) - break - } - if (runState === 'paused') { - pauseRequested = true - queue.unshift(sessionId) - break - } - } - }) - await Promise.all(workers) - } - - const pendingSessionIds = [...queue] - if (stopRequested && pendingSessionIds.length > 0) { - return { - success: true, - successCount, - failCount, - stopped: true, - pendingSessionIds, - successSessionIds, - failedSessionIds, - failedSessionErrors, - sessionOutputPaths - } - } - if (pauseRequested) { - return { - success: true, - successCount, - failCount, - paused: true, - pendingSessionIds, - successSessionIds, - failedSessionIds, - failedSessionErrors, - sessionOutputPaths - } - } - - emitProgress({ - current: sessionIds.length, - total: sessionIds.length, - currentSession: '', - currentSessionId: '', - phase: 'complete' - }, { force: true }) - progressEmitter.flush() - - const allFailed = successCount === 0 && failCount > 0 - const failureSummary = allFailed - ? Object.values(failedSessionErrors).slice(0, 3).join(';') || '所有会话导出失败' - : undefined - return { - success: !allFailed, - successCount, - failCount, - successSessionIds, - failedSessionIds, - failedSessionErrors, - sessionOutputPaths, - error: failureSummary - } - } catch (e) { - progressEmitter.flush() - return { success: false, successCount, failCount, error: String(e) } - } finally { - this.clearMediaRuntimeState() - } - } -} - -export const exportService = new ExportService() diff --git a/electron/services/exportTaskControlService.ts b/electron/services/exportTaskControlService.ts deleted file mode 100644 index fc31244..0000000 --- a/electron/services/exportTaskControlService.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as path from 'path' -import { rm, rmdir } from 'fs/promises' - -export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested' - -export interface ExportTaskControlHooks { - shouldPause: () => boolean - shouldStop: () => boolean - recordCreatedFile: (filePath: string) => void - recordCreatedDir: (dirPath: string) => void -} - -interface ExportTaskManifest { - outputDir: string - files: Set - dirs: Set -} - -interface ExportTaskControlRecord { - state: ExportTaskControlState - manifest: ExportTaskManifest - createdAt: number - updatedAt: number -} - -export interface ExportTaskCleanupResult { - success: boolean - filesDeleted: number - dirsDeleted: number - error?: string -} - -class ExportTaskControlService { - private tasks = new Map() - - createControl(taskId: string, outputDir: string): ExportTaskControlHooks { - this.registerTask(taskId, outputDir) - return { - shouldPause: () => this.getState(taskId) === 'pause_requested', - shouldStop: () => this.getState(taskId) === 'cancel_requested', - recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath), - recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath) - } - } - - registerTask(taskId: string, outputDir: string): void { - const normalizedTaskId = this.normalizeTaskId(taskId) - if (!normalizedTaskId) return - - const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.') - const existing = this.tasks.get(normalizedTaskId) - if (existing) { - existing.state = 'running' - existing.updatedAt = Date.now() - if (!existing.manifest.outputDir) { - existing.manifest.outputDir = normalizedOutputDir - } - return - } - - this.tasks.set(normalizedTaskId, { - state: 'running', - manifest: { - outputDir: normalizedOutputDir, - files: new Set(), - dirs: new Set() - }, - createdAt: Date.now(), - updatedAt: Date.now() - }) - } - - pauseTask(taskId: string): boolean { - return this.setState(taskId, 'pause_requested') - } - - resumeTask(taskId: string): boolean { - return this.setState(taskId, 'running') - } - - cancelTask(taskId: string): boolean { - return this.setState(taskId, 'cancel_requested') - } - - getState(taskId: string): ExportTaskControlState | null { - const normalizedTaskId = this.normalizeTaskId(taskId) - if (!normalizedTaskId) return null - return this.tasks.get(normalizedTaskId)?.state || null - } - - releaseTask(taskId: string): void { - const normalizedTaskId = this.normalizeTaskId(taskId) - if (!normalizedTaskId) return - this.tasks.delete(normalizedTaskId) - } - - recordCreatedFile(taskId: string, filePath: string): void { - const task = this.getTaskForManifestWrite(taskId, filePath) - if (!task) return - task.manifest.files.add(path.resolve(filePath)) - task.updatedAt = Date.now() - } - - recordCreatedDir(taskId: string, dirPath: string): void { - const task = this.getTaskForManifestWrite(taskId, dirPath) - if (!task) return - task.manifest.dirs.add(path.resolve(dirPath)) - task.updatedAt = Date.now() - } - - async cleanupTask(taskId: string): Promise { - const normalizedTaskId = this.normalizeTaskId(taskId) - const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined - if (!task) { - return { success: true, filesDeleted: 0, dirsDeleted: 0 } - } - - const outputDir = task.manifest.outputDir - let filesDeleted = 0 - let dirsDeleted = 0 - const errors: string[] = [] - - const files = Array.from(task.manifest.files) - .filter(filePath => this.isInsideOutputDir(filePath, outputDir)) - .sort((a, b) => b.length - a.length) - - for (const filePath of files) { - try { - await rm(filePath, { force: true, recursive: false }) - filesDeleted++ - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code - if (code !== 'ENOENT') { - errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`) - } - } - } - - const dirs = Array.from(task.manifest.dirs) - .filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir)) - .sort((a, b) => b.length - a.length) - - for (const dirPath of dirs) { - try { - await rmdir(dirPath) - dirsDeleted++ - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code - if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') { - errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`) - } - } - } - - if (errors.length === 0) { - this.releaseTask(normalizedTaskId) - return { success: true, filesDeleted, dirsDeleted } - } - - return { - success: false, - filesDeleted, - dirsDeleted, - error: errors.slice(0, 3).join('; ') - } - } - - private setState(taskId: string, state: ExportTaskControlState): boolean { - const normalizedTaskId = this.normalizeTaskId(taskId) - if (!normalizedTaskId) return false - const task = this.tasks.get(normalizedTaskId) - if (!task) return false - task.state = state - task.updatedAt = Date.now() - return true - } - - private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null { - const normalizedTaskId = this.normalizeTaskId(taskId) - if (!normalizedTaskId) return null - const task = this.tasks.get(normalizedTaskId) - if (!task) return null - if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) { - return null - } - return task - } - - private isInsideOutputDir(targetPath: string, outputDir: string): boolean { - const resolvedTarget = path.resolve(targetPath) - const resolvedOutputDir = path.resolve(outputDir) - const relativePath = path.relative(resolvedOutputDir, resolvedTarget) - return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath) - } - - private isSamePath(left: string, right: string): boolean { - const resolvedLeft = path.resolve(left) - const resolvedRight = path.resolve(right) - if (process.platform === 'win32') { - return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() - } - return resolvedLeft === resolvedRight - } - - private normalizeTaskId(taskId: string): string { - return String(taskId || '').trim() - } -} - -export const exportTaskControlService = new ExportTaskControlService() diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts deleted file mode 100644 index 9d93072..0000000 --- a/electron/services/groupAnalyticsService.ts +++ /dev/null @@ -1,2042 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import ExcelJS from 'exceljs' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { chatService } from './chatService' -import type { Message } from './chatService' -import type { ChatStatistics } from './analyticsService' - -export interface GroupChatInfo { - username: string - displayName: string - memberCount: number - avatarUrl?: string -} - -export interface GroupMember { - username: string - displayName: string - avatarUrl?: string - nickname?: string - alias?: string - remark?: string - groupNickname?: string - isOwner?: boolean -} - -export interface GroupMembersPanelEntry extends GroupMember { - isFriend: boolean - messageCount: number -} - -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 -} - -export interface GroupMemberAnalytics { - statistics: ChatStatistics - timeDistribution: Record - commonPhrases?: Array<{ phrase: string; count: number }> - commonEmojis?: Array<{ emoji: string; count: number }> -} - -export interface GroupMemberMessagesPage { - messages: Message[] - hasMore: boolean - nextCursor: number -} - -interface GroupMemberContactInfo { - remark: string - nickName: string - alias: string - username: string - userName: string - encryptUsername: string - encryptUserName: string - localType: number -} - -class GroupAnalyticsService { - private configService: ConfigService - private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000 - private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000 - private readonly groupMembersPanelFullTimeoutMs = 25 * 1000 - private readonly groupMembersPanelCache = new Map() - private readonly groupMembersPanelInFlight = new Map< - string, - Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> - >() - private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) - - constructor() { - this.configService = new ConfigService() - } - - // 并发控制:限制同时执行的 Promise 数量 - private async parallelLimit( - items: T[], - limit: number, - fn: (item: T, index: number) => Promise - ): Promise { - const results: R[] = new Array(items.length) - let currentIndex = 0 - - async function runNext(): Promise { - while (currentIndex < items.length) { - const index = currentIndex++ - results[index] = await fn(items[index], index) - } - } - - const workers = Array(Math.min(limit, items.length)) - .fill(null) - .map(() => runNext()) - - await Promise.all(workers) - return results - } - - 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 - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - private resolveMemberUsername( - candidate: unknown, - memberLookup: Map - ): string | null { - if (typeof candidate !== 'string') return null - const raw = candidate.trim() - if (!raw) return null - if (memberLookup.has(raw)) return memberLookup.get(raw) || null - const cleaned = this.cleanAccountDirName(raw) - if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null - - const parts = raw.split(/[,\s;|]+/).filter(Boolean) - for (const part of parts) { - if (memberLookup.has(part)) return memberLookup.get(part) || null - const normalizedPart = this.cleanAccountDirName(part) - if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null - } - - if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) { - try { - const parsed = JSON.parse(raw) - return this.extractOwnerUsername(parsed, memberLookup, 0) - } catch { - return null - } - } - - return null - } - - private extractOwnerUsername( - value: unknown, - memberLookup: Map, - depth: number - ): string | null { - if (depth > 4 || value == null) return null - if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null - - if (typeof value === 'string') { - return this.resolveMemberUsername(value, memberLookup) - } - - if (Array.isArray(value)) { - for (const item of value) { - const owner = this.extractOwnerUsername(item, memberLookup, depth + 1) - if (owner) return owner - } - return null - } - - if (typeof value !== 'object') return null - const row = value as Record - - for (const [key, entry] of Object.entries(row)) { - const keyLower = key.toLowerCase() - if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) { - continue - } - - if (typeof entry === 'boolean') { - if (entry && typeof row.username === 'string') { - const owner = this.resolveMemberUsername(row.username, memberLookup) - if (owner) return owner - } - continue - } - - const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1) - if (owner) return owner - } - - return null - } - - private async detectGroupOwnerUsername( - chatroomId: string, - members: Array<{ username: string; [key: string]: unknown }> - ): Promise { - const memberLookup = new Map() - for (const member of members) { - const username = String(member.username || '').trim() - if (!username) continue - const cleaned = this.cleanAccountDirName(username) - memberLookup.set(username, username) - memberLookup.set(cleaned, username) - } - if (memberLookup.size === 0) return undefined - - const tryResolve = (candidate: unknown): string | undefined => { - const owner = this.extractOwnerUsername(candidate, memberLookup, 0) - return owner || undefined - } - - for (const member of members) { - const owner = tryResolve(member) - if (owner) return owner - } - - try { - const groupContact = await wcdbService.getContact(chatroomId) - if (groupContact.success && groupContact.contact) { - const owner = tryResolve(groupContact.contact) - if (owner) return owner - } - } catch { - // ignore - } - - try { - const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId) - if (roomExt.success && roomExt.extBuffer) { - const owner = tryResolve({ ext_buffer: roomExt.extBuffer }) - if (owner) return owner - } - } catch { - // ignore - } - - return undefined - } - - private async ensureConnected(): Promise<{ success: boolean; error?: string }> { - const wxid = this.configService.getMyWxidCleaned() - 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 accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) return { success: false, error: '无法找到账号目录' } - const ok = await wcdbService.open(accountDir, decryptKey) - if (!ok) return { success: false, error: 'WCDB 打开失败' } - return { success: true } - } - - /** - * 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。 - */ - private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { - try { - const dllResult = await wcdbService.getGroupNicknames(chatroomId) - if (!dllResult.success || !dllResult.nicknames) { - return new Map() - } - return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates) - } catch (e) { - console.error('getGroupNicknamesForRoom service error:', e) - return new Map() - } - } - - private normalizeGroupNicknameIdentity(value: string): string { - const raw = String(value || '').trim() - if (!raw) return '' - return raw.toLowerCase() - } - - private buildTrustedGroupNicknameMap( - entries: Iterable<[string, string]>, - candidates: string[] = [] - ): Map { - const candidateSet = new Set( - this.buildGroupNicknameIdCandidates(candidates) - .map((id) => this.normalizeGroupNicknameIdentity(id)) - .filter(Boolean) - ) - - const buckets = new Map>() - for (const [memberIdRaw, nicknameRaw] of entries) { - const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '') - if (!identity) continue - if (candidateSet.size > 0 && !candidateSet.has(identity)) continue - - const nickname = this.normalizeGroupNickname(nicknameRaw || '') - if (!nickname) continue - - const slot = buckets.get(identity) - if (slot) { - slot.add(nickname) - } else { - buckets.set(identity, new Set([nickname])) - } - } - - const trusted = new Map() - for (const [identity, nicknameSet] of buckets.entries()) { - if (nicknameSet.size !== 1) continue - trusted.set(identity, Array.from(nicknameSet)[0]) - } - return trusted - } - - private mergeGroupNicknameEntries( - target: Map, - entries: Iterable<[string, string]> - ): void { - for (const [memberIdRaw, nicknameRaw] of entries) { - const nickname = this.normalizeGroupNickname(nicknameRaw || '') - if (!nickname) continue - for (const alias of this.buildIdCandidates([memberIdRaw])) { - if (!alias) continue - if (!target.has(alias)) target.set(alias, nickname) - const lower = alias.toLowerCase() - if (!target.has(lower)) target.set(lower, nickname) - } - } - } - - 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 decodeExtBuffer(value: unknown): Buffer | null { - if (!value) return null - if (Buffer.isBuffer(value)) return value - if (value instanceof Uint8Array) return Buffer.from(value) - - if (typeof value === 'string') { - const raw = value.trim() - if (!raw) return null - - if (this.looksLikeHex(raw)) { - try { return Buffer.from(raw, 'hex') } catch { } - } - if (this.looksLikeBase64(raw)) { - try { return Buffer.from(raw, 'base64') } catch { } - } - - try { return Buffer.from(raw, 'hex') } catch { } - try { return Buffer.from(raw, 'base64') } catch { } - try { return Buffer.from(raw, 'utf8') } catch { } - return null - } - - return null - } - - private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null { - let value = 0 - let shift = 0 - let pos = offset - while (pos < limit && shift <= 53) { - const byte = buffer[pos] - value += (byte & 0x7f) * Math.pow(2, shift) - pos += 1 - if ((byte & 0x80) === 0) return { value, next: pos } - shift += 7 - } - return null - } - - private isLikelyMemberId(value: string): boolean { - const id = String(value || '').trim() - if (!id) return false - if (id.includes('@chatroom')) return false - if (id.length < 4 || id.length > 80) return false - return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id) - } - - private isLikelyNickname(value: string): boolean { - const cleaned = this.normalizeGroupNickname(value) - if (!cleaned) return false - if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false - if (cleaned.includes('@chatroom')) return false - if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false - if (cleaned.length === 1) { - const code = cleaned.charCodeAt(0) - const isCjk = code >= 0x3400 && code <= 0x9fff - if (!isCjk) return false - } - return true - } - - private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map { - const nicknameMap = new Map() - if (!buffer || buffer.length === 0) return nicknameMap - - try { - const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase())) - - for (let i = 0; i < buffer.length - 2; i += 1) { - if (buffer[i] !== 0x0a) continue - - const idLenInfo = this.readVarint(buffer, i + 1) - if (!idLenInfo) continue - const idLen = idLenInfo.value - if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue - - const idStart = idLenInfo.next - const idEnd = idStart + idLen - if (idEnd > buffer.length) continue - - const memberId = buffer.toString('utf8', idStart, idEnd).trim() - if (!this.isLikelyMemberId(memberId)) continue - - const memberIdLower = memberId.toLowerCase() - if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) { - i = idEnd - 1 - continue - } - - const cursor = idEnd - if (cursor >= buffer.length || buffer[cursor] !== 0x12) { - i = idEnd - 1 - continue - } - - const nickLenInfo = this.readVarint(buffer, cursor + 1) - if (!nickLenInfo) { - i = idEnd - 1 - continue - } - - const nickLen = nickLenInfo.value - if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) { - i = idEnd - 1 - continue - } - - const nickStart = nickLenInfo.next - const nickEnd = nickStart + nickLen - if (nickEnd > buffer.length) { - i = idEnd - 1 - continue - } - - const rawNick = buffer.toString('utf8', nickStart, nickEnd) - const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim()) - if (!this.isLikelyNickname(nickname)) { - i = nickEnd - 1 - continue - } - - if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname) - if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname) - i = nickEnd - 1 - } - } catch (e) { - console.error('Failed to parse chat_room.ext_buffer:', e) - } - - return nicknameMap - } - - private escapeCsvValue(value: string): string { - if (value == null) return '' - const str = String(value) - if (/[",\n\r]/.test(str)) { - return `"${str.replace(/"/g, '""')}"` - } - return str - } - - private normalizeGroupNickname(value: string): string { - const trimmed = (value || '').trim() - if (!trimmed) return '' - if (/^["'@]+$/.test(trimmed)) return '' - return trimmed - } - - private buildIdCandidates(values: Array): string[] { - const set = new Set() - for (const rawValue of values) { - const raw = String(rawValue || '').trim() - if (!raw) continue - set.add(raw) - const cleaned = this.cleanAccountDirName(raw) - if (cleaned && cleaned !== raw) { - set.add(cleaned) - } - } - return Array.from(set) - } - - private buildGroupNicknameIdCandidates(values: Array): string[] { - const set = new Set() - for (const rawValue of values) { - const raw = String(rawValue || '').trim() - if (!raw) continue - set.add(raw) - } - return Array.from(set) - } - - private toNonNegativeInteger(value: unknown): number { - const parsed = Number(value) - if (!Number.isFinite(parsed)) return 0 - return Math.max(0, Math.floor(parsed)) - } - - private pickStringField(row: Record, keys: string[]): string { - for (const key of keys) { - const value = row[key] - if (value == null) continue - const text = String(value).trim() - if (text) return text - } - return '' - } - - private pickIntegerField(row: Record, keys: string[], fallback: number = 0): number { - for (const key of keys) { - const value = row[key] - if (value == null || value === '') continue - const parsed = Number(value) - if (Number.isFinite(parsed)) return Math.floor(parsed) - } - return fallback - } - - private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string { - const dbPath = String(this.configService.get('dbPath') || '').trim() - const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim()) - const mode = includeMessageCounts ? 'full' : 'members' - return `${dbPath}::${wxid}::${chatroomId}::${mode}` - } - - private pruneGroupMembersPanelCache(maxEntries: number = 80): void { - if (this.groupMembersPanelCache.size <= maxEntries) return - const entries = Array.from(this.groupMembersPanelCache.entries()) - .sort((a, b) => a[1].updatedAt - b[1].updatedAt) - const removeCount = this.groupMembersPanelCache.size - maxEntries - for (let i = 0; i < removeCount; i += 1) { - this.groupMembersPanelCache.delete(entries[i][0]) - } - } - - private async withPromiseTimeout( - promise: Promise, - timeoutMs: number, - timeoutResult: T - ): Promise { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - return promise - } - - let timeoutTimer: ReturnType | null = null - const timeoutPromise = new Promise((resolve) => { - timeoutTimer = setTimeout(() => { - resolve(timeoutResult) - }, timeoutMs) - }) - - try { - return await Promise.race([promise, timeoutPromise]) - } finally { - if (timeoutTimer) { - clearTimeout(timeoutTimer) - } - } - } - - private async buildGroupMemberContactLookup(usernames: string[]): Promise> { - const lookup = new Map() - const candidates = this.buildIdCandidates(usernames) - if (candidates.length === 0) return lookup - - const appendContactsToLookup = (rows: Record[]) => { - for (const row of rows) { - const contact: GroupMemberContactInfo = { - remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']), - nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']), - alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']), - username: this.pickStringField(row, ['username', 'WCDB_CT_username']), - userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']), - encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']), - encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']), - localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - } - const lookupKeys = this.buildIdCandidates([ - contact.username, - contact.userName, - contact.encryptUsername, - contact.encryptUserName, - contact.alias - ]) - for (const key of lookupKeys) { - const normalized = key.toLowerCase() - if (!lookup.has(normalized)) { - lookup.set(normalized, contact) - } - } - } - } - - const batchSize = 200 - for (let i = 0; i < candidates.length; i += batchSize) { - const batch = candidates.slice(i, i + batchSize) - if (batch.length === 0) continue - - const result = await wcdbService.getContactsCompact(batch) - if (!result.success || !result.contacts) continue - appendContactsToLookup(result.contacts as Record[]) - } - return lookup - } - - private resolveContactByCandidates( - lookup: Map, - candidates: Array - ): GroupMemberContactInfo | undefined { - const ids = this.buildIdCandidates(candidates) - for (const id of ids) { - const hit = lookup.get(id.toLowerCase()) - if (hit) return hit - } - return undefined - } - - private async buildGroupMessageCountLookup(chatroomId: string): Promise> { - const lookup = new Map() - const result = await wcdbService.getGroupStats(chatroomId, 0, 0) - if (!result.success || !result.data) return lookup - - const sessionData = result.data?.sessions?.[chatroomId] - if (!sessionData || !sessionData.senders) return lookup - - const idMap = result.data.idMap || {} - for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record)) { - const username = String(idMap[senderId] || senderId || '').trim() - if (!username) continue - const count = this.toNonNegativeInteger(rawCount) - const keys = this.buildIdCandidates([username]) - for (const key of keys) { - const normalized = key.toLowerCase() - const prev = lookup.get(normalized) || 0 - if (count > prev) { - lookup.set(normalized, count) - } - } - } - return lookup - } - - private resolveMessageCountByCandidates( - lookup: Map, - candidates: Array - ): number { - let maxCount = 0 - const ids = this.buildIdCandidates(candidates) - for (const id of ids) { - const count = lookup.get(id.toLowerCase()) - if (typeof count === 'number' && count > maxCount) { - maxCount = count - } - } - return maxCount - } - - private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean { - const normalizedWxid = String(wxid || '').trim().toLowerCase() - if (!normalizedWxid) return false - if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false - if (this.friendExcludeNames.has(normalizedWxid)) return false - if (!contact) return false - return contact.localType === 1 - } - - private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] { - return members.sort((a, b) => { - const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) - if (ownerDiff !== 0) return ownerDiff - - const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend)) - if (friendDiff !== 0) return friendDiff - - if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount - return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') - }) - } - - private resolveGroupNicknameByCandidates(groupNicknames: Map, candidates: string[]): string { - const idCandidates = this.buildGroupNicknameIdCandidates(candidates) - if (idCandidates.length === 0) return '' - - let resolved = '' - for (const id of idCandidates) { - const normalizedId = this.normalizeGroupNicknameIdentity(id) - if (!normalizedId) continue - const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '') - if (!candidateNickname) continue - if (!resolved) { - resolved = candidateNickname - continue - } - if (resolved !== candidateNickname) return '' - } - - return resolved - } - - private sanitizeWorksheetName(name: string): string { - const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim() - const limited = cleaned.slice(0, 31) - return limited || 'Sheet1' - } - - private formatDateTime(date: Date): string { - const pad = (value: number) => String(value).padStart(2, '0') - const year = date.getFullYear() - const month = pad(date.getMonth() + 1) - const day = pad(date.getDate()) - const hour = pad(date.getHours()) - const minute = pad(date.getMinutes()) - const second = pad(date.getSeconds()) - return `${year}-${month}-${day} ${hour}:${minute}:${second}` - } - - private formatUnixTime(createTime: number): string { - if (!Number.isFinite(createTime) || createTime <= 0) return '' - const milliseconds = createTime > 1e12 ? createTime : createTime * 1000 - const date = new Date(milliseconds) - if (Number.isNaN(date.getTime())) return String(createTime) - return this.formatDateTime(date) - } - - private getSimpleMessageTypeName(localType: number): string { - const typeMap: Record = { - 1: '文本', - 3: '图片', - 34: '语音', - 42: '名片', - 43: '视频', - 47: '表情', - 48: '位置', - 49: '链接/文件', - 50: '通话', - 10000: '系统', - 266287972401: '拍一拍', - 8594229559345: '红包', - 8589934592049: '转账' - } - return typeMap[localType] || `类型(${localType})` - } - - private normalizeIdCandidates(values: Array): string[] { - return this.buildIdCandidates(values).map(value => value.toLowerCase()) - } - - private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean { - const leftCandidates = this.normalizeIdCandidates([left]) - const rightCandidates = this.normalizeIdCandidates([right]) - if (leftCandidates.length === 0 || rightCandidates.length === 0) return false - - const rightSet = new Set(rightCandidates) - for (const leftCandidate of leftCandidates) { - if (rightSet.has(leftCandidate)) return true - for (const rightCandidate of rightCandidates) { - if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) { - return true - } - } - } - return false - } - - private resolveExportMessageContent(message: Message): string { - const parsed = String(message.parsedContent || '').trim() - if (parsed) return parsed - const raw = String(message.rawContent || '').trim() - if (raw) return raw - return '' - } - - private normalizeCursorTimestamp(value: number): number { - if (!Number.isFinite(value) || value <= 0) return 0 - const normalized = Math.floor(value) - return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized - } - - private extractRowSenderUsername(row: Record, myWxid?: string): string { - const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send - if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) { - return myWxid - } - - const candidates = [ - row.sender_username, - row.senderUsername, - row.sender, - row.WCDB_CT_sender_username - ] - for (const candidate of candidates) { - const value = String(candidate || '').trim() - if (value) return value - } - for (const [key, value] of Object.entries(row)) { - const normalizedKey = key.toLowerCase() - if ( - normalizedKey === 'sender_username' || - normalizedKey === 'senderusername' || - normalizedKey === 'sender' || - normalizedKey === 'wcdb_ct_sender_username' - ) { - const normalizedValue = String(value || '').trim() - if (normalizedValue) return normalizedValue - } - } - - // Fallback: fast extract from raw content to avoid full parse - const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim() - if (rawContent) { - const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(rawContent) - if (match && match[1]) { - return match[1].trim() - } - } - - return '' - } - - private parseSingleMessageRow(row: Record): Message | null { - try { - const mapped = chatService.mapRowsToMessagesForApi([row]) - if (Array.isArray(mapped) && mapped.length > 0) { - const msg = mapped[0] - if (!msg.localType) { - msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10) - } - if (!msg.createTime) { - msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10) - } - return msg - } - return null - } catch { - return null - } - } - - private async openMemberMessageCursor( - chatroomId: string, - batchSize: number, - ascending: boolean, - startTime: number, - endTime: number - ): Promise<{ success: boolean; cursor?: number; error?: string }> { - const beginTimestamp = this.normalizeCursorTimestamp(startTime) - const endTimestamp = this.normalizeCursorTimestamp(endTime) - const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) - if (liteResult.success && liteResult.cursor) return liteResult - return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) - } - - private async collectMessagesByMember( - chatroomId: string, - memberUsername: string, - startTime: number, - endTime: number - ): Promise<{ success: boolean; data?: Message[]; error?: string }> { - const batchSize = 800 - const matchedMessages: Message[] = [] - const senderMatchCache = new Map() - const matchesTargetSender = (sender: string | null | undefined): boolean => { - const key = String(sender || '').trim().toLowerCase() - if (!key) return false - const cached = senderMatchCache.get(key) - if (typeof cached === 'boolean') return cached - const matched = this.isSameAccountIdentity(memberUsername, sender) - senderMatchCache.set(key, matched) - return matched - } - - const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime) - if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '创建群消息游标失败' } - } - - const cursor = cursorResult.cursor - try { - while (true) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - return { success: false, error: batch.error || '获取群消息失败' } - } - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - if (rows.length === 0) break - - for (const row of rows) { - const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim()) - if (senderFromRow && !matchesTargetSender(senderFromRow)) { - continue - } - const message = this.parseSingleMessageRow(row) - if (!message) continue - if (matchesTargetSender(message.senderUsername)) { - matchedMessages.push(message) - } - } - - if (!batch.hasMore) break - } - } finally { - await wcdbService.closeMessageCursor(cursor) - } - - return { success: true, data: matchedMessages } - } - - async getGroupMemberMessages( - chatroomId: string, - memberUsername: string, - options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } - ): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> { - try { - const conn = await this.ensureConnected() - if (!conn.success) return { success: false, error: conn.error } - - const normalizedChatroomId = String(chatroomId || '').trim() - const normalizedMemberUsername = String(memberUsername || '').trim() - if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } - if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' } - - const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number' - ? Math.max(0, Math.floor(options.startTime)) - : 0 - const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number' - ? Math.max(0, Math.floor(options.endTime)) - : 0 - const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number' - ? Math.max(1, Math.min(100, Math.floor(options.limit))) - : 50 - let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number' - ? Math.max(0, Math.floor(options.cursor)) - : 0 - - const matchedMessages: Message[] = [] - const senderMatchCache = new Map() - const matchesTargetSender = (sender: string | null | undefined): boolean => { - const key = String(sender || '').trim().toLowerCase() - if (!key) return false - const cached = senderMatchCache.get(key) - if (typeof cached === 'boolean') return cached - const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) - senderMatchCache.set(key, matched) - return matched - } - const batchSize = Math.max(limit * 4, 240) - let hasMore = false - - const cursorResult = await this.openMemberMessageCursor( - normalizedChatroomId, - batchSize, - false, - startTimeValue, - endTimeValue - ) - if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '创建群成员消息游标失败' } - } - - let consumedRows = 0 - const dbCursor = cursorResult.cursor - - try { - while (matchedMessages.length < limit) { - const batch = await wcdbService.fetchMessageBatch(dbCursor) - if (!batch.success) { - return { success: false, error: batch.error || '获取群成员消息失败' } - } - - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - if (rows.length === 0) { - hasMore = false - break - } - - let startIndex = 0 - if (cursor > consumedRows) { - const skipCount = Math.min(cursor - consumedRows, rows.length) - consumedRows += skipCount - startIndex = skipCount - if (startIndex >= rows.length) { - if (!batch.hasMore) { - hasMore = false - break - } - continue - } - } - - for (let index = startIndex; index < rows.length; index += 1) { - const row = rows[index] - consumedRows += 1 - - const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim()) - if (senderFromRow && !matchesTargetSender(senderFromRow)) { - continue - } - - const message = this.parseSingleMessageRow(row) - if (!message) continue - if (!matchesTargetSender(message.senderUsername)) { - continue - } - - matchedMessages.push(message) - if (matchedMessages.length >= limit) { - cursor = consumedRows - hasMore = index < rows.length - 1 || batch.hasMore === true - break - } - } - - if (matchedMessages.length >= limit) break - - cursor = consumedRows - if (!batch.hasMore) { - hasMore = false - break - } - } - } finally { - await wcdbService.closeMessageCursor(dbCursor) - } - - return { - success: true, - data: { - messages: matchedMessages, - hasMore, - nextCursor: cursor - } - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - 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 [memberCounts, contactInfo] = await Promise.all([ - wcdbService.getGroupMemberCounts(groupIds), - chatService.enrichSessionsContactInfo(groupIds) - ]) - - let fallbackNames: { success: boolean; map?: Record } | null = null - let fallbackAvatars: { success: boolean; map?: Record } | null = null - if (!contactInfo.success || !contactInfo.contacts) { - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(groupIds), - wcdbService.getAvatarUrls(groupIds) - ]) - fallbackNames = displayNames - fallbackAvatars = avatarUrls - } - - const groups: GroupChatInfo[] = [] - for (const groupId of groupIds) { - const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined - const displayName = contact?.displayName || - (fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') || - groupId - const avatarUrl = contact?.avatarUrl || - (fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined) - - groups.push({ - username: groupId, - displayName, - memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' - ? memberCounts.map[groupId] - : 0, - avatarUrl - }) - } - - groups.sort((a, b) => b.memberCount - a.memberCount) - return { success: true, data: groups } - } catch (e) { - return { success: false, error: String(e) } - } - } - - private async loadGroupMembersPanelDataFresh( - chatroomId: string, - includeMessageCounts: boolean - ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> { - const membersResult = await wcdbService.getGroupMembers(chatroomId) - if (!membersResult.success || !membersResult.members) { - return { success: false, error: membersResult.error || '获取群成员失败' } - } - - const members = membersResult.members as Array<{ - username: string - avatarUrl?: string - originalName?: string - [key: string]: unknown - }> - if (members.length === 0) return { success: true, data: [] } - - const usernames = members - .map((member) => String(member.username || '').trim()) - .filter(Boolean) - if (usernames.length === 0) return { success: true, data: [] } - - const displayNamesPromise = wcdbService.getDisplayNames(usernames) - const contactLookupPromise = this.buildGroupMemberContactLookup(usernames) - const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members) - const messageCountLookupPromise = includeMessageCounts - ? this.buildGroupMessageCountLookup(chatroomId) - : Promise.resolve(new Map()) - - const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([ - displayNamesPromise, - contactLookupPromise, - ownerPromise, - messageCountLookupPromise - ]) - - const nicknameCandidates = this.buildIdCandidates([ - ...members.map((member) => member.username), - ...members.map((member) => member.originalName), - ...Array.from(contactLookup.values()).map((contact) => contact?.username), - ...Array.from(contactLookup.values()).map((contact) => contact?.userName), - ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername), - ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName), - ...Array.from(contactLookup.values()).map((contact) => contact?.alias) - ]) - const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) - const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') - let myGroupMessageCountHint: number | undefined - - const data: GroupMembersPanelEntry[] = members - .map((member) => { - const wxid = String(member.username || '').trim() - if (!wxid) return null - - const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName]) - const nickname = contact?.nickName || '' - const remark = contact?.remark || '' - const alias = contact?.alias || '' - const normalizedWxid = this.cleanAccountDirName(wxid) - const lookupCandidates = this.buildIdCandidates([ - wxid, - member.originalName as string | undefined, - contact?.username, - contact?.userName, - contact?.encryptUsername, - contact?.encryptUserName, - alias - ]) - if (normalizedWxid === myWxid) { - lookupCandidates.push(myWxid) - } - const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) - const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid - - return { - username: wxid, - displayName, - nickname, - alias, - remark, - groupNickname, - avatarUrl: member.avatarUrl, - isOwner: Boolean(ownerUsername && ownerUsername === wxid), - isFriend: this.isFriendMember(wxid, contact), - messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates) - } - }) - .filter((member): member is GroupMembersPanelEntry => Boolean(member)) - - if (includeMessageCounts && myWxid) { - const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid) - if (selfEntry && Number.isFinite(selfEntry.messageCount)) { - myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount)) - } - } - - if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) { - void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number) - } - - return { success: true, data: this.sortGroupMembersPanelEntries(data) } - } - - async getGroupMembersPanelData( - chatroomId: string, - options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } - ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> { - try { - const normalizedChatroomId = String(chatroomId || '').trim() - if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } - - const forceRefresh = Boolean(options?.forceRefresh) - const includeMessageCounts = options?.includeMessageCounts !== false - const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts) - const now = Date.now() - const cached = this.groupMembersPanelCache.get(cacheKey) - if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) { - return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt } - } - - if (!forceRefresh) { - const pending = this.groupMembersPanelInFlight.get(cacheKey) - if (pending) return pending - } - - const requestPromise = (async () => { - const conn = await this.ensureConnected() - if (!conn.success) return { success: false, error: conn.error } - - const timeoutMs = includeMessageCounts - ? this.groupMembersPanelFullTimeoutMs - : this.groupMembersPanelMembersTimeoutMs - const fresh = await this.withPromiseTimeout( - this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts), - timeoutMs, - { - success: false, - error: includeMessageCounts - ? '群成员发言统计加载超时,请稍后重试' - : '群成员列表加载超时,请稍后重试' - } - ) - if (!fresh.success || !fresh.data) { - return { success: false, error: fresh.error || '获取群成员面板数据失败' } - } - - const updatedAt = Date.now() - this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data }) - this.pruneGroupMembersPanelCache() - return { success: true, data: fresh.data, fromCache: false, updatedAt } - })().finally(() => { - this.groupMembersPanelInFlight.delete(cacheKey) - }) - - this.groupMembersPanelInFlight.set(cacheKey, requestPromise) - return await requestPromise - } 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 Array<{ - username: string - avatarUrl?: string - originalName?: string - [key: string]: unknown - }> - const usernames = members.map((m) => m.username).filter(Boolean) - - const displayNamesPromise = wcdbService.getDisplayNames(usernames) - - const contactMap = new Map() - const concurrency = 6 - await this.parallelLimit(usernames, concurrency, async (username) => { - const contactResult = await wcdbService.getContact(username) - if (contactResult.success && contactResult.contact) { - const contact = contactResult.contact as any - contactMap.set(username, { - remark: contact.remark || '', - nickName: contact.nickName || contact.nick_name || '', - alias: contact.alias || '', - username: contact.username || '', - userName: contact.userName || contact.user_name || '', - encryptUsername: contact.encryptUsername || contact.encrypt_username || '', - encryptUserName: contact.encryptUserName || '' - }) - } else { - contactMap.set(username, { remark: '', nickName: '', alias: '' }) - } - }) - - const displayNames = await displayNamesPromise - const nicknameCandidates = this.buildIdCandidates([ - ...members.map((m) => m.username), - ...members.map((m) => m.originalName), - ...Array.from(contactMap.values()).map((c) => c?.username), - ...Array.from(contactMap.values()).map((c) => c?.userName), - ...Array.from(contactMap.values()).map((c) => c?.encryptUsername), - ...Array.from(contactMap.values()).map((c) => c?.encryptUserName), - ...Array.from(contactMap.values()).map((c) => c?.alias) - ]) - const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) - - const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') - const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members) - const data: GroupMember[] = members.map((m) => { - const wxid = m.username || '' - const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid - const contact = contactMap.get(wxid) - const nickname = contact?.nickName || '' - const remark = contact?.remark || '' - const alias = contact?.alias || '' - const normalizedWxid = this.cleanAccountDirName(wxid) - const lookupCandidates = this.buildIdCandidates([ - wxid, - m.originalName, - contact?.username, - contact?.userName, - contact?.encryptUsername, - contact?.encryptUserName, - alias - ]) - if (normalizedWxid === myWxid) { - lookupCandidates.push(myWxid) - } - const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) - - return { - username: wxid, - displayName, - nickname, - alias, - remark, - groupNickname, - avatarUrl: m.avatarUrl, - isOwner: Boolean(ownerUsername && ownerUsername === wxid) - } - }) - - 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) } - } - } - - async getGroupMemberAnalytics( - chatroomId: string, - memberUsername: string, - startTime?: number, - endTime?: number - ): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> { - try { - const conn = await this.ensureConnected() - if (!conn.success) return { success: false, error: conn.error } - - const normalizedChatroomId = String(chatroomId || '').trim() - const normalizedMemberUsername = String(memberUsername || '').trim() - - const batchSize = 10000 - const senderMatchCache = new Map() - const matchesTargetSender = (sender: string | null | undefined): boolean => { - const key = String(sender || '').trim().toLowerCase() - if (!key) return false - const cached = senderMatchCache.get(key) - if (typeof cached === 'boolean') return cached - const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) - senderMatchCache.set(key, matched) - return matched - } - - const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0) - if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '创建游标失败' } - } - - const cursor = cursorResult.cursor - const stats: ChatStatistics = { - totalMessages: 0, - textMessages: 0, - imageMessages: 0, - voiceMessages: 0, - videoMessages: 0, - emojiMessages: 0, - otherMessages: 0, - sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages - receivedMessages: 0, // No meaning here - firstMessageTime: null, - lastMessageTime: null, - activeDays: 0, - messageTypeCounts: {} - } - - const hourlyDistribution: Record = {} - for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0 - const dailySet = new Set() - const textTypes = [1, 244813135921] - - const phraseCounts = new Map() - const emojiCounts = new Map() - - const myWxid = String(this.configService.getMyWxidCleaned() || '').trim() - - try { - while (true) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - return { success: false, error: batch.error || '获取分析数据失败' } - } - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - if (rows.length === 0) break - - for (const row of rows) { - let senderFromRow = this.extractRowSenderUsername(row, myWxid) - - const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send - const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false - - if (isSend) { - senderFromRow = myWxid - } - - if (!senderFromRow || !matchesTargetSender(senderFromRow)) { - continue - } - - const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10) - const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10) - - let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '') - if (content) { - content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i, '') - } - - stats.totalMessages++ - if (textTypes.includes(msgType)) { - stats.textMessages++ - if (content) { - const text = content.trim() - if (text && text.length <= 20) { - phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1) - } - const emojiMatches = text.match(/\[.*?\]/g) - if (emojiMatches) { - for (const em of emojiMatches) { - emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1) - } - } - } - } - else if (msgType === 3) stats.imageMessages++ - else if (msgType === 34) stats.voiceMessages++ - else if (msgType === 43) stats.videoMessages++ - else if (msgType === 47) stats.emojiMessages++ - else stats.otherMessages++ - - stats.sentMessages++ - - stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1 - - if (createTime > 0) { - if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime - if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime - - const d = new Date(createTime * 1000) - const hour = d.getHours() - hourlyDistribution[hour]++ - dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`) - } - } - if (!batch.hasMore) break - } - } finally { - await wcdbService.closeMessageCursor(cursor) - } - - stats.activeDays = dailySet.size - - const commonPhrases = Array.from(phraseCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([phrase, count]) => ({ phrase, count })) - - const commonEmojis = Array.from(emojiCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([emoji, count]) => ({ emoji, count })) - - return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async exportGroupMemberMessages( - chatroomId: string, - memberUsername: string, - outputPath: string, - startTime?: number, - endTime?: number - ): Promise<{ success: boolean; count?: number; error?: string }> { - try { - const conn = await this.ensureConnected() - if (!conn.success) return { success: false, error: conn.error } - - const normalizedChatroomId = String(chatroomId || '').trim() - const normalizedMemberUsername = String(memberUsername || '').trim() - if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } - if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' } - - const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number' - ? Math.max(0, Math.floor(startTime)) - : 0 - const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number' - ? Math.max(0, Math.floor(endTime)) - : 0 - - const exportDate = new Date() - const exportTime = this.formatDateTime(exportDate) - const exportVersion = '0.0.2' - const exportGenerator = 'WeFlow' - const exportPlatform = 'wechat' - - const groupDisplay = await wcdbService.getDisplayNames([normalizedChatroomId, normalizedMemberUsername]) - const groupName = groupDisplay.success && groupDisplay.map - ? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId) - : normalizedChatroomId - const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map - ? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername) - : normalizedMemberUsername - - let memberDisplayName = defaultMemberDisplayName - let memberAlias = '' - let memberRemark = '' - let memberGroupNickname = '' - const membersResult = await this.getGroupMembers(normalizedChatroomId) - if (membersResult.success && membersResult.data) { - const matchedMember = membersResult.data.find((item) => - this.isSameAccountIdentity(item.username, normalizedMemberUsername) - ) - if (matchedMember) { - memberDisplayName = matchedMember.displayName || defaultMemberDisplayName - memberAlias = matchedMember.alias || '' - memberRemark = matchedMember.remark || '' - memberGroupNickname = matchedMember.groupNickname || '' - } - } - - const collected = await this.collectMessagesByMember( - normalizedChatroomId, - normalizedMemberUsername, - beginTimestamp, - endTimestampValue - ) - if (!collected.success || !collected.data) { - return { success: false, error: collected.error || '获取成员消息失败' } - } - - const records = collected.data.map((message, index) => ({ - index: index + 1, - time: this.formatUnixTime(message.createTime), - sender: message.senderUsername || '', - messageType: this.getSimpleMessageTypeName(message.localType), - content: this.resolveExportMessageContent(message) - })) - - fs.mkdirSync(path.dirname(outputPath), { recursive: true }) - const ext = path.extname(outputPath).toLowerCase() - if (ext === '.csv') { - const infoTitleRow = ['会话信息'] - const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, ''] - const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias] - const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime] - const header = ['序号', '时间', '发送者wxid', '消息类型', '内容'] - - const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header] - for (const record of records) { - csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content]) - } - - const csvLines = csvRows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(',')) - const content = '\ufeff' + csvLines.join('\n') - fs.writeFileSync(outputPath, content, 'utf8') - } else { - const workbook = new ExcelJS.Workbook() - const worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录')) - - worksheet.getCell(1, 1).value = '会话信息' - worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getRow(1).height = 24 - - worksheet.getCell(2, 1).value = '群聊ID' - worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.mergeCells(2, 2, 2, 3) - worksheet.getCell(2, 2).value = normalizedChatroomId - - worksheet.getCell(2, 4).value = '群聊名称' - worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(2, 5).value = groupName - worksheet.getCell(2, 6).value = '成员wxid' - worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.mergeCells(2, 7, 2, 8) - worksheet.getCell(2, 7).value = normalizedMemberUsername - - worksheet.getCell(3, 1).value = '成员显示名' - worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(3, 2).value = memberDisplayName - worksheet.getCell(3, 3).value = '成员备注' - worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(3, 4).value = memberRemark - worksheet.getCell(3, 5).value = '群昵称' - worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(3, 6).value = memberGroupNickname - worksheet.getCell(3, 7).value = '微信号' - worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(3, 8).value = memberAlias - - worksheet.getCell(4, 1).value = '导出工具' - worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(4, 2).value = exportGenerator - worksheet.getCell(4, 3).value = '导出版本' - worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(4, 4).value = exportVersion - worksheet.getCell(4, 5).value = '平台' - worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(4, 6).value = exportPlatform - worksheet.getCell(4, 7).value = '导出时间' - worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 } - worksheet.getCell(4, 8).value = exportTime - - const headerRow = worksheet.getRow(5) - const header = ['序号', '时间', '发送者wxid', '消息类型', '内容'] - header.forEach((title, index) => { - const cell = headerRow.getCell(index + 1) - cell.value = title - cell.font = { name: 'Calibri', bold: true, size: 11 } - }) - headerRow.height = 22 - - worksheet.getColumn(1).width = 10 - worksheet.getColumn(2).width = 22 - worksheet.getColumn(3).width = 30 - worksheet.getColumn(4).width = 16 - worksheet.getColumn(5).width = 90 - worksheet.getColumn(6).width = 16 - worksheet.getColumn(7).width = 20 - worksheet.getColumn(8).width = 24 - - let currentRow = 6 - for (const record of records) { - const row = worksheet.getRow(currentRow) - row.getCell(1).value = record.index - row.getCell(2).value = record.time - row.getCell(3).value = record.sender - row.getCell(4).value = record.messageType - row.getCell(5).value = record.content - row.alignment = { vertical: 'top', wrapText: true } - currentRow += 1 - } - - await workbook.xlsx.writeFile(outputPath) - } - - return { success: true, count: records.length } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> { - try { - const conn = await this.ensureConnected() - if (!conn.success) return { success: false, error: conn.error } - - const exportDate = new Date() - const exportTime = this.formatDateTime(exportDate) - const exportVersion = '0.0.2' - const exportGenerator = 'WeFlow' - const exportPlatform = 'wechat' - - const groupDisplay = await wcdbService.getDisplayNames([chatroomId]) - const groupName = groupDisplay.success && groupDisplay.map - ? (groupDisplay.map[chatroomId] || chatroomId) - : chatroomId - - const groupContact = await wcdbService.getContact(chatroomId) - const sessionRemark = (groupContact.success && groupContact.contact) - ? (groupContact.contact.remark || '') - : '' - - const membersResult = await wcdbService.getGroupMembers(chatroomId) - if (!membersResult.success || !membersResult.members) { - return { success: false, error: membersResult.error || '获取群成员失败' } - } - - const members = membersResult.members as Array<{ - username: string - avatarUrl?: string - originalName?: string - }> - if (members.length === 0) { - return { success: false, error: '群成员为空' } - } - - const usernames = members.map((m) => m.username).filter(Boolean) - const displayNamesPromise = wcdbService.getDisplayNames(usernames) - - const contactMap = new Map() - const concurrency = 6 - await this.parallelLimit(usernames, concurrency, async (username) => { - const result = await wcdbService.getContact(username) - if (result.success && result.contact) { - const contact = result.contact as any - contactMap.set(username, { - remark: contact.remark || '', - nickName: contact.nickName || contact.nick_name || '', - alias: contact.alias || '', - username: contact.username || '', - userName: contact.userName || contact.user_name || '', - encryptUsername: contact.encryptUsername || contact.encrypt_username || '', - encryptUserName: contact.encryptUserName || '' - }) - } else { - contactMap.set(username, { remark: '', nickName: '', alias: '' }) - } - }) - - const infoTitleRow = ['会话信息'] - const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', ''] - const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime] - - const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号'] - const rows: string[][] = [infoTitleRow, infoRow, metaRow, header] - const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') - - const displayNames = await displayNamesPromise - const nicknameCandidates = this.buildIdCandidates([ - ...members.map((m) => m.username), - ...members.map((m) => m.originalName), - ...Array.from(contactMap.values()).map((c) => c?.username), - ...Array.from(contactMap.values()).map((c) => c?.userName), - ...Array.from(contactMap.values()).map((c) => c?.encryptUsername), - ...Array.from(contactMap.values()).map((c) => c?.encryptUserName), - ...Array.from(contactMap.values()).map((c) => c?.alias) - ]) - const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) - - for (const member of members) { - const wxid = member.username - const normalizedWxid = this.cleanAccountDirName(wxid || '') - const contact = contactMap.get(wxid) - const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : '' - const nickName = contact?.nickName || fallbackName || '' - const remark = contact?.remark || '' - const alias = contact?.alias || '' - const lookupCandidates = this.buildIdCandidates([ - wxid, - member.originalName, - contact?.username, - contact?.userName, - contact?.encryptUsername, - contact?.encryptUserName, - alias - ]) - if (normalizedWxid === myWxid) { - lookupCandidates.push(myWxid) - } - const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) - - rows.push([nickName, remark, groupNickname, wxid, alias]) - } - - const ext = path.extname(outputPath).toLowerCase() - if (ext === '.csv') { - const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(',')) - const content = '\ufeff' + csvLines.join('\n') - fs.writeFileSync(outputPath, content, 'utf8') - } else { - const workbook = new ExcelJS.Workbook() - const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表')) - - let currentRow = 1 - const titleCell = sheet.getCell(currentRow, 1) - titleCell.value = '会话信息' - titleCell.font = { name: 'Calibri', bold: true, size: 11 } - titleCell.alignment = { vertical: 'middle', horizontal: 'left' } - sheet.getRow(currentRow).height = 25 - currentRow++ - - sheet.getCell(currentRow, 1).value = '微信ID' - sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 } - sheet.mergeCells(currentRow, 2, currentRow, 3) - sheet.getCell(currentRow, 2).value = chatroomId - sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 } - - sheet.getCell(currentRow, 4).value = '昵称' - sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 } - sheet.getCell(currentRow, 5).value = groupName - sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 } - - sheet.getCell(currentRow, 6).value = '备注' - sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 } - sheet.mergeCells(currentRow, 7, currentRow, 8) - sheet.getCell(currentRow, 7).value = sessionRemark - sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 } - - sheet.getRow(currentRow).height = 20 - currentRow++ - - sheet.getCell(currentRow, 1).value = '导出工具' - sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 } - sheet.getCell(currentRow, 2).value = exportGenerator - sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 } - - sheet.getCell(currentRow, 3).value = '导出版本' - sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 } - sheet.getCell(currentRow, 4).value = exportVersion - sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 } - - sheet.getCell(currentRow, 5).value = '平台' - sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 } - sheet.getCell(currentRow, 6).value = exportPlatform - sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 } - - sheet.getCell(currentRow, 7).value = '导出时间' - sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 } - sheet.getCell(currentRow, 8).value = exportTime - sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 } - - sheet.getRow(currentRow).height = 20 - currentRow++ - - const headerRow = sheet.getRow(currentRow) - headerRow.height = 22 - header.forEach((text, index) => { - const cell = headerRow.getCell(index + 1) - cell.value = text - cell.font = { name: 'Calibri', bold: true, size: 11 } - }) - currentRow++ - - sheet.getColumn(1).width = 28 - sheet.getColumn(2).width = 28 - sheet.getColumn(3).width = 28 - sheet.getColumn(4).width = 36 - sheet.getColumn(5).width = 28 - sheet.getColumn(6).width = 18 - sheet.getColumn(7).width = 24 - sheet.getColumn(8).width = 22 - - for (let i = 4; i < rows.length; i++) { - const [nickName, remark, groupNickname, wxid, alias] = rows[i] - const row = sheet.getRow(currentRow) - row.getCell(1).value = nickName - row.getCell(2).value = remark - row.getCell(3).value = groupNickname - row.getCell(4).value = wxid - row.getCell(5).value = alias - row.alignment = { vertical: 'top', wrapText: true } - currentRow++ - } - - await workbook.xlsx.writeFile(outputPath) - } - - return { success: true, count: members.length } - } catch (e) { - return { success: false, error: String(e) } - } - } - - - -} - -export const groupAnalyticsService = new GroupAnalyticsService() diff --git a/electron/services/groupMyMessageCountCacheService.ts b/electron/services/groupMyMessageCountCacheService.ts deleted file mode 100644 index 68ee346..0000000 --- a/electron/services/groupMyMessageCountCacheService.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { join, dirname } from 'path' -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' -import { ConfigService } from './config' - -const CACHE_VERSION = 1 -const MAX_GROUP_ENTRIES_PER_SCOPE = 3000 -const MAX_SCOPE_ENTRIES = 12 - -export interface GroupMyMessageCountCacheEntry { - updatedAt: number - messageCount: number -} - -interface GroupMyMessageCountScopeMap { - [chatroomId: string]: GroupMyMessageCountCacheEntry -} - -interface GroupMyMessageCountCacheStore { - version: number - scopes: Record -} - -function toNonNegativeInt(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) return undefined - return Math.max(0, Math.floor(value)) -} - -function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null { - if (!raw || typeof raw !== 'object') return null - const source = raw as Record - const updatedAt = toNonNegativeInt(source.updatedAt) - const messageCount = toNonNegativeInt(source.messageCount) - if (updatedAt === undefined || messageCount === undefined) return null - return { - updatedAt, - messageCount - } -} - -export class GroupMyMessageCountCacheService { - private readonly cacheFilePath: string - private store: GroupMyMessageCountCacheStore = { - version: CACHE_VERSION, - scopes: {} - } - - constructor(cacheBasePath?: string) { - const basePath = cacheBasePath && cacheBasePath.trim().length > 0 - ? cacheBasePath - : ConfigService.getInstance().getCacheBasePath() - this.cacheFilePath = join(basePath, 'group-my-message-counts.json') - this.ensureCacheDir() - this.load() - } - - private ensureCacheDir(): void { - const dir = dirname(this.cacheFilePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - } - - private load(): void { - if (!existsSync(this.cacheFilePath)) return - try { - const raw = readFileSync(this.cacheFilePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const payload = parsed as Record - const scopesRaw = payload.scopes - if (!scopesRaw || typeof scopesRaw !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const scopes: Record = {} - for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { - if (!scopeValue || typeof scopeValue !== 'object') continue - const normalizedScope: GroupMyMessageCountScopeMap = {} - for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record)) { - const entry = normalizeEntry(entryRaw) - if (!entry) continue - normalizedScope[chatroomId] = entry - } - if (Object.keys(normalizedScope).length > 0) { - scopes[scopeKey] = normalizedScope - } - } - - this.store = { - version: CACHE_VERSION, - scopes - } - } catch (error) { - console.error('GroupMyMessageCountCacheService: 载入缓存失败', error) - this.store = { version: CACHE_VERSION, scopes: {} } - } - } - - get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined { - if (!scopeKey || !chatroomId) return undefined - const scope = this.store.scopes[scopeKey] - if (!scope) return undefined - const entry = normalizeEntry(scope[chatroomId]) - if (!entry) { - delete scope[chatroomId] - if (Object.keys(scope).length === 0) { - delete this.store.scopes[scopeKey] - } - this.persist() - return undefined - } - return entry - } - - set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void { - if (!scopeKey || !chatroomId) return - const normalized = normalizeEntry(entry) - if (!normalized) return - - if (!this.store.scopes[scopeKey]) { - this.store.scopes[scopeKey] = {} - } - - const existing = this.store.scopes[scopeKey][chatroomId] - if (existing && existing.updatedAt > normalized.updatedAt) { - return - } - - this.store.scopes[scopeKey][chatroomId] = normalized - this.trimScope(scopeKey) - this.trimScopes() - this.persist() - } - - delete(scopeKey: string, chatroomId: string): void { - if (!scopeKey || !chatroomId) return - const scope = this.store.scopes[scopeKey] - if (!scope) return - if (!(chatroomId in scope)) return - delete scope[chatroomId] - if (Object.keys(scope).length === 0) { - delete this.store.scopes[scopeKey] - } - this.persist() - } - - clearScope(scopeKey: string): void { - if (!scopeKey) return - if (!this.store.scopes[scopeKey]) return - delete this.store.scopes[scopeKey] - this.persist() - } - - clearAll(): void { - this.store = { version: CACHE_VERSION, scopes: {} } - try { - rmSync(this.cacheFilePath, { force: true }) - } catch (error) { - console.error('GroupMyMessageCountCacheService: 清理缓存失败', error) - } - } - - private trimScope(scopeKey: string): void { - const scope = this.store.scopes[scopeKey] - if (!scope) return - const entries = Object.entries(scope) - if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return - entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) - const trimmed: GroupMyMessageCountScopeMap = {} - for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) { - trimmed[chatroomId] = entry - } - this.store.scopes[scopeKey] = trimmed - } - - private trimScopes(): void { - const scopeEntries = Object.entries(this.store.scopes) - if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return - scopeEntries.sort((a, b) => { - const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) - const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) - return bUpdatedAt - aUpdatedAt - }) - - const trimmedScopes: Record = {} - for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { - trimmedScopes[scopeKey] = scopeMap - } - this.store.scopes = trimmedScopes - } - - private persist(): void { - try { - writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') - } catch (error) { - console.error('GroupMyMessageCountCacheService: 保存缓存失败', error) - } - } -} diff --git a/electron/services/groupSummaryRecordService.ts b/electron/services/groupSummaryRecordService.ts deleted file mode 100644 index 5cadced..0000000 --- a/electron/services/groupSummaryRecordService.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { app } from 'electron' -import fs from 'fs' -import path from 'path' -import { createHash, randomUUID } from 'crypto' -import { ConfigService } from './config' - -export type GroupSummaryTriggerType = 'auto' | 'manual' - -export interface GroupSummaryTopic { - title: string - participants: string[] - keyPoints: string[] - conclusion: string -} - -export interface GroupSummaryLog { - endpoint: string - model: string - temperature: number - triggerType: GroupSummaryTriggerType - periodStart: number - periodEnd: number - messageCount: number - readableMessageCount: number - systemPrompt: string - userPrompt: string - rawOutput: string - finalSummary: string - durationMs: number - createdAt: number - responseFormatJson?: boolean - responseFormatFallback?: boolean - responseFormatFallbackReason?: string - parsedTopics?: GroupSummaryTopic[] -} - -export interface GroupSummaryRecord { - id: string - accountScope: string - createdAt: number - sessionId: string - displayName: string - avatarUrl?: string - triggerType: GroupSummaryTriggerType - periodStart: number - periodEnd: number - messageCount: number - readableMessageCount: number - topics: GroupSummaryTopic[] - summaryText: string - rawOutput: string - log: GroupSummaryLog -} - -export interface GroupSummaryRecordSummary { - id: string - createdAt: number - sessionId: string - displayName: string - avatarUrl?: string - triggerType: GroupSummaryTriggerType - periodStart: number - periodEnd: number - messageCount: number - readableMessageCount: number - topics: GroupSummaryTopic[] - summaryText: string -} - -export interface GroupSummaryRecordFilters { - sessionId?: string - startTime?: number - endTime?: number - limit?: number - offset?: number -} - -export interface GroupSummaryRecordListResult { - success: boolean - records: GroupSummaryRecordSummary[] - total: number - error?: string -} - -interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary { - accountScope: string - logFile?: string -} - -interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord { - rawOutput?: string - log?: GroupSummaryLog -} - -class GroupSummaryRecordService { - private readonly maxRecordsPerScope = 2000 - private filePath: string | null = null - private logDir: string | null = null - private loaded = false - private records: GroupSummaryIndexRecord[] = [] - - private resolveUserDataPath(): string { - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() - fs.mkdirSync(userDataPath, { recursive: true }) - return userDataPath - } - - private resolveFilePath(): string { - if (this.filePath) return this.filePath - this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json') - return this.filePath - } - - private resolveLogDir(): string { - if (this.logDir) return this.logDir - this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs') - fs.mkdirSync(this.logDir, { recursive: true }) - return this.logDir - } - - private normalizeTimestampSeconds(value: unknown): number { - const numeric = Number(value || 0) - if (!Number.isFinite(numeric) || numeric <= 0) return 0 - let normalized = Math.floor(numeric) - while (normalized > 10000000000) { - normalized = Math.floor(normalized / 1000) - } - return normalized - } - - private safeLogFileName(id: string): string { - const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '') - return `${normalized || randomUUID()}.json` - } - - private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined { - try { - const fileName = this.safeLogFileName(recordId) - const logPath = path.join(this.resolveLogDir(), fileName) - fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8') - return fileName - } catch { - return undefined - } - } - - private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null { - if (!fileName) return null - try { - const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, ''))) - if (!fs.existsSync(logPath)) return null - const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8')) - const log = parsed?.log - if (!log || typeof log !== 'object') return null - return { - rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''), - log: log as GroupSummaryLog - } - } catch { - return null - } - } - - private ensureLoaded(): void { - if (this.loaded) return - this.loaded = true - const filePath = this.resolveFilePath() - try { - if (!fs.existsSync(filePath)) return - const raw = fs.readFileSync(filePath, 'utf-8') - const parsed = JSON.parse(raw) - const records = Array.isArray(parsed) ? parsed : parsed?.records - if (!Array.isArray(records)) return - - const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[] - const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput)) - if (needsMigration) { - this.backupLegacyFile(filePath) - } - - this.records = legacyRecords.map((record) => { - const id = String(record.id || randomUUID()) - const logFile = record.log - ? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || '')) - : record.logFile - return { - id, - accountScope: String(record.accountScope || 'default'), - createdAt: Number(record.createdAt || Date.now()), - sessionId: String(record.sessionId || ''), - displayName: String(record.displayName || record.sessionId || ''), - avatarUrl: record.avatarUrl, - triggerType: record.triggerType === 'auto' ? 'auto' : 'manual', - periodStart: this.normalizeTimestampSeconds(record.periodStart), - periodEnd: this.normalizeTimestampSeconds(record.periodEnd), - messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))), - readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))), - topics: Array.isArray(record.topics) ? record.topics : [], - summaryText: String(record.summaryText || ''), - logFile - } - }).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart) - - if (needsMigration) { - this.persist() - } - } catch { - this.records = [] - } - } - - private backupLegacyFile(filePath: string): void { - try { - const backupPath = `${filePath}.legacy-${Date.now()}.bak` - if (!fs.existsSync(backupPath)) { - fs.copyFileSync(filePath, backupPath) - } - } catch { - // Backup failure should not block reading existing records. - } - } - - private persist(): void { - try { - const filePath = this.resolveFilePath() - fs.writeFileSync(filePath, JSON.stringify({ version: 2, records: this.records }, null, 2), 'utf-8') - } catch { - // Summary generation should not fail because local record persistence failed. - } - } - - private getCurrentAccountScope(): string { - const config = ConfigService.getInstance() - const myWxid = String(config.getMyWxidCleaned() || '').trim() - if (myWxid) return `wxid:${myWxid}` - - const dbPath = String(config.get('dbPath') || '').trim() - if (dbPath) { - const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16) - return `db:${hash}` - } - return 'default' - } - - private toSummary(record: GroupSummaryIndexRecord): GroupSummaryRecordSummary { - return { - id: record.id, - createdAt: record.createdAt, - sessionId: record.sessionId, - displayName: record.displayName, - avatarUrl: record.avatarUrl, - triggerType: record.triggerType, - periodStart: record.periodStart, - periodEnd: record.periodEnd, - messageCount: record.messageCount, - readableMessageCount: record.readableMessageCount, - topics: Array.isArray(record.topics) ? record.topics : [], - summaryText: record.summaryText || '' - } - } - - private getScopedRecords(): GroupSummaryIndexRecord[] { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - return this.records.filter((record) => record.accountScope === scope) - } - - addRecord(input: { - sessionId: string - displayName: string - avatarUrl?: string - triggerType: GroupSummaryTriggerType - periodStart: number - periodEnd: number - messageCount: number - readableMessageCount: number - topics: GroupSummaryTopic[] - summaryText: string - rawOutput: string - log: GroupSummaryLog - }): GroupSummaryRecordSummary { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const id = randomUUID() - const logFile = this.writeLogFile(id, input.log, input.rawOutput) - const record: GroupSummaryIndexRecord = { - id, - accountScope: scope, - createdAt: Date.now(), - sessionId: input.sessionId, - displayName: input.displayName, - avatarUrl: input.avatarUrl, - triggerType: input.triggerType, - periodStart: input.periodStart, - periodEnd: input.periodEnd, - messageCount: input.messageCount, - readableMessageCount: input.readableMessageCount, - topics: input.topics, - summaryText: input.summaryText, - logFile - } - - this.records.push(record) - const scopedRecords = this.records - .filter((item) => item.accountScope === scope) - .sort((a, b) => b.createdAt - a.createdAt) - const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id)) - this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id)) - this.persist() - return this.toSummary(record) - } - - hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return false - return this.getScopedRecords().some((record) => - record.triggerType === 'auto' && - record.sessionId === normalizedSessionId && - Number(record.periodStart || 0) === periodStart && - Number(record.periodEnd || 0) === periodEnd - ) - } - - listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult { - try { - const sessionId = String(filters.sessionId || '').trim() - const startTime = this.normalizeTimestampSeconds(filters.startTime) - const endTime = this.normalizeTimestampSeconds(filters.endTime) - const offset = Math.max(0, Math.floor(Number(filters.offset || 0))) - const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100)))) - - const filtered = this.getScopedRecords() - .filter((record) => { - if (sessionId && record.sessionId !== sessionId) return false - const periodStart = Number(record.periodStart || 0) - const periodEnd = Number(record.periodEnd || 0) - if (startTime > 0 && periodEnd < startTime) return false - if (endTime > 0 && periodStart > endTime) return false - return true - }) - .sort((a, b) => Number(b.periodStart || b.createdAt) - Number(a.periodStart || a.createdAt)) - - return { - success: true, - records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)), - total: filtered.length - } - } catch (error) { - return { success: false, records: [], total: 0, error: (error as Error).message || String(error) } - } - } - - getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } { - this.ensureLoaded() - const normalizedId = String(id || '').trim() - if (!normalizedId) return { success: false, error: '记录 ID 为空' } - const scope = this.getCurrentAccountScope() - const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope) - if (!record) return { success: false, error: '未找到该群聊总结记录' } - - const logData = this.readLogFile(record.logFile) - if (!logData) return { success: false, error: '未找到该群聊总结日志' } - - return { - success: true, - record: { - ...this.toSummary(record), - accountScope: record.accountScope, - rawOutput: logData.rawOutput, - log: logData.log - } - } - } - - clearRuntimeCache(): void { - this.loaded = false - this.records = [] - this.filePath = null - this.logDir = null - } -} - -export const groupSummaryRecordService = new GroupSummaryRecordService() diff --git a/electron/services/groupSummaryService.ts b/electron/services/groupSummaryService.ts deleted file mode 100644 index 8eb4200..0000000 --- a/electron/services/groupSummaryService.ts +++ /dev/null @@ -1,801 +0,0 @@ -import https from 'https' -import http from 'http' -import { URL } from 'url' -import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json' -import { ConfigService } from './config' -import { chatService, type Message } from './chatService' -import { wcdbService } from './wcdbService' -import { - groupSummaryRecordService, - type GroupSummaryLog, - type GroupSummaryRecord, - type GroupSummaryRecordFilters, - type GroupSummaryRecordListResult, - type GroupSummaryRecordSummary, - type GroupSummaryTopic, - type GroupSummaryTriggerType -} from './groupSummaryRecordService' - -const API_TIMEOUT_MS = 90_000 -const API_TEMPERATURE = 0.4 -const MIN_SUMMARY_MESSAGES = 5 -const MAX_MANUAL_RANGE_SECONDS = 48 * 60 * 60 -const MAX_MESSAGES_PER_SUMMARY = 3000 -const SUMMARY_CURSOR_BATCH_SIZE = 360 -const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim() -const SUMMARY_CONFIG_KEYS = new Set([ - 'aiGroupSummaryEnabled', - 'aiGroupSummaryIntervalHours', - 'aiGroupSummarySystemPrompt', - 'aiGroupSummaryFilterMode', - 'aiGroupSummaryFilterList', - 'aiModelApiBaseUrl', - 'aiModelApiKey', - 'aiModelApiModel', - 'aiInsightApiBaseUrl', - 'aiInsightApiKey', - 'aiInsightApiModel', - 'dbPath', - 'decryptKey', - 'myWxid' -]) - -interface SharedAiModelConfig { - apiBaseUrl: string - apiKey: string - model: string -} - -interface GroupSummaryTriggerResult { - success: boolean - message: string - recordId?: string - record?: GroupSummaryRecordSummary - skipped?: boolean - skippedReason?: string -} - -interface GroupSummaryDayTriggerResult { - success: boolean - message: string - generated: number - skipped: number - records: GroupSummaryRecordSummary[] -} - -class ApiRequestError extends Error { - statusCode?: number - responseBody?: string - - constructor(message: string, statusCode?: number, responseBody?: string) { - super(message) - this.name = 'ApiRequestError' - this.statusCode = statusCode - this.responseBody = responseBody - } -} - -function buildApiUrl(baseUrl: string, path: string): string { - const base = baseUrl.replace(/\/+$/, '') - const suffix = path.startsWith('/') ? path : `/${path}` - return `${base}${suffix}` -} - -function normalizeSessionIdList(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) -} - -function normalizeIntervalHours(value: unknown): number { - const allowed = new Set([1, 2, 4, 8, 12, 24]) - const numeric = Math.floor(Number(value) || 4) - return allowed.has(numeric) ? numeric : 4 -} - -function getStartOfDaySeconds(date: Date = new Date()): number { - const next = new Date(date) - next.setHours(0, 0, 0, 0) - return Math.floor(next.getTime() / 1000) -} - -function clampText(value: unknown, maxLength: number): string { - const text = String(value || '').replace(/\s+/g, ' ').trim() - if (text.length <= maxLength) return text - return `${text.slice(0, Math.max(0, maxLength - 1))}…` -} - -function stripJsonFence(value: string): string { - const text = String(value || '').trim() - const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) - if (fenced) return fenced[1].trim() - const firstBrace = text.indexOf('{') - const lastBrace = text.lastIndexOf('}') - if (firstBrace >= 0 && lastBrace > firstBrace) { - return text.slice(firstBrace, lastBrace + 1).trim() - } - return text -} - -function shouldFallbackJsonMode(error: unknown): boolean { - const statusCode = (error as ApiRequestError)?.statusCode - if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true - const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() - return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') -} - -function formatTimestamp(createTime: number): string { - const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000 - const date = new Date(ms) - 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}` -} - -function callChatCompletions( - apiBaseUrl: string, - apiKey: string, - model: string, - messages: Array<{ role: string; content: string }>, - options?: { responseFormatJson?: boolean } -): Promise { - return new Promise((resolve, reject) => { - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - let urlObj: URL - try { - urlObj = new URL(endpoint) - } catch { - reject(new Error(`无效的 API URL: ${endpoint}`)) - return - } - - const payload: Record = { - model, - messages, - temperature: API_TEMPERATURE, - stream: false - } - if (options?.responseFormatJson) { - payload.response_format = { type: 'json_object' } - } - - const body = JSON.stringify(payload) - const requestOptions = { - hostname: urlObj.hostname, - port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: 'POST' as const, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body).toString(), - Authorization: `Bearer ${apiKey}` - } - } - - const requestFn = urlObj.protocol === 'https:' ? https.request : http.request - const req = requestFn(requestOptions, (res) => { - let data = '' - res.on('data', (chunk) => { data += chunk }) - res.on('end', () => { - try { - if (res.statusCode && res.statusCode >= 400) { - reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) - return - } - const parsed = JSON.parse(data) - const content = parsed?.choices?.[0]?.message?.content - if (typeof content === 'string' && content.trim()) { - resolve(content.trim()) - } else { - reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) - } - } catch { - reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) - } - }) - }) - - req.setTimeout(API_TIMEOUT_MS, () => { - req.destroy() - reject(new Error('API 请求超时')) - }) - req.on('error', reject) - req.write(body) - req.end() - }) -} - -function parseTopics(rawOutput: string): GroupSummaryTopic[] { - const parsed = JSON.parse(stripJsonFence(rawOutput)) as unknown - if (!parsed || typeof parsed !== 'object') { - throw new Error('模型输出格式异常:JSON 根节点不是对象') - } - const source = parsed as Record - const rawTopics = Array.isArray(source.topics) ? source.topics : [] - const topics = rawTopics.map((item, index) => { - const topic = item && typeof item === 'object' ? item as Record : {} - const participantsRaw = Array.isArray(topic.participants) ? topic.participants : [] - const keyPointsRaw = Array.isArray(topic.key_points) - ? topic.key_points - : (Array.isArray(topic.keyPoints) ? topic.keyPoints : []) - return { - title: clampText(topic.title || `话题 ${index + 1}`, 48) || `话题 ${index + 1}`, - participants: participantsRaw.map((value) => clampText(value, 24)).filter(Boolean).slice(0, 12), - keyPoints: keyPointsRaw.map((value) => clampText(value, 120)).filter(Boolean).slice(0, 8), - conclusion: clampText(topic.conclusion, 180) || '无明确结论' - } - }).filter((topic) => topic.title || topic.keyPoints.length > 0 || topic.conclusion) - - if (topics.length === 0) { - throw new Error('模型输出格式异常:topics 为空') - } - return topics -} - -function buildSummaryText(topics: GroupSummaryTopic[]): string { - return topics.map((topic) => { - const participants = topic.participants.length > 0 ? topic.participants.join('、') : '未明确' - const keyPoints = topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无' - return `【${topic.title}】参与者:${participants}。关键/矛盾点:${keyPoints}。结论:${topic.conclusion}` - }).join('\n') -} - -function fallbackTopicFromRaw(rawOutput: string): GroupSummaryTopic { - return { - title: '未归类总结', - participants: [], - keyPoints: [clampText(rawOutput, 500)], - conclusion: '模型未按固定 JSON 格式返回,请查看完整日志。' - } -} - -class GroupSummaryService { - private config: ConfigService - private started = false - private scanTimer: NodeJS.Timeout | null = null - private processing = false - private pendingAutoRun = false - private dbConnected = false - - constructor() { - this.config = ConfigService.getInstance() - } - - start(): void { - if (this.started) return - this.started = true - void this.refreshConfiguration('startup') - } - - stop(): void { - this.started = false - this.clearTimers() - this.processing = false - this.pendingAutoRun = false - this.dbConnected = false - } - - async handleConfigChanged(key: string): Promise { - const normalizedKey = String(key || '').trim() - if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return - if (normalizedKey === 'aiGroupSummarySystemPrompt') return - if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { - this.dbConnected = false - groupSummaryRecordService.clearRuntimeCache() - } - await this.refreshConfiguration(`config:${normalizedKey}`) - } - - handleConfigCleared(): void { - this.clearTimers() - this.processing = false - this.pendingAutoRun = false - this.dbConnected = false - groupSummaryRecordService.clearRuntimeCache() - } - - listRecords(filters?: GroupSummaryRecordFilters): GroupSummaryRecordListResult { - return groupSummaryRecordService.listRecords(filters || {}) - } - - getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } { - return groupSummaryRecordService.getRecord(id) - } - - async triggerManual(params: { - sessionId: string - displayName?: string - avatarUrl?: string - startTime: number - endTime: number - }): Promise { - if (!this.isEnabled()) { - return { success: false, message: '请先在设置中开启「AI 群聊总结」' } - } - const sessionId = String(params?.sessionId || '').trim() - if (!sessionId.endsWith('@chatroom')) { - return { success: false, message: 'AI 群聊总结仅支持群聊' } - } - const startTime = this.normalizeTimestampSeconds(params?.startTime) - const endTime = this.normalizeTimestampSeconds(params?.endTime) - if (startTime <= 0 || endTime <= startTime) { - return { success: false, message: '请选择有效的总结时段' } - } - if (endTime - startTime > MAX_MANUAL_RANGE_SECONDS) { - return { success: false, message: '手动总结时段不能超过 48 小时' } - } - - const displayName = String(params?.displayName || sessionId).trim() || sessionId - const avatarUrl = String(params?.avatarUrl || '').trim() || undefined - return this.generateSummaryForPeriod({ - sessionId, - displayName, - avatarUrl, - periodStart: startTime, - periodEnd: endTime, - triggerType: 'manual' - }) - } - - async triggerDay(params: { - sessionId: string - displayName?: string - avatarUrl?: string - date: string - }): Promise { - if (!this.isEnabled()) { - return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] } - } - const sessionId = String(params?.sessionId || '').trim() - if (!sessionId.endsWith('@chatroom')) { - return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] } - } - const dayRange = this.parseLocalDateDayRange(params?.date) - if (!dayRange) { - return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] } - } - const todayStart = getStartOfDaySeconds(new Date()) - if (dayRange.start > todayStart) { - return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] } - } - - const now = Math.floor(Date.now() / 1000) - const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end - const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false) - if (periods.length === 0) { - return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] } - } - - const displayName = String(params?.displayName || sessionId).trim() || sessionId - const avatarUrl = String(params?.avatarUrl || '').trim() || undefined - return this.generateSummariesForPeriods({ - sessionId, - displayName, - avatarUrl, - periods, - triggerType: 'manual' - }) - } - - private async refreshConfiguration(_reason: string): Promise { - if (!this.started) return - this.clearTimers() - if (!this.isEnabled()) return - await this.queueDueAutoSummaries() - this.scheduleNextAutoRun() - } - - private isEnabled(): boolean { - return this.config.get('aiGroupSummaryEnabled') === true - } - - private clearTimers(): void { - if (this.scanTimer !== null) { - clearTimeout(this.scanTimer) - this.scanTimer = null - } - } - - private scheduleNextAutoRun(): void { - if (!this.started || !this.isEnabled()) return - const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) - const now = Math.floor(Date.now() / 1000) - const dayStart = getStartOfDaySeconds(new Date()) - const intervalSeconds = intervalHours * 60 * 60 - const elapsed = Math.max(0, now - dayStart) - const nextBoundary = dayStart + (Math.floor(elapsed / intervalSeconds) + 1) * intervalSeconds - const delayMs = Math.max(1_000, (nextBoundary - now) * 1000 + 1_000) - - this.scanTimer = setTimeout(async () => { - this.scanTimer = null - await this.queueDueAutoSummaries() - this.scheduleNextAutoRun() - }, delayMs) - } - - private async ensureConnected(): Promise { - if (this.dbConnected) return true - const result = await chatService.connect() - this.dbConnected = result.success === true - return this.dbConnected - } - - private getSharedAiModelConfig(): SharedAiModelConfig { - const apiBaseUrl = String( - this.config.get('aiModelApiBaseUrl') - || this.config.get('aiInsightApiBaseUrl') - || '' - ).trim() - const apiKey = String( - this.config.get('aiModelApiKey') - || this.config.get('aiInsightApiKey') - || '' - ).trim() - const model = String( - this.config.get('aiModelApiModel') - || this.config.get('aiInsightApiModel') - || 'gpt-4o-mini' - ).trim() || 'gpt-4o-mini' - return { apiBaseUrl, apiKey, model } - } - - private getAutoScopeSessionIds(): string[] { - return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList')) - .filter((sessionId) => sessionId.endsWith('@chatroom')) - } - - private normalizeTimestampSeconds(value: unknown): number { - const numeric = Number(value || 0) - if (!Number.isFinite(numeric) || numeric <= 0) return 0 - let normalized = Math.floor(numeric) - while (normalized > 10000000000) { - normalized = Math.floor(normalized / 1000) - } - return normalized - } - - private parseLocalDateDayRange(value: unknown): { start: number; end: number } | null { - const text = String(value || '').trim() - const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/) - if (!match) return null - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - const start = new Date(year, month - 1, day, 0, 0, 0, 0) - if ( - !Number.isFinite(start.getTime()) || - start.getFullYear() !== year || - start.getMonth() !== month - 1 || - start.getDate() !== day - ) { - return null - } - const end = new Date(start) - end.setDate(end.getDate() + 1) - return { - start: Math.floor(start.getTime() / 1000), - end: Math.floor(end.getTime() / 1000) - } - } - - private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> { - const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) - const intervalSeconds = intervalHours * 60 * 60 - const periods: Array<{ start: number; end: number }> = [] - for (let start = startTime; start < endTime; start += intervalSeconds) { - const end = Math.min(start + intervalSeconds, endTime) - if (!includePartial && end - start < intervalSeconds) continue - if (end > start) periods.push({ start, end }) - } - return periods - } - - private getCompletedPeriodsToday(): Array<{ start: number; end: number }> { - const dayStart = getStartOfDaySeconds(new Date()) - const now = Math.floor(Date.now() / 1000) - return this.getIntervalPeriods(dayStart, now, false) - } - - private async queueDueAutoSummaries(): Promise { - if (!this.started || !this.isEnabled()) return - if (this.processing) { - this.pendingAutoRun = true - return - } - this.processing = true - try { - do { - this.pendingAutoRun = false - await this.runDueAutoSummariesOnce() - } while (this.pendingAutoRun && this.started && this.isEnabled()) - } finally { - this.processing = false - } - } - - private async runDueAutoSummariesOnce(): Promise { - if (!this.started || !this.isEnabled()) return - try { - const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) return - const scopeSessionIds = this.getAutoScopeSessionIds() - if (scopeSessionIds.length === 0) return - if (!await this.ensureConnected()) return - - const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {} - - const periods = this.getCompletedPeriodsToday() - for (const period of periods) { - for (const sessionId of scopeSessionIds) { - if (!this.started || !this.isEnabled()) return - if (!sessionId) continue - if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue - await this.generateSummaryForPeriod({ - sessionId, - displayName: contacts[sessionId]?.displayName || sessionId, - avatarUrl: contacts[sessionId]?.avatarUrl, - periodStart: period.start, - periodEnd: period.end, - triggerType: 'auto' - }) - } - } - } catch (error) { - console.warn('[GroupSummaryService] 自动总结失败:', error) - } - } - - private async readMessagesInPeriod(sessionId: string, startTime: number, endTime: number): Promise { - if (!await this.ensureConnected()) { - throw new Error('数据库连接失败,请先在“数据库连接”页完成配置') - } - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - SUMMARY_CURSOR_BATCH_SIZE, - true, - startTime, - endTime - ) - if (!cursorResult.success || !cursorResult.cursor) { - throw new Error(cursorResult.error || '打开消息游标失败') - } - - const cursor = cursorResult.cursor - const messages: Message[] = [] - try { - let hasMore = true - while (hasMore && messages.length < MAX_MESSAGES_PER_SUMMARY) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - throw new Error(batch.error || '读取消息失败') - } - hasMore = batch.hasMore === true - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - if (rows.length === 0) { - if (!hasMore) break - continue - } - const mapped = chatService.mapRowsToMessagesForApi(rows, sessionId) - for (const message of mapped) { - const createTime = Number(message.createTime || 0) - if (createTime < startTime || createTime > endTime) continue - messages.push(message) - if (messages.length >= MAX_MESSAGES_PER_SUMMARY) break - } - } - } finally { - await wcdbService.closeMessageCursor(cursor).catch(() => {}) - } - - return messages.sort((a, b) => { - if (a.createTime !== b.createTime) return a.createTime - b.createTime - if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq - return a.localId - b.localId - }) - } - - private normalizeMessageText(message: Message): string { - const parsedContent = String(message.parsedContent || '').replace(/\s+/g, ' ').trim() - const quotedContent = String(message.quotedContent || '').replace(/\s+/g, ' ').trim() - const quotedSender = String(message.quotedSender || '').replace(/\s+/g, ' ').trim() - let text = parsedContent - if (quotedContent) { - const quote = quotedSender ? `${quotedSender}:${quotedContent}` : quotedContent - text = text && text !== '[引用消息]' ? `${text} [引用 ${quote}]` : `[引用 ${quote}]` - } - if (!text) { - text = String(message.linkTitle || message.fileName || message.appMsgDesc || '').replace(/\s+/g, ' ').trim() - } - if (!text) return '' - if (/^<\?xml|^ { - const readableMessages = messages.filter((message) => this.normalizeMessageText(message)) - const senderIds = Array.from(new Set( - readableMessages - .map((message) => String(message.senderUsername || '').trim()) - .filter(Boolean) - )) - const contacts = senderIds.length > 0 - ? (await chatService.enrichSessionsContactInfo(senderIds).catch(() => null))?.contacts || {} - : {} - const myWxid = String(this.config.getMyWxidCleaned() || '').trim() - - const lines = readableMessages.map((message) => { - const senderUsername = String(message.senderUsername || '').trim() - const senderName = message.isSend === 1 || (senderUsername && myWxid && senderUsername === myWxid) - ? '我' - : (contacts[senderUsername]?.displayName || senderUsername || '未知成员') - return `${formatTimestamp(message.createTime)} ${senderName}:${this.normalizeMessageText(message)}` - }) - - return { - transcript: lines.join('\n'), - readableMessages - } - } - - private async generateSummaryForPeriod(params: { - sessionId: string - displayName: string - avatarUrl?: string - periodStart: number - periodEnd: number - triggerType: GroupSummaryTriggerType - }): Promise { - const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - try { - const messages = await this.readMessagesInPeriod(params.sessionId, params.periodStart, params.periodEnd) - const { transcript, readableMessages } = await this.buildTranscript(params.sessionId, messages) - if (readableMessages.length < MIN_SUMMARY_MESSAGES) { - return { - success: true, - skipped: true, - skippedReason: 'message_count_too_low', - message: `该时段可总结消息少于 ${MIN_SUMMARY_MESSAGES} 条,已跳过` - } - } - - const customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim() - const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT - const userPrompt = `群聊:${params.displayName} -总结时段:${formatTimestamp(params.periodStart)} 至 ${formatTimestamp(params.periodEnd)} -消息数量:${readableMessages.length} - -群聊记录: -${transcript} - -请只输出指定 JSON。` - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ] - - let rawOutput = '' - let responseFormatJson = true - let responseFormatFallback = false - let responseFormatFallbackReason = '' - const startedAt = Date.now() - try { - rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages, { responseFormatJson: true }) - } catch (error) { - if (!shouldFallbackJsonMode(error)) throw error - responseFormatJson = false - responseFormatFallback = true - responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' - rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages) - } - - let topics: GroupSummaryTopic[] - let finalSummary: string - try { - topics = parseTopics(rawOutput) - finalSummary = buildSummaryText(topics) - } catch { - topics = [fallbackTopicFromRaw(rawOutput)] - finalSummary = buildSummaryText(topics) - } - - const log: GroupSummaryLog = { - endpoint, - model, - temperature: API_TEMPERATURE, - triggerType: params.triggerType, - periodStart: params.periodStart, - periodEnd: params.periodEnd, - messageCount: messages.length, - readableMessageCount: readableMessages.length, - systemPrompt, - userPrompt, - rawOutput, - finalSummary, - durationMs: Date.now() - startedAt, - createdAt: Date.now(), - responseFormatJson, - responseFormatFallback, - responseFormatFallbackReason, - parsedTopics: topics - } - - const record = groupSummaryRecordService.addRecord({ - sessionId: params.sessionId, - displayName: params.displayName, - avatarUrl: params.avatarUrl, - triggerType: params.triggerType, - periodStart: params.periodStart, - periodEnd: params.periodEnd, - messageCount: messages.length, - readableMessageCount: readableMessages.length, - topics, - summaryText: finalSummary, - rawOutput, - log - }) - - return { success: true, message: '群聊总结已生成', recordId: record.id, record } - } catch (error) { - return { success: false, message: `生成失败:${(error as Error).message || String(error)}` } - } - } - - private async generateSummariesForPeriods(params: { - sessionId: string - displayName: string - avatarUrl?: string - periods: Array<{ start: number; end: number }> - triggerType: GroupSummaryTriggerType - }): Promise { - const records: GroupSummaryRecordSummary[] = [] - let skipped = 0 - let failed = 0 - let firstError = '' - - for (const period of params.periods) { - const result = await this.generateSummaryForPeriod({ - sessionId: params.sessionId, - displayName: params.displayName, - avatarUrl: params.avatarUrl, - periodStart: period.start, - periodEnd: period.end, - triggerType: params.triggerType - }) - if (result.success && result.record) { - records.push(result.record) - continue - } - if (result.success && result.skipped) { - skipped += 1 - continue - } - failed += 1 - if (!firstError) firstError = result.message - } - - const generated = records.length - const parts = [`生成 ${generated} 段`, `跳过 ${skipped} 段`] - if (failed > 0) parts.push(`失败 ${failed} 段`) - const message = failed > 0 && generated === 0 && skipped === 0 - ? (firstError || '群聊总结生成失败') - : `群聊总结完成:${parts.join(',')}` - - return { - success: generated > 0 || skipped > 0 || failed === 0, - message, - generated, - skipped, - records - } - } -} - -export const groupSummaryService = new GroupSummaryService() diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts deleted file mode 100644 index 52eece8..0000000 --- a/electron/services/httpService.ts +++ /dev/null @@ -1,2434 +0,0 @@ -/** - * HTTP API 服务 - * 提供 ChatLab 标准化格式的消息查询 API - */ -import * as http from 'http' -import * as fs from 'fs' -import * as path from 'path' -import { URL } from 'url' -import { timingSafeEqual } from 'crypto' -import { chatService, Message } from './chatService' -import { wcdbService } from './wcdbService' -import { ConfigService } from './config' -import { videoService } from './videoService' -import { imageDecryptService } from './imageDecryptService' -import { groupAnalyticsService } from './groupAnalyticsService' -import { snsService } from './snsService' - -// ChatLab 格式定义 -interface ChatLabHeader { - version: string - exportedAt: number - generator: string - description?: string -} - -interface ChatLabMeta { - name: string - platform: string - type: ApiSessionType - groupId?: string - groupAvatar?: string - ownerId?: string -} - -interface ChatLabMember { - platformId: string - accountName: string - groupNickname?: string - aliases?: string[] - avatar?: string -} - -interface ChatLabMessage { - sender: string - accountName: string - groupNickname?: string - timestamp: number - type: number - content: string | null - platformMessageId?: string - replyToMessageId?: string - mediaPath?: string -} - -interface ApiQuoteSnapshot { - platformMessageId?: string - sender?: string - accountName?: string - content?: string - type?: number -} - -interface ApiQuoteInfo { - replyText?: string - replyToMessageId?: string - quote?: ApiQuoteSnapshot -} - -interface ChatLabData { - chatlab: ChatLabHeader - meta: ChatLabMeta - members: ChatLabMember[] - messages: ChatLabMessage[] -} - -interface ApiMediaOptions { - enabled: boolean - exportImages: boolean - exportVoices: boolean - exportVideos: boolean - exportEmojis: boolean -} - -type MediaKind = 'image' | 'voice' | 'video' | 'emoji' -type ApiSessionType = 'group' | 'private' | 'channel' | 'other' - -interface ApiExportedMedia { - kind: MediaKind - fileName: string - fullPath: string - relativePath: string -} - -interface MessagePushReplayEvent { - id: number - body: string - createdAt: number -} - -// ChatLab 消息类型映射 -const ChatLabType = { - TEXT: 0, - IMAGE: 1, - VOICE: 2, - VIDEO: 3, - FILE: 4, - EMOJI: 5, - LINK: 7, - LOCATION: 8, - RED_PACKET: 20, - TRANSFER: 21, - POKE: 22, - CALL: 23, - SHARE: 24, - REPLY: 25, - FORWARD: 26, - CONTACT: 27, - SYSTEM: 80, - RECALL: 81, - OTHER: 99 -} as const - -class HttpService { - private server: http.Server | null = null - private configService: ConfigService - private port: number = 5031 - private host: string = '127.0.0.1' - private running: boolean = false - private connections: Set = new Set() - private messagePushClients: Set = new Set() - private messagePushReplayBuffer: MessagePushReplayEvent[] = [] - private messagePushHeartbeatTimer: ReturnType | null = null - private connectionMutex: boolean = false - private messagePushEventId = 0 - private readonly messagePushReplayLimit = 1000 - private readonly messagePushReplayTtlMs = 10 * 60 * 1000 - - constructor() { - this.configService = ConfigService.getInstance() - } - - /** - * 启动 HTTP 服务 - */ - async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> { - if (this.running && this.server) { - return { success: true, port: this.port } - } - - this.port = port - this.host = host - - return new Promise((resolve) => { - this.server = http.createServer((req, res) => this.handleRequest(req, res)) - - // 跟踪所有连接,以便关闭时能强制断开 - this.server.on('connection', (socket) => { - // 使用互斥锁防止并发修改 - if (!this.connectionMutex) { - this.connectionMutex = true - this.connections.add(socket) - this.connectionMutex = false - } - - socket.on('close', () => { - // 使用互斥锁防止并发修改 - if (!this.connectionMutex) { - this.connectionMutex = true - this.connections.delete(socket) - this.connectionMutex = false - } - }) - }) - - this.server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - console.error(`[HttpService] Port ${this.port} is already in use`) - resolve({ success: false, error: `Port ${this.port} is already in use` }) - } else { - console.error('[HttpService] Server error:', err) - resolve({ success: false, error: err.message }) - } - }) - - this.server.listen(this.port, this.host, () => { - this.running = true - this.startMessagePushHeartbeat() - console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`) - resolve({ success: true, port: this.port }) - }) - }) - } - - /** - * 停止 HTTP 服务 - */ - async stop(): Promise { - return new Promise((resolve) => { - if (this.server) { - for (const client of this.messagePushClients) { - try { - client.end() - } catch {} - } - this.messagePushClients.clear() - this.messagePushReplayBuffer = [] - if (this.messagePushHeartbeatTimer) { - clearInterval(this.messagePushHeartbeatTimer) - this.messagePushHeartbeatTimer = null - } - // 使用互斥锁保护连接集合操作 - this.connectionMutex = true - const socketsToClose = Array.from(this.connections) - this.connections.clear() - this.connectionMutex = false - - // 强制关闭所有活动连接 - for (const socket of socketsToClose) { - try { - socket.destroy() - } catch (err) { - console.error('[HttpService] Error destroying socket:', err) - } - } - - this.server.close(() => { - this.running = false - this.server = null - console.log('[HttpService] HTTP API server stopped') - resolve() - }) - } else { - this.running = false - resolve() - } - }) - } - - /** - * 检查服务是否运行 - */ - isRunning(): boolean { - return this.running - } - - /** - * 获取当前端口 - */ - getPort(): number { - return this.port - } - - getDefaultMediaExportPath(): string { - return this.getApiMediaExportPath() - } - - getMessagePushStreamUrl(): string { - return `http://${this.host}:${this.port}/api/v1/push/messages` - } - - private nextMessagePushEventId(): number { - this.messagePushEventId += 1 - if (!Number.isSafeInteger(this.messagePushEventId) || this.messagePushEventId <= 0) { - this.messagePushEventId = 1 - } - return this.messagePushEventId - } - - private rememberMessagePushEvent(id: number, body: string): void { - this.pruneMessagePushReplayBuffer() - this.messagePushReplayBuffer.push({ id, body, createdAt: Date.now() }) - if (this.messagePushReplayBuffer.length > this.messagePushReplayLimit) { - this.messagePushReplayBuffer.splice(0, this.messagePushReplayBuffer.length - this.messagePushReplayLimit) - } - } - - private pruneMessagePushReplayBuffer(): void { - const cutoff = Date.now() - this.messagePushReplayTtlMs - while (this.messagePushReplayBuffer.length > 0 && this.messagePushReplayBuffer[0].createdAt < cutoff) { - this.messagePushReplayBuffer.shift() - } - } - - private parseMessagePushLastEventId(req: http.IncomingMessage, url?: URL): number { - const queryValue = url?.searchParams.get('lastEventId') || url?.searchParams.get('last_event_id') || '' - const headerValue = Array.isArray(req.headers['last-event-id']) - ? req.headers['last-event-id'][0] - : req.headers['last-event-id'] - const parsed = Number.parseInt(String(queryValue || headerValue || '0').trim(), 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 - } - - private replayMessagePushEvents(res: http.ServerResponse, lastEventId: number): void { - this.pruneMessagePushReplayBuffer() - const events = lastEventId > 0 - ? this.messagePushReplayBuffer.filter((event) => event.id > lastEventId) - : this.messagePushReplayBuffer - - for (const event of events) { - if (res.writableEnded || res.destroyed) return - res.write(event.body) - } - } - - broadcastMessagePush(payload: Record): void { - if (!this.running) return - const eventId = this.nextMessagePushEventId() - const eventName = this.getMessagePushEventName(payload) - const eventBody = `id: ${eventId}\nevent: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n` - this.rememberMessagePushEvent(eventId, eventBody) - if (this.messagePushClients.size === 0) return - - for (const client of Array.from(this.messagePushClients)) { - try { - if (client.writableEnded || client.destroyed) { - this.messagePushClients.delete(client) - continue - } - client.write(eventBody) - } catch { - this.messagePushClients.delete(client) - try { client.end() } catch {} - } - } - } - - private getMessagePushEventName(payload: Record): string { - const eventName = String(payload?.event || '').trim() - return /^[a-z0-9._-]+$/i.test(eventName) ? eventName : 'message.new' - } - - async autoStart(): Promise { - const enabled = this.configService.get('httpApiEnabled') - if (enabled) { - const port = Number(this.configService.get('httpApiPort')) || 5031 - const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1' - try { - await this.start(port, host) - console.log(`[HttpService] Auto-started on port ${port}`) - } catch (err) { - console.error('[HttpService] Auto-start failed:', err) - } - } - } - - /** - * 解析 POST 请求的 JSON Body - */ - private async parseBody(req: http.IncomingMessage): Promise> { - if (req.method !== 'POST') return {} - const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB - return new Promise((resolve) => { - let body = '' - let bodySize = 0 - req.on('data', chunk => { - bodySize += chunk.length - if (bodySize > MAX_BODY_SIZE) { - req.destroy() - resolve({}) - return - } - body += chunk.toString() - }) - req.on('end', () => { - try { - resolve(JSON.parse(body)) - } catch { - resolve({}) - } - }) - req.on('error', () => resolve({})) - }) - } - - /** - * 鉴权拦截器 - */ - private safeEqual(a: string, b: string): boolean { - const bufA = Buffer.from(a) - const bufB = Buffer.from(b) - if (bufA.length !== bufB.length) return false - return timingSafeEqual(bufA, bufB) - } - - private verifyToken(req: http.IncomingMessage, url: URL, body: Record): boolean { - const expectedToken = String(this.configService.get('httpApiToken') || '').trim() - if (!expectedToken) { - // token 未配置时拒绝所有请求,防止未授权访问 - console.warn('[HttpService] Access denied: httpApiToken not configured') - return false - } - - const authHeader = req.headers.authorization - if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7).trim() - if (this.safeEqual(token, expectedToken)) return true - } - - const queryToken = url.searchParams.get('access_token') - if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true - - const bodyToken = body['access_token'] - return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken)) - } - - /** - * 处理 HTTP 请求 (重构后) - */ - private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { - // 仅允许本地来源的跨域请求 - const origin = req.headers.origin || '' - if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin) - res.setHeader('Vary', 'Origin') - } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') - - if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - return - } - - const url = new URL(req.url || '/', `http://${this.host}:${this.port}`) - const pathname = url.pathname - - try { - const bodyParams = await this.parseBody(req) - - for (const [key, value] of Object.entries(bodyParams)) { - if (!url.searchParams.has(key)) { - url.searchParams.set(key, String(value)) - } - } - - if (pathname !== '/health' && pathname !== '/api/v1/health') { - if (!this.verifyToken(req, url, bodyParams)) { - this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token') - return - } - } - - if (pathname === '/health' || pathname === '/api/v1/health') { - this.sendJson(res, { status: 'ok' }) - } else if (pathname === '/api/v1/push/messages') { - this.handleMessagePushStream(req, res, url) - } else if (pathname === '/api/v1/messages') { - await this.handleMessages(url, res) - } else if (pathname === '/api/v1/sessions') { - await this.handleSessions(url, res) - } else if ( - pathname.startsWith('/api/v1/sessions/') && - pathname.endsWith('/messages') - ) { - const parts = pathname.split('/') - const sessionId = decodeURIComponent(parts[4] || '') - if (!sessionId) { - this.sendError(res, 400, 'Missing session ID') - } else { - await this.handlePullMessages(sessionId, url, res) - } - } else if (pathname === '/api/v1/contacts') { - await this.handleContacts(url, res) - } else if (pathname === '/api/v1/group-members') { - await this.handleGroupMembers(url, res) - } else if (pathname === '/api/v1/sns/timeline') { - if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') - await this.handleSnsTimeline(url, res) - } else if (pathname === '/api/v1/sns/usernames') { - if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') - await this.handleSnsUsernames(res) - } else if (pathname === '/api/v1/sns/export/stats') { - if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') - await this.handleSnsExportStats(url, res) - } else if (pathname === '/api/v1/sns/media/proxy') { - if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') - await this.handleSnsMediaProxy(url, res) - } else if (pathname === '/api/v1/sns/export') { - if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') - await this.handleSnsExport(url, res) - } else if (pathname === '/api/v1/sns/block-delete/status') { - if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') - await this.handleSnsBlockDeleteStatus(res) - } else if (pathname === '/api/v1/sns/block-delete/install') { - if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') - await this.handleSnsBlockDeleteInstall(res) - } else if (pathname === '/api/v1/sns/block-delete/uninstall') { - if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') - await this.handleSnsBlockDeleteUninstall(res) - } else if (pathname.startsWith('/api/v1/sns/post/')) { - if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE') - await this.handleSnsDeletePost(pathname, res) - } else if (pathname.startsWith('/api/v1/media/')) { - this.handleMediaRequest(pathname, res) - } else { - this.sendError(res, 404, 'Not Found') - } - } catch (error) { - console.error('[HttpService] Request error:', error) - this.sendError(res, 500, String(error)) - } - } - private startMessagePushHeartbeat(): void { - if (this.messagePushHeartbeatTimer) return - this.messagePushHeartbeatTimer = setInterval(() => { - for (const client of Array.from(this.messagePushClients)) { - try { - if (client.writableEnded || client.destroyed) { - this.messagePushClients.delete(client) - continue - } - client.write(': ping\n\n') - } catch { - this.messagePushClients.delete(client) - try { client.end() } catch {} - } - } - }, 25000) - } - - private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void { - if (this.configService.get('messagePushEnabled') !== true) { - this.sendError(res, 403, 'Message push is disabled') - return - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no' - }) - res.flushHeaders?.() - - this.messagePushClients.add(res) - res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`) - this.replayMessagePushEvents(res, this.parseMessagePushLastEventId(req, url)) - - const cleanup = () => { - this.messagePushClients.delete(res) - } - - req.on('close', cleanup) - res.on('close', cleanup) - res.on('error', cleanup) - } - - private handleMediaRequest(pathname: string, res: http.ServerResponse): void { - const mediaBasePath = path.resolve(this.getApiMediaExportPath()) - const relativePath = pathname.replace('/api/v1/media/', '') - const fullPath = path.resolve(mediaBasePath, relativePath) - - // 防止路径穿越攻击 - if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) { - this.sendError(res, 403, 'Forbidden') - return - } - - if (!fs.existsSync(fullPath)) { - this.sendError(res, 404, 'Media not found') - return - } - - const ext = path.extname(fullPath).toLowerCase() - const mimeTypes: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.wav': 'audio/wav', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4' - } - const contentType = mimeTypes[ext] || 'application/octet-stream' - - try { - const stat = fs.statSync(fullPath) - res.setHeader('Content-Type', contentType) - res.setHeader('Content-Length', stat.size) - res.writeHead(200) - - const stream = fs.createReadStream(fullPath) - stream.on('error', () => { - if (!res.headersSent) { - this.sendError(res, 500, 'Failed to read media file') - } else { - try { res.destroy() } catch {} - } - }) - stream.pipe(res) - } catch (e) { - this.sendError(res, 500, 'Failed to read media file') - } - } - - /** - * 批量获取消息(循环游标直到满足 limit) - * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 - */ - private async fetchMessagesBatch( - talker: string, - offset: number, - limit: number, - startTime: number, - endTime: number, - ascending: boolean, - useLiteMapping: boolean = true - ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { - try { - // 深分页时放大 batch,避免 offset 很大时出现大量小批次循环。 - const batchSize = Math.min(2000, Math.max(500, limit)) - const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime - const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime - - const cursorResult = await wcdbService.openMessageCursorLite(talker, batchSize, ascending, beginTimestamp, endTimestamp) - if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '打开消息游标失败' } - } - - const cursor = cursorResult.cursor - try { - const collectedRows: Record[] = [] - let hasMore = true - let skipped = 0 - let reachedLimit = false - - // 循环获取消息,处理 offset 跳过 + limit 累积 - while (collectedRows.length < limit && hasMore) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success || !batch.rows || batch.rows.length === 0) { - hasMore = false - break - } - - let rows = batch.rows - hasMore = batch.hasMore === true - - // 处理 offset:跳过前 N 条 - if (skipped < offset) { - const remaining = offset - skipped - if (remaining >= rows.length) { - skipped += rows.length - continue - } - rows = rows.slice(remaining) - skipped = offset - } - - const remainingCapacity = limit - collectedRows.length - if (rows.length > remainingCapacity) { - collectedRows.push(...rows.slice(0, remainingCapacity)) - reachedLimit = true - break - } - - collectedRows.push(...rows) - } - - const finalHasMore = hasMore || reachedLimit - const messages = useLiteMapping - ? chatService.mapRowsToMessagesLiteForApi(collectedRows) - : chatService.mapRowsToMessagesForApi(collectedRows) - await this.backfillMissingSenderUsernames(talker, messages) - return { success: true, messages, hasMore: finalHasMore } - } finally { - await wcdbService.closeMessageCursor(cursor) - } - } catch (e) { - console.error('[HttpService] fetchMessagesBatch error:', e) - return { success: false, error: String(e) } - } - } - - /** - * Query param helpers. - */ - private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number { - const parsed = parseInt(value || '', 10) - if (!Number.isFinite(parsed)) return defaultValue - return Math.min(Math.max(parsed, min), max) - } - - private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise { - if (!talker.endsWith('@chatroom')) return - - const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim()) - if (targets.length === 0) return - - const myWxid = (this.configService.getMyWxidCleaned() || '').trim() - const MAX_DETAIL_BACKFILL = 120 - if (targets.length > MAX_DETAIL_BACKFILL) { - for (const msg of targets) { - if (!msg.senderUsername && msg.isSend === 1 && myWxid) { - msg.senderUsername = myWxid - } - } - return - } - - const queue = [...targets] - const workerCount = Math.max(1, Math.min(6, queue.length)) - const state = { - attempted: 0, - hydrated: 0, - consecutiveMiss: 0 - } - const MAX_DETAIL_LOOKUPS = 80 - const MAX_CONSECUTIVE_MISS = 36 - const runWorker = async (): Promise => { - while (queue.length > 0) { - if (state.attempted >= MAX_DETAIL_LOOKUPS) break - if (state.consecutiveMiss >= MAX_CONSECUTIVE_MISS && state.hydrated <= 0) break - const msg = queue.shift() - if (!msg) break - - const localId = Number(msg.localId || 0) - if (Number.isFinite(localId) && localId > 0) { - state.attempted += 1 - try { - const detail = await wcdbService.getMessageById(talker, localId) - if (detail.success && detail.message) { - const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0] - if (hydrated?.senderUsername) { - msg.senderUsername = hydrated.senderUsername - } - if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) { - msg.isSend = hydrated.isSend - } - if (!msg.rawContent && hydrated?.rawContent) { - msg.rawContent = hydrated.rawContent - } - if (msg.senderUsername) { - state.hydrated += 1 - state.consecutiveMiss = 0 - } else { - state.consecutiveMiss += 1 - } - } else { - state.consecutiveMiss += 1 - } - } catch (error) { - console.warn('[HttpService] backfill sender failed:', error) - state.consecutiveMiss += 1 - } - } - - if (!msg.senderUsername && msg.isSend === 1 && myWxid) { - msg.senderUsername = myWxid - } - } - } - - await Promise.all(Array.from({ length: workerCount }, () => runWorker())) - } - - private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean { - for (const key of keys) { - const raw = url.searchParams.get(key) - if (raw === null) continue - const normalized = raw.trim().toLowerCase() - if (['1', 'true', 'yes', 'on'].includes(normalized)) return true - if (['0', 'false', 'no', 'off'].includes(normalized)) return false - } - return defaultValue - } - - private parseStringListParam(value: string | null): string[] | undefined { - if (!value) return undefined - const values = value - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - return values.length > 0 ? Array.from(new Set(values)) : undefined - } - - private parseMediaOptions(url: URL): ApiMediaOptions { - const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) - if (!mediaEnabled) { - return { - enabled: false, - exportImages: false, - exportVoices: false, - exportVideos: false, - exportEmojis: false - } - } - - return { - enabled: true, - exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true), - exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true), - exportVideos: this.parseBooleanParam(url, ['video'], true), - exportEmojis: this.parseBooleanParam(url, ['emoji'], true) - } - } - - private getApiSessionType(username: string): ApiSessionType { - const normalized = String(username || '').trim() - const lowered = normalized.toLowerCase() - if (!normalized) return 'other' - if (lowered.endsWith('@chatroom')) return 'group' - if (lowered.startsWith('gh_')) return 'channel' - if (lowered.includes('@openim')) return 'channel' - if (lowered.startsWith('weixin') && lowered !== 'weixin') return 'channel' - return 'private' - } - - private async handleMessages(url: URL, res: http.ServerResponse): Promise { - const talker = (url.searchParams.get('talker') || '').trim() - const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) - const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) - const keyword = (url.searchParams.get('keyword') || '').trim() - const startParam = url.searchParams.get('start') - const endParam = url.searchParams.get('end') - const chatlab = this.parseBooleanParam(url, ['chatlab'], false) - const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase() - const format = formatParam || (chatlab ? 'chatlab' : 'json') - const mediaOptions = this.parseMediaOptions(url) - - if (!talker) { - this.sendError(res, 400, 'Missing required parameter: talker') - return - } - - if (format !== 'json' && format !== 'chatlab') { - this.sendError(res, 400, 'Invalid format, supported: json/chatlab') - return - } - - const startTime = this.parseTimeParam(startParam) - const endTime = this.parseTimeParam(endParam, true) - let messages: Message[] = [] - let hasMore = false - - if (keyword) { - const searchLimit = Math.max(1, limit) + 1 - const searchResult = await chatService.searchMessages( - keyword, - talker, - searchLimit, - offset, - startTime, - endTime - ) - if (!searchResult.success || !searchResult.messages) { - this.sendError(res, 500, searchResult.error || 'Failed to search messages') - return - } - hasMore = searchResult.messages.length > limit - messages = hasMore ? searchResult.messages.slice(0, limit) : searchResult.messages - } else { - const result = await this.fetchMessagesBatch( - talker, - offset, - limit, - startTime, - endTime, - false, - !mediaOptions.enabled - ) - if (!result.success || !result.messages) { - this.sendError(res, 500, result.error || 'Failed to get messages') - return - } - messages = result.messages - hasMore = result.hasMore === true - } - - const mediaMap = mediaOptions.enabled - ? await this.exportMediaForMessages(messages, talker, mediaOptions) - : new Map() - - const displayNames = await this.getDisplayNames([talker]) - const talkerName = displayNames[talker] || talker - - if (format === 'chatlab') { - const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap) - this.sendJson(res, { - ...chatLabData, - media: { - enabled: mediaOptions.enabled, - exportPath: this.getApiMediaExportPath(), - count: mediaMap.size - } - }) - return - } - - const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId))) - this.sendJson(res, { - success: true, - talker, - count: apiMessages.length, - hasMore, - media: { - enabled: mediaOptions.enabled, - exportPath: this.getApiMediaExportPath(), - count: mediaMap.size - }, - messages: apiMessages - }) - } - - /** - * 处理会话列表查询 - * GET /api/v1/sessions?keyword=xxx&limit=100 - */ - private async handleSessions(url: URL, res: http.ServerResponse): Promise { - const keyword = (url.searchParams.get('keyword') || '').trim() - const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) - const format = (url.searchParams.get('format') || '').trim().toLowerCase() - - try { - const sessions = await chatService.getSessions() - if (!sessions.success || !sessions.sessions) { - this.sendError(res, 500, sessions.error || 'Failed to get sessions') - return - } - - let filteredSessions = sessions.sessions - if (keyword) { - const lowerKeyword = keyword.toLowerCase() - filteredSessions = sessions.sessions.filter(s => - s.username.toLowerCase().includes(lowerKeyword) || - (s.displayName && s.displayName.toLowerCase().includes(lowerKeyword)) - ) - } - - const limitedSessions = filteredSessions.slice(0, limit) - - if (format === 'chatlab') { - this.sendJson(res, { - sessions: limitedSessions.map(s => ({ - id: s.username, - name: s.displayName || s.username, - platform: 'wechat', - type: this.getApiSessionType(s.username), - messageCount: s.messageCountHint || undefined, - lastMessageAt: s.lastTimestamp - })) - }) - return - } - - this.sendJson(res, { - success: true, - count: limitedSessions.length, - sessions: limitedSessions.map(s => ({ - username: s.username, - displayName: s.displayName, - type: s.type, - sessionType: this.getApiSessionType(s.username), - lastTimestamp: s.lastTimestamp, - unreadCount: s.unreadCount - })) - }) - } catch (error) { - this.sendError(res, 500, String(error)) - } - } - - /** - * ChatLab Pull: GET /api/v1/sessions/:id/messages?since=&limit=&offset=&end= - * 返回 ChatLab 标准格式 + sync 分页块 - */ - private async handlePullMessages(sessionId: string, url: URL, res: http.ServerResponse): Promise { - const PULL_MAX_LIMIT = 5000 - const limit = this.parseIntParam(url.searchParams.get('limit'), PULL_MAX_LIMIT, 1, PULL_MAX_LIMIT) - const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) - const sinceParam = url.searchParams.get('since') - const endParam = url.searchParams.get('end') - - const startTime = sinceParam ? this.parseTimeParam(sinceParam) : 0 - const endTime = endParam ? this.parseTimeParam(endParam, true) : 0 - - try { - const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true, true) - if (!result.success || !result.messages) { - this.sendError(res, 500, result.error || 'Failed to get messages') - return - } - - const messages = result.messages - const hasMore = result.hasMore === true - - const displayNames = await this.getDisplayNames([sessionId]) - const talkerName = displayNames[sessionId] || sessionId - const chatLabData = await this.convertToChatLab(messages, sessionId, talkerName) - - const lastTimestamp = messages.length > 0 - ? messages[messages.length - 1].createTime - : undefined - - this.sendJson(res, { - ...chatLabData, - sync: { - hasMore, - nextSince: hasMore && lastTimestamp ? lastTimestamp : undefined, - nextOffset: hasMore ? offset + messages.length : undefined, - watermark: Math.floor(Date.now() / 1000) - } - }) - } catch (error) { - console.error('[HttpService] handlePullMessages error:', error) - this.sendError(res, 500, String(error)) - } - } - - /** - * 处理联系人查询 - * GET /api/v1/contacts?keyword=xxx&limit=100 - */ - private async handleContacts(url: URL, res: http.ServerResponse): Promise { - const keyword = (url.searchParams.get('keyword') || '').trim() - const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) - - try { - const contacts = await chatService.getContacts() - if (!contacts.success || !contacts.contacts) { - this.sendError(res, 500, contacts.error || 'Failed to get contacts') - return - } - - let filteredContacts = contacts.contacts - if (keyword) { - const lowerKeyword = keyword.toLowerCase() - filteredContacts = contacts.contacts.filter(c => - c.username.toLowerCase().includes(lowerKeyword) || - (c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) || - (c.remark && c.remark.toLowerCase().includes(lowerKeyword)) || - (c.displayName && c.displayName.toLowerCase().includes(lowerKeyword)) - ) - } - - const limited = filteredContacts.slice(0, limit) - - this.sendJson(res, { - success: true, - count: limited.length, - contacts: limited - }) - } catch (error) { - this.sendError(res, 500, String(error)) - } - } - - /** - * 处理群成员查询 - * GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0 - */ - private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise { - const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim() - const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false) - const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false) - - if (!chatroomId) { - this.sendError(res, 400, 'Missing chatroomId') - return - } - - try { - const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, { - forceRefresh, - includeMessageCounts - }) - if (!result.success || !result.data) { - this.sendError(res, 500, result.error || 'Failed to get group members') - return - } - - this.sendJson(res, { - success: true, - chatroomId, - count: result.data.length, - fromCache: result.fromCache, - updatedAt: result.updatedAt, - members: result.data.map((member) => ({ - wxid: member.username, - displayName: member.displayName, - nickname: member.nickname || '', - remark: member.remark || '', - alias: member.alias || '', - groupNickname: member.groupNickname || '', - avatarUrl: member.avatarUrl, - isOwner: Boolean(member.isOwner), - isFriend: Boolean(member.isFriend), - messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0 - })) - }) - } catch (error) { - this.sendError(res, 500, String(error)) - } - } - - private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise { - const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200) - const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) - const usernames = this.parseStringListParam(url.searchParams.get('usernames')) - const keyword = (url.searchParams.get('keyword') || '').trim() || undefined - const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true) - const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false) - const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true) - const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) - const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) - const startTime = startTimeRaw > 0 ? startTimeRaw : undefined - const endTime = endTimeRaw > 0 ? endTimeRaw : undefined - - const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to get sns timeline') - return - } - - let timeline = result.timeline || [] - if (resolveMedia && timeline.length > 0) { - timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia) - } - - this.sendJson(res, { - success: true, - count: timeline.length, - timeline - }) - } - - private async handleSnsUsernames(res: http.ServerResponse): Promise { - const result = await snsService.getSnsUsernames() - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to get sns usernames') - return - } - this.sendJson(res, { - success: true, - usernames: result.usernames || [] - }) - } - - private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise { - const fast = this.parseBooleanParam(url, ['fast'], false) - const result = fast - ? await snsService.getExportStatsFast() - : await snsService.getExportStats() - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to get sns export stats') - return - } - this.sendJson(res, result) - } - - private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise { - const mediaUrl = (url.searchParams.get('url') || '').trim() - if (!mediaUrl) { - this.sendError(res, 400, 'Missing required parameter: url') - return - } - - const key = this.toSnsMediaKey(url.searchParams.get('key')) - const result = await snsService.downloadImage(mediaUrl, key) - if (!result.success) { - this.sendError(res, 502, result.error || 'Failed to proxy sns media') - return - } - - if (result.data) { - res.setHeader('Content-Type', result.contentType || 'application/octet-stream') - res.setHeader('Content-Length', result.data.length) - res.writeHead(200) - res.end(result.data) - return - } - - if (result.cachePath && fs.existsSync(result.cachePath)) { - try { - const stat = fs.statSync(result.cachePath) - res.setHeader('Content-Type', result.contentType || 'application/octet-stream') - res.setHeader('Content-Length', stat.size) - res.writeHead(200) - - const stream = fs.createReadStream(result.cachePath) - stream.on('error', () => { - if (!res.headersSent) { - this.sendError(res, 500, 'Failed to read proxied sns media') - } else { - try { res.destroy() } catch {} - } - }) - stream.pipe(res) - return - } catch (error) { - console.error('[HttpService] Failed to stream sns media cache:', error) - } - } - - this.sendError(res, 502, result.error || 'Failed to proxy sns media') - } - - private async handleSnsExport(url: URL, res: http.ServerResponse): Promise { - const outputDir = String(url.searchParams.get('outputDir') || '').trim() - if (!outputDir) { - this.sendError(res, 400, 'Missing required field: outputDir') - return - } - - const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase() - const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat - if (!['json', 'html', 'arkmejson'].includes(format)) { - this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson') - return - } - - const usernames = this.parseStringListParam(url.searchParams.get('usernames')) - const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined - const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) - const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) - - const options: { - outputDir: string - format: 'json' | 'html' | 'arkmejson' - usernames?: string[] - keyword?: string - exportMedia?: boolean - exportImages?: boolean - exportLivePhotos?: boolean - exportVideos?: boolean - startTime?: number - endTime?: number - } = { - outputDir, - format: format as 'json' | 'html' | 'arkmejson', - usernames, - keyword, - exportMedia: this.parseBooleanParam(url, ['exportMedia'], false) - } - - if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false) - if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false) - if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false) - if (startTimeRaw > 0) options.startTime = startTimeRaw - if (endTimeRaw > 0) options.endTime = endTimeRaw - - const result = await snsService.exportTimeline(options) - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to export sns timeline') - return - } - this.sendJson(res, result) - } - - private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise { - const result = await snsService.checkSnsBlockDeleteTrigger() - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to check sns block-delete status') - return - } - this.sendJson(res, result) - } - - private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise { - const result = await snsService.installSnsBlockDeleteTrigger() - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger') - return - } - this.sendJson(res, result) - } - - private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise { - const result = await snsService.uninstallSnsBlockDeleteTrigger() - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger') - return - } - this.sendJson(res, result) - } - - private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise { - const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim() - if (!postId) { - this.sendError(res, 400, 'Missing required path parameter: postId') - return - } - - const result = await snsService.deleteSnsPost(postId) - if (!result.success) { - this.sendError(res, 500, result.error || 'Failed to delete sns post') - return - } - this.sendJson(res, result) - } - - private toSnsMediaKey(value: unknown): string | number | undefined { - if (value == null) return undefined - if (typeof value === 'number' && Number.isFinite(value)) return value - const text = String(value).trim() - if (!text) return undefined - if (/^-?\d+$/.test(text)) return Number(text) - return text - } - - private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined { - const target = String(rawUrl || '').trim() - if (!target) return undefined - const params = new URLSearchParams({ url: target }) - if (key !== undefined) params.set('key', String(key)) - return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}` - } - - private async resolveSnsMediaUrl( - rawUrl: string, - key: string | number | undefined, - inline: boolean - ): Promise<{ resolvedUrl?: string; proxyUrl?: string }> { - const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key) - if (!proxyUrl) return {} - if (!inline) return { resolvedUrl: proxyUrl, proxyUrl } - - try { - const resolved = await snsService.proxyImage(rawUrl, key) - if (resolved.success && resolved.dataUrl) { - return { resolvedUrl: resolved.dataUrl, proxyUrl } - } - } catch (error) { - console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error) - } - - return { resolvedUrl: proxyUrl, proxyUrl } - } - - private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise { - return Promise.all( - (posts || []).map(async (post) => { - const mediaList = Array.isArray(post?.media) ? post.media : [] - if (mediaList.length === 0) return post - - const nextMedia = await Promise.all( - mediaList.map(async (media: any) => { - const rawUrl = typeof media?.url === 'string' ? media.url : '' - const rawThumb = typeof media?.thumb === 'string' ? media.thumb : '' - const mediaKey = this.toSnsMediaKey(media?.key) - - const [urlResolved, thumbResolved] = await Promise.all([ - this.resolveSnsMediaUrl(rawUrl, mediaKey, inline), - this.resolveSnsMediaUrl(rawThumb, mediaKey, inline) - ]) - - const nextItem: any = { - ...media, - rawUrl, - rawThumb, - resolvedUrl: urlResolved.resolvedUrl, - resolvedThumbUrl: thumbResolved.resolvedUrl, - proxyUrl: urlResolved.proxyUrl, - proxyThumbUrl: thumbResolved.proxyUrl - } - - if (replace) { - nextItem.url = urlResolved.resolvedUrl || rawUrl - nextItem.thumb = thumbResolved.resolvedUrl || rawThumb - } - - if (media?.livePhoto && typeof media.livePhoto === 'object') { - const livePhoto = media.livePhoto - const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : '' - const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : '' - const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey) - - const [liveUrlResolved, liveThumbResolved] = await Promise.all([ - this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline), - this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline) - ]) - - const nextLive: any = { - ...livePhoto, - rawUrl: rawLiveUrl, - rawThumb: rawLiveThumb, - resolvedUrl: liveUrlResolved.resolvedUrl, - resolvedThumbUrl: liveThumbResolved.resolvedUrl, - proxyUrl: liveUrlResolved.proxyUrl, - proxyThumbUrl: liveThumbResolved.proxyUrl - } - - if (replace) { - nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl - nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb - } - - nextItem.livePhoto = nextLive - } - - return nextItem - }) - ) - - return { - ...post, - media: nextMedia - } - }) - ) - } - - private getApiMediaExportPath(): string { - return path.join(this.configService.getCacheBasePath(), 'api-media') - } - - private sanitizeFileName(value: string, fallback: string): string { - const safe = (value || '') - .trim() - .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') - .replace(/\.+$/g, '') - return safe || fallback - } - - private ensureDir(dirPath: string): void { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }) - } - } - - private detectImageExt(buffer: Buffer): string { - if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg' - if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png' - if (buffer.length >= 6) { - const sig6 = buffer.subarray(0, 6).toString('ascii') - if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif' - } - if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp' - if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp' - return '.jpg' - } - - private writeFileIfLarger(fullPath: string, data: Buffer): void { - if (fs.existsSync(fullPath)) { - try { - const stat = fs.statSync(fullPath) - if (!stat.isFile()) return - if (data.length <= stat.size) return - } catch { - // If the existing export cannot be inspected, overwrite it below. - } - } - - fs.writeFileSync(fullPath, data) - } - - private async exportMediaForMessages( - messages: Message[], - talker: string, - options: ApiMediaOptions - ): Promise> { - const mediaMap = new Map() - if (!options.enabled || messages.length === 0) { - return mediaMap - } - - const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session')) - this.ensureDir(sessionDir) - - // 预热图片 hardlink 索引,减少逐条导出时的查找开销 - if (options.exportImages) { - const imageMd5Set = new Set() - for (const msg of messages) { - if (msg.localType !== 3) continue - const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() - if (imageMd5) { - imageMd5Set.add(imageMd5) - continue - } - const imageDatName = String(msg.imageDatName || '').trim().toLowerCase() - if (/^[a-f0-9]{32}$/i.test(imageDatName)) { - imageMd5Set.add(imageDatName) - } - } - if (imageMd5Set.size > 0) { - try { - await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)) - } catch { - // ignore preload failures - } - } - } - - for (const msg of messages) { - const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options) - if (exported) { - mediaMap.set(msg.localId, exported) - } - } - - return mediaMap - } - - private async exportMediaForMessage( - msg: Message, - talker: string, - sessionDir: string, - options: ApiMediaOptions - ): Promise { - try { - if (msg.localType === 3 && options.exportImages) { - const result = await imageDecryptService.decryptImage({ - sessionId: talker, - imageMd5: msg.imageMd5, - imageDatName: msg.imageDatName, - createTime: msg.createTime, - force: true, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - suppressEvents: true - }) - - let imagePath = result.success ? result.localPath : undefined - if (!imagePath) { - try { - const cached = await imageDecryptService.resolveCachedImage({ - sessionId: talker, - imageMd5: msg.imageMd5, - imageDatName: msg.imageDatName, - createTime: msg.createTime, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - suppressEvents: true - }) - if (cached.success && cached.localPath) { - imagePath = cached.localPath - } - } catch { - // ignore resolve failures - } - } - - if (imagePath) { - if (imagePath.startsWith('data:')) { - const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/) - if (!base64Match) return null - const imageBuffer = Buffer.from(base64Match[1], 'base64') - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - this.writeFileIfLarger(fullPath, imageBuffer) - const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` - return { kind: 'image', fileName, fullPath, relativePath } - } - - if (fs.existsSync(imagePath)) { - const imageBuffer = fs.readFileSync(imagePath) - const ext = this.detectImageExt(imageBuffer) - const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`) - const fileName = `${fileBase}${ext}` - const targetDir = path.join(sessionDir, 'images') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - this.writeFileIfLarger(fullPath, imageBuffer) - const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` - return { kind: 'image', fileName, fullPath, relativePath } - } - } - } - - if (msg.localType === 34 && options.exportVoices) { - const result = await chatService.getVoiceData( - talker, - String(msg.localId), - msg.createTime || undefined, - this.getMessageServerId(msg) || undefined - ) - if (result.success && result.data) { - const fileName = `voice_${msg.localId}.wav` - const targetDir = path.join(sessionDir, 'voices') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64')) - } - const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}` - return { kind: 'voice', fileName, fullPath, relativePath } - } - } - - if (msg.localType === 43 && options.exportVideos && msg.videoMd5) { - const info = await videoService.getVideoInfo(msg.videoMd5) - if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) { - const ext = path.extname(info.videoUrl) || '.mp4' - const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}` - const targetDir = path.join(sessionDir, 'videos') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.copyFileSync(info.videoUrl, fullPath) - } - const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}` - return { kind: 'video', fileName, fullPath, relativePath } - } - } - - if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) { - const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5) - if (result.success && result.localPath && fs.existsSync(result.localPath)) { - const sourceExt = path.extname(result.localPath) || '.gif' - const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}` - const targetDir = path.join(sessionDir, 'emojis') - const fullPath = path.join(targetDir, fileName) - this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.copyFileSync(result.localPath, fullPath) - } - const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}` - return { kind: 'emoji', fileName, fullPath, relativePath } - } - } - } catch (e) { - console.warn('[HttpService] exportMediaForMessage failed:', e) - } - - return null - } - - private toApiMessage(msg: Message, media?: ApiExportedMedia): Record { - const serverId = this.getMessageServerId(msg) - const quoteInfo = this.extractApiQuoteInfo(msg) - const apiMessage: Record = { - localId: msg.localId, - serverId: serverId || '0', - localType: msg.localType, - createTime: msg.createTime, - sortSeq: msg.sortSeq, - isSend: msg.isSend, - senderUsername: msg.senderUsername, - content: this.getMessageContent(msg, quoteInfo), - rawContent: msg.rawContent, - parsedContent: msg.parsedContent, - mediaType: media?.kind, - mediaFileName: media?.fileName, - mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined, - mediaLocalPath: media?.fullPath - } - - if (quoteInfo?.replyToMessageId) { - apiMessage.replyToMessageId = quoteInfo.replyToMessageId - } - if (quoteInfo?.quote && Object.keys(quoteInfo.quote).length > 0) { - apiMessage.quote = quoteInfo.quote - } - - return apiMessage - } - - private getMessageServerId(msg: Message): string { - const raw = this.normalizeUnsignedIntToken(msg.serverIdRaw) - if (raw && raw !== '0') return raw - - const fallback = this.normalizeUnsignedIntToken(msg.serverId) - return fallback && fallback !== '0' ? fallback : '' - } - - private normalizeUnsignedIntToken(value: unknown): string { - if (value === null || value === undefined) return '' - const text = String(value).trim() - if (!text) return '' - if (/^\d+$/.test(text)) { - return text.replace(/^0+(?=\d)/, '') - } - - const numeric = Number(value) - if (!Number.isFinite(numeric) || numeric <= 0) return '' - return String(Math.floor(numeric)) - } - - /** - * 解析时间参数 - * 支持 YYYYMMDD 格式,返回秒级时间戳 - */ - private parseTimeParam(param: string | null, isEnd: boolean = false): number { - if (!param) return 0 - - // 纯数字且长度为 8,视为 YYYYMMDD - if (/^\d{8}$/.test(param)) { - const year = parseInt(param.slice(0, 4), 10) - const month = parseInt(param.slice(4, 6), 10) - 1 - const day = parseInt(param.slice(6, 8), 10) - const date = new Date(year, month, day) - if (isEnd) { - // 结束时间设为当天 23:59:59 - date.setHours(23, 59, 59, 999) - } - return Math.floor(date.getTime() / 1000) - } - - // 纯数字,视为时间戳 - if (/^\d+$/.test(param)) { - const ts = parseInt(param, 10) - // 如果是毫秒级时间戳,转为秒级 - return ts > 10000000000 ? Math.floor(ts / 1000) : ts - } - - return 0 - } - - private normalizeAccountId(value: string): string { - const trimmed = String(value || '').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})$/) - return suffixMatch ? suffixMatch[1] : trimmed - } - - /** - * 获取显示名称 - */ - private async getDisplayNames(usernames: string[]): Promise> { - try { - const result = await wcdbService.getDisplayNames(usernames) - if (result.success && result.map) { - return result.map - } - } catch (e) { - console.error('[HttpService] Failed to get display names:', e) - } - // 返回空对象,调用方会使用 username 作为备用 - return {} - } - - private async getAvatarUrls(usernames: string[]): Promise> { - const lookupUsernames = Array.from(new Set( - usernames.flatMap((username) => { - const normalized = String(username || '').trim() - if (!normalized) return [] - const cleaned = this.normalizeAccountId(normalized) - return cleaned && cleaned !== normalized ? [normalized, cleaned] : [normalized] - }) - )) - - if (lookupUsernames.length === 0) return {} - - try { - const result = await wcdbService.getAvatarUrls(lookupUsernames) - if (result.success && result.map) { - const avatarMap: Record = {} - for (const [username, avatarUrl] of Object.entries(result.map)) { - const normalizedUsername = String(username || '').trim() - const normalizedAvatarUrl = String(avatarUrl || '').trim() - if (!normalizedUsername || !normalizedAvatarUrl) continue - - avatarMap[normalizedUsername] = normalizedAvatarUrl - avatarMap[normalizedUsername.toLowerCase()] = normalizedAvatarUrl - - const cleaned = this.normalizeAccountId(normalizedUsername) - if (cleaned) { - avatarMap[cleaned] = normalizedAvatarUrl - avatarMap[cleaned.toLowerCase()] = normalizedAvatarUrl - } - } - return avatarMap - } - } catch (e) { - console.error('[HttpService] Failed to get avatar urls:', e) - } - - return {} - } - - private resolveAvatarUrl(avatarMap: Record, candidates: Array): string | undefined { - for (const candidate of candidates) { - const normalized = String(candidate || '').trim() - if (!normalized) continue - - const cleaned = this.normalizeAccountId(normalized) - const avatarUrl = avatarMap[normalized] - || avatarMap[normalized.toLowerCase()] - || avatarMap[cleaned] - || avatarMap[cleaned.toLowerCase()] - - if (avatarUrl) return avatarUrl - } - - return undefined - } - - private lookupGroupNickname(groupNicknamesMap: Map, sender: string): string { - const key = String(sender || '').trim().toLowerCase() - if (!key) return '' - return groupNicknamesMap.get(key) || '' - } - - private buildTrustedGroupNicknameMap(nicknames: Record): Map { - const buckets = new Map>() - for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) { - const memberId = String(memberIdRaw || '').trim().toLowerCase() - const nickname = String(nicknameRaw || '').trim() - if (!memberId || !nickname) continue - const slot = buckets.get(memberId) - if (slot) { - slot.add(nickname) - } else { - buckets.set(memberId, new Set([nickname])) - } - } - - const trusted = new Map() - for (const [memberId, nicknameSet] of buckets.entries()) { - if (nicknameSet.size !== 1) continue - trusted.set(memberId, Array.from(nicknameSet)[0]) - } - return trusted - } - - private resolveChatLabSenderInfo( - msg: Message, - talkerId: string, - talkerName: string, - myWxid: string, - isGroup: boolean, - senderNames: Record, - groupNicknamesMap: Map - ): { sender: string; accountName: string; groupNickname?: string } { - let sender = String(msg.senderUsername || '').trim() - let usedUnknownPlaceholder = false - const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase() - const isSelf = msg.isSend === 1 || sameAsMe - - if (!sender && isSelf && myWxid) { - sender = myWxid - } - - if (!sender) { - if (msg.localType === 10000 || msg.localType === 266287972401) { - sender = talkerId - } else { - sender = `unknown_sender_${msg.localId || msg.createTime || 0}` - usedUnknownPlaceholder = true - } - } - - const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : '' - const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender) - const accountName = isSelf ? '我' : (displayName || '未知发送者') - - return { - sender, - accountName, - groupNickname: groupNickname || undefined - } - } - - /** - * 转换为 ChatLab 格式 - */ - private async convertToChatLab( - messages: Message[], - talkerId: string, - talkerName: string, - mediaMap: Map = new Map() - ): Promise { - const isGroup = talkerId.endsWith('@chatroom') - const myWxid = this.configService.getMyWxidCleaned() || '' - const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase() - - // 收集所有发送者 - const senderSet = new Set() - for (const msg of messages) { - if (msg.senderUsername) { - senderSet.add(msg.senderUsername) - } - } - - // 获取发送者显示名 - const senderNames = await this.getDisplayNames(Array.from(senderSet)) - - // 获取群昵称(如果是群聊) - let groupNicknamesMap = new Map() - if (isGroup) { - try { - const result = await wcdbService.getGroupNicknames(talkerId) - if (result.success && result.nicknames) { - groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames) - } - } catch (e) { - console.error('[HttpService] Failed to get group nicknames:', e) - } - } - - // 构建成员列表 - const memberMap = new Map() - for (const msg of messages) { - const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) - if (!memberMap.has(senderInfo.sender)) { - memberMap.set(senderInfo.sender, { - platformId: senderInfo.sender, - accountName: senderInfo.accountName, - groupNickname: senderInfo.groupNickname - }) - } - } - - const [memberAvatarMap, myAvatarResult, sessionAvatarInfo] = await Promise.all([ - this.getAvatarUrls(Array.from(memberMap.keys()).filter((sender) => !sender.startsWith('unknown_sender_'))), - myWxid - ? chatService.getMyAvatarUrl() - : Promise.resolve<{ success: boolean; avatarUrl?: string }>({ success: true }), - isGroup ? chatService.getContactAvatar(talkerId) : Promise.resolve(null) - ]) - - for (const [sender, member] of memberMap.entries()) { - if (sender.startsWith('unknown_sender_')) continue - - const normalizedSender = this.normalizeAccountId(sender).toLowerCase() - const isSelfMember = Boolean(normalizedMyWxid && normalizedSender && normalizedSender === normalizedMyWxid) - const avatarUrl = (isSelfMember ? myAvatarResult.avatarUrl : undefined) - || this.resolveAvatarUrl(memberAvatarMap, isSelfMember ? [sender, myWxid] : [sender]) - - if (avatarUrl) { - member.avatar = avatarUrl - } - } - - // 转换消息 - const chatLabMessages: ChatLabMessage[] = messages.map(msg => { - const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) - const quoteInfo = this.extractApiQuoteInfo(msg) - - const chatLabMessage: ChatLabMessage = { - sender: senderInfo.sender, - accountName: senderInfo.accountName, - groupNickname: senderInfo.groupNickname, - timestamp: msg.createTime, - type: this.mapMessageType(msg.localType, msg), - content: this.getMessageContent(msg, quoteInfo), - platformMessageId: this.getMessageServerId(msg) || undefined, - mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined - } - if (quoteInfo?.replyToMessageId) { - chatLabMessage.replyToMessageId = quoteInfo.replyToMessageId - } - return chatLabMessage - }) - - return { - chatlab: { - version: '0.0.2', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'WeFlow' - }, - meta: { - name: talkerName, - platform: 'wechat', - type: this.getApiSessionType(talkerId), - groupId: isGroup ? talkerId : undefined, - groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined, - ownerId: myWxid || undefined - }, - members: Array.from(memberMap.values()), - messages: chatLabMessages - } - } - - /** - * 映射 WeChat 消息类型到 ChatLab 类型 - */ - private mapMessageType(localType: number, msg: Message): number { - switch (localType) { - case 1: // 文本 - return ChatLabType.TEXT - case 3: // 图片 - return ChatLabType.IMAGE - case 34: // 语音 - return ChatLabType.VOICE - case 43: // 视频 - return ChatLabType.VIDEO - case 47: // 动画表情 - return ChatLabType.EMOJI - case 48: // 位置 - return ChatLabType.LOCATION - case 42: // 名片 - return ChatLabType.CONTACT - case 50: // 语音/视频通话 - return ChatLabType.CALL - case 10000: // 系统消息 - return ChatLabType.SYSTEM - case 49: // 复合消息 - return this.mapType49(msg) - case 244813135921: // 引用消息 - return ChatLabType.REPLY - case 266287972401: // 拍一拍 - return ChatLabType.POKE - case 8594229559345: // 红包 - return ChatLabType.RED_PACKET - case 8589934592049: // 转账 - return ChatLabType.TRANSFER - default: - return ChatLabType.OTHER - } - } - - /** - * 映射 Type 49 子类型 - */ - private mapType49(msg: Message): number { - const xmlType = this.resolveType49Subtype(msg) - - switch (xmlType) { - case '5': // 链接 - case '49': - return ChatLabType.LINK - case '6': // 文件 - return ChatLabType.FILE - case '19': // 聊天记录 - return ChatLabType.FORWARD - case '33': // 小程序 - case '36': - return ChatLabType.SHARE - case '57': // 引用消息 - return ChatLabType.REPLY - case '2000': // 转账 - return ChatLabType.TRANSFER - case '2001': // 红包 - return ChatLabType.RED_PACKET - default: - return ChatLabType.OTHER - } - } - - private extractType49Subtype(rawContent: string): string { - const content = this.normalizeAppMessageContent(String(rawContent || '')) - if (!content) return '' - - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) - if (appmsgMatch) { - const appmsgInner = appmsgMatch[1] - .replace(//gi, '') - .replace(//gi, '') - const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) - if (typeMatch) { - return typeMatch[1].replace(//g, '').trim() - } - } - - const fallbackMatch = /([\s\S]*?)<\/type>/i.exec(content) - if (fallbackMatch) { - return fallbackMatch[1].replace(//g, '').trim() - } - - return '' - } - - private resolveType49Subtype(msg: Message): string { - const xmlType = String(msg.xmlType || '').trim() - if (xmlType) return xmlType - - const extractedType = this.extractType49Subtype(msg.rawContent) - if (extractedType) return extractedType - - switch (msg.appMsgKind) { - case 'official-link': - case 'link': - return '5' - case 'file': - return '6' - case 'chat-record': - return '19' - case 'miniapp': - return '33' - case 'quote': - return '57' - case 'transfer': - return '2000' - case 'red-packet': - return '2001' - case 'music': - return '3' - default: - if (msg.linkUrl) return '5' - if (msg.fileName) return '6' - return '' - } - } - - private getType49Content(msg: Message, quoteInfo?: ApiQuoteInfo): string { - const subtype = this.resolveType49Subtype(msg) - const title = msg.linkTitle || msg.fileName || this.extractAppMessageTitle(msg.rawContent) || '' - - switch (subtype) { - case '5': - case '49': - return title ? `[链接] ${title}` : '[链接]' - case '6': - return title ? `[文件] ${title}` : '[文件]' - case '19': - return title ? `[聊天记录] ${title}` : '[聊天记录]' - case '33': - case '36': - return title ? `[小程序] ${title}` : '[小程序]' - case '57': - return msg.parsedContent || quoteInfo?.replyText || title || '[引用消息]' - case '2000': - return title ? `[转账] ${title}` : '[转账]' - case '2001': - return title ? `[红包] ${title}` : '[红包]' - case '3': - return title ? `[音乐] ${title}` : '[音乐]' - default: - return msg.parsedContent || title || '[消息]' - } - } - - /** - * 获取消息内容 - */ - private getMessageContent(msg: Message, quoteInfo?: ApiQuoteInfo): string | null { - const normalizeTextContent = (value: string | null | undefined): string | null => { - const text = String(value || '') - if (!text) return null - return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '').trim() - } - - if (msg.localType === 49) { - return this.getType49Content(msg, quoteInfo) - } - - if (this.isReplyMessage(msg, quoteInfo)) { - return msg.parsedContent || quoteInfo?.replyText || this.extractAppMessageTitle(msg.rawContent) || '[引用消息]' - } - - // 优先使用已解析的内容 - if (msg.parsedContent) { - return msg.parsedContent - } - - // 根据类型返回占位符 - switch (msg.localType) { - case 1: - return normalizeTextContent(msg.parsedContent || msg.rawContent) - case 3: - return '[图片]' - case 34: - return '[语音]' - case 43: - return '[视频]' - case 47: - return '[表情]' - case 42: - return msg.cardNickname || '[名片]' - case 48: - return '[位置]' - case 49: - return this.getType49Content(msg, quoteInfo) - default: - return normalizeTextContent(msg.parsedContent || msg.rawContent) || null - } - } - - private isReplyMessage(msg: Message, quoteInfo?: ApiQuoteInfo): boolean { - if (!quoteInfo?.replyToMessageId && !quoteInfo?.quote) return false - if (msg.localType === 244813135921) return true - if (msg.localType === 49 && this.resolveType49Subtype(msg) === '57') return true - return false - } - - private extractApiQuoteInfo(msg: Message): ApiQuoteInfo | undefined { - const rawContent = String(msg.rawContent || msg.content || '') - if (!rawContent || !this.messageMayContainQuote(rawContent)) { - return undefined - } - - const normalized = this.normalizeAppMessageContent(rawContent) - const referMsgXml = this.extractXmlBlock(normalized, 'refermsg') - if (!referMsgXml) return undefined - - const replyToMessageId = this.extractReplyToMessageId(referMsgXml) - const referTypeRaw = this.extractXmlValue(referMsgXml, 'type') - const referContentRaw = this.extractXmlValue(referMsgXml, 'content') - const quoteContent = this.resolveQuotedContent(referMsgXml, referTypeRaw, referContentRaw) - const sender = this.resolveQuotedSender(referMsgXml) - const accountName = this.resolveQuotedAccountName(referMsgXml) - const quoteType = this.mapQuotedMessageType(referTypeRaw, referContentRaw) - - const quote: ApiQuoteSnapshot = {} - if (replyToMessageId) quote.platformMessageId = replyToMessageId - if (sender) quote.sender = sender - if (accountName) quote.accountName = accountName - if (quoteContent) quote.content = quoteContent - if (quoteType !== undefined) quote.type = quoteType - - const replyText = this.extractAppMessageTitle(normalized) - - if (!replyToMessageId && Object.keys(quote).length === 0 && !replyText) { - return undefined - } - - return { - replyText: replyText || undefined, - replyToMessageId, - quote: Object.keys(quote).length > 0 ? quote : undefined - } - } - - private messageMayContainQuote(content: string): boolean { - return content.includes('') || - content.includes('<refermsg>') || - content.includes('57') || - content.includes('<type>57</type>') - } - - private normalizeAppMessageContent(content: string): string { - return this.decodeHtmlEntities(String(content || '')) - } - - private decodeHtmlEntities(text: string): string { - if (!text) return '' - return text - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - } - - private extractXmlBlock(xml: string, tag: string): string { - if (!xml || !tag) return '' - const match = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'i').exec(xml) - return match ? match[0] : '' - } - - private extractXmlValue(xml: string, tag: string): string { - if (!xml || !tag) return '' - const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml) - if (!match) return '' - return this.decodeHtmlEntities(match[1]) - .replace(//g, '') - .trim() - } - - private extractAppMessageTitle(content: string): string { - const normalized = this.normalizeAppMessageContent(content || '') - if (!normalized) return '' - const appMsgXml = this.extractXmlBlock(normalized, 'appmsg') - return this.sanitizeQuotedContent(this.extractXmlValue(appMsgXml || normalized, 'title')) - } - - private extractReplyToMessageId(referMsgXml: string): string | undefined { - const candidates = [ - this.extractXmlValue(referMsgXml, 'svrid'), - this.extractXmlValue(referMsgXml, 'msgsvrid'), - this.extractXmlValue(referMsgXml, 'newmsgid'), - this.extractXmlValue(referMsgXml, 'msgid') - ] - - for (const candidate of candidates) { - const normalized = this.normalizeUnsignedIntToken(candidate) - if (normalized && normalized !== '0') return normalized - } - - return undefined - } - - private resolveQuotedSender(referMsgXml: string): string | undefined { - const chatusr = this.extractXmlValue(referMsgXml, 'chatusr') - if (chatusr) return chatusr - - const fromusr = this.extractXmlValue(referMsgXml, 'fromusr') - if (fromusr && !fromusr.endsWith('@chatroom')) return fromusr - - return undefined - } - - private resolveQuotedAccountName(referMsgXml: string): string | undefined { - const displayName = this.extractXmlValue(referMsgXml, 'displayname') - if (!displayName || this.looksLikeWxid(displayName)) return undefined - return displayName - } - - private looksLikeWxid(value: string): boolean { - const text = String(value || '').trim().toLowerCase() - return Boolean(text) && (text.startsWith('wxid_') || /^wx[a-z0-9_-]{4,}$/.test(text)) - } - - private resolveQuotedContent(referMsgXml: string, referTypeRaw: string, referContentRaw: string): string { - const referType = String(referTypeRaw || '').trim() - switch (referType) { - case '1': - return this.extractPreferredQuotedText(referMsgXml) - case '3': - return '[图片]' - case '34': - return '[语音]' - case '43': - return '[视频]' - case '47': - return '[动画表情]' - case '42': - return '[名片]' - case '48': - return '[位置]' - case '49': { - const innerType = this.extractType49Subtype(referContentRaw) - if (innerType === '57') { - return this.extractAppMessageTitle(referContentRaw) || '[引用消息]' - } - if (innerType === '6') return '[文件]' - if (innerType === '19') return '[聊天记录]' - if (innerType === '33' || innerType === '36') return '[小程序]' - return '[链接]' - } - default: - if (!referContentRaw || referContentRaw.includes('wxid_')) return '[消息]' - return this.sanitizeQuotedContent(referContentRaw) - } - } - - private extractPreferredQuotedText(referMsgXml: string): string { - const candidateTags = [ - 'selectedcontent', - 'selectedtext', - 'selectcontent', - 'selecttext', - 'quotecontent', - 'quotetext', - 'partcontent', - 'parttext', - 'excerpt', - 'summary', - 'preview', - 'content' - ] - - for (const tag of candidateTags) { - const value = this.sanitizeQuotedContent(this.extractXmlValue(referMsgXml, tag)) - if (value) return value - } - - return '' - } - - private sanitizeQuotedContent(content: string): string { - if (!content) return '' - return String(content || '') - .replace(/wxid_[A-Za-z0-9_-]{3,}/g, '') - .replace(/^[\s::\-]+/, '') - .replace(/[::]{2,}/g, ':') - .replace(/^[\s::\-]+/, '') - .replace(/\s+/g, ' ') - .trim() - } - - private mapQuotedMessageType(referTypeRaw: string, referContentRaw: string): number | undefined { - const referType = String(referTypeRaw || '').trim() - switch (referType) { - case '1': - return ChatLabType.TEXT - case '3': - return ChatLabType.IMAGE - case '34': - return ChatLabType.VOICE - case '43': - return ChatLabType.VIDEO - case '47': - return ChatLabType.EMOJI - case '48': - return ChatLabType.LOCATION - case '42': - return ChatLabType.CONTACT - case '50': - return ChatLabType.CALL - case '10000': - return ChatLabType.SYSTEM - case '49': - return this.mapQuotedType49MessageType(referContentRaw) - default: - return undefined - } - } - - private mapQuotedType49MessageType(content: string): number { - const subtype = this.extractType49Subtype(content) - switch (subtype) { - case '57': - return ChatLabType.REPLY - case '6': - return ChatLabType.FILE - case '19': - return ChatLabType.FORWARD - case '33': - case '36': - return ChatLabType.SHARE - case '2000': - return ChatLabType.TRANSFER - case '2001': - return ChatLabType.RED_PACKET - case '5': - case '49': - default: - return ChatLabType.LINK - } - } - - /** - * 发送 JSON 响应 - */ - private sendJson(res: http.ServerResponse, data: any): void { - res.setHeader('Content-Type', 'application/json; charset=utf-8') - res.writeHead(200) - res.end(JSON.stringify(data, null, 2)) - } - - private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void { - res.setHeader('Allow', allow) - this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`) - } - - /** - * 发送错误响应 - */ - private sendError(res: http.ServerResponse, code: number, message: string): void { - res.setHeader('Content-Type', 'application/json; charset=utf-8') - res.writeHead(code) - res.end(JSON.stringify({ error: message })) - } -} - -export const httpService = new HttpService() diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts deleted file mode 100644 index e8cc420..0000000 --- a/electron/services/imageDecryptService.ts +++ /dev/null @@ -1,2246 +0,0 @@ -import { app, BrowserWindow } from 'electron' -import { basename, dirname, extname, join } from 'path' -import { pathToFileURL } from 'url' -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' -import { writeFile, rm, readdir } from 'fs/promises' -import { homedir, tmpdir } from 'os' -import crypto from 'crypto' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' -import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt' - -// 获取 ffmpeg-static 的路径 -function getStaticFfmpegPath(): string | null { - try { - // 方法1: 直接 require ffmpeg-static - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ffmpegStatic = require('ffmpeg-static') - - if (typeof ffmpegStatic === 'string') { - // 修复:如果路径包含 app.asar(打包后),自动替换为 app.asar.unpacked - let fixedPath = ffmpegStatic - if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) { - fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked') - } - - if (existsSync(fixedPath)) { - return fixedPath - } - } - - // 方法2: 手动构建路径(开发环境) - const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') - if (existsSync(devPath)) { - return devPath - } - - // 方法3: 打包后的路径 - if (app?.isPackaged) { - const resourcesPath = process.resourcesPath - const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') - if (existsSync(packedPath)) { - return packedPath - } - } - - return null - } catch { - return null - } -} - -type DecryptResult = { - success: boolean - localPath?: string - error?: string - failureKind?: 'not_found' | 'decrypt_failed' - isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) -} - -type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed' - -type CachedImagePayload = { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - hardlinkOnly?: boolean - disableUpdateCheck?: boolean - allowCacheIndex?: boolean - suppressEvents?: boolean -} - -type DecryptImagePayload = CachedImagePayload & { - force?: boolean -} - -export class ImageDecryptService { - private configService = new ConfigService() - private resolvedCache = new Map() - private pending = new Map>() - private updateFlags = new Map() - private nativeLogged = false - private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null - private datNameScanMissAt = new Map() - private readonly datNameScanMissTtlMs = 1200 - private readonly accountDirCache = new Map() - private cacheRootPath: string | null = null - private readonly ensuredDirs = new Set() - - private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean { - if (payload?.suppressEvents === true) return false - // 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。 - if (process.env.WEFLOW_WORKER === '1') return false - return true - } - - private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean { - if (payload?.disableUpdateCheck === true) return false - return this.shouldEmitImageEvents(payload) - } - - setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { - this.runtimeConfig = config - } - - private getConfiguredDbPath(): string { - return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() - } - - private getConfiguredMyWxid(): string { - return String(this.runtimeConfig?.myWxid || this.configService.getMyWxidCleaned() || '').trim() - } - - private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } { - const runtimeImageXorKey = this.runtimeConfig?.imageXorKey - const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== '' - const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim() - if (hasRuntimeXorKey || runtimeAesKey) { - const fallback = this.configService.getImageKeysForCurrentWxid() - return { - xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey, - aesKey: runtimeAesKey || fallback.aesKey - } - } - return this.configService.getImageKeysForCurrentWxid() - } - - private logInfo(message: string, meta?: Record): void { - if (!this.configService.get('logEnabled')) return - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - this.writeLog(logLine) - } - - private logError(message: string, error?: unknown, meta?: Record): void { - if (!this.configService.get('logEnabled')) return - const timestamp = new Date().toISOString() - const errorStr = error ? ` Error: ${String(error)}` : '' - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n` - console.error(message, error, meta) - this.writeLog(logLine) - } - - private writeLog(line: string): void { - try { - const logDir = join(this.getUserDataPath(), 'logs') - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }) - } - appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' }) - } catch (err) { - console.error('写入日志失败:', err) - } - } - - async resolveCachedImage(payload: CachedImagePayload): Promise { - const cacheKeys = this.getCacheKeys(payload) - const cacheKey = cacheKeys[0] - if (!cacheKey) { - return { success: false, error: '缺少图片标识', failureKind: 'not_found' } - } - for (const key of cacheKeys) { - const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) { - const upgraded = !this.isHdPath(cached) - ? await this.tryPromoteThumbnailCache(payload, key, cached) - : null - const finalPath = upgraded || cached - const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) - const isNonHd = !this.isHdPath(finalPath) - const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false - if (isNonHd) { - if (this.shouldCheckImageUpdate(payload)) { - this.triggerUpdateCheck(payload, key, finalPath) - } - } else { - this.updateFlags.delete(key) - } - this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath)) - return { success: true, localPath, hasUpdate } - } - if (cached && !this.isUsableImageCacheFile(cached)) { - this.resolvedCache.delete(key) - } - } - - const accountDir = this.resolveCurrentAccountDir() - if (accountDir) { - const datPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { - allowThumbnail: true, - skipResolvedCache: false, - hardlinkOnly: true, - allowDatNameScanFallback: payload.allowCacheIndex !== false - } - ) - if (datPath) { - const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false) - if (existing) { - const upgraded = !this.isHdPath(existing) - ? await this.tryPromoteThumbnailCache(payload, cacheKey, existing) - : null - const finalPath = upgraded || existing - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath) - const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) - const isNonHd = !this.isHdPath(finalPath) - const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false - if (isNonHd) { - if (this.shouldCheckImageUpdate(payload)) { - this.triggerUpdateCheck(payload, cacheKey, finalPath) - } - } else { - this.updateFlags.delete(cacheKey) - } - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath)) - return { success: true, localPath, hasUpdate } - } - } - } - this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到缓存图片', failureKind: 'not_found' } - } - - async decryptImage(payload: DecryptImagePayload): Promise { - const cacheKeys = this.getCacheKeys(payload) - const cacheKey = cacheKeys[0] - if (!cacheKey) { - return { success: false, error: '缺少图片标识', failureKind: 'not_found' } - } - this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running') - - if (payload.force) { - for (const key of cacheKeys) { - const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath } - } - if (cached && !this.isUsableImageCacheFile(cached)) { - this.resolvedCache.delete(key) - } - } - - } - - if (!payload.force) { - const cached = this.resolvedCache.get(cacheKey) - if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) { - const upgraded = !this.isHdPath(cached) - ? await this.tryPromoteThumbnailCache(payload, cacheKey, cached) - : null - const finalPath = upgraded || cached - const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath } - } - if (cached && !this.isUsableImageCacheFile(cached)) { - this.resolvedCache.delete(cacheKey) - } - } - - const pending = this.pending.get(cacheKey) - if (pending) { - this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running') - return pending - } - - const task = this.decryptImageInternal(payload, cacheKey) - this.pending.set(cacheKey, task) - try { - return await task - } finally { - this.pending.delete(cacheKey) - } - } - - async preloadImageHardlinkMd5s(md5List: string[]): Promise { - const normalizedList = Array.from( - new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) - ) - if (normalizedList.length === 0) return - - const wxid = this.getConfiguredMyWxid() - const dbPath = this.getConfiguredDbPath() - if (!wxid || !dbPath) return - - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) return - - try { - for (const md5 of normalizedList) { - if (!this.looksLikeMd5(md5)) continue - const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true) - if (!selectedPath) continue - this.cacheDatPath(accountDir, md5, selectedPath) - const fileName = basename(selectedPath).toLowerCase() - if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath) - } - } catch { - // ignore preload failures - } - } - - private async decryptImageInternal( - payload: DecryptImagePayload, - cacheKey: string - ): Promise { - this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) - this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running') - try { - const wxid = this.getConfiguredMyWxid() - const dbPath = this.getConfiguredDbPath() - if (!wxid || !dbPath) { - this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失') - return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' } - } - - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) { - this.logError('未找到账号目录', undefined, { dbPath, wxid }) - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失') - return { success: false, error: '未找到账号目录', failureKind: 'not_found' } - } - - let datPath: string | null = null - let usedHdAttempt = false - let fallbackToThumbnail = false - - // force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。 - if (payload.force) { - usedHdAttempt = true - datPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { - allowThumbnail: false, - skipResolvedCache: false, - hardlinkOnly: payload.hardlinkOnly === true, - allowDatNameScanFallback: payload.allowCacheIndex !== false - } - ) - if (!datPath) { - datPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { - allowThumbnail: true, - skipResolvedCache: false, - hardlinkOnly: payload.hardlinkOnly === true, - allowDatNameScanFallback: payload.allowCacheIndex !== false - } - ) - fallbackToThumbnail = Boolean(datPath) - if (fallbackToThumbnail) { - this.logInfo('高清缺失,回退解密缩略图', { - md5: payload.imageMd5, - datName: payload.imageDatName - }) - } - } - } else { - datPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { - allowThumbnail: true, - skipResolvedCache: false, - hardlinkOnly: payload.hardlinkOnly === true, - allowDatNameScanFallback: payload.allowCacheIndex !== false - } - ) - } - - if (!datPath) { - this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件') - if (usedHdAttempt) { - return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' } - } - return { success: false, error: '未找到图片文件', failureKind: 'not_found' } - } - - this.logInfo('找到DAT文件', { datPath }) - this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running') - - if (!extname(datPath).toLowerCase().includes('dat')) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) - const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) - const isThumb = this.isThumbnailPath(datPath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath, isThumb } - } - - const preferHdCache = Boolean(payload.force && !fallbackToThumbnail) - const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache) - if (existingFast) { - this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) }) - const isHd = this.isHdPath(existingFast) - if (!(payload.force && !isHd)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast) - const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath) - const isThumb = this.isThumbnailPath(existingFast) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath)) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath, isThumb } - } - } - - // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置 - const imageKeys = this.getConfiguredImageKeys() - const xorKeyRaw = imageKeys.xorKey - // 支持十六进制格式(如 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)) { - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥') - return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' } - } - - const aesKeyRaw = imageKeys.aesKey - const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : '' - const aesKeyForNative = aesKeyText || undefined - - this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) }) - this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running') - const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative) - if (!nativeResult) { - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用') - return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' } - } - let decrypted: Buffer = nativeResult.data - this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running') - - // 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致 - const wxgfResult = await this.unwrapWxgf(decrypted) - decrypted = wxgfResult.data - - const detectedExt = this.detectImageExtension(decrypted) - - // 如果解密产物无法识别为图片,归类为“解密失败”。 - if (!detectedExt) { - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片') - return { - success: false, - error: '解密后不是有效图片', - failureKind: 'decrypt_failed', - isThumb: this.isThumbnailPath(datPath) - } - } - - const finalExt = detectedExt - - const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) - this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running') - await writeFile(outputPath, decrypted) - this.logInfo('解密成功', { outputPath, size: decrypted.length }) - - const isThumb = this.isThumbnailPath(datPath) - const isHdCache = this.isHdPath(outputPath) - this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath) - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) - if (isHdCache) { - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - } else { - if (this.shouldCheckImageUpdate(payload)) { - this.triggerUpdateCheck(payload, cacheKey, outputPath) - } - } - const localPath = payload.preferFilePath - ? outputPath - : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) - const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, emitPath) - this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done') - return { success: true, localPath, isThumb } - } catch (e) { - this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e)) - return { success: false, error: String(e), failureKind: 'not_found' } - } - } - - private resolveAccountDir(dbPath: string, wxid: string): string | null { - return this.configService.getAccountDir(dbPath, wxid) - } - - private resolveCurrentAccountDir(): string | null { - return this.configService.getAccountDir() - } - - /** - * 获取解密后的缓存目录(用于查找 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})$/) - const cleaned = suffixMatch ? suffixMatch[1] : trimmed - - return cleaned - } - - private async resolveDatPath( - accountDir: string, - imageMd5?: string, - imageDatName?: string, - sessionId?: string, - createTime?: number, - options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean } - ): Promise { - const allowThumbnail = options?.allowThumbnail ?? true - const skipResolvedCache = options?.skipResolvedCache ?? false - const hardlinkOnly = options?.hardlinkOnly ?? false - const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true - this.logInfo('[ImageDecrypt] resolveDatPath', { - imageMd5, - imageDatName, - createTime, - allowThumbnail, - skipResolvedCache, - hardlinkOnly, - allowDatNameScanFallback - }) - - const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback) - if (lookupBases.length === 0) { - this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { imageMd5, imageDatName }) - return null - } - - if (!skipResolvedCache) { - const cacheCandidates = Array.from(new Set([ - ...lookupBases, - String(imageMd5 || '').trim().toLowerCase(), - String(imageDatName || '').trim().toLowerCase() - ].filter(Boolean))) - for (const cacheKey of cacheCandidates) { - const scopedKey = `${accountDir}|${cacheKey}` - const cached = this.resolvedCache.get(scopedKey) - if (!cached || !existsSync(cached)) continue - if (!allowThumbnail && !this.isHdDatPath(cached)) continue - return cached - } - } - - for (const baseMd5 of lookupBases) { - const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail) - if (!selectedPath) continue - - this.cacheDatPath(accountDir, baseMd5, selectedPath) - if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath) - const normalizedFileName = basename(selectedPath).toLowerCase() - if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath) - this.logInfo('[ImageDecrypt] dat scan selected', { - baseMd5, - selectedPath, - allowThumbnail - }) - return selectedPath - } - - this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', { - imageMd5, - imageDatName, - lookupBases, - allowThumbnail - }) - return null - } - - private async checkHasUpdate( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }, - _cacheKey: string, - cachedPath: string - ): Promise { - if (!cachedPath || !existsSync(cachedPath)) return false - if (this.isHdPath(cachedPath)) 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 lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true) - if (lookupBases.length === 0) return false - - let currentTier = this.getCachedPathTier(cachedPath) - let bestDatPath: string | null = null - let bestDatTier = -1 - for (const baseMd5 of lookupBases) { - const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true) - if (!candidate) continue - const candidateTier = this.getDatTier(candidate, baseMd5) - if (candidateTier <= 0) continue - if (!bestDatPath) { - bestDatPath = candidate - bestDatTier = candidateTier - continue - } - if (candidateTier > bestDatTier) { - bestDatPath = candidate - bestDatTier = candidateTier - continue - } - if (candidateTier === bestDatTier) { - const candidateSize = this.fileSizeSafe(candidate) - const bestSize = this.fileSizeSafe(bestDatPath) - if (candidateSize > bestSize) { - bestDatPath = candidate - bestDatTier = candidateTier - } - } - } - if (!bestDatPath || bestDatTier <= 0) return false - if (currentTier < 0) currentTier = 1 - return bestDatTier > currentTier - } - - private async tryPromoteThumbnailCache( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean }, - cacheKey: string, - cachedPath: string - ): Promise { - if (!cachedPath || !existsSync(cachedPath)) return null - if (!this.isImageFile(cachedPath)) return null - if (this.isHdPath(cachedPath)) return null - - const accountDir = this.resolveCurrentAccountDir() - if (!accountDir) return null - - const hdDatPath = await this.resolveDatPath( - accountDir, - payload.imageMd5, - payload.imageDatName, - payload.sessionId, - payload.createTime, - { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false } - ) - if (!hdDatPath) return null - - const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true) - if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(existingHd)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - this.removeThumbnailCacheFile(cachedPath, existingHd) - this.logInfo('[ImageDecrypt] thumbnail cache upgraded', { - cacheKey, - oldPath: cachedPath, - newPath: existingHd, - mode: 'existing' - }) - return existingHd - } - - const upgraded = await this.decryptImage({ - sessionId: payload.sessionId, - imageMd5: payload.imageMd5, - imageDatName: payload.imageDatName, - createTime: payload.createTime, - preferFilePath: true, - force: true, - hardlinkOnly: true, - disableUpdateCheck: true - }) - if (!upgraded.success) return null - - const cachedResult = this.resolvedCache.get(cacheKey) - const upgradedPath = (cachedResult && existsSync(cachedResult)) - ? cachedResult - : String(upgraded.localPath || '').trim() - if (!upgradedPath || !existsSync(upgradedPath)) return null - if (!this.isImageFile(upgradedPath) || !this.isHdPath(upgradedPath)) return null - - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath) - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - this.removeThumbnailCacheFile(cachedPath, upgradedPath) - this.logInfo('[ImageDecrypt] thumbnail cache upgraded', { - cacheKey, - oldPath: cachedPath, - newPath: upgradedPath, - mode: 're-decrypt' - }) - return upgradedPath - } - - private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void { - if (!oldPath) return - if (keepPath && oldPath === keepPath) return - if (!existsSync(oldPath)) return - if (this.isHdPath(oldPath)) return - void rm(oldPath, { force: true }).catch(() => { }) - } - - private triggerUpdateCheck( - payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - disableUpdateCheck?: boolean - suppressEvents?: boolean - }, - cacheKey: string, - cachedPath: string - ): void { - if (!this.shouldCheckImageUpdate(payload)) return - if (this.updateFlags.get(cacheKey)) return - void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => { - if (!hasUpdate) return - this.updateFlags.set(cacheKey, true) - const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath) - if (upgradedPath) { - this.updateFlags.delete(cacheKey) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath)) - return - } - this.emitImageUpdate(payload, cacheKey) - }).catch(() => { }) - } - - private async tryAutoRefreshBetterCache( - payload: { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number - preferFilePath?: boolean - disableUpdateCheck?: boolean - suppressEvents?: boolean - }, - cacheKey: string, - cachedPath: string - ): Promise { - if (!cachedPath || !existsSync(cachedPath)) return null - if (this.isHdPath(cachedPath)) return null - const refreshed = await this.decryptImage({ - sessionId: payload.sessionId, - imageMd5: payload.imageMd5, - imageDatName: payload.imageDatName, - createTime: payload.createTime, - preferFilePath: true, - force: true, - hardlinkOnly: true, - disableUpdateCheck: true, - suppressEvents: true - }) - if (!refreshed.success || !refreshed.localPath) return null - const refreshedPath = String(refreshed.localPath || '').trim() - if (!refreshedPath || !existsSync(refreshedPath)) return null - if (!this.isImageFile(refreshedPath)) return null - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath) - this.removeThumbnailCacheFile(cachedPath, refreshedPath) - return refreshedPath - } - - - - private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] { - const keys: string[] = [] - const pushMd5 = (value?: string) => { - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) return - if (!this.looksLikeMd5(normalized)) return - if (!keys.includes(normalized)) keys.push(normalized) - } - - pushMd5(imageMd5) - - const datNameRaw = String(imageDatName || '').trim().toLowerCase() - if (!datNameRaw) return keys - pushMd5(datNameRaw) - const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw - pushMd5(datNameNoExt) - pushMd5(this.normalizeDatBase(datNameNoExt)) - return keys - } - - private collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] { - const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) - if (!allowDatNameScanFallback) return bases - const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase() - if (!fallbackRaw) return bases - const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw - const fallbackBase = this.normalizeDatBase(fallbackNoExt) - if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) { - bases.push(fallbackBase) - } - return bases - } - - private collectAllDatCandidatesForBase( - accountDir: string, - baseMd5: string, - sessionId?: string, - createTime?: number - ): string[] { - const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime) - return Array.from(new Set(sessionMonth.filter((item) => { - const path = String(item || '').trim() - return path && existsSync(path) && path.toLowerCase().endsWith('.dat') - }))) - } - - private isImgScopedDatPath(filePath: string): boolean { - const lower = String(filePath || '').toLowerCase() - return /[\\/](img|image|msgimg)[\\/]/.test(lower) - } - - private fileSizeSafe(filePath: string): number { - try { - return statSync(filePath).size || 0 - } catch { - return 0 - } - } - - private fileMtimeSafe(filePath: string): number { - try { - return statSync(filePath).mtimeMs || 0 - } catch { - return 0 - } - } - - private pickLargestDatPath(paths: string[]): string | null { - const list = Array.from(new Set(paths.filter(Boolean))) - if (list.length === 0) return null - list.sort((a, b) => { - const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a) - if (sizeDiff !== 0) return sizeDiff - const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a) - if (mtimeDiff !== 0) return mtimeDiff - return a.localeCompare(b) - }) - return list[0] || null - } - - private selectBestDatPathByBase( - accountDir: string, - baseMd5: string, - sessionId?: string, - createTime?: number, - allowThumbnail = true - ): string | null { - const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime) - if (candidates.length === 0) return null - - const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item)) - const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item)) - const hdInImg = this.pickLargestDatPath(imgHdCandidates) - if (hdInImg) return hdInImg - - if (!allowThumbnail) { - // 高清优先仅认 img/image/msgimg 路径中的 H 变体; - // 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。 - return null - } - - // 无 H 时,优先尝试原始无后缀 DAT({md5}.dat)。 - const baseDatInImg = this.pickLargestDatPath( - imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5)) - ) - if (baseDatInImg) return baseDatInImg - - const baseDatAny = this.pickLargestDatPath( - candidates.filter((item) => this.isBaseDatPath(item, baseMd5)) - ) - if (baseDatAny) return baseDatAny - - const thumbDatInImg = this.pickLargestDatPath( - imgCandidates.filter((item) => this.isTVariantDat(item)) - ) - if (thumbDatInImg) return thumbDatInImg - - const thumbDatAny = this.pickLargestDatPath( - candidates.filter((item) => this.isTVariantDat(item)) - ) - if (thumbDatAny) return thumbDatAny - - return null - } - - private resolveDatPathFromParsedDatName( - accountDir: string, - imageDatName?: string, - sessionId?: string, - createTime?: number, - allowThumbnail = true - ): string | null { - const datNameRaw = String(imageDatName || '').trim().toLowerCase() - if (!datNameRaw) return null - const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw - const baseMd5 = this.normalizeDatBase(datNameNoExt) - if (!this.looksLikeMd5(baseMd5)) return null - - const monthKey = this.resolveYearMonthFromCreateTime(createTime) - const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}` - const lastMiss = this.datNameScanMissAt.get(missKey) || 0 - if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) { - return null - } - - const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime) - if (sessionMonthCandidates.length > 0) { - const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5) - for (const candidatePath of orderedSessionMonth) { - if (!allowThumbnail && !this.isHdDatPath(candidatePath)) continue - this.datNameScanMissAt.delete(missKey) - this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', { - accountDir, - sessionId, - imageDatName: datNameRaw, - createTime, - monthKey, - baseMd5, - allowThumbnail, - selectedPath: candidatePath - }) - return candidatePath - } - } - - // 新策略:只扫描会话月目录,不做 account-wide 根目录回退。 - this.datNameScanMissAt.set(missKey, Date.now()) - this.logInfo('[ImageDecrypt] datName fallback precise scan miss', { - accountDir, - sessionId, - imageDatName: datNameRaw, - createTime, - monthKey, - baseMd5, - allowThumbnail - }) - return null - } - - private resolveYearMonthFromCreateTime(createTime?: number): string { - const raw = Number(createTime) - if (!Number.isFinite(raw) || raw <= 0) return '' - const ts = raw > 1e12 ? raw : raw * 1000 - const d = new Date(ts) - if (Number.isNaN(d.getTime())) return '' - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - return `${y}-${m}` - } - - private collectDatCandidatesFromSessionMonth( - accountDir: string, - baseMd5: string, - sessionId?: string, - createTime?: number - ): string[] { - const normalizedSessionId = String(sessionId || '').trim() - const monthKey = this.resolveYearMonthFromCreateTime(createTime) - if (!normalizedSessionId || !monthKey) return [] - - const sessionDir = this.resolveSessionDirForStorage(normalizedSessionId) - if (!sessionDir) return [] - const candidates = new Set() - const budget = { remaining: 240 } - const targetDirs: Array<{ dir: string; depth: number }> = [ - // 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img - { dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 } - ] - - for (const target of targetDirs) { - if (budget.remaining <= 0) break - this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget) - } - - return Array.from(candidates) - } - - private resolveSessionDirForStorage(sessionId: string): string { - const normalized = String(sessionId || '').trim().toLowerCase() - if (!normalized) return '' - if (this.looksLikeMd5(normalized)) return normalized - const cleaned = this.cleanAccountDirName(normalized).toLowerCase() - if (this.looksLikeMd5(cleaned)) return cleaned - return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase() - } - - private scanDatCandidatesUnderRoot( - rootDir: string, - baseMd5: string, - maxDepth: number, - out: Set, - budget: { remaining: number } - ): void { - if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return - if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return - - const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }] - while (stack.length > 0 && budget.remaining > 0) { - const current = stack.pop() - if (!current) break - budget.remaining -= 1 - - let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }> - try { - entries = readdirSync(current.dir, { withFileTypes: true }) - } catch { - continue - } - - for (const entry of entries) { - if (!entry.isFile()) continue - const name = String(entry.name || '') - if (!this.isHardlinkCandidateName(name, baseMd5)) continue - const fullPath = join(current.dir, name) - if (existsSync(fullPath)) out.add(fullPath) - } - - if (current.depth >= maxDepth) continue - for (const entry of entries) { - if (!entry.isDirectory()) continue - const name = String(entry.name || '') - if (!name || name === '.' || name === '..') continue - if (name.startsWith('.')) continue - stack.push({ dir: join(current.dir, name), depth: current.depth + 1 }) - } - } - } - - private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] { - const list = Array.from(new Set(paths.filter(Boolean))) - list.sort((a, b) => { - const nameA = basename(a).toLowerCase() - const nameB = basename(b).toLowerCase() - const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5) - const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5) - if (priorityA !== priorityB) return priorityA - priorityB - - let sizeA = 0 - let sizeB = 0 - try { - sizeA = statSync(a).size - } catch { } - try { - sizeB = statSync(b).size - } catch { } - if (sizeA !== sizeB) return sizeB - sizeA - - let mtimeA = 0 - let mtimeB = 0 - try { - mtimeA = statSync(a).mtimeMs - } catch { } - try { - mtimeB = statSync(b).mtimeMs - } catch { } - if (mtimeA !== mtimeB) return mtimeB - mtimeA - return nameA.localeCompare(nameB) - }) - return list - } - - private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean { - const lower = String(fileName || '').trim().toLowerCase() - if (!lower.endsWith('.dat')) return false - const base = lower.slice(0, -4) - if (base === baseMd5) return true - if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true - if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true - return this.normalizeDatBase(base) === baseMd5 - } - - private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number { - const lower = String(fileName || '').trim().toLowerCase() - if (!lower.endsWith('.dat')) return 999 - - const base = lower.slice(0, -4) - if ( - base.endsWith('_h') || - base.endsWith('.h') || - base.endsWith('_hd') || - base.endsWith('.hd') - ) { - return 0 - } - if (base.endsWith('_b') || base.endsWith('.b')) return 1 - if (this.isThumbnailDat(lower)) return 3 - return 2 - } - - private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string { - const normalizedPath = String(fullPath || '').trim() - const normalizedFileName = String(fileName || '').trim().toLowerCase() - if (!normalizedPath || !normalizedFileName) return normalizedPath - if (!normalizedFileName.endsWith('.dat')) return normalizedPath - const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4)) - if (!this.looksLikeMd5(normalizedBase)) return '' - - // 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT)。 - return normalizedPath - } - - private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { - try { - const normalizedMd5 = String(md5 || '').trim().toLowerCase() - if (!this.looksLikeMd5(normalizedMd5)) return null - const ready = await this.ensureWcdbReady() - if (!ready) { - this.logInfo('[ImageDecrypt] hardlink db not ready') - return null - } - - const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir) - if (!resolveResult.success || !resolveResult.data) return null - const fileName = String(resolveResult.data.file_name || '').trim() - const fullPath = String(resolveResult.data.full_path || '').trim() - if (!fileName || !fullPath) return null - - const lowerFileName = String(fileName).toLowerCase() - if (lowerFileName.endsWith('.dat')) { - const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4)) - if (!this.looksLikeMd5(normalizedBase)) { - this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName }) - return null - } - } - - const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName) - if (existsSync(selectedPath)) { - this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath }) - return selectedPath - } - - this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath }) - return null - } catch { - // ignore - } - return null - } - - 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 accountDir = this.configService.getAccountDir(dbPath, wxid) - if (!accountDir) return false - return await wcdbService.open(accountDir, decryptKey) - } - - 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 stripDatVariantSuffix(base: string): string { - const lower = base.toLowerCase() - const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c'] - for (const suffix of suffixes) { - if (lower.endsWith(suffix)) { - return lower.slice(0, -suffix.length) - } - } - if (/[._][a-z]$/.test(lower)) { - return lower.slice(0, -2) - } - return lower - } - - private normalizeDatBase(name: string): string { - let base = name.toLowerCase() - if (base.endsWith('.dat') || base.endsWith('.jpg')) { - base = base.slice(0, -4) - } - for (;;) { - const stripped = this.stripDatVariantSuffix(base) - if (stripped === base) { - return base - } - base = stripped - } - } - - private getCacheVariantSuffixFromDat(datPath: string): string { - if (this.isHdDatPath(datPath)) return '_hd' - const name = basename(datPath) - const lower = name.toLowerCase() - const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const base = this.normalizeDatBase(stem) - const rawSuffix = stem.slice(base.length) - if (!rawSuffix) return '' - const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') - if (!safe) return '' - if (safe.startsWith('_') || safe.startsWith('.')) return safe - return `_${safe}` - } - - private getCacheVariantSuffixFromCachedPath(cachePath: string): string { - const raw = String(cachePath || '').split('?')[0] - const name = basename(raw) - const ext = extname(name).toLowerCase() - const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase() - const base = this.normalizeDatBase(stem) - const rawSuffix = stem.slice(base.length) - if (!rawSuffix) return '' - const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') - if (!safe) return '' - if (safe.startsWith('_') || safe.startsWith('.')) return safe - return `_${safe}` - } - - private buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] { - const fallbackSuffixes = [ - '_hd', - '_thumb', - '_t', - '.t', - '_b', - '.b', - '_w', - '.w', - '_c', - '.c', - '' - ] - const ordered = preferHd - ? ['_hd', primarySuffix, ...fallbackSuffixes] - : [primarySuffix, '_hd', ...fallbackSuffixes] - return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0))) - } - - private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { - const name = basename(datPath) - const lower = name.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const normalizedBase = this.normalizeDatBase(base) - const suffix = this.getCacheVariantSuffixFromDat(datPath) - - const contactDir = this.sanitizeDirName(sessionId || 'unknown') - const timeDir = this.resolveTimeDir(datPath) - const outputDir = join(this.getCacheRoot(), contactDir, timeDir) - this.ensureDir(outputDir) - - return join(outputDir, `${normalizedBase}${suffix}${ext}`) - } - - private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] { - const name = basename(datPath) - const lower = name.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - const normalizedBase = this.normalizeDatBase(base) - const primarySuffix = this.getCacheVariantSuffixFromDat(datPath) - const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd) - const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - - const root = this.getCacheRoot() - const contactDir = this.sanitizeDirName(sessionId || 'unknown') - const timeDir = this.resolveTimeDir(datPath) - const currentDir = join(root, contactDir, timeDir) - const legacyDir = join(root, normalizedBase) - const candidates: string[] = [] - - for (const suffix of suffixes) { - for (const ext of extensions) { - candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`)) - } - } - - // 兼容旧目录结构 - for (const suffix of suffixes) { - for (const ext of extensions) { - candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`)) - } - } - - // 兼容最旧平铺结构 - for (const ext of extensions) { - candidates.push(join(root, `${normalizedBase}${ext}`)) - candidates.push(join(root, `${normalizedBase}_t${ext}`)) - candidates.push(join(root, `${normalizedBase}_hd${ext}`)) - } - - return candidates - } - - private removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void { - const candidateSets = [ - ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false), - ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true) - ] - const candidates = Array.from(new Set(candidateSets)) - for (const candidate of candidates) { - if (!candidate || candidate === keepPath) continue - if (!existsSync(candidate)) continue - if (!this.isImageFile(candidate)) continue - void rm(candidate, { force: true }).catch(() => { }) - } - } - - private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null { - const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd) - for (const candidate of candidates) { - if (!existsSync(candidate)) continue - if (this.isUsableImageCacheFile(candidate)) return candidate - } - return null - } - - 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 getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> { - try { - const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows - if (typeof getter !== 'function') return [] - const windows = getter() - if (!Array.isArray(windows)) return [] - return windows.filter((win) => ( - win && - typeof win.isDestroyed === 'function' && - win.webContents && - typeof win.webContents.send === 'function' - )) - } catch { - return [] - } - } - - private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void { - if (!this.shouldEmitImageEvents(payload)) return - const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName } - for (const win of this.getActiveWindowsSafely()) { - if (!win.isDestroyed()) { - win.webContents.send('image:updateAvailable', message) - } - } - } - - private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void { - if (!this.shouldEmitImageEvents(payload)) return - const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath } - for (const win of this.getActiveWindowsSafely()) { - if (!win.isDestroyed()) { - win.webContents.send('image:cacheResolved', message) - } - } - } - - private emitDecryptProgress( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, - cacheKey: string, - stage: DecryptProgressStage, - progress: number, - status: 'running' | 'done' | 'error', - message?: string - ): void { - if (!this.shouldEmitImageEvents(payload)) return - const safeProgress = Math.max(0, Math.min(100, Math.floor(progress))) - const event = { - cacheKey, - imageMd5: payload.imageMd5, - imageDatName: payload.imageDatName, - stage, - progress: safeProgress, - status, - message: message || '' - } - for (const win of this.getActiveWindowsSafely()) { - if (!win.isDestroyed()) { - win.webContents.send('image:decryptProgress', event) - } - } - } - - private getCacheRoot(): string { - let root = this.cacheRootPath - if (!root) { - const configured = this.configService.get('cachePath') - root = configured - ? join(configured, 'Images') - : join(this.getDocumentsPath(), 'WeFlow', 'Images') - this.cacheRootPath = root - } - this.ensureDir(root) - return root - } - - private ensureDir(dirPath: string): void { - if (!dirPath) return - if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }) - } - this.ensuredDirs.add(dirPath) - } - - private tryDecryptDatWithNative( - datPath: string, - xorKey: number, - aesKey?: string - ): { data: Buffer; ext: string; isWxgf: boolean } | null { - const result = decryptDatViaNative(datPath, xorKey, aesKey) - if (!this.nativeLogged) { - this.nativeLogged = true - if (result) { - this.logInfo('Rust 原生解密已启用', { - addonPath: nativeAddonLocation(), - source: 'native' - }) - } else { - this.logInfo('Rust 原生解密不可用', { - addonPath: nativeAddonLocation(), - source: 'native_unavailable' - }) - } - } - if (result) return result - const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey) - if (fallback) { - this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext }) - } - return fallback - } - - private tryDecryptDatWithJs( - datPath: string, - xorKey: number, - aesKey?: string - ): { data: Buffer; ext: string; isWxgf: boolean } | null { - try { - const encrypted = readFileSync(datPath) - const directExt = this.detectImageExtension(encrypted) - if (directExt) return { data: encrypted, ext: directExt, isWxgf: false } - - const candidates: Buffer[] = [] - const aesKeyText = String(aesKey || '').trim() - const datVersion = this.getDatVersion(encrypted) - if (datVersion === 2 && aesKeyText.length >= 16) { - try { - candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16))) - } catch { } - } - if (datVersion !== 2) { - candidates.push(this.decryptDatV3WithJs(encrypted, xorKey)) - } - - for (const candidate of candidates) { - const ext = this.detectImageExtension(candidate) - if (ext) return { data: candidate, ext, isWxgf: false } - } - } catch (error) { - this.logError('JS DAT 解密 fallback 失败', error, { datPath }) - } - return null - } - - private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer { - const output = Buffer.allocUnsafe(data.length) - for (let i = 0; i < data.length; i += 1) { - output[i] = data[i] ^ xorKey - } - return output - } - - private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer { - if (data.length < 0x0f) { - throw new Error('dat file too small') - } - const header = data.subarray(0, 0x0f) - const payload = data.subarray(0x0f) - const aesSize = this.readInt32LeSafe(header, 6) - const xorSize = this.readInt32LeSafe(header, 10) - const remainder = ((aesSize % 16) + 16) % 16 - const alignedAesSize = aesSize + (16 - remainder) - if (alignedAesSize > payload.length) throw new Error('invalid aes size') - - const aesData = payload.subarray(0, alignedAesSize) - - let plainAes = Buffer.alloc(0) - if (aesData.length > 0) { - const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0)) - decipher.setAutoPadding(false) - plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()])) - } - - const remaining = payload.subarray(alignedAesSize) - if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size') - - let rawData = Buffer.alloc(0) - let decodedXor = Buffer.alloc(0) - if (xorSize > 0) { - const rawLength = remaining.length - xorSize - if (rawLength < 0) throw new Error('invalid raw size') - rawData = remaining.subarray(0, rawLength) - const xorData = remaining.subarray(rawLength) - decodedXor = Buffer.allocUnsafe(xorData.length) - for (let i = 0; i < xorData.length; i += 1) { - decodedXor[i] = xorData[i] ^ xorKey - } - } else { - rawData = remaining - } - return Buffer.concat([plainAes, rawData, decodedXor]) - } - - 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 readInt32LeSafe(buffer: Buffer, offset: number): number { - if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset') - return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24) - } - - private strictRemovePkcs7Padding(data: Buffer): Buffer { - if (data.length === 0) throw new Error('empty decrypted data') - const pad = data[data.length - 1] - if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding') - for (let i = data.length - pad; i < data.length; i += 1) { - if (data[i] !== pad) throw new Error('invalid pkcs7 padding') - } - return data.subarray(0, data.length - pad) - } - - 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 resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string { - if (preferFilePath) return filePath - return this.resolveEmitPath(filePath, false) - } - - private resolveEmitPath(filePath: string, preferFilePath?: boolean): string { - if (preferFilePath) return this.filePathToUrl(filePath) - return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath) - } - - 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 isUsableImageCacheFile(filePath: string): boolean { - if (!this.isImageFile(filePath)) return false - if (!existsSync(filePath)) return false - if (this.isLikelyCorruptedDecodedImage(filePath)) { - this.logInfo('[ImageDecrypt] 跳过疑似损坏缓存文件', { filePath }) - void rm(filePath, { force: true }).catch(() => { }) - return false - } - return true - } - - private isLikelyCorruptedDecodedImage(filePath: string): boolean { - try { - const ext = extname(filePath).toLowerCase() - if (ext !== '.jpg' && ext !== '.jpeg') return false - const data = readFileSync(filePath) - return this.isLikelyCorruptedJpegBuffer(data) - } catch { - return false - } - } - - private isLikelyCorruptedJpegBuffer(data: Buffer): boolean { - if (data.length < 4096) return false - let zeroCount = 0 - for (let i = 0; i < data.length; i += 1) { - if (data[i] === 0x00) zeroCount += 1 - } - const zeroRatio = zeroCount / data.length - if (zeroRatio >= 0.985) return true - - const hasLavcTag = data.length >= 24 && data.subarray(0, 24).includes(Buffer.from('Lavc')) - if (!hasLavcTag) return false - - // JPEG 扫描段若几乎全是 0,通常表示解码失败但被编码器强行输出。 - let sosPos = -1 - for (let i = 2; i < data.length - 1; i += 1) { - if (data[i] === 0xff && data[i + 1] === 0xda) { - sosPos = i - break - } - } - if (sosPos < 0 || sosPos + 4 >= data.length) return zeroRatio >= 0.95 - - const sosLength = (data[sosPos + 2] << 8) | data[sosPos + 3] - const scanStart = sosPos + 2 + sosLength - if (scanStart >= data.length - 2) return zeroRatio >= 0.95 - - let eoiPos = -1 - for (let i = data.length - 2; i >= scanStart; i -= 1) { - if (data[i] === 0xff && data[i + 1] === 0xd9) { - eoiPos = i - break - } - } - if (eoiPos < 0 || eoiPos <= scanStart) return zeroRatio >= 0.95 - - const scanData = data.subarray(scanStart, eoiPos) - if (scanData.length < 1024) return zeroRatio >= 0.95 - let scanZeroCount = 0 - for (let i = 0; i < scanData.length; i += 1) { - if (scanData[i] === 0x00) scanZeroCount += 1 - } - const scanZeroRatio = scanZeroCount / scanData.length - return scanZeroRatio >= 0.985 - } - - /** - * 解包 wxgf 格式 - * wxgf 是微信的图片格式,内部使用 HEVC 编码 - */ - private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> { - // 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf") - if (buffer.length < 20 || - buffer[0] !== 0x77 || buffer[1] !== 0x78 || - buffer[2] !== 0x67 || buffer[3] !== 0x66) { - return { data: buffer, isWxgf: false } - } - - // 先尝试搜索内嵌的传统图片签名 - for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) { - if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { - return { data: buffer.subarray(i), isWxgf: false } - } - if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 && - buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) { - return { data: buffer.subarray(i), isWxgf: false } - } - } - - const hevcCandidates = this.buildWxgfHevcCandidates(buffer) - this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { - candidateCount: hevcCandidates.length, - candidates: hevcCandidates.map((item) => `${item.name}:${item.data.length}`) - }) - - for (const candidate of hevcCandidates) { - try { - const jpgData = await this.convertHevcToJpg(candidate.data) - if (!jpgData || jpgData.length === 0) continue - return { data: jpgData, isWxgf: false } - } catch (e) { - this.logError('unwrapWxgf: 候选流转换失败', e, { candidate: candidate.name }) - } - } - - const fallback = hevcCandidates[0]?.data || buffer.subarray(4) - return { data: fallback, isWxgf: true } - } - - private buildWxgfHevcCandidates(buffer: Buffer): Array<{ name: string; data: Buffer }> { - const units = this.extractHevcNaluUnits(buffer) - const candidates: Array<{ name: string; data: Buffer }> = [] - - const addCandidate = (name: string, data: Buffer | null | undefined): void => { - if (!data || data.length < 100) return - if (candidates.some((item) => item.data.equals(data))) return - candidates.push({ name, data }) - } - - // 1) 优先尝试按 VPS(32) 分组后的候选流 - const vpsStarts: number[] = [] - for (let i = 0; i < units.length; i += 1) { - const unit = units[i] - if (!unit || unit.length < 2) continue - const type = (unit[0] >> 1) & 0x3f - if (type === 32) vpsStarts.push(i) - } - const groups: Array<{ index: number; data: Buffer; size: number }> = [] - for (let i = 0; i < vpsStarts.length; i += 1) { - const start = vpsStarts[i] - const end = i + 1 < vpsStarts.length ? vpsStarts[i + 1] : units.length - const groupUnits = units.slice(start, end) - if (groupUnits.length === 0) continue - let hasVcl = false - for (const unit of groupUnits) { - if (!unit || unit.length < 2) continue - const type = (unit[0] >> 1) & 0x3f - if (type === 19 || type === 20 || type === 1) { - hasVcl = true - break - } - } - if (!hasVcl) continue - const merged = this.mergeHevcNaluUnits(groupUnits) - groups.push({ index: i, data: merged, size: merged.length }) - } - groups.sort((a, b) => b.size - a.size) - for (const group of groups) { - addCandidate(`group_${group.index}`, group.data) - } - - // 2) 全量扫描提取流 - addCandidate('scan_all_nalus', this.mergeHevcNaluUnits(units)) - - // 3) 兜底:直接跳过 wxgf 头喂 ffmpeg - addCandidate('raw_skip4', buffer.subarray(4)) - - return candidates - } - - private mergeHevcNaluUnits(units: Buffer[]): Buffer { - if (!Array.isArray(units) || units.length === 0) return Buffer.alloc(0) - const merged: Buffer[] = [] - for (const unit of units) { - if (!unit || unit.length < 2) continue - merged.push(Buffer.from([0x00, 0x00, 0x00, 0x01])) - merged.push(unit) - } - return Buffer.concat(merged) - } - - private extractHevcNaluUnits(buffer: Buffer): Buffer[] { - const starts: number[] = [] - let i = 4 - while (i < buffer.length - 3) { - const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && - buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01 - const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 && - buffer[i + 2] === 0x01 - if (hasPrefix4 || hasPrefix3) { - starts.push(i) - i += hasPrefix4 ? 4 : 3 - continue - } - i += 1 - } - if (starts.length === 0) return [] - - const units: Buffer[] = [] - let keptUnits = 0 - let droppedUnits = 0 - for (let index = 0; index < starts.length; index += 1) { - const start = starts[index] - const end = index + 1 < starts.length ? starts[index + 1] : buffer.length - const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 && - buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01 - const prefixLength = hasPrefix4 ? 4 : 3 - const payloadStart = start + prefixLength - if (payloadStart >= end) continue - const payload = buffer.subarray(payloadStart, end) - if (payload.length < 2) { - droppedUnits += 1 - continue - } - if ((payload[0] & 0x80) !== 0) { - droppedUnits += 1 - continue - } - units.push(payload) - keptUnits += 1 - } - return units - } - - /** - * 从 wxgf 数据中提取 HEVC NALU 裸流 - */ - private extractHevcNalu(buffer: Buffer): Buffer | null { - const units = this.extractHevcNaluUnits(buffer) - if (units.length === 0) return null - const merged = this.mergeHevcNaluUnits(units) - return merged.length > 0 ? merged : null - } - - /** - * 获取 ffmpeg 可执行文件路径 - */ - private getFfmpegPath(): string { - const staticPath = getStaticFfmpegPath() - this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false }) - - if (staticPath) { - return staticPath - } - - // 回退到系统 ffmpeg - return 'ffmpeg' - } - - /** - * 使用 ffmpeg 将 HEVC 裸流转换为 JPG - */ - private async convertHevcToJpg(hevcData: Buffer): Promise { - const ffmpeg = this.getFfmpegPath() - this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) - - const tmpDir = join(this.getTempPath(), 'weflow_hevc') - if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) - const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}` - const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`) - const tmpOutput = join(tmpDir, `hevc_${uniqueId}.jpg`) - - try { - await writeFile(tmpInput, hevcData) - - // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 - const attempts: { label: string; inputArgs: string[]; outputArgs?: string[] }[] = [ - { label: 'hevc raw frame0', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, - { label: 'hevc raw frame1', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, - { label: 'hevc raw frame5', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, - { label: 'h265 raw frame0', inputArgs: ['-f', 'h265', '-i', tmpInput] }, - { label: 'h265 raw frame1', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, - { label: 'h265 raw frame5', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, - { label: 'auto detect frame0', inputArgs: ['-i', tmpInput] }, - { label: 'auto detect frame1', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] }, - { label: 'auto detect frame5', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] }, - ] - - for (const attempt of attempts) { - // 清理上一轮的输出 - try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} - - const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs) - if (!result) continue - if (this.isLikelyCorruptedJpegBuffer(result)) continue - return result - } - - return null - } catch (e) { - this.logError('ffmpeg 转换异常', e) - return null - } finally { - try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {} - try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} - } - } - - private runFfmpegConvert( - ffmpeg: string, - inputArgs: string[], - tmpOutput: string, - label: string, - outputArgs?: string[] - ): Promise { - return new Promise((resolve) => { - const { spawn } = require('child_process') - const errChunks: Buffer[] = [] - - const args = [ - '-hide_banner', '-loglevel', 'error', - '-y', - ...inputArgs, - ...(outputArgs || []), - '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput - ] - this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) - - const proc = spawn(ffmpeg, args, { - stdio: ['ignore', 'ignore', 'pipe'], - windowsHide: true - }) - - proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - this.logError(`ffmpeg [${label}] 超时(15s)`) - resolve(null) - }, 15000) - - proc.on('close', (code: number) => { - clearTimeout(timer) - if (code === 0 && existsSync(tmpOutput)) { - try { - const jpgBuf = readFileSync(tmpOutput) - if (jpgBuf.length > 0) { - this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length }) - resolve(jpgBuf) - return - } - } catch (e) { - this.logError(`ffmpeg [${label}] 读取输出失败`, e) - } - } - const errMsg = Buffer.concat(errChunks).toString().trim() - this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg }) - resolve(null) - }) - - proc.on('error', (err: Error) => { - clearTimeout(timer) - this.logError(`ffmpeg [${label}] 进程错误`, err) - resolve(null) - }) - }) - } - - private looksLikeMd5(s: string): boolean { - return /^[a-f0-9]{32}$/i.test(s) - } - - private isThumbnailDat(name: string): boolean { - const lower = name.toLowerCase() - return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat') - } - - private isHdDatPath(datPath: string): boolean { - const name = basename(String(datPath || '')).toLowerCase() - if (!name.endsWith('.dat')) return false - const stem = name.slice(0, -4) - return ( - stem.endsWith('_h') || - stem.endsWith('.h') || - stem.endsWith('_hd') || - stem.endsWith('.hd') - ) - } - - private isTVariantDat(datPath: string): boolean { - const name = basename(String(datPath || '')).toLowerCase() - return this.isThumbnailDat(name) - } - - private isBaseDatPath(datPath: string, baseMd5: string): boolean { - const normalizedBase = String(baseMd5 || '').trim().toLowerCase() - if (!normalizedBase) return false - const name = basename(String(datPath || '')).toLowerCase() - return name === `${normalizedBase}.dat` - } - - private getDatTier(datPath: string, baseMd5: string): number { - if (this.isHdDatPath(datPath)) return 3 - if (this.isBaseDatPath(datPath, baseMd5)) return 2 - if (this.isTVariantDat(datPath)) return 1 - return 0 - } - - private getCachedPathTier(cachePath: string): number { - if (this.isHdPath(cachePath)) return 3 - const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath) - if (!suffix) return 2 - const normalized = suffix.toLowerCase() - if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') { - return 1 - } - return 1 - } - - private isHdPath(p: string): boolean { - const raw = String(p || '').split('?')[0] - const name = basename(raw).toLowerCase() - const ext = extname(name).toLowerCase() - const stem = ext ? name.slice(0, -ext.length) : name - return stem.endsWith('_hd') - } - - private isThumbnailPath(p: string): boolean { - const lower = p.toLowerCase() - return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.') - } - - private sanitizeDirName(s: string): string { - return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown' - } - - private resolveTimeDir(filePath: string): string { - try { - const stats = statSync(filePath) - const d = new Date(stats.mtime) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` - } catch { - const d = new Date() - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` - } - } - - private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null { - try { - const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath - if (typeof getter !== 'function') return null - const value = getter(name) - return typeof value === 'string' && value.trim() ? value : null - } catch { - return null - } - } - - private getUserDataPath(): string { - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - if (workerUserDataPath) return workerUserDataPath - return this.getElectronPath('userData') || process.cwd() - } - - private getDocumentsPath(): string { - return this.getElectronPath('documents') || join(homedir(), 'Documents') - } - - private getTempPath(): string { - return this.getElectronPath('temp') || tmpdir() - } - - async clearCache(): Promise<{ success: boolean; error?: string }> { - this.resolvedCache.clear() - this.pending.clear() - this.updateFlags.clear() - this.accountDirCache.clear() - this.ensuredDirs.clear() - this.cacheRootPath = null - - const configured = this.configService.get('cachePath') - const root = configured - ? join(configured, 'Images') - : join(this.getDocumentsPath(), 'WeFlow', 'Images') - - try { - if (!existsSync(root)) { - return { success: true } - } - const monthPattern = /^\d{4}-\d{2}$/ - const clearFilesInDir = async (dirPath: string): Promise => { - let entries: Array<{ name: string; isDirectory: () => boolean }> - try { - entries = await readdir(dirPath, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - await clearFilesInDir(fullPath) - continue - } - try { - await rm(fullPath, { force: true }) - } catch { } - } - } - const traverse = async (dirPath: string): Promise => { - let entries: Array<{ name: string; isDirectory: () => boolean }> - try { - entries = await readdir(dirPath, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - const fullPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - if (monthPattern.test(entry.name)) { - await clearFilesInDir(fullPath) - } else { - await traverse(fullPath) - } - continue - } - try { - await rm(fullPath, { force: true }) - } catch { } - } - } - await traverse(root) - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } -} - -export const imageDecryptService = new ImageDecryptService() diff --git a/electron/services/imageDownloadService.ts b/electron/services/imageDownloadService.ts deleted file mode 100644 index 78eff18..0000000 --- a/electron/services/imageDownloadService.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { app } from 'electron' -import { join } from 'path' -import { existsSync } from 'fs' -import { execFile } from 'child_process' -import { promisify } from 'util' -// import { ConfigService } from './config' - -const execFileAsync = promisify(execFile) - -export class ImageDownloadService { - private static instance: ImageDownloadService - private koffi: any = null - private lib: any = null - private initialized = false - - private initImgHelper: any = null - private uninstallImgHelper: any = null - private getImgHelperError: any = null - - private currentPid: number | null = null - private pollTimer: NodeJS.Timeout | null = null - private isHooked = false - - private lastWhitelist: string[] = [] - - static getInstance(): ImageDownloadService { - if (!ImageDownloadService.instance) { - ImageDownloadService.instance = new ImageDownloadService() - } - return ImageDownloadService.instance - } - - private constructor() { - } - - private async ensureInitialized(): Promise { - if (this.initialized) return true - if (process.platform !== 'win32' || process.arch !== 'x64') return false - - try { - this.koffi = require('koffi') - const dllPath = this.getDllPath() - if (!existsSync(dllPath)) return false - - this.lib = this.koffi.load(dllPath) - - this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)') - this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()') - this.getImgHelperError = this.lib.func('const char* GetImgHelperError()') - - this.initialized = true - return true - } catch (error) { - console.error('[ImageDownloadService] failed to initialize:', error) - return false - } - } - - private getDllPath(): string { - const isPackaged = app.isPackaged - const candidates: string[] = [] - - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll')) - } else { - candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll')) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - return candidates[0] - } - - private async findMainWeChatPid(): Promise { - try { - const script = ` - Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" | - Select-Object ProcessId, CommandLine | - ConvertTo-Json -Compress - `; - - const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script]) - if (!stdout || !stdout.trim()) return null - - let processes = JSON.parse(stdout.trim()) - if (!Array.isArray(processes)) processes = [processes] - - const target = processes - .filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe')) - .sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0] - - return target ? target.ProcessId : null; - } catch (e) { - return null - } - } - - async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> { - if (!await this.ensureInitialized()) { - return { success: false, error: '核心组件初始化失败' } - } - - if (this.isHooked) { - await this.unhook() - } - - this.lastWhitelist = whitelist - - if (!this.pollTimer) { - this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000) - } - - return await this.checkAndHook(whitelist, true) - } - - async stopAutoDownload() { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - await this.unhook() - } - - private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> { - const pid = await this.findMainWeChatPid() - - if (!pid) { - if (this.isHooked) { - console.log('[ImageDownloadService] WeChat exited, unhooking') - await this.unhook() - } - return { success: true, error: '等待微信启动' } - } - - if (this.isHooked && this.currentPid === pid) { - return { success: true } - } - - if (this.isHooked && this.currentPid !== pid) { - console.log('[ImageDownloadService] WeChat PID changed, re-hooking') - await this.unhook() - } - - console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`) - try { - let whitelistBuffer: Buffer | null = null; - if (typeof whitelist === 'string') { - if (whitelist.length > 0) { - whitelistBuffer = Buffer.from(whitelist, 'utf8'); - } - } else if (Array.isArray(whitelist) && whitelist.length > 0) { - whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8'); - } - - const success = this.initImgHelper(pid, whitelistBuffer) - - if (success) { - this.isHooked = true - this.currentPid = pid - console.log('[ImageDownloadService] hook successful') - return { success: true } - } else { - const err = this.getImgHelperError() - console.error(`[ImageDownloadService] hook failed: ${err}`) - if (isManualStart && this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - return { success: false, error: err || 'Hook 失败' } - } - } catch (e: any) { - console.error('[ImageDownloadService] InitImgHelper call crashed:', e) - if (isManualStart && this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - return { success: false, error: `调用异常: ${e.message || String(e)}` } - } - } - - private async unhook() { - if (this.isHooked && this.uninstallImgHelper) { - try { - this.uninstallImgHelper() - } catch (e) { - console.error('[ImageDownloadService] uninstall failed:', e) - } - } - this.isHooked = false - this.currentPid = null - } - - async getStatus() { - return { - isHooked: this.isHooked, - pid: this.currentPid, - supported: process.platform === 'win32' && process.arch === 'x64' - } - } -} - -export const imageDownloadService = ImageDownloadService.getInstance() diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts deleted file mode 100644 index dacee88..0000000 --- a/electron/services/imagePreloadService.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { imageDecryptService } from './imageDecryptService' - -type PreloadImagePayload = { - sessionId?: string - imageMd5?: string - imageDatName?: string - createTime?: number -} - -type PreloadOptions = { - allowDecrypt?: boolean - allowCacheIndex?: boolean -} - -type PreloadTask = PreloadImagePayload & { - key: string - allowDecrypt: boolean - allowCacheIndex: boolean -} - -export class ImagePreloadService { - private queue: PreloadTask[] = [] - private pending = new Set() - private activeCache = 0 - private activeDecrypt = 0 - private readonly maxCacheConcurrent = 8 - private readonly maxDecryptConcurrent = 2 - private readonly maxQueueSize = 320 - - enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { - if (!Array.isArray(payloads) || payloads.length === 0) return - const allowDecrypt = options?.allowDecrypt !== false - const allowCacheIndex = options?.allowCacheIndex !== false - for (const payload of payloads) { - if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break - 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, allowDecrypt, allowCacheIndex }) - } - this.processQueue() - } - - private processQueue(): void { - while (this.queue.length > 0) { - const taskIndex = this.queue.findIndex((task) => ( - task.allowDecrypt - ? this.activeDecrypt < this.maxDecryptConcurrent - : this.activeCache < this.maxCacheConcurrent - )) - if (taskIndex < 0) return - - const task = this.queue.splice(taskIndex, 1)[0] - if (!task) return - - if (task.allowDecrypt) this.activeDecrypt += 1 - else this.activeCache += 1 - - void this.handleTask(task).finally(() => { - if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1) - else this.activeCache = Math.max(0, this.activeCache - 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, - createTime: task.createTime, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: !task.allowDecrypt, - allowCacheIndex: task.allowCacheIndex, - suppressEvents: true - }) - if (cached.success) return - if (!task.allowDecrypt) return - await imageDecryptService.decryptImage({ - sessionId: task.sessionId, - imageMd5: task.imageMd5, - imageDatName: task.imageDatName, - createTime: task.createTime, - preferFilePath: true, - hardlinkOnly: true, - disableUpdateCheck: true, - suppressEvents: true - }) - } catch { - // ignore preload failures - } - } -} - -export const imagePreloadService = new ImagePreloadService() diff --git a/electron/services/insightProfileService.ts b/electron/services/insightProfileService.ts deleted file mode 100644 index 79854a2..0000000 --- a/electron/services/insightProfileService.ts +++ /dev/null @@ -1,1001 +0,0 @@ -import fs from 'fs' -import path from 'path' -import https from 'https' -import http from 'http' -import { URL } from 'url' -import { app } from 'electron' -import { randomUUID, createHash } from 'crypto' -import { ConfigService } from './config' -import { chatService, type Message } from './chatService' -import { wcdbService } from './wcdbService' - -const API_TIMEOUT_MS = 45_000 -const API_TEMPERATURE = 0.7 -const MONTH_MATERIAL_CHAR_LIMIT = 45_000 -const DIRECT_MONTH_MESSAGE_LIMIT = 1000 -const MONTH_CURSOR_BATCH_SIZE = 800 -const MAX_RETRY_ATTEMPTS = 5 -const MONTHLY_OUTPUT_MIN_TOKENS = 1600 -const FINAL_OUTPUT_MIN_TOKENS = 2400 - -type ProfileStatusValue = 'none' | 'ready' | 'running' | 'failed' - -interface SharedAiModelConfig { - apiBaseUrl: string - apiKey: string - model: string - maxTokens: number -} - -interface ActiveProfileTask { - taskId: string - sessionId: string - displayName: string - controller: AbortController - phase: string - startedAt: number - cursor?: number -} - -interface MonthWindow { - key: string - label: string - startSec: number - endSec: number -} - -interface MonthStats { - total: number - mine: number - peer: number - activeDays: number - longestActiveDayStreak: number - longestSilenceDays: number - topHours: string[] - firstTime?: number - lastTime?: number -} - -interface PreparedMonthMaterial { - text: string - compressed: boolean - stats: MonthStats - scannedMessages: number - sampledMessages: number -} - -interface MonthSummary { - month: string - messageCount: number - compressed: boolean - sampledMessages: number - summary: string -} - -export interface InsightProfileRecord { - id: string - accountScope: string - sessionId: string - displayName: string - avatarUrl?: string - createdAt: number - updatedAt: number - rangeStart: number - rangeEnd: number - months: string[] - emptyMonths: string[] - monthlySummaries: MonthSummary[] - finalProfile: string - stats: { - scannedMessages: number - summarizedMonths: number - emptyMonths: number - compressedMonths: number - } - model: string -} - -export interface InsightProfileStatus { - sessionId: string - status: ProfileStatusValue - updatedAt?: number - error?: string - phase?: string - busy?: boolean -} - -export interface InsightProfileStatusListResult { - success: boolean - statuses: Record - activeTask?: { - sessionId: string - displayName: string - phase: string - startedAt: number - } - error?: string -} - -export interface InsightProfileGenerateResult { - success: boolean - message: string - cancelled?: boolean - profile?: InsightProfileRecord - error?: string -} - -class AbortRequestError extends Error { - constructor(message = '画像任务已取消') { - super(message) - this.name = 'AbortError' - } -} - -class ApiRequestError extends Error { - statusCode?: number - responseBody?: string - - constructor(message: string, statusCode?: number, responseBody?: string) { - super(message) - this.name = 'ApiRequestError' - this.statusCode = statusCode - this.responseBody = responseBody - } -} - -function isAbortError(error: unknown): boolean { - return (error as Error)?.name === 'AbortError' || String((error as Error)?.message || '').includes('取消') -} - -function abortIfNeeded(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new AbortRequestError() - } -} - -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - abortIfNeeded(signal) - let settled = false - const timer = setTimeout(() => { - if (settled) return - settled = true - cleanup() - resolve() - }, ms) - const onAbort = () => { - if (settled) return - settled = true - clearTimeout(timer) - cleanup() - reject(new AbortRequestError()) - } - const cleanup = () => { - signal?.removeEventListener('abort', onAbort) - } - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -function normalizeApiMaxTokens(value: unknown): number { - const numeric = Number(value) - if (!Number.isFinite(numeric)) return 1024 - return Math.min(2_000_000, Math.max(1, Math.floor(numeric))) -} - -function buildApiUrl(baseUrl: string, apiPath: string): string { - const base = baseUrl.replace(/\/+$/, '') - const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}` - return `${base}${suffix}` -} - -function formatPromptCurrentTime(date: Date = new Date()): string { - 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}` -} - -function appendPromptCurrentTime(prompt: string): string { - const base = String(prompt || '').trimEnd() - return base ? `${base}\n\n${formatPromptCurrentTime()}` : formatPromptCurrentTime() -} - -function clampText(value: unknown, maxLength: number): string { - const text = String(value || '').replace(/\s+/g, ' ').trim() - if (text.length <= maxLength) return text - return `${text.slice(0, Math.max(0, maxLength - 1))}…` -} - -function truncateStructuredText(value: unknown, maxLength: number): string { - const text = String(value || '').replace(/\u0000/g, '').trim() - if (text.length <= maxLength) return text - return `${text.slice(0, Math.max(0, maxLength - 3))}...` -} - -function formatDateTime(timestampSeconds: number): string { - if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) return '' - const date = new Date(timestampSeconds * 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') - return `${year}-${month}-${day} ${hours}:${minutes}` -} - -function formatMonthKey(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` -} - -function getMonthStart(date: Date): Date { - return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0) -} - -function toSeconds(date: Date): number { - return Math.floor(date.getTime() / 1000) -} - -function buildRecentTwelveMonthWindows(now: Date = new Date()): MonthWindow[] { - const currentMonthStart = getMonthStart(now) - const windows: MonthWindow[] = [] - for (let index = 11; index >= 0; index -= 1) { - const start = new Date(currentMonthStart) - start.setMonth(currentMonthStart.getMonth() - index) - const next = new Date(start) - next.setMonth(start.getMonth() + 1) - const isCurrentMonth = index === 0 - const end = isCurrentMonth ? now : new Date(next.getTime() - 1000) - const key = formatMonthKey(start) - windows.push({ - key, - label: key, - startSec: toSeconds(start), - endSec: Math.max(toSeconds(start), toSeconds(end)) - }) - } - return windows -} - -function callProfileApi( - config: SharedAiModelConfig, - messages: Array<{ role: string; content: string }>, - maxTokens: number, - signal?: AbortSignal -): Promise { - return new Promise((resolve, reject) => { - try { - abortIfNeeded(signal) - const endpoint = buildApiUrl(config.apiBaseUrl, '/chat/completions') - const urlObj = new URL(endpoint) - const payload = JSON.stringify({ - model: config.model, - messages, - max_tokens: normalizeApiMaxTokens(maxTokens), - temperature: API_TEMPERATURE, - stream: false - }) - const requestOptions = { - hostname: urlObj.hostname, - port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: 'POST' as const, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload).toString(), - Authorization: `Bearer ${config.apiKey}` - } - } - - const requestFn = urlObj.protocol === 'https:' ? https.request : http.request - const req = requestFn(requestOptions, (res) => { - let data = '' - res.on('data', (chunk) => { data += chunk }) - res.on('end', () => { - try { - if (res.statusCode && res.statusCode >= 400) { - reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) - return - } - const parsed = JSON.parse(data) - const content = parsed?.choices?.[0]?.message?.content - if (typeof content === 'string' && content.trim()) { - resolve(content.trim()) - } else { - reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) - } - } catch { - reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) - } - }) - }) - - const onAbort = () => { - req.destroy(new AbortRequestError()) - } - signal?.addEventListener('abort', onAbort, { once: true }) - - req.setTimeout(API_TIMEOUT_MS, () => { - req.destroy() - reject(new Error('API 请求超时')) - }) - req.on('error', (error) => { - signal?.removeEventListener('abort', onAbort) - reject(isAbortError(error) || signal?.aborted ? new AbortRequestError() : error) - }) - req.on('close', () => { - signal?.removeEventListener('abort', onAbort) - }) - req.write(payload) - req.end() - } catch (error) { - reject(error) - } - }) -} - -async function callProfileApiWithRetry( - config: SharedAiModelConfig, - messages: Array<{ role: string; content: string }>, - maxTokens: number, - signal?: AbortSignal -): Promise { - let lastError: unknown - for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) { - abortIfNeeded(signal) - try { - return await callProfileApi(config, messages, maxTokens, signal) - } catch (error) { - if (isAbortError(error) || signal?.aborted) throw new AbortRequestError() - lastError = error - if (attempt >= MAX_RETRY_ATTEMPTS) break - await sleep(Math.min(10_000, 800 * Math.pow(2, attempt - 1)), signal) - } - } - throw lastError instanceof Error ? lastError : new Error(String(lastError || 'API 请求失败')) -} - -class InsightProfileService { - private readonly config = ConfigService.getInstance() - private filePath: string | null = null - private loaded = false - private records: InsightProfileRecord[] = [] - private activeTask: ActiveProfileTask | null = null - private failedStatus = new Map() - - private resolveFilePath(): string { - if (this.filePath) return this.filePath - const userDataPath = app?.getPath?.('userData') || process.cwd() - fs.mkdirSync(userDataPath, { recursive: true }) - this.filePath = path.join(userDataPath, 'weflow-insight-profiles.json') - return this.filePath - } - - private ensureLoaded(): void { - if (this.loaded) return - this.loaded = true - try { - const filePath = this.resolveFilePath() - if (!fs.existsSync(filePath)) return - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) - const records = Array.isArray(parsed) ? parsed : parsed?.records - if (Array.isArray(records)) { - this.records = records.filter((item) => item && typeof item === 'object') as InsightProfileRecord[] - } - } catch { - this.records = [] - } - } - - private persist(): void { - try { - fs.writeFileSync(this.resolveFilePath(), JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8') - } catch { - // Profile generation should not crash when local persistence fails. - } - } - - private getCurrentAccountScope(): string { - const myWxid = String(this.config.getMyWxidCleaned() || '').trim() - if (myWxid) return `wxid:${myWxid}` - const dbPath = String(this.config.get('dbPath') || '').trim() - if (dbPath) { - const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16) - return `db:${hash}` - } - return 'default' - } - - private getSharedAiModelConfig(): SharedAiModelConfig { - const apiBaseUrl = String( - this.config.get('aiModelApiBaseUrl') - || this.config.get('aiInsightApiBaseUrl') - || '' - ).trim().replace(/\/+$/, '') - const apiKey = String( - this.config.get('aiModelApiKey') - || this.config.get('aiInsightApiKey') - || '' - ).trim() - const model = String( - this.config.get('aiModelApiModel') - || this.config.get('aiInsightApiModel') - || 'gpt-4o-mini' - ).trim() || 'gpt-4o-mini' - const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens')) - return { apiBaseUrl, apiKey, model, maxTokens } - } - - private findLatestRecord(sessionId: string): InsightProfileRecord | null { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return null - const matches = this.records - .filter((record) => record.accountScope === scope && record.sessionId === normalizedSessionId) - .sort((a, b) => b.updatedAt - a.updatedAt) - return matches[0] || null - } - - listProfileStatuses(sessionIds: string[]): InsightProfileStatusListResult { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - const latestBySession = new Map() - for (const record of this.records.filter((item) => item.accountScope === scope)) { - const existing = latestBySession.get(record.sessionId) - if (!existing || record.updatedAt > existing.updatedAt) { - latestBySession.set(record.sessionId, record) - } - } - - const statuses: Record = {} - for (const sessionId of normalizedIds) { - const activeForSession = this.activeTask?.sessionId === sessionId - if (activeForSession && this.activeTask) { - statuses[sessionId] = { - sessionId, - status: 'running', - phase: this.activeTask.phase, - updatedAt: this.activeTask.startedAt, - busy: false - } - continue - } - - const record = latestBySession.get(sessionId) - if (record) { - statuses[sessionId] = { - sessionId, - status: 'ready', - updatedAt: record.updatedAt, - busy: Boolean(this.activeTask) - } - continue - } - - const failed = this.failedStatus.get(sessionId) - if (failed) { - statuses[sessionId] = { - sessionId, - status: 'failed', - updatedAt: failed.updatedAt, - error: failed.error, - busy: Boolean(this.activeTask) - } - continue - } - - statuses[sessionId] = { - sessionId, - status: 'none', - busy: Boolean(this.activeTask) - } - } - - return { - success: true, - statuses, - activeTask: this.activeTask - ? { - sessionId: this.activeTask.sessionId, - displayName: this.activeTask.displayName, - phase: this.activeTask.phase, - startedAt: this.activeTask.startedAt - } - : undefined - } - } - - getProfileContextSection(sessionId: string): string { - const record = this.findLatestRecord(sessionId) - if (!record?.finalProfile) return '' - const rangeStart = formatDateTime(record.rangeStart) - const rangeEnd = formatDateTime(record.rangeEnd) - return [ - `联系人长期 AI 画像(覆盖 ${rangeStart} 至 ${rangeEnd},生成于 ${new Date(record.updatedAt).toLocaleString('zh-CN')}):`, - clampText(record.finalProfile, 3000) - ].join('\n') - } - - cancelProfile(sessionId?: string): { success: boolean; message: string } { - const normalizedSessionId = String(sessionId || '').trim() - if (!this.activeTask) return { success: true, message: '当前没有画像任务' } - if (normalizedSessionId && normalizedSessionId !== this.activeTask.sessionId) { - return { success: false, message: '当前运行中的画像任务不属于该联系人' } - } - this.activeTask.phase = '正在取消画像...' - this.activeTask.controller.abort() - return { success: true, message: '已请求取消画像任务' } - } - - cancelActiveTask(reason = '画像任务已取消'): void { - if (!this.activeTask) return - this.activeTask.phase = reason - this.activeTask.controller.abort() - } - - async generateProfile(params: { - sessionId: string - displayName?: string - avatarUrl?: string - }): Promise { - const sessionId = String(params?.sessionId || '').trim() - if (!sessionId || sessionId.endsWith('@chatroom')) { - return { success: false, message: 'AI 画像仅支持私聊联系人' } - } - if (this.activeTask) { - return { - success: false, - message: `「${this.activeTask.displayName}」的画像正在生成,请等待完成或取消后再试` - } - } - - const aiConfig = this.getSharedAiModelConfig() - if (!aiConfig.apiBaseUrl || !aiConfig.apiKey) { - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - const existing = this.findLatestRecord(sessionId) - const displayName = clampText(params?.displayName || existing?.displayName || sessionId, 80) || sessionId - const controller = new AbortController() - const task: ActiveProfileTask = { - taskId: randomUUID(), - sessionId, - displayName, - controller, - phase: '正在初始化画像...', - startedAt: Date.now() - } - this.activeTask = task - - try { - const connectResult = await chatService.connect() - abortIfNeeded(controller.signal) - if (!connectResult.success) { - throw new Error('数据库连接失败,请先在“数据库连接”页完成配置') - } - - const windows = buildRecentTwelveMonthWindows() - const monthlySummaries: MonthSummary[] = [] - const emptyMonths: string[] = [] - let scannedMessages = 0 - let compressedMonths = 0 - - for (let index = 0; index < windows.length; index += 1) { - abortIfNeeded(controller.signal) - const month = windows[index] - task.phase = `正在读取 ${month.label} 聊天记录 (${index + 1}/12)...` - const messages = await this.readMonthMessages(sessionId, month, task) - scannedMessages += messages.length - - if (messages.length === 0) { - emptyMonths.push(month.label) - continue - } - - const material = this.prepareMonthMaterial(messages, displayName) - if (material.compressed) compressedMonths += 1 - - task.phase = `正在生成 ${month.label} 月度画像 (${monthlySummaries.length + 1})...` - const summary = await this.generateMonthlySummary(aiConfig, displayName, month.label, material, controller.signal) - monthlySummaries.push({ - month: month.label, - messageCount: material.scannedMessages, - compressed: material.compressed, - sampledMessages: material.sampledMessages, - summary - }) - } - - if (monthlySummaries.length === 0) { - throw new Error('最近 12 个自然月没有可用于画像的聊天记录') - } - - task.phase = '正在汇总完整 AI 画像...' - const finalProfile = await this.generateFinalProfile(aiConfig, displayName, windows, emptyMonths, monthlySummaries, controller.signal) - abortIfNeeded(controller.signal) - - const now = Date.now() - const record: InsightProfileRecord = { - id: randomUUID(), - accountScope: this.getCurrentAccountScope(), - sessionId, - displayName, - avatarUrl: String(params?.avatarUrl || existing?.avatarUrl || '').trim() || undefined, - createdAt: existing?.createdAt || now, - updatedAt: now, - rangeStart: windows[0].startSec, - rangeEnd: windows[windows.length - 1].endSec, - months: windows.map((month) => month.label), - emptyMonths, - monthlySummaries, - finalProfile, - stats: { - scannedMessages, - summarizedMonths: monthlySummaries.length, - emptyMonths: emptyMonths.length, - compressedMonths - }, - model: aiConfig.model - } - - this.upsertRecord(record) - this.failedStatus.delete(sessionId) - return { - success: true, - message: `已完成「${displayName}」的 AI 画像`, - profile: record - } - } catch (error) { - if (isAbortError(error) || controller.signal.aborted) { - return { success: false, cancelled: true, message: '画像已取消' } - } - const message = (error as Error).message || String(error) - if (!existing) { - this.failedStatus.set(sessionId, { error: message, updatedAt: Date.now() }) - } - return { success: false, message: `画像失败:${message}`, error: message } - } finally { - if (task.cursor) { - await wcdbService.closeMessageCursor(task.cursor).catch(() => {}) - } - if (this.activeTask?.taskId === task.taskId) { - this.activeTask = null - } - } - } - - private upsertRecord(record: InsightProfileRecord): void { - this.ensureLoaded() - this.records = this.records.filter((item) => !(item.accountScope === record.accountScope && item.sessionId === record.sessionId)) - this.records.push(record) - this.persist() - } - - private async readMonthMessages(sessionId: string, month: MonthWindow, task: ActiveProfileTask): Promise { - const cursorResult = await wcdbService.openMessageCursorLite( - sessionId, - MONTH_CURSOR_BATCH_SIZE, - true, - month.startSec, - month.endSec - ) - if (!cursorResult.success || !cursorResult.cursor) { - throw new Error(cursorResult.error || `读取 ${month.label} 聊天记录失败`) - } - - task.cursor = cursorResult.cursor - const messages: Message[] = [] - try { - while (true) { - abortIfNeeded(task.controller.signal) - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success) { - throw new Error(batch.error || `读取 ${month.label} 聊天记录失败`) - } - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - if (rows.length > 0) { - const mapped = chatService.mapRowsToMessagesLiteForApi(rows) - for (const message of mapped) { - const createTime = Number(message.createTime || 0) - if (createTime < month.startSec || createTime > month.endSec) continue - messages.push({ - ...message, - rawContent: clampText(message.rawContent || message.content || '', 1200), - content: undefined - }) - } - } - if (!batch.hasMore) break - } - messages.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq) || (a.localId - b.localId)) - return messages - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) - if (task.cursor === cursorResult.cursor) task.cursor = undefined - } - } - - private prepareMonthMaterial(messages: Message[], peerDisplayName: string): PreparedMonthMaterial { - const stats = this.computeMonthStats(messages) - const lines = messages.map((message) => this.formatMessageLine(message, peerDisplayName)) - const fullText = lines.join('\n') - if (messages.length <= DIRECT_MONTH_MESSAGE_LIMIT && fullText.length <= MONTH_MATERIAL_CHAR_LIMIT) { - return { - text: fullText, - compressed: false, - stats, - scannedMessages: messages.length, - sampledMessages: messages.length - } - } - - const statsText = this.formatMonthStats(stats) - const selectedIndices = this.selectRepresentativeIndices(messages) - const sampledLines = Array.from(selectedIndices) - .sort((a, b) => a - b) - .map((index) => lines[index]) - - const sampledText = this.fitLinesToBudget(sampledLines, Math.max(10_000, MONTH_MATERIAL_CHAR_LIMIT - statsText.length - 800)) - const text = [ - '本月聊天记录已完整扫描。由于原文过长,以下为本地统计摘要、时间均匀抽样与高信息密度片段;请基于这些证据谨慎概括,不要把抽样片段视为全部事实。', - '', - statsText, - '', - '代表性聊天片段(按时间顺序):', - sampledText || '无可读文本片段' - ].join('\n') - - return { - text: truncateStructuredText(text, MONTH_MATERIAL_CHAR_LIMIT), - compressed: true, - stats, - scannedMessages: messages.length, - sampledMessages: sampledLines.length - } - } - - private computeMonthStats(messages: Message[]): MonthStats { - const daySet = new Set() - const hourCounts = new Map() - let mine = 0 - let peer = 0 - let firstTime = 0 - let lastTime = 0 - - for (const message of messages) { - const ts = Math.max(0, Math.floor(Number(message.createTime || 0))) - if (ts > 0) { - if (!firstTime || ts < firstTime) firstTime = ts - if (!lastTime || ts > lastTime) lastTime = ts - const date = new Date(ts * 1000) - const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` - daySet.add(day) - const hour = date.getHours() - hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1) - } - if (message.isSend === 1) mine += 1 - else peer += 1 - } - - const sortedDays = Array.from(daySet).sort() - let longestActiveDayStreak = 0 - let currentStreak = 0 - let longestSilenceDays = 0 - let prevDayTime = 0 - for (const day of sortedDays) { - const dayTime = new Date(`${day}T00:00:00`).getTime() - if (!prevDayTime || dayTime - prevDayTime === 86_400_000) { - currentStreak += 1 - } else { - currentStreak = 1 - longestSilenceDays = Math.max(longestSilenceDays, Math.floor((dayTime - prevDayTime) / 86_400_000) - 1) - } - prevDayTime = dayTime - longestActiveDayStreak = Math.max(longestActiveDayStreak, currentStreak) - } - - const topHours = Array.from(hourCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([hour, count]) => `${String(hour).padStart(2, '0')}:00(${count}条)`) - - return { - total: messages.length, - mine, - peer, - activeDays: daySet.size, - longestActiveDayStreak, - longestSilenceDays, - topHours, - firstTime: firstTime || undefined, - lastTime: lastTime || undefined - } - } - - private formatMonthStats(stats: MonthStats): string { - return [ - '本月统计摘要:', - `消息总数:${stats.total}`, - `我发送:${stats.mine};对方发送:${stats.peer}`, - `活跃天数:${stats.activeDays}`, - `最长连续活跃:${stats.longestActiveDayStreak} 天`, - `最长无互动间隔:${stats.longestSilenceDays} 天`, - `主要互动时段:${stats.topHours.length > 0 ? stats.topHours.join('、') : '无'}`, - `首条消息时间:${stats.firstTime ? formatDateTime(stats.firstTime) : '无'}`, - `末条消息时间:${stats.lastTime ? formatDateTime(stats.lastTime) : '无'}` - ].join('\n') - } - - private selectRepresentativeIndices(messages: Message[]): Set { - const selected = new Set() - const addWindow = (center: number, radius = 2) => { - for (let index = Math.max(0, center - radius); index <= Math.min(messages.length - 1, center + radius); index += 1) { - selected.add(index) - } - } - - if (messages.length === 0) return selected - - const bucketCount = Math.min(24, Math.max(6, Math.ceil(messages.length / 250))) - for (let bucket = 0; bucket < bucketCount; bucket += 1) { - const start = Math.floor((messages.length * bucket) / bucketCount) - const end = Math.max(start, Math.floor((messages.length * (bucket + 1)) / bucketCount) - 1) - addWindow(start, 1) - addWindow(Math.floor((start + end) / 2), 1) - addWindow(end, 1) - } - - const scored = messages.map((message, index) => ({ - index, - score: this.scoreMessageForProfile(message) - })) - .filter((item) => item.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 120) - - for (const item of scored) { - addWindow(item.index, 2) - } - - addWindow(0, 3) - addWindow(Math.floor(messages.length / 2), 3) - addWindow(messages.length - 1, 3) - return selected - } - - private scoreMessageForProfile(message: Message): number { - const content = this.extractReadableContent(message) - if (!content || content.startsWith('[')) return 0 - const emotionWords = [ - '谢谢', '感谢', '抱歉', '对不起', '开心', '高兴', '难过', '委屈', '生气', '焦虑', '压力', '累', - '想你', '喜欢', '爱', '在乎', '担心', '害怕', '烦', '崩溃', '见面', '一起', '约', '陪', '帮' - ] - let score = Math.min(80, content.length) - if (/[??]/.test(content)) score += 18 - if (/[!!]{1,}/.test(content)) score += 8 - for (const word of emotionWords) { - if (content.includes(word)) score += 24 - } - if (message.quotedContent) score += 12 - if (content.length >= 80) score += 16 - return score - } - - private fitLinesToBudget(lines: string[], budget: number): string { - const output: string[] = [] - let used = 0 - for (const line of lines) { - const normalized = clampText(line, 700) - const nextUsed = used + normalized.length + 1 - if (nextUsed > budget) break - output.push(normalized) - used = nextUsed - } - return output.join('\n') - } - - private extractReadableContent(message: Message): string { - const parsed = String(message.parsedContent || '').trim() - if (parsed) return clampText(parsed, 600) - const raw = String(message.rawContent || message.content || '').trim() - if (!raw) return '[其他消息]' - if (/^(<\?xml| { - const systemPrompt = `你是一个克制、细致的长期关系画像分析助手。你只根据给定聊天材料分析,不做诊断,不给道德评判,不编造事实。你的目标是从一个自然月的聊天中提炼这个人的沟通风格、情绪模式、关系需求、关注主题、互动节奏,以及与“我”的关系变化线索。 - -要求: -1. 输出中文纯文本,不使用 Markdown。 -2. 控制在 400-600 字。 -3. 必须区分“有证据支持的观察”和“不确定但可留意的倾向”。 -4. 不要逐条复述聊天记录,要提炼稳定模式和本月变化。 -5. 对敏感内容使用概括,不输出隐私细节。 -6. 如果材料经过压缩或抽样,明确保持谨慎,不把局部片段当成全部事实。` - - const userPrompt = appendPromptCurrentTime(`对象:${displayName} -月份:${monthLabel} -材料状态:${material.compressed ? '本月原始记录过长,已完整扫描后进行本地结构化压缩与代表性抽样' : '本月记录未超过预算,按时间顺序提供'} -扫描消息数:${material.scannedMessages} -用于输入的代表消息数:${material.sampledMessages} - -本月聊天材料: -${material.text} - -请输出本月画像总结,覆盖:沟通风格、情绪与压力线索、关注主题、与我的互动模式、本月关系变化、后续相处建议。`) - - return callProfileApiWithRetry( - config, - [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - Math.max(config.maxTokens, MONTHLY_OUTPUT_MIN_TOKENS), - signal - ) - } - - private async generateFinalProfile( - config: SharedAiModelConfig, - displayName: string, - months: MonthWindow[], - emptyMonths: string[], - monthlySummaries: MonthSummary[], - signal?: AbortSignal - ): Promise { - const systemPrompt = `你是用户的私人关系画像整理助手。你需要把最近 12 个自然月的月度画像总结合成为一份长期 AI 画像。你只能基于月度总结和空月信息判断,不编造缺失月份内容。 - -要求: -1. 输出中文纯文本,不使用 Markdown。 -2. 控制在 900-1400 字。 -3. 画像要稳定、克制、可用于后续 AI 见解上下文。 -4. 优先总结长期模式,其次指出近三个月变化。 -5. 给出与这个人互动时最值得注意的 3-5 条原则。 -6. 不做医学、法律、心理诊断;避免贴标签式结论。` - - const summaryText = monthlySummaries - .map((item) => `【${item.month}】消息数:${item.messageCount};材料${item.compressed ? '已压缩抽样' : '未压缩'}\n${item.summary}`) - .join('\n\n') - - const userPrompt = appendPromptCurrentTime(`对象:${displayName} -时间范围:${months[0].label} 至 ${months[months.length - 1].label} -空月:${emptyMonths.length > 0 ? emptyMonths.join('、') : '无'} - -月度总结: -${summaryText} - -请生成完整 AI 画像,结构包含:整体印象、沟通风格、情绪/压力模式、核心关注、关系互动模式、最近变化、相处建议、后续 AI 见解使用注意事项。`) - - return callProfileApiWithRetry( - config, - [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - Math.max(config.maxTokens, FINAL_OUTPUT_MIN_TOKENS), - signal - ) - } -} - -export const insightProfileService = new InsightProfileService() diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts deleted file mode 100644 index b36b203..0000000 --- a/electron/services/insightRecordService.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { app } from 'electron' -import fs from 'fs' -import path from 'path' -import { createHash, randomUUID } from 'crypto' -import { ConfigService } from './config' - -export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis' -export type InsightRecordSourceType = 'insight' | 'message_analysis' - -export interface MessageInsightAnalysis { - explicitText: string - emotion: string - intent: string - topic: string -} - -export interface MessageInsightTarget { - targetLocalId: number - targetCreateTime: number - targetMessageKey: string - targetSenderName: string - targetTextPreview: string - analysis: MessageInsightAnalysis -} - -export interface InsightRecordLog { - endpoint: string - model: string - maxTokens: number - temperature: number - triggerReason: InsightRecordTriggerReason - allowContext: boolean - contextCount: number - systemPrompt: string - userPrompt: string - rawOutput: string - finalInsight: string - durationMs: number - createdAt: number - responseFormatJson?: boolean - responseFormatFallback?: boolean - responseFormatFallbackReason?: string - targetMessage?: { - localId: number - createTime: number - messageKey: string - senderName: string - textPreview: string - } - contextStats?: { - requested: number - beforeTarget: number - afterTarget: number - readError?: string - } - parsedAnalysis?: MessageInsightAnalysis -} - -export interface InsightRecord { - id: string - accountScope: string - sourceType: InsightRecordSourceType - createdAt: number - sessionId: string - displayName: string - avatarUrl?: string - triggerReason: InsightRecordTriggerReason - insight: string - read: boolean - messageInsight?: MessageInsightTarget - log: InsightRecordLog -} - -export interface InsightRecordSummary { - id: string - sourceType: InsightRecordSourceType - createdAt: number - sessionId: string - displayName: string - avatarUrl?: string - triggerReason: InsightRecordTriggerReason - insight: string - read: boolean - messageInsight?: MessageInsightTarget -} - -export interface InsightRecordContactFacet { - sessionId: string - displayName: string - avatarUrl?: string - count: number -} - -export interface InsightRecordFilters { - keyword?: string - sessionId?: string - startTime?: number - endTime?: number - sourceType?: InsightRecordSourceType | 'all' - limit?: number - offset?: number -} - -export interface InsightRecordListResult { - success: boolean - records: InsightRecordSummary[] - total: number - todayCount: number - unreadCount: number - contacts: InsightRecordContactFacet[] - error?: string -} - -class InsightRecordService { - private readonly maxRecordsPerScope = 1000 - private filePath: string | null = null - private loaded = false - private records: InsightRecord[] = [] - - private resolveFilePath(): string { - if (this.filePath) return this.filePath - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() - fs.mkdirSync(userDataPath, { recursive: true }) - this.filePath = path.join(userDataPath, 'weflow-insight-records.json') - return this.filePath - } - - private ensureLoaded(): void { - if (this.loaded) return - this.loaded = true - const filePath = this.resolveFilePath() - try { - if (!fs.existsSync(filePath)) return - const raw = fs.readFileSync(filePath, 'utf-8') - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) { - this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[] - } else if (Array.isArray(parsed?.records)) { - this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[] - } - } catch { - this.records = [] - } - } - - private persist(): void { - try { - const filePath = this.resolveFilePath() - fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8') - } catch { - // Keep insight generation non-blocking even if local persistence fails. - } - } - - private getCurrentAccountScope(): string { - const config = ConfigService.getInstance() - const myWxid = String(config.getMyWxidCleaned() || '').trim() - if (myWxid) return `wxid:${myWxid}` - - const dbPath = String(config.get('dbPath') || '').trim() - if (dbPath) { - const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16) - return `db:${hash}` - } - return 'default' - } - - private getStartOfToday(): number { - const date = new Date() - date.setHours(0, 0, 0, 0) - return date.getTime() - } - - private toSummary(record: InsightRecord): InsightRecordSummary { - return { - id: record.id, - sourceType: record.sourceType || 'insight', - createdAt: record.createdAt, - sessionId: record.sessionId, - displayName: record.displayName, - avatarUrl: record.avatarUrl, - triggerReason: record.triggerReason, - insight: record.insight, - read: record.read, - messageInsight: record.messageInsight - } - } - - private getScopedRecords(): InsightRecord[] { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - return this.records.filter((record) => record.accountScope === scope) - } - - addRecord(input: { - sessionId: string - displayName: string - avatarUrl?: string - sourceType?: InsightRecordSourceType - triggerReason: InsightRecordTriggerReason - insight: string - messageInsight?: MessageInsightTarget - log: InsightRecordLog - }): InsightRecord { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const now = Date.now() - const record: InsightRecord = { - id: randomUUID(), - accountScope: scope, - sourceType: input.sourceType || 'insight', - createdAt: now, - sessionId: input.sessionId, - displayName: input.displayName, - avatarUrl: input.avatarUrl, - triggerReason: input.triggerReason, - insight: input.insight, - read: false, - messageInsight: input.messageInsight, - log: input.log - } - - this.records.push(record) - const scopedRecords = this.records - .filter((item) => item.accountScope === scope) - .sort((a, b) => b.createdAt - a.createdAt) - const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id)) - this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id)) - this.persist() - return record - } - - listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult { - try { - const allScoped = this.getScopedRecords() - const todayStart = this.getStartOfToday() - const contactsMap = new Map() - for (const record of allScoped) { - const existing = contactsMap.get(record.sessionId) - if (existing) { - existing.count += 1 - } else { - contactsMap.set(record.sessionId, { - sessionId: record.sessionId, - displayName: record.displayName, - avatarUrl: record.avatarUrl, - count: 1 - }) - } - } - - const keyword = String(filters.keyword || '').trim().toLowerCase() - const sessionId = String(filters.sessionId || '').trim() - const sourceType = String(filters.sourceType || 'all').trim() - const startTime = Number(filters.startTime || 0) - const endTime = Number(filters.endTime || 0) - const offset = Math.max(0, Math.floor(Number(filters.offset || 0))) - const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100)))) - - const filtered = allScoped - .filter((record) => { - if (sessionId && record.sessionId !== sessionId) return false - const recordSourceType = record.sourceType || 'insight' - if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) return false - if (startTime > 0 && record.createdAt < startTime) return false - if (endTime > 0 && record.createdAt > endTime) return false - if (keyword) { - const haystack = [ - record.displayName, - record.sessionId, - record.insight, - record.messageInsight?.targetSenderName, - record.messageInsight?.targetTextPreview, - record.messageInsight?.analysis?.explicitText, - record.messageInsight?.analysis?.emotion, - record.messageInsight?.analysis?.intent, - record.messageInsight?.analysis?.topic - ].join('\n').toLowerCase() - if (!haystack.includes(keyword)) return false - } - return true - }) - .sort((a, b) => b.createdAt - a.createdAt) - - return { - success: true, - records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)), - total: filtered.length, - todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length, - unreadCount: allScoped.filter((record) => !record.read).length, - contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count) - } - } catch (error) { - return { - success: false, - records: [], - total: 0, - todayCount: 0, - unreadCount: 0, - contacts: [], - error: (error as Error).message - } - } - } - - getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } { - this.ensureLoaded() - const normalizedId = String(id || '').trim() - if (!normalizedId) return { success: false, error: '记录 ID 为空' } - const scope = this.getCurrentAccountScope() - const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope) - if (!record) return { success: false, error: '未找到该见解记录' } - return { success: true, record } - } - - findLatestMessageAnalysis(input: { - sessionId: string - targetLocalId?: number - targetCreateTime?: number - targetMessageKey?: string - }): InsightRecord | null { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const sessionId = String(input.sessionId || '').trim() - if (!sessionId) return null - const targetLocalId = Math.floor(Number(input.targetLocalId || 0)) - const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0)) - const targetMessageKey = String(input.targetMessageKey || '').trim() - const matches = this.records - .filter((record) => { - if (record.accountScope !== scope) return false - if ((record.sourceType || 'insight') !== 'message_analysis') return false - if (record.sessionId !== sessionId) return false - const target = record.messageInsight - if (!target) return false - if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) { - if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true - } - if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true - return false - }) - .sort((a, b) => b.createdAt - a.createdAt) - return matches[0] || null - } - - markRecordRead(id: string): { success: boolean; error?: string } { - this.ensureLoaded() - const normalizedId = String(id || '').trim() - const scope = this.getCurrentAccountScope() - const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope) - if (!record) return { success: false, error: '未找到该见解记录' } - if (!record.read) { - record.read = true - this.persist() - } - return { success: true } - } - - clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } { - this.ensureLoaded() - const scope = this.getCurrentAccountScope() - const sessionId = String(filters.sessionId || '').trim() - const startTime = Number(filters.startTime || 0) - const endTime = Number(filters.endTime || 0) - let removed = 0 - this.records = this.records.filter((record) => { - if (record.accountScope !== scope) return true - if (sessionId && record.sessionId !== sessionId) return true - if (startTime > 0 && record.createdAt < startTime) return true - if (endTime > 0 && record.createdAt > endTime) return true - removed += 1 - return false - }) - this.persist() - return { success: true, removed } - } -} - -export const insightRecordService = new InsightRecordService() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts deleted file mode 100644 index c23b093..0000000 --- a/electron/services/insightService.ts +++ /dev/null @@ -1,1772 +0,0 @@ -/** - * insightService.ts - * - * AI 见解后台服务: - * 1. 监听 DB 变更事件(debounce 500ms 防抖,避免开机/重连时爆发大量事件阻塞主线程) - * 2. 沉默联系人扫描(独立 setInterval,每 4 小时一次) - * 3. 触发后拉取真实聊天上下文(若用户授权),组装 prompt 调用单一 AI 模型 - * 4. 输出 ≤80 字见解,通过现有 showNotification 弹出右下角通知 - * - * 设计原则: - * - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API - * - 所有失败静默处理,不影响主流程 - * - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt - */ - -import https from 'https' -import http from 'http' -import { URL } from 'url' -import { ConfigService } from './config' -import { chatService, ChatSession, Message } from './chatService' -import { snsService } from './snsService' -import { weiboService } from './social/weiboService' -import { showNotification } from '../windows/notificationWindow' -import { insightProfileService } from './insightProfileService' -import { - insightRecordService, - type InsightRecordLog, - type InsightRecordTriggerReason, - type MessageInsightAnalysis -} from './insightRecordService' - -// ─── 常量 ──────────────────────────────────────────────────────────────────── - -/** - * DB 变更防抖延迟(毫秒)。 - * 设为 2s:微信写库通常是批量操作,500ms 过短会在开机/重连时产生大量连续触发。 - */ -const DB_CHANGE_DEBOUNCE_MS = 2000 - -/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */ -const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000 - -/** 单次 API 请求超时(毫秒) */ -const API_TIMEOUT_MS = 45_000 -const API_MAX_TOKENS_DEFAULT = 1024 -const API_MAX_TOKENS_MIN = 1 -const API_MAX_TOKENS_MAX = 2_000_000 -const API_TEMPERATURE = 0.7 -const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png' -const MIMO_FOOTPRINT_MIN_TOKENS = 4096 -const FOOTPRINT_API_TEMPERATURE = 0.2 - -const DEFAULT_FOOTPRINT_SYSTEM_PROMPT = `你是“我的微信足迹”模块的总结器,只能根据用户提供的统计数据生成最终复盘文案。 -硬性输出规则: -1. 只输出最终总结正文,不输出思考过程、步骤、标题、列表、JSON、Markdown、代码块、引号或字段名。 -2. 输出 2 句中文,总长度 60-160 字,最多 180 字。 -3. 第 1 句概括联络活跃度、回复情况或 @我情况;第 2 句给出一个当天/当前范围内可执行的沟通建议。 -4. 必须引用至少 2 个输入数字,例如人数、回复率、@我次数或群聊数。 -5. 数据为 0 时如实说明,不臆测具体聊天内容、关系、情绪、诊断或原因。 -6. 禁止出现“首先”“其次”“根据”“综上”“作为AI”“我认为”“以下是”等过程性表达。 -输出格式:直接输出两句自然中文。` - -/** 沉默天数阈值默认值 */ -const DEFAULT_SILENCE_DAYS = 3 -const INSIGHT_CONFIG_KEYS = new Set([ - 'aiInsightEnabled', - 'aiInsightScanIntervalHours', - 'aiModelApiBaseUrl', - 'aiModelApiKey', - 'aiModelApiModel', - 'aiModelApiMaxTokens', - 'aiInsightFilterMode', - 'aiInsightFilterList', - 'aiInsightAllowMomentsContext', - 'aiInsightMomentsContextCount', - 'aiInsightMomentsBindings', - 'aiInsightAllowSocialContext', - 'aiInsightSocialContextCount', - 'aiInsightWeiboCookie', - 'aiInsightWeiboBindings', - 'dbPath', - 'decryptKey', - 'myWxid' -]) - -// ─── 类型 ──────────────────────────────────────────────────────────────────── - -interface TodayTriggerRecord { - /** 该会话今日触发的时间戳列表(毫秒) */ - timestamps: number[] -} - -interface SharedAiModelConfig { - apiBaseUrl: string - apiKey: string - model: string - maxTokens: number -} - -interface SessionInsightTriggerResult { - success: boolean - message: string - recordId?: string - insight?: string - skipped?: boolean - notificationEnabled?: boolean -} - -type InsightFilterMode = 'whitelist' | 'blacklist' - -interface CallApiOptions { - temperature?: number - disableThinking?: boolean - useMaxCompletionTokens?: boolean - responseFormatJson?: boolean -} - -class ApiRequestError extends Error { - statusCode?: number - responseBody?: string - - constructor(message: string, statusCode?: number, responseBody?: string) { - super(message) - this.name = 'ApiRequestError' - this.statusCode = statusCode - this.responseBody = responseBody - } -} - -// ─── 日志 ───────────────────────────────────────────────────────────────────── - -type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' - -function insightDebugLine(_level: InsightLogLevel, _message: string): void { - // Desktop debug log export has been replaced by per-insight request logs. -} - -function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void { - // Desktop debug log export has been replaced by per-insight request logs. -} - -/** - * 仅输出到 console,不落盘到文件。 - */ -function insightLog(level: InsightLogLevel, message: string): void { - if (level === 'ERROR' || level === 'WARN') { - console.warn(`[InsightService] ${message}`) - } else { - console.log(`[InsightService] ${message}`) - } - insightDebugLine(level, message) -} - -// ─── 工具函数 ───────────────────────────────────────────────────────────────── - -/** - * 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。 - * - * 例如: - * baseUrl = "https://api.ohmygpt.com/v1" - * path = "/chat/completions" - * 结果为 "https://api.ohmygpt.com/v1/chat/completions" - * - * 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)), - * 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地 - * 丢弃 v1,变成 https://api.example.com/chat/completions。 - */ -function buildApiUrl(baseUrl: string, path: string): string { - const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠 - const suffix = path.startsWith('/') ? path : `/${path}` - return `${base}${suffix}` -} - -function getStartOfDay(date: Date = new Date()): number { - const d = new Date(date) - d.setHours(0, 0, 0, 0) - return d.getTime() -} - -function formatTimestamp(ts: number): string { - return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) -} - -function formatPromptCurrentTime(date: Date = new Date()): string { - 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}` -} - -function appendPromptCurrentTime(prompt: string): string { - const base = String(prompt || '').trimEnd() - if (!base) return formatPromptCurrentTime() - return `${base}\n\n${formatPromptCurrentTime()}` -} - -function normalizeApiMaxTokens(value: unknown): number { - const numeric = Number(value) - if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT - return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) -} - -function normalizeSessionIdList(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) -} - -function isMimoModel(apiBaseUrl: string, model: string): boolean { - const target = `${apiBaseUrl} ${model}`.toLowerCase() - return target.includes('mimo') || target.includes('xiaomi') -} - -function buildFootprintSystemPrompt(customPrompt: string): string { - const custom = String(customPrompt || '').trim() - if (!custom || custom === DEFAULT_FOOTPRINT_SYSTEM_PROMPT) { - return DEFAULT_FOOTPRINT_SYSTEM_PROMPT - } - return `${DEFAULT_FOOTPRINT_SYSTEM_PROMPT} - -用户自定义补充要求如下,只能在不违反上述硬性输出规则时执行: -${custom}` -} - -function normalizeFootprintInsight(text: string): string { - let normalized = String(text || '').trim() - if (!normalized) return '' - - if (normalized.startsWith('{') && normalized.endsWith('}')) { - try { - const parsed = JSON.parse(normalized) - const value = parsed?.summary || parsed?.insight || parsed?.content || parsed?.text - if (typeof value === 'string' && value.trim()) { - normalized = value.trim() - } - } catch { } - } - - normalized = normalized - .replace(/^```(?:text|markdown|md|json)?/i, '') - .replace(/```$/i, '') - .replace(/^(足迹复盘|AI足迹总结|AI 足迹总结|总结|建议)[::]\s*/i, '') - .replace(/^\s*[-*•]\s*/gm, '') - .replace(/\s*\n+\s*/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim() - - if (normalized.length > 180) { - const sliced = normalized.slice(0, 180) - const lastStop = Math.max(sliced.lastIndexOf('。'), sliced.lastIndexOf('!'), sliced.lastIndexOf('?')) - normalized = lastStop >= 60 ? sliced.slice(0, lastStop + 1) : `${sliced.replace(/[,,;;、\s]+$/g, '')}。` - } - - return normalized -} - -function clampText(value: unknown, maxLength: number): string { - const text = String(value || '').replace(/\s+/g, ' ').trim() - if (text.length <= maxLength) return text - return `${text.slice(0, Math.max(0, maxLength - 1))}…` -} - -function stripJsonFence(value: string): string { - const text = String(value || '').trim() - const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) - if (fenced) return fenced[1].trim() - const firstBrace = text.indexOf('{') - const lastBrace = text.lastIndexOf('}') - if (firstBrace >= 0 && lastBrace > firstBrace) { - return text.slice(firstBrace, lastBrace + 1).trim() - } - return text -} - -function parseMessageInsightAnalysis(rawOutput: string): MessageInsightAnalysis { - let parsed: unknown - try { - parsed = JSON.parse(stripJsonFence(rawOutput)) - } catch { - throw new Error('模型输出格式异常:不是合法 JSON') - } - if (!parsed || typeof parsed !== 'object') { - throw new Error('模型输出格式异常:JSON 根节点不是对象') - } - const source = parsed as Record - const explicitText = clampText(source.explicit_text ?? source.explicitText, 120) - const emotion = clampText(source.emotion, 16) - const intent = clampText(source.intent, 20) - const topic = clampText(source.topic, 20) - if (!explicitText || !emotion || !intent || !topic) { - throw new Error('模型输出格式异常:缺少必要字段') - } - return { explicitText, emotion, intent, topic } -} - -function shouldFallbackJsonMode(error: unknown): boolean { - const statusCode = Number((error as ApiRequestError)?.statusCode || 0) - if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true - const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() - return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') -} - -/** - * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 - * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 - */ -function callApi( - apiBaseUrl: string, - apiKey: string, - model: string, - messages: Array<{ role: string; content: string }>, - timeoutMs: number = API_TIMEOUT_MS, - maxTokens: number = API_MAX_TOKENS_DEFAULT, - options: CallApiOptions = {} -): Promise { - return new Promise((resolve, reject) => { - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - let urlObj: URL - try { - urlObj = new URL(endpoint) - } catch (e) { - reject(new Error(`无效的 API URL: ${endpoint}`)) - return - } - - const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens) - const payload: Record = { - model, - messages, - temperature: options.temperature ?? API_TEMPERATURE, - stream: false - } - if (options.useMaxCompletionTokens) { - payload.max_completion_tokens = normalizedMaxTokens - } else { - payload.max_tokens = normalizedMaxTokens - } - if (options.disableThinking) { - payload.thinking = { type: 'disabled' } - payload.enable_thinking = false - } - if (options?.responseFormatJson) { - payload.response_format = { type: 'json_object' } - } - const body = JSON.stringify(payload) - - const requestOptions = { - hostname: urlObj.hostname, - port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: 'POST' as const, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body).toString(), - Authorization: `Bearer ${apiKey}` - } - } - - const isHttps = urlObj.protocol === 'https:' - const requestFn = isHttps ? https.request : http.request - const req = requestFn(requestOptions, (res) => { - let data = '' - res.on('data', (chunk) => { data += chunk }) - res.on('end', () => { - try { - if (res.statusCode && res.statusCode >= 400) { - reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) - return - } - const parsed = JSON.parse(data) - const content = parsed?.choices?.[0]?.message?.content - if (typeof content === 'string' && content.trim()) { - resolve(content.trim()) - } else { - const finishReason = parsed?.choices?.[0]?.finish_reason - const reasoningContent = parsed?.choices?.[0]?.message?.reasoning_content - if (typeof reasoningContent === 'string' && reasoningContent.trim()) { - reject(new Error(`API 仅返回推理内容未返回正文${finishReason ? `(finish_reason=${finishReason})` : ''},请增大最大输出 Token 或关闭思考模式`)) - return - } - reject(new Error(`API 返回格式异常${finishReason ? `(finish_reason=${finishReason})` : ''}: ${data.slice(0, 200)}`)) - } - } catch (e) { - reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) - } - }) - }) - - req.setTimeout(timeoutMs, () => { - req.destroy() - reject(new Error('API 请求超时')) - }) - - req.on('error', (e) => reject(e)) - req.write(body) - req.end() - }) -} - -// ─── InsightService 主类 ────────────────────────────────────────────────────── - -class InsightService { - private readonly config: ConfigService - - /** DB 变更防抖定时器 */ - private dbDebounceTimer: NodeJS.Timeout | null = null - - /** 沉默扫描定时器 */ - private silenceScanTimer: NodeJS.Timeout | null = null - private silenceInitialDelayTimer: NodeJS.Timeout | null = null - - /** 是否正在处理中(防重入) */ - private processing = false - - /** - * 当日触发记录:sessionId -> TodayTriggerRecord - * 每天 00:00 之后自动重置(通过检查日期实现) - */ - private todayTriggers: Map = new Map() - private todayDate = getStartOfDay() - - /** - * 活跃分析冷却记录:sessionId -> 上次分析时间戳(毫秒) - * 同一会话 2 小时内不重复触发活跃分析,防止 DB 频繁变更时爆量调用 API。 - */ - private lastActivityAnalysis: Map = new Map() - - /** - * 跟踪每个会话上次见到的最新消息时间戳,用于判断是否有真正的新消息。 - * sessionId -> lastMessageTimestamp(秒,与微信 DB 保持一致) - */ - private lastSeenTimestamp: Map = new Map() - - /** - * 本地会话快照缓存,避免 analyzeRecentActivity 在每次 DB 变更时都做全量读取。 - * 首次调用时填充,此后只在沉默扫描里刷新(沉默扫描间隔更长,更合适做全量刷新)。 - */ - private sessionCache: ChatSession[] | null = null - /** sessionCache 最后刷新时间戳(ms),超过 15 分钟强制重新拉取 */ - private sessionCacheAt = 0 - /** 缓存 TTL 设为 15 分钟,大幅减少 connect() + getSessions() 调用频率 */ - private static readonly SESSION_CACHE_TTL_MS = 15 * 60 * 1000 - /** 数据库是否已连接(避免重复调用 chatService.connect()) */ - private dbConnected = false - - private started = false - - constructor() { - this.config = ConfigService.getInstance() - } - - // ── 公开 API ──────────────────────────────────────────────────────────────── - - start(): void { - if (this.started) return - this.started = true - void this.refreshConfiguration('startup') - } - - stop(): void { - const hadActiveFlow = - this.dbDebounceTimer !== null || - this.silenceScanTimer !== null || - this.silenceInitialDelayTimer !== null || - this.processing - this.started = false - this.clearTimers() - this.clearRuntimeCache() - this.processing = false - insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消') - if (hadActiveFlow) { - insightLog('INFO', '已停止') - } - } - - async handleConfigChanged(key: string): Promise { - const normalizedKey = String(key || '').trim() - if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return - - // 数据库相关配置变更后,丢弃缓存并强制下次重连 - if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') { - weiboService.clearCache() - } - - if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { - insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消') - this.clearRuntimeCache() - } - - await this.refreshConfiguration(`config:${normalizedKey}`) - } - - handleConfigCleared(): void { - this.clearTimers() - this.clearRuntimeCache() - insightProfileService.cancelActiveTask('配置已清除,画像任务已取消') - this.processing = false - } - - private async refreshConfiguration(_reason: string): Promise { - if (!this.started) return - if (!this.isEnabled()) { - this.clearTimers() - this.clearRuntimeCache() - this.processing = false - return - } - this.scheduleSilenceScan() - } - - private clearRuntimeCache(): void { - this.dbConnected = false - this.sessionCache = null - this.sessionCacheAt = 0 - this.lastActivityAnalysis.clear() - this.lastSeenTimestamp.clear() - this.todayTriggers.clear() - this.todayDate = getStartOfDay() - weiboService.clearCache() - } - - private clearTimers(): void { - if (this.dbDebounceTimer !== null) { - clearTimeout(this.dbDebounceTimer) - this.dbDebounceTimer = null - } - if (this.silenceScanTimer !== null) { - clearTimeout(this.silenceScanTimer) - this.silenceScanTimer = null - } - if (this.silenceInitialDelayTimer !== null) { - clearTimeout(this.silenceInitialDelayTimer) - this.silenceInitialDelayTimer = null - } - } - - /** - * 由 main.ts 在 addDbMonitorListener 回调中调用。 - * 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。 - * 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。 - */ - handleDbMonitorChange(_type: string, _json: string): void { - if (!this.started) return - if (!this.isEnabled()) return - // 正在处理时忽略新事件,避免 timer 堆积 - if (this.processing) return - - if (this.dbDebounceTimer !== null) { - clearTimeout(this.dbDebounceTimer) - } - this.dbDebounceTimer = setTimeout(() => { - this.dbDebounceTimer = null - void this.analyzeRecentActivity() - }, DB_CHANGE_DEBOUNCE_MS) - } - - /** - * 测试 API 连接,返回 { success, message }。 - * 供设置页"测试连接"按钮调用。 - */ - async testConnection(): Promise<{ success: boolean; message: string }> { - const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() - - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写 API 地址和 API Key' } - } - - try { - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }] - insightDebugSection( - 'INFO', - 'AI 测试连接请求', - [ - `Endpoint: ${endpoint}`, - `Model: ${model}`, - `Max Tokens: ${maxTokens}`, - '', - '用户提示词:', - requestMessages[0].content - ].join('\n') - ) - - const result = await callApi( - apiBaseUrl, - apiKey, - model, - requestMessages, - 15_000, - maxTokens - ) - insightDebugSection('INFO', 'AI 测试连接输出原文', result) - return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` } - } catch (e) { - insightDebugSection( - 'ERROR', - 'AI 测试连接失败', - `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` - ) - return { success: false, message: `连接失败:${(e as Error).message}` } - } - } - - /** - * 强制立即对最近一个私聊会话触发一次见解(忽略冷却,用于测试)。 - * 返回触发结果描述,供设置页展示。 - */ - async triggerTest(): Promise<{ success: boolean; message: string }> { - insightLog('INFO', '手动触发测试见解...') - const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写 API 地址和 Key' } - } - try { - const connectResult = await chatService.connect() - if (!connectResult.success) { - return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' } - } - const sessionsResult = await chatService.getSessions() - if (!sessionsResult.success || !sessionsResult.sessions || sessionsResult.sessions.length === 0) { - return { success: false, message: '未找到任何会话,请确认数据库已正确连接' } - } - // 找第一个允许的私聊 - const session = (sessionsResult.sessions as ChatSession[]).find((s) => { - const id = s.username?.trim() || '' - return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) - }) - if (!session) { - return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' } - } - const sessionId = session.username?.trim() || '' - const displayName = session.displayName || sessionId - insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`) - const result = await this.generateInsightForSession({ - sessionId, - displayName, - triggerReason: 'test' - }) - if (!result.success) { - return { success: false, message: result.message } - } - const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false - return { - success: true, - message: notificationEnabled - ? `已向「${displayName}」发送测试见解,请查看通知弹窗` - : `已生成「${displayName}」的测试见解,AI 见解消息通知当前已关闭` - } - } catch (e) { - return { success: false, message: `测试失败:${(e as Error).message}` } - } - } - - /** - * 手动对指定会话立即触发一次 AI 见解。 - * 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。 - */ - async triggerSessionInsight(params: { - sessionId: string - displayName?: string - avatarUrl?: string - }): Promise { - const sessionId = String(params?.sessionId || '').trim() - if (!sessionId) { - return { success: false, message: '当前会话无效,无法触发 AI 见解' } - } - if (!this.isEnabled()) { - return { success: false, message: '请先在设置中开启「AI 见解」' } - } - - const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - try { - const connectResult = await chatService.connect() - if (!connectResult.success) { - return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' } - } - this.dbConnected = true - - const displayName = String(params?.displayName || sessionId).trim() || sessionId - insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`) - return await this.generateInsightForSession({ - sessionId, - displayName, - triggerReason: 'manual' - }) - } catch (error) { - return { success: false, message: `触发失败:${(error as Error).message}` } - } - } - - /** 获取今日触发统计(供设置页展示) */ - getTodayStats(): { sessionId: string; count: number; times: string[] }[] { - this.resetIfNewDay() - const result: { sessionId: string; count: number; times: string[] }[] = [] - for (const [sessionId, record] of this.todayTriggers.entries()) { - result.push({ - sessionId, - count: record.timestamps.length, - times: record.timestamps.map(formatTimestamp) - }) - } - return result - } - - async generateFootprintInsight(params: { - rangeLabel: string - summary: { - private_inbound_people?: number - private_replied_people?: number - private_outbound_people?: number - private_reply_rate?: number - mention_count?: number - mention_group_count?: number - } - privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> - mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> - }): Promise<{ success: boolean; message: string; insight?: string }> { - const enabled = this.config.get('aiFootprintEnabled') === true - if (!enabled) { - return { success: false, message: '请先在设置中开启「AI 足迹总结」' } - } - - const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - const summary = params?.summary || {} - const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围' - const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : [] - const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : [] - const mimoMode = isMimoModel(apiBaseUrl, model) - - const topPrivateText = privateSegments.length > 0 - ? privateSegments - .map((item, idx) => { - const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim() - const inbound = Number(item.incoming_count) || 0 - const outbound = Number(item.outgoing_count) || 0 - const total = Math.max(Number(item.message_count) || 0, inbound + outbound) - return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''})` - }) - .join('\n') - : '无' - - const topMentionText = mentionGroups.length > 0 - ? mentionGroups - .map((item, idx) => { - const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim() - const count = Number(item.count) || 0 - return `${idx + 1}. ${name}(@我 ${count} 次)` - }) - .join('\n') - : '无' - - const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() - const systemPrompt = buildFootprintSystemPrompt(customPrompt) - - const inboundPeople = Number(summary.private_inbound_people) || 0 - const repliedPeople = Number(summary.private_replied_people) || 0 - const outboundPeople = Number(summary.private_outbound_people) || 0 - const replyRate = (((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1) - const mentionCount = Number(summary.mention_count) || 0 - const mentionGroupCount = Number(summary.mention_group_count) || 0 - - const userPromptBase = `任务:基于下面的“我的微信足迹”统计生成最终总结正文。 - -输出要求再强调一次: -- 只输出 2 句中文自然语言,不要输出分析过程。 -- 不要输出 JSON / Markdown / 列表 / 标题 / 代码块。 -- 第 1 句做总体观察,第 2 句给一个可执行建议。 -- 必须引用至少 2 个统计数字。 - -统计范围:${rangeLabel} -有聊天的人数:${inboundPeople} -我有回复的人数:${outboundPeople} -实际回复了其中:${repliedPeople} -回复率:${replyRate}% -@我次数:${mentionCount} -涉及群聊:${mentionGroupCount} - -私聊重点: -${topPrivateText} - -群聊@我重点: -${topMentionText} - -现在直接输出最终总结正文:` - const userPrompt = appendPromptCurrentTime(userPromptBase) - - try { - const result = await callApi( - apiBaseUrl, - apiKey, - model, - [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ], - 25_000, - mimoMode ? Math.max(maxTokens, MIMO_FOOTPRINT_MIN_TOKENS) : maxTokens, - { - temperature: FOOTPRINT_API_TEMPERATURE, - disableThinking: mimoMode, - useMaxCompletionTokens: mimoMode - } - ) - const insight = normalizeFootprintInsight(result) - if (!insight) return { success: false, message: '模型返回为空' } - return { success: true, message: '生成成功', insight } - } catch (error) { - return { success: false, message: `生成失败:${(error as Error).message}` } - } - } - - async generateMessageInsight(params: { - sessionId: string - displayName?: string - avatarUrl?: string - targetLocalId?: number - targetCreateTime?: number - targetMessageKey?: string - targetText: string - targetSenderName?: string - contextCount?: number - forceRefresh?: boolean - }): Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }> { - const enabled = this.config.get('aiMessageInsightEnabled') === true - if (!enabled) { - return { success: false, message: '请先在设置中开启「消息解析」' } - } - - const sessionId = String(params?.sessionId || '').trim() - const targetText = clampText(params?.targetText || '', 500) - const targetCreateTime = Math.floor(Number(params?.targetCreateTime || 0)) - const targetLocalId = Math.floor(Number(params?.targetLocalId || 0)) - const targetMessageKey = String(params?.targetMessageKey || '').trim() - if (!sessionId || !targetText || targetCreateTime <= 0) { - return { success: false, message: '目标消息无效,无法解析' } - } - - if (params?.forceRefresh !== true) { - const cached = insightRecordService.findLatestMessageAnalysis({ - sessionId, - targetLocalId, - targetCreateTime, - targetMessageKey - }) - if (cached?.messageInsight?.analysis) { - return { - success: true, - message: '已读取缓存解析', - cached: true, - recordId: cached.id, - data: cached.messageInsight.analysis - } - } - } - - const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() - if (!apiBaseUrl || !apiKey) { - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - const configuredContextCount = Number(this.config.get('aiMessageInsightContextCount') || 50) - const contextCount = Math.max(1, Math.min(200, Math.floor(Number(params?.contextCount || configuredContextCount) || 50))) - const displayName = await this.resolveInsightSessionDisplayName(sessionId, String(params?.displayName || sessionId)) - const targetSenderName = clampText(params?.targetSenderName || displayName, 40) || displayName - const targetTextPreview = clampText(targetText, 120) - let avatarUrl = String(params?.avatarUrl || '').trim() || undefined - if (!avatarUrl) { - try { - const contact = await chatService.getContactAvatar(sessionId) - avatarUrl = String(contact?.avatarUrl || '').trim() || undefined - } catch { - avatarUrl = undefined - } - } - - let beforeMessages: Message[] = [] - let afterMessages: Message[] = [] - let contextReadError = '' - try { - const aroundResult = await chatService.getMessagesAround( - sessionId, - { localId: targetLocalId, createTime: targetCreateTime, messageKey: targetMessageKey }, - contextCount - ) - if (aroundResult.success) { - beforeMessages = aroundResult.before || [] - afterMessages = aroundResult.after || [] - } else { - contextReadError = aroundResult.error || '读取上下文失败' - } - } catch (error) { - contextReadError = (error as Error).message || String(error) - } - - const formatLine = (message: Message) => { - const senderName = message.isSend === 1 ? '我' : (message.senderDisplayName || targetSenderName || displayName) - return `${this.formatInsightMessageTimestamp(message.createTime)} ${senderName}:${this.formatInsightMessageContent(message)}` - } - const beforeText = beforeMessages.length > 0 ? beforeMessages.map(formatLine).join('\n') : '无' - const afterText = afterMessages.length > 0 ? afterMessages.map(formatLine).join('\n') : '无' - - const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。 - -严格要求: -1. 必须且只能输出合法的纯 JSON。 -2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。 -3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。 -4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。 -5. emotion、intent、topic 必须是短标签。 - -JSON 输出格式: -{ - "explicit_text": "暗示转明示,80字以内", - "emotion": "2-6字情绪标签", - "intent": "2-8字意图标签", - "topic": "2-8字话题标签" -}` - const customPrompt = String(this.config.get('aiMessageInsightSystemPrompt') || '').trim() - const systemPrompt = customPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT - const userPromptBase = `会话:${displayName} -目标发送者:${targetSenderName} -目标消息时间:${this.formatInsightMessageTimestamp(targetCreateTime)} - -目标消息: -${targetText} - -目标消息之前的上下文(${beforeMessages.length} 条): -${beforeText} - -目标消息之后的上下文(${afterMessages.length} 条): -${afterText} - -请分析目标消息,只输出指定 JSON。` - const userPrompt = appendPromptCurrentTime(userPromptBase) - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ] - - let rawOutput = '' - let responseFormatJson = true - let responseFormatFallback = false - let responseFormatFallbackReason = '' - const startedAt = Date.now() - try { - try { - rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens, { responseFormatJson: true }) - } catch (error) { - if (!shouldFallbackJsonMode(error)) throw error - responseFormatJson = false - responseFormatFallback = true - responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' - rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens) - } - const analysis = parseMessageInsightAnalysis(rawOutput) - const finalInsight = analysis.explicitText - const log: InsightRecordLog = { - endpoint, - model, - maxTokens, - temperature: API_TEMPERATURE, - triggerReason: 'message_analysis', - allowContext: true, - contextCount, - systemPrompt, - userPrompt, - rawOutput, - finalInsight, - durationMs: Date.now() - startedAt, - createdAt: Date.now(), - responseFormatJson, - responseFormatFallback, - responseFormatFallbackReason, - targetMessage: { - localId: targetLocalId, - createTime: targetCreateTime, - messageKey: targetMessageKey, - senderName: targetSenderName, - textPreview: targetTextPreview - }, - contextStats: { - requested: contextCount, - beforeTarget: beforeMessages.length, - afterTarget: afterMessages.length, - readError: contextReadError || undefined - }, - parsedAnalysis: analysis - } - const record = insightRecordService.addRecord({ - sessionId, - displayName, - avatarUrl, - sourceType: 'message_analysis', - triggerReason: 'message_analysis', - insight: finalInsight, - messageInsight: { - targetLocalId, - targetCreateTime, - targetMessageKey, - targetSenderName, - targetTextPreview, - analysis - }, - log - }) - return { success: true, message: '解析完成', cached: false, recordId: record.id, data: analysis } - } catch (error) { - return { success: false, message: `解析失败:${(error as Error).message}` } - } - } - - // ── 私有方法 ──────────────────────────────────────────────────────────────── - - private isEnabled(): boolean { - return this.config.get('aiInsightEnabled') === true - } - - private getSharedAiModelConfig(): SharedAiModelConfig { - const apiBaseUrl = String( - this.config.get('aiModelApiBaseUrl') - || this.config.get('aiInsightApiBaseUrl') - || '' - ).trim() - const apiKey = String( - this.config.get('aiModelApiKey') - || this.config.get('aiInsightApiKey') - || '' - ).trim() - const model = String( - this.config.get('aiModelApiModel') - || this.config.get('aiInsightApiModel') - || 'gpt-4o-mini' - ).trim() || 'gpt-4o-mini' - const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens')) - - return { apiBaseUrl, apiKey, model, maxTokens } - } - - private looksLikeWxid(text: string): boolean { - const normalized = String(text || '').trim() - if (!normalized) return false - return /^wxid_[a-z0-9]+$/i.test(normalized) - || /^[a-z0-9_]+@chatroom$/i.test(normalized) - } - - private looksLikeXmlPayload(text: string): boolean { - const normalized = String(text || '').trim() - if (!normalized) return false - return /^(<\?xml| 1_000_000_000_000 ? createTime : createTime * 1000 - const date = new Date(ms) - 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 resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise { - const fallback = String(fallbackDisplayName || '').trim() - if (fallback && !this.looksLikeWxid(fallback)) { - return fallback - } - - try { - const sessions = await this.getSessionsCached() - const matched = sessions.find((session) => String(session.username || '').trim() === sessionId) - const cachedDisplayName = String(matched?.displayName || '').trim() - if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) { - return cachedDisplayName - } - } catch { - // ignore display name lookup failures - } - - try { - const contact = await chatService.getContactAvatar(sessionId) - const contactDisplayName = String(contact?.displayName || '').trim() - if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) { - return contactDisplayName - } - } catch { - // ignore display name lookup failures - } - - return fallback || sessionId - } - - private formatInsightMessageContent(message: Message): string { - const parsedContent = this.normalizeInsightText(String(message.parsedContent || '')) - const quotedPreview = this.normalizeInsightText(String(message.quotedContent || '')) - const quotedSender = this.normalizeInsightText(String(message.quotedSender || '')) - - if (quotedPreview) { - const cleanQuotedSender = quotedSender && !this.looksLikeWxid(quotedSender) ? quotedSender : '' - const quoteLabel = cleanQuotedSender ? `${cleanQuotedSender}:${quotedPreview}` : quotedPreview - const replyText = parsedContent && parsedContent !== '[引用消息]' ? parsedContent : '' - return replyText ? `${replyText}[引用 ${quoteLabel}]` : `[引用 ${quoteLabel}]` - } - - if (parsedContent) { - return parsedContent - } - - const rawContent = this.normalizeInsightText(String(message.rawContent || '')) - if (rawContent && !this.looksLikeXmlPayload(rawContent)) { - return rawContent - } - - return '[其他消息]' - } - - private buildInsightContextSection(messages: Message[], peerDisplayName: string): string { - if (!messages.length) return '' - - const lines = messages.map((message) => { - const senderName = message.isSend === 1 ? '我' : peerDisplayName - const content = this.formatInsightMessageContent(message) - return `${this.formatInsightMessageTimestamp(message.createTime)} '${senderName}'\n${content}` - }) - - return `近期聊天记录(最近 ${lines.length} 条):\n\n${lines.join('\n\n')}` - } - - /** - * 判断某个会话是否允许触发见解。 - * white/black 模式二选一: - * - whitelist:仅名单内允许 - * - blacklist:名单内屏蔽,其他允许 - */ - private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } { - const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase() - const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist' - const list = normalizeSessionIdList(this.config.get('aiInsightFilterList')) - return { mode, list } - } - - private isSessionAllowed(sessionId: string): boolean { - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return false - const { mode, list } = this.getInsightFilterConfig() - if (mode === 'whitelist') return list.includes(normalizedSessionId) - return !list.includes(normalizedSessionId) - } - - /** - * 获取会话列表,优先使用缓存(15 分钟 TTL)。 - * 缓存命中时完全跳过数据库访问,避免频繁 connect() + getSessions() 消耗 CPU。 - * forceRefresh=true 时强制重新拉取(仅用于沉默扫描等低频场景)。 - */ - private async getSessionsCached(forceRefresh = false): Promise { - const now = Date.now() - // 缓存命中:直接返回,零数据库操作 - if ( - !forceRefresh && - this.sessionCache !== null && - now - this.sessionCacheAt < InsightService.SESSION_CACHE_TTL_MS - ) { - return this.sessionCache - } - // 缓存未命中或强制刷新:连接数据库并拉取 - try { - // 只在首次或强制刷新时调用 connect(),避免重复建立连接 - if (!this.dbConnected || forceRefresh) { - const connectResult = await chatService.connect() - if (!connectResult.success) { - insightLog('WARN', '数据库连接失败,使用旧缓存') - return this.sessionCache ?? [] - } - this.dbConnected = true - } - const result = await chatService.getSessions() - if (result.success && result.sessions) { - this.sessionCache = result.sessions as ChatSession[] - this.sessionCacheAt = now - } - } catch (e) { - insightLog('WARN', `获取会话缓存失败: ${(e as Error).message}`) - // 连接可能已断开,下次强制重连 - this.dbConnected = false - } - return this.sessionCache ?? [] - } - - private resetIfNewDay(): void { - const todayStart = getStartOfDay() - if (todayStart > this.todayDate) { - this.todayDate = todayStart - this.todayTriggers.clear() - } - } - - /** - * 记录成功推送的见解,用于设置页展示今日触发统计。 - */ - private recordTrigger(sessionId: string): void { - this.resetIfNewDay() - const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] } - existing.timestamps.push(Date.now()) - this.todayTriggers.set(sessionId, existing) - } - - private formatWeiboTimestamp(raw: string): string { - const parsed = Date.parse(String(raw || '')) - if (!Number.isFinite(parsed)) { - return String(raw || '').trim() - } - return new Date(parsed).toLocaleString('zh-CN') - } - - private formatMomentsTimestamp(raw: unknown): string { - const numeric = Number(raw) - if (!Number.isFinite(numeric) || numeric <= 0) { - return '' - } - const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000 - return new Date(ms).toLocaleString('zh-CN') - } - - private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string { - const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim() - if (contentDesc) return contentDesc - - const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim() - if (linkTitle) return `[链接] ${linkTitle}` - - return '' - } - - private async getMomentsContextSection(sessionId: string): Promise { - const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true - if (!allowMomentsContext) return '' - - const bindings = - (this.config.get('aiInsightMomentsBindings') as Record | undefined) || {} - const isEnabledForSession = bindings[sessionId]?.enabled === true - if (!isEnabledForSession) return '' - - const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5) - const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5)) - - try { - const result = await snsService.getTimeline(momentsCount, 0, [sessionId]) - const posts = result.success && Array.isArray(result.timeline) ? result.timeline : [] - if (posts.length === 0) return '' - - const lines = posts - .map((post) => { - const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown }) - if (!text) return '' - const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text - const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime) - return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}` - }) - .filter(Boolean) as string[] - - if (lines.length === 0) return '' - insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`) - return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}` - } catch (error) { - insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`) - return '' - } - } - - private async getSocialContextSection(sessionId: string): Promise { - const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true - if (!allowSocialContext) return '' - - const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim() - - const bindings = - (this.config.get('aiInsightWeiboBindings') as Record | undefined) || {} - const binding = bindings[sessionId] - const uid = String(binding?.uid || '').trim() - if (!uid) return '' - - const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3) - const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3)) - - try { - const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount) - if (posts.length === 0) return '' - - const lines = posts.map((post) => { - const time = this.formatWeiboTimestamp(post.createdAt) - const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text - return `[微博 ${time}] ${text}` - }) - insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`) - return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}` - } catch (error) { - insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`) - return '' - } - } - - // ── 沉默联系人扫描 ────────────────────────────────────────────────────────── - - private scheduleSilenceScan(): void { - this.clearTimers() - if (!this.started || !this.isEnabled()) return - - // 等待扫描完成后再安排下一次,避免并发堆积 - const scheduleNext = () => { - if (!this.started || !this.isEnabled()) return - const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4 - const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000 - insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`) - this.silenceScanTimer = setTimeout(async () => { - this.silenceScanTimer = null - await this.runSilenceScan() - scheduleNext() - }, intervalMs) - } - - this.silenceInitialDelayTimer = setTimeout(async () => { - this.silenceInitialDelayTimer = null - await this.runSilenceScan() - scheduleNext() - }, SILENCE_SCAN_INITIAL_DELAY_MS) - } - - private async runSilenceScan(): Promise { - if (!this.isEnabled()) { - return - } - if (this.processing) { - insightLog('INFO', '沉默扫描:正在处理中,跳过本次') - return - } - - this.processing = true - insightLog('INFO', '开始沉默联系人扫描...') - try { - const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS - const thresholdMs = silenceDays * 24 * 60 * 60 * 1000 - const now = Date.now() - - insightLog('INFO', `沉默阈值:${silenceDays} 天`) - - // 沉默扫描间隔较长,强制刷新缓存以获取最新数据 - const sessions = await this.getSessionsCached(true) - if (sessions.length === 0) { - insightLog('WARN', '获取会话列表失败,跳过沉默扫描') - return - } - - insightLog('INFO', `共 ${sessions.length} 个会话,开始过滤...`) - - let silentCount = 0 - for (const session of sessions) { - if (!this.isEnabled()) return - const sessionId = session.username?.trim() || '' - if (!sessionId || sessionId.endsWith('@chatroom')) continue - if (sessionId.toLowerCase().includes('placeholder')) continue - if (!this.isSessionAllowed(sessionId)) continue - - const lastTimestamp = (session.lastTimestamp || 0) * 1000 - if (!lastTimestamp || lastTimestamp <= 0) continue - - const silentMs = now - lastTimestamp - if (silentMs < thresholdMs) continue - - silentCount++ - const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000)) - insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`) - - await this.generateInsightForSession({ - sessionId, - displayName: session.displayName || session.username, - triggerReason: 'silence', - silentDays - }) - } - insightLog('INFO', `沉默扫描完成,共发现 ${silentCount} 个沉默联系人`) - } catch (e) { - insightLog('ERROR', `沉默扫描出错: ${(e as Error).message}`) - } finally { - this.processing = false - } - } - - // ── 活跃会话分析 ──────────────────────────────────────────────────────────── - - /** - * 在 DB 变更防抖后执行,分析最近活跃的会话。 - * - * 触发条件(必须同时满足): - * 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新) - * 2. 该会话距上次活跃分析已超过冷却期 - * - * whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。 - * blacklist 模式:从缓存拉取会话后过滤名单。 - */ - private async analyzeRecentActivity(): Promise { - if (!this.isEnabled()) return - if (this.processing) return - - this.processing = true - try { - const now = Date.now() - const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120 - const cooldownMs = cooldownMinutes * 60 * 1000 - const { mode: filterMode, list: filterList } = this.getInsightFilterConfig() - - // whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。 - // 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。 - if (filterMode === 'whitelist' && filterList.length > 0) { - // 确保数据库已连接(首次时连接,之后复用) - if (!this.dbConnected) { - const connectResult = await chatService.connect() - if (!connectResult.success) return - this.dbConnected = true - } - - for (const sessionId of filterList) { - if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue - - // 冷却期检查(先过滤,减少不必要的 DB 查询) - if (cooldownMs > 0) { - const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0 - if (cooldownMs - (now - lastAnalysis) > 0) continue - } - - // 拉取最新 1 条消息,用时间戳判断是否有新消息,避免全量 getSessions() - try { - const msgsResult = await chatService.getLatestMessages(sessionId, 1) - if (!msgsResult.success || !msgsResult.messages || msgsResult.messages.length === 0) continue - - const latestMsg = msgsResult.messages[0] - const latestTs = Number(latestMsg.createTime) || 0 - const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0 - - if (latestTs <= lastSeen) continue // 没有新消息 - this.lastSeenTimestamp.set(sessionId, latestTs) - } catch { - continue - } - - insightLog('INFO', `白名单会话 ${sessionId} 有新消息,准备生成见解...`) - this.lastActivityAnalysis.set(sessionId, now) - - // displayName 使用白名单 sessionId,generateInsightForSession 内部会从上下文里获取真实名称 - await this.generateInsightForSession({ - sessionId, - displayName: sessionId, - triggerReason: 'activity' - }) - break // 每次最多处理 1 个会话 - } - return - } - - if (filterMode === 'whitelist' && filterList.length === 0) { - insightLog('INFO', '白名单模式且名单为空,跳过活跃分析') - return - } - - // blacklist 模式:拉取会话缓存后按过滤规则筛选 - const sessions = await this.getSessionsCached() - if (sessions.length === 0) return - - const candidateSessions = sessions.filter((s) => { - const id = s.username?.trim() || '' - if (!id || id.toLowerCase().includes('placeholder')) return false - return this.isSessionAllowed(id) - }) - - for (const session of candidateSessions.slice(0, 10)) { - const sessionId = session.username?.trim() || '' - if (!sessionId) continue - - const currentTimestamp = session.lastTimestamp || 0 - const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0 - if (currentTimestamp <= lastSeen) continue - this.lastSeenTimestamp.set(sessionId, currentTimestamp) - - if (cooldownMs > 0) { - const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0 - if (cooldownMs - (now - lastAnalysis) > 0) continue - } - - insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`) - this.lastActivityAnalysis.set(sessionId, now) - - await this.generateInsightForSession({ - sessionId, - displayName: session.displayName || session.username, - triggerReason: 'activity' - }) - break - } - } catch (e) { - insightLog('ERROR', `活跃分析出错: ${(e as Error).message}`) - } finally { - this.processing = false - } - } - - // ── 核心见解生成 ──────────────────────────────────────────────────────────── - - private async generateInsightForSession(params: { - sessionId: string - displayName: string - triggerReason: InsightRecordTriggerReason - silentDays?: number - }): Promise { - const { sessionId, displayName, triggerReason, silentDays } = params - if (!sessionId) return { success: false, message: '会话无效,无法生成见解' } - if (!this.isEnabled()) return { success: false, message: '请先在设置中开启「AI 见解」' } - - const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() - const allowContext = this.config.get('aiInsightAllowContext') as boolean - const contextCount = (this.config.get('aiInsightContextCount') as number) || 40 - const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName) - let resolvedAvatarUrl: string | undefined - try { - const contact = await chatService.getContactAvatar(sessionId) - resolvedAvatarUrl = String(contact?.avatarUrl || '').trim() || undefined - } catch { - resolvedAvatarUrl = undefined - } - - insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`) - - if (!apiBaseUrl || !apiKey) { - insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成') - return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } - } - - // ── 构建 prompt ──────────────────────────────────────────────────────────── - - let contextSection = '' - if (allowContext) { - try { - const msgsResult = await chatService.getLatestMessages(sessionId, contextCount) - if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) { - const messages: Message[] = msgsResult.messages - contextSection = this.buildInsightContextSection(messages, resolvedDisplayName) - insightLog('INFO', `已加载 ${messages.length} 条上下文消息`) - } - } catch (e) { - insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`) - } - } - - const momentsContextSection = await this.getMomentsContextSection(sessionId) - const socialContextSection = await this.getSocialContextSection(sessionId) - const profileContextSection = insightProfileService.getProfileContextSection(sessionId) - - // ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)──── - const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 - -要求: -1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。 -2. 控制在 80 字以内,直接、具体、一针见血。不要废话。 -3. 输出纯文本,不使用 Markdown。 -4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。` - - // 优先使用用户自定义 prompt,为空则使用默认值 - const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || '' - const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT - - const userPromptBase = [ - triggerReason === 'silence' && silentDays - ? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。` - : '', - contextSection, - profileContextSection, - momentsContextSection, - socialContextSection, - '请给出你的见解(≤80字):' - ].filter(Boolean).join('\n\n') - const userPrompt = appendPromptCurrentTime(userPromptBase) - - const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ] - - insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`) - insightDebugSection( - 'INFO', - `AI 请求 ${resolvedDisplayName} (${sessionId})`, - [ - `接口地址:${endpoint}`, - `模型:${model}`, - `Max Tokens:${maxTokens}`, - `触发类型:${triggerReason}`, - `上下文开关:${allowContext ? '开启' : '关闭'}`, - `上下文条数:${contextCount}`, - '', - '系统提示词:', - systemPrompt, - '', - '用户提示词:', - userPrompt - ].join('\n') - ) - - try { - const apiStartedAt = Date.now() - const result = await callApi( - apiBaseUrl, - apiKey, - model, - requestMessages, - API_TIMEOUT_MS, - maxTokens - ) - const apiDurationMs = Date.now() - apiStartedAt - - insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`) - insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result) - - // 模型主动选择跳过 - if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) { - insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`) - return { success: true, message: `模型判断「${resolvedDisplayName}」暂无可生成的见解`, skipped: true } - } - if (!this.isEnabled()) return { success: false, message: 'AI 见解已关闭,生成结果未保存' } - - const insight = result.trim() - const notifTitle = `见解 · ${resolvedDisplayName}` - const recordLog: InsightRecordLog = { - endpoint, - model, - maxTokens, - temperature: API_TEMPERATURE, - triggerReason, - allowContext, - contextCount, - systemPrompt, - userPrompt, - rawOutput: result, - finalInsight: insight, - durationMs: apiDurationMs, - createdAt: Date.now() - } - const record = insightRecordService.addRecord({ - sessionId, - displayName: resolvedDisplayName, - avatarUrl: resolvedAvatarUrl, - triggerReason, - insight, - log: recordLog - }) - - const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false - if (insightNotificationEnabled) { - insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`) - - // 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。 - await showNotification({ - title: notifTitle, - content: insight, - avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL, - sessionId, - insightRecordId: record.id, - channel: 'ai-insight' - }) - } else { - insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`) - } - - // 渠道二:Telegram Bot 推送(可选) - const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean - if (telegramEnabled) { - const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || '' - const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || '' - if (telegramToken && telegramChatIds) { - const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean) - const telegramText = `【WeFlow】 ${notifTitle}\n\n${insight}` - for (const chatId of chatIds) { - this.sendTelegram(telegramToken, chatId, telegramText).catch((e) => { - insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`) - }) - } - } else { - insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过') - } - } - - insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`) - this.recordTrigger(sessionId) - return { - success: true, - message: insightNotificationEnabled - ? `已生成「${resolvedDisplayName}」的 AI 见解,请查看通知弹窗` - : `已生成「${resolvedDisplayName}」的 AI 见解,AI 见解消息通知当前已关闭`, - recordId: record.id, - insight, - notificationEnabled: insightNotificationEnabled - } - } catch (e) { - insightDebugSection( - 'ERROR', - `AI 请求失败 ${resolvedDisplayName} (${sessionId})`, - `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` - ) - insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`) - return { success: false, message: `生成失败:${(e as Error).message}` } - } - } - - /** - * 通过 Telegram Bot API 发送消息。 - * 使用 Node 原生 https 模块,无需第三方依赖。 - */ - private sendTelegram(token: string, chatId: string, text: string): Promise { - return new Promise((resolve, reject) => { - const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' }) - const options = { - hostname: 'api.telegram.org', - port: 443, - path: `/bot${token}/sendMessage`, - method: 'POST' as const, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body).toString() - } - } - const req = https.request(options, (res) => { - let data = '' - res.on('data', (chunk) => { data += chunk }) - res.on('end', () => { - try { - const parsed = JSON.parse(data) - if (parsed.ok) { - resolve() - } else { - reject(new Error(parsed.description || '未知错误')) - } - } catch { - reject(new Error(`响应解析失败: ${data.slice(0, 100)}`)) - } - }) - }) - req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) }) - req.on('error', reject) - req.write(body) - req.end() - }) - } -} - -export const insightService = new InsightService() diff --git a/electron/services/isaac64.ts b/electron/services/isaac64.ts deleted file mode 100644 index 8be407b..0000000 --- a/electron/services/isaac64.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * ISAAC-64: A fast cryptographic PRNG - * Re-implemented in TypeScript using BigInt for 64-bit support. - * Used for WeChat Channels/SNS video decryption. - */ - -export class Isaac64 { - private mm = new BigUint64Array(256); - private aa = 0n; - private bb = 0n; - private cc = 0n; - private randrsl = new BigUint64Array(256); - private randcnt = 0; - private static readonly MASK = 0xFFFFFFFFFFFFFFFFn; - - constructor(seed: number | string | bigint) { - const seedBig = BigInt(seed); - // 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充) - // 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见 - this.randrsl.fill(0n); - this.randrsl[0] = seedBig; - this.init(true); - } - - private init(flag: boolean) { - let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint; - a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n; - - const mix = () => { - a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK; - b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK; - c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK; - d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK; - e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK; - f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK; - g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK; - h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK; - }; - - for (let i = 0; i < 4; i++) mix(); - - for (let i = 0; i < 256; i += 8) { - if (flag) { - a = (a + this.randrsl[i]) & Isaac64.MASK; - b = (b + this.randrsl[i + 1]) & Isaac64.MASK; - c = (c + this.randrsl[i + 2]) & Isaac64.MASK; - d = (d + this.randrsl[i + 3]) & Isaac64.MASK; - e = (e + this.randrsl[i + 4]) & Isaac64.MASK; - f = (f + this.randrsl[i + 5]) & Isaac64.MASK; - g = (g + this.randrsl[i + 6]) & Isaac64.MASK; - h = (h + this.randrsl[i + 7]) & Isaac64.MASK; - } - mix(); - this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d; - this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h; - } - - if (flag) { - for (let i = 0; i < 256; i += 8) { - a = (a + this.mm[i]) & Isaac64.MASK; - b = (b + this.mm[i + 1]) & Isaac64.MASK; - c = (c + this.mm[i + 2]) & Isaac64.MASK; - d = (d + this.mm[i + 3]) & Isaac64.MASK; - e = (e + this.mm[i + 4]) & Isaac64.MASK; - f = (f + this.mm[i + 5]) & Isaac64.MASK; - g = (g + this.mm[i + 6]) & Isaac64.MASK; - h = (h + this.mm[i + 7]) & Isaac64.MASK; - mix(); - this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d; - this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h; - } - } - - this.isaac64(); - this.randcnt = 256; - } - - private isaac64() { - this.cc = (this.cc + 1n) & Isaac64.MASK; - this.bb = (this.bb + this.cc) & Isaac64.MASK; - for (let i = 0; i < 256; i++) { - let x = this.mm[i]; - switch (i & 3) { - case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break; - case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break; - case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break; - case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break; - } - this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK; - const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK; - this.mm[i] = y; - this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK; - this.randrsl[i] = this.bb; - } - } - - public getNext(): bigint { - if (this.randcnt === 0) { - this.isaac64(); - this.randcnt = 256; - } - return this.randrsl[--this.randcnt]; - } - - /** - * Generates a keystream where each 64-bit block is Big-Endian. - * This matches WeChat's behavior (Reverse index order + byte reversal). - */ - public generateKeystreamBE(size: number): Buffer { - const buffer = Buffer.allocUnsafe(size); - const fullBlocks = Math.floor(size / 8); - - for (let i = 0; i < fullBlocks; i++) { - buffer.writeBigUInt64BE(this.getNext(), i * 8); - } - - const remaining = size % 8; - if (remaining > 0) { - const lastK = this.getNext(); - const temp = Buffer.allocUnsafe(8); - temp.writeBigUInt64BE(lastK, 0); - temp.copy(buffer, fullBlocks * 8, 0, remaining); - } - - return buffer; - } -} diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts deleted file mode 100644 index 7f3de2d..0000000 --- a/electron/services/keyService.ts +++ /dev/null @@ -1,1218 +0,0 @@ -import { app } from 'electron' -import { join, dirname } from 'path' -import { existsSync, copyFileSync, mkdirSync } from 'fs' -import { execFile, spawn } from 'child_process' -import { promisify } from 'util' -import os from 'os' -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; verified?: boolean; error?: string } -type DbKeyPollResult = - | { status: 'success'; key: string; loginRequiredDetected: boolean } - | { status: 'process-ended'; loginRequiredDetected: boolean } - | { status: 'timeout'; loginRequiredDetected: boolean } - -export class KeyService { - private readonly isMac = process.platform === 'darwin' - 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 - private getImageKeyDll: 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 TerminateProcess: any = null - private QueryFullProcessImageNameW: 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 PostMessageW: 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 readonly WM_CLOSE = 0x0010 - private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000 - - private getDllPath(): string { - const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' - const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' - const candidates: string[] = [] - - if (process.env.WX_KEY_DLL_PATH) { - candidates.push(process.env.WX_KEY_DLL_PATH) - } - - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll')) - candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll')) - candidates.push(join(process.resourcesPath, 'wx_key.dll')) - } else { - const cwd = process.cwd() - candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll')) - candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) - candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll')) - candidates.push(join(cwd, 'resources', 'wx_key.dll')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll')) - candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll')) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - - return candidates[0] - } - - private isNetworkPath(path: string): boolean { - if (path.startsWith('\\\\')) return true - return false - } - - private localizeNetworkDll(originalPath: string): string { - try { - const tempDir = join(os.tmpdir(), 'weflow_dll_cache') - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }) - } - const localPath = join(tempDir, 'wx_key.dll') - if (existsSync(localPath)) return localPath - - copyFileSync(originalPath, localPath) - return localPath - } catch (e) { - console.error('DLL 本地化失败:', e) - return originalPath - } - } - - private ensureLoaded(): boolean { - if (this.initialized) return true - - let dllPath = '' - try { - this.koffi = require('koffi') - dllPath = this.getDllPath() - - if (!existsSync(dllPath)) { - console.error(`wx_key.dll 不存在于路径: ${dllPath}`) - return false - } - - if (this.isNetworkPath(dllPath)) { - dllPath = this.localizeNetworkDll(dllPath) - } - - 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.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)') - - this.initialized = true - return true - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - console.error(`加载 wx_key.dll 失败\n 路径: ${dllPath}\n 错误: ${errorMsg}`) - 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') - this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32']) - this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*']) - this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32']) - this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) - - 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') - - 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.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', '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') - - 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 --- - - private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null { - if (!this.ensureAdvapi32()) return null - const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2') - const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null - const phkResult = Buffer.alloc(8) - - 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) - - 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 - - let str = dataBuf.toString('ucs2') - if (str.endsWith('\0')) str = str.slice(0, -1) - return str - } finally { - this.RegCloseKey(hKey) - } - } - - private async getProcessExecutablePath(pid: number): Promise { - if (!this.ensureKernel32()) return null - const hProcess = this.OpenProcess(0x1000, false, pid) - if (!hProcess) return null - - try { - const sizeBuf = Buffer.alloc(4) - sizeBuf.writeUInt32LE(1024, 0) - const pathBuf = Buffer.alloc(1024 * 2) - - const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf) - if (ret) { - const len = sizeBuf.readUInt32LE(0) - return pathBuf.toString('ucs2', 0, len * 2) - } - return null - } catch (e) { - console.error('获取进程路径失败:', e) - return null - } finally { - this.CloseHandle(hProcess) - } - } - - private async findWeChatInstallPath(): Promise { - try { - const pid = await this.findWeChatPid() - if (pid) { - const runPath = await this.getProcessExecutablePath(pid) - if (runPath && existsSync(runPath)) return runPath - } - } catch (e) { - console.error('尝试获取运行中微信路径失败:', e) - } - - const uninstallKeys = [ - 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', - 'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall' - ] - const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER] - 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') - } - } - - for (const root of roots) { - for (const parent of uninstallKeys) { - const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation') - if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe') - } - } - - 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 findPidsByImageName(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) - const pids: number[] = [] - 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)) pids.push(pid) - } - } - return pids - } catch (e) { - return [] - } - } - - private async findWeChatPids(): Promise { - const pids: number[] = [] - const pushUnique = (pid: number | null | undefined) => { - if (!pid || pids.includes(pid)) return - pids.push(pid) - } - - for (const name of ['Weixin.exe', 'WeChat.exe']) { - const found = await this.findPidsByImageName(name) - found.forEach(pushUnique) - } - return pids - } - - private async isWeChatPidActive(pid: number): Promise { - const pids = await this.findWeChatPids() - if (pids.includes(pid)) return true - - const fallbackPid = await this.waitForWeChatWindow(250) - return fallbackPid === pid - } - - private async waitForWeChatPid(timeoutMs: number): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - const pids = await this.findWeChatPids() - if (pids.length > 0) return pids[0] - - const fallbackPid = await this.waitForWeChatWindow(250) - if (fallbackPid) return fallbackPid - - await new Promise(r => setTimeout(r, 500)) - } - return null - } - - private getRemainingMs(deadline: number): number { - return Math.max(0, deadline - Date.now()) - } - - private async pollDbKeyFromHook( - pid: number, - deadline: number, - logs: string[], - onStatus?: (message: string, level: number) => void - ): Promise { - const keyBuffer = Buffer.alloc(128) - let loginRequiredDetected = false - let nextProcessCheckAt = 0 - - while (Date.now() < deadline) { - const now = Date.now() - if (now >= nextProcessCheckAt) { - nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS - if (!await this.isWeChatPidActive(pid)) { - return { status: 'process-ended', loginRequiredDetected } - } - } - - if (this.pollKeyData(keyBuffer, keyBuffer.length)) { - const key = this.decodeUtf8(keyBuffer) - if (key.length === 64) { - onStatus?.('密钥获取成功', 1) - return { status: 'success', key, loginRequiredDetected } - } - } - - 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) - if (this.isLoginRelatedText(msg)) { - loginRequiredDetected = true - } - onStatus?.(msg, level) - } - } - await new Promise((resolve) => setTimeout(resolve, 120)) - } - - return { status: 'timeout', loginRequiredDetected } - } - - private cleanupDbKeyHook(): void { - try { - this.cleanupHook() - } catch { } - } - - private buildInitHookError(): string { - const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' - if (error) { - if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { - return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' - } - return error - } - - const statusBuffer = Buffer.alloc(256) - const levelOut = [0] - const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) - ? this.decodeUtf8(statusBuffer) - : '' - return status || '初始化失败' - } - - private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise { - while (this.getRemainingMs(deadline) > 0) { - onStatus?.('正在查找微信进程...', 0) - const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000)) - if (pid) return pid - } - return null - } - - private shouldRetryAfterProcessLost(deadline: number): boolean { - return this.getRemainingMs(deadline) > 1000 - } - - private async delayBeforeRetry(): Promise { - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise { - if (!this.shouldRetryAfterProcessLost(deadline)) return null - onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0) - await this.delayBeforeRetry() - return this.waitForNextDbKeyPid(deadline, onStatus) - } - - private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise { - if (loginRequiredDetected) return true - if (!pid) return false - if (!await this.isWeChatPidActive(pid)) return false - return await this.detectWeChatLoginRequired(pid) - } - - private async findWeChatPid(): Promise { - const pids = await this.findWeChatPids() - if (pids.length > 0) return pids[0] - const fallbackPid = await this.waitForWeChatWindow(5000) - return fallbackPid ?? null - } - - private async waitForWeChatExit(timeoutMs = 8000): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - const runningPids = await this.findWeChatPids() - if (runningPids.length === 0) return true - await new Promise(r => setTimeout(r, 400)) - } - return false - } - - private async closeWeChatWindows(): Promise { - if (!this.ensureUser32()) return false - let requested = false - - const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => { - if (!this.IsWindowVisible(hWnd)) return true - const title = this.getWindowTitle(hWnd) - const className = this.getClassName(hWnd) - const classLower = (className || '').toLowerCase() - const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin') - if (!isWeChatWindow) return true - - requested = true - try { - this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0) - } catch { } - return true - }, this.WNDENUMPROC_PTR) - - this.EnumWindows(enumWindowsCallback, 0) - this.koffi.unregister(enumWindowsCallback) - - return requested - } - - private async killWeChatProcesses(): Promise { - const requested = await this.closeWeChatWindows() - if (requested) { - const gracefulOk = await this.waitForWeChatExit(1500) - if (gracefulOk) return true - } - - try { - await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe']) - await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe']) - } catch (e) { } - - return await this.waitForWeChatExit(5000) - } - - // --- 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 isLoginRelatedText(value: string): boolean { - const normalized = String(value || '').replace(/\s+/g, '').toLowerCase() - if (!normalized) return false - const keywords = [ - '登录', - '扫码', - '二维码', - '请在手机上确认', - '手机确认', - '切换账号', - 'wechatlogin', - 'qrcode', - 'scan' - ] - return keywords.some((keyword) => normalized.includes(keyword)) - } - - private async detectWeChatLoginRequired(pid: number): Promise { - if (!this.ensureUser32()) return false - let loginRequired = 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 - - if (this.isLoginRelatedText(title)) { - loginRequired = true - return false - } - - const children = this.collectChildWindowInfos(hWnd) - for (const child of children) { - if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) { - loginRequired = true - return false - } - } - return true - }, this.WNDENUMPROC_PTR) - - this.EnumWindows(enumWindowsCallback, 0) - this.koffi.unregister(enumWindowsCallback) - - return loginRequired - } - - 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 - } - - // --- DB Key Logic (core hook/poll flow unchanged) --- - - 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[] = [] - const deadline = Date.now() + timeoutMs - onStatus?.('正在查找微信进程...', 0) - let pid = await this.findWeChatPid() - if (!pid) { - const err = '未找到微信进程,请先启动微信' - onStatus?.(err, 2) - return { success: false, error: err } - } - let lastAttemptLoginRequiredDetected = false - - while (pid && this.getRemainingMs(deadline) > 0) { - onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) - onStatus?.('正在检测微信界面组件...', 0) - await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline))) - - if (!await this.isWeChatPidActive(pid)) { - pid = await this.waitForProcessRestart(deadline, onStatus) - continue - } - - const ok = this.initHook(pid) - if (!ok) { - if (!await this.isWeChatPidActive(pid)) { - this.cleanupDbKeyHook() - pid = await this.waitForProcessRestart(deadline, onStatus) - continue - } - return { success: false, error: this.buildInitHookError(), logs } - } - - let pollResult: DbKeyPollResult - try { - pollResult = await this.pollDbKeyFromHook(pid, deadline, logs, onStatus) - } finally { - this.cleanupDbKeyHook() - } - - lastAttemptLoginRequiredDetected = pollResult.loginRequiredDetected - if (pollResult.status === 'success') { - return { success: true, key: pollResult.key, logs } - } - if (pollResult.status === 'process-ended') { - lastAttemptLoginRequiredDetected = false - pid = await this.waitForProcessRestart(deadline, onStatus) - continue - } - break - } - - const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected) - if (loginRequired) { - return { - success: false, - error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。', - logs - } - } - - return { success: false, error: '获取密钥超时', logs } - } - - private cleanWxid(wxid: string): string { - const first = wxid.indexOf('_') - if (first === -1) return wxid - const second = wxid.indexOf('_', first + 1) - if (second === -1) return wxid - return wxid.substring(0, second) - } - - private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { - const cleanedWxid = this.cleanWxid(wxid) - const xorKey = code & 0xFF - const dataToHash = code.toString() + cleanedWxid - const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') - const aesKey = md5Full.substring(0, 16) - return { xorKey, aesKey } - } - - private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { - try { - if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false - const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { - return false - } - } - - private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise { - const candidates: string[] = [] - const pushUnique = (value: string) => { - const v = String(value || '').trim() - if (!v || candidates.includes(v)) return - candidates.push(v) - } - - if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) - - if (manualDir) { - const normalized = manualDir.replace(/[\\/]+$/, '') - const dirName = normalized.split(/[\\/]/).pop() ?? '' - if (dirName.startsWith('wxid_')) pushUnique(dirName) - - const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i) - if (marker) { - const root = normalized.slice(0, marker.index! + marker[0].length) - try { - const { readdirSync, statSync } = await import('fs') - const { join } = await import('path') - for (const entry of readdirSync(root)) { - if (!entry.startsWith('wxid_')) continue - const full = join(root, entry) - try { - if (statSync(full).isDirectory()) pushUnique(entry) - } catch { } - } - } catch { } - } - } - - pushUnique('unknown') - return candidates - } - - async autoGetImageKey( - manualDir?: string, - onProgress?: (message: string) => void, - wxidParam?: string - ): Promise { - if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } - if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } - - onProgress?.('正在从缓存目录扫描图片密钥...') - - const resultBuffer = Buffer.alloc(8192) - const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length) - - if (!ok) { - const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败' - return { success: false, error: errMsg } - } - - const jsonStr = this.decodeUtf8(resultBuffer) - let parsed: any - try { - parsed = JSON.parse(jsonStr) - } catch { - return { success: false, error: '解析密钥数据失败' } - } - - // 从任意账号提取 code 列表(code 来自 kvcomm,与 wxid 无关,所有账号都一样) - const accounts: any[] = parsed.accounts ?? [] - if (!accounts.length || !accounts[0]?.keys?.length) { - return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } - } - - const codes: number[] = accounts[0].keys.map((k: any) => k.code) - console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) - - const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam) - let verifyCiphertext: Buffer | null = null - if (manualDir && existsSync(manualDir)) { - const template = await this._findTemplateData(manualDir, 32) - verifyCiphertext = template.ciphertext - } - - if (verifyCiphertext) { - onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateWxid of wxidCandidates) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue - onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) - return { success: true, xorKey, aesKey, verified: true } - } - } - return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } - } - - // 无模板密文可验真时回退旧策略 - const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown' - const fallbackCode = codes[0] - const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) - onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) - console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) - return { success: true, xorKey, aesKey, verified: false } - } - - // --- 内存扫描备选方案(融合 Dart+Python 优点)--- - // 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式 - // 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式 - - async autoGetImageKeyByMemoryScan( - userDir: string, - onProgress?: (message: string) => void - ): Promise { - if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } - - try { - // 1. 查找模板文件获取密文和 XOR 密钥 - onProgress?.('正在查找模板文件...') - let result = await this._findTemplateData(userDir, 32) - let { ciphertext, xorKey } = result - - // 如果找不到密钥,尝试扫描更多文件 - if (ciphertext && xorKey === null) { - onProgress?.('未找到有效密钥,尝试扫描更多文件...') - result = await this._findTemplateData(userDir, 100) - xorKey = result.xorKey - } - - if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } - if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' } - - onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - - // 2. 找微信 PID - const pid = await this.findWeChatPid() - if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } - - onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) - - // 3. 持续轮询内存扫描,最多 60 秒 - const deadline = Date.now() + 60_000 - let scanCount = 0 - while (Date.now() < deadline) { - scanCount++ - onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) - const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) - if (aesKey) { - onProgress?.('密钥获取成功') - return { success: true, xorKey, aesKey } - } - // 等 5 秒再试 - await new Promise(r => setTimeout(r, 5000)) - } - - return { - success: false, - error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。' - } - } catch (e) { - return { success: false, error: `内存扫描失败: ${e}` } - } - } - - private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { - const { readdirSync, readFileSync, statSync } = await import('fs') - const { join } = await import('path') - const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) - - // 递归收集 *_t.dat 文件 - const collect = (dir: string, results: string[], maxFiles: number) => { - if (results.length >= maxFiles) return - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (results.length >= maxFiles) break - const full = join(dir, entry.name) - if (entry.isDirectory()) collect(full, results, maxFiles) - else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) - } - } catch { /* 忽略无权限目录 */ } - } - - const files: string[] = [] - collect(userDir, files, limit) - - // 按修改时间降序 - files.sort((a, b) => { - try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } - }) - - let ciphertext: Buffer | null = null - const tailCounts: Record = {} - - for (const f of files.slice(0, 32)) { - try { - const data = readFileSync(f) - if (data.length < 8) continue - - // 统计末尾两字节用于 XOR 密钥 - if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { - const key = `${data[data.length - 2]}_${data[data.length - 1]}` - tailCounts[key] = (tailCounts[key] ?? 0) + 1 - } - - // 提取密文(取第一个有效的) - if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { - ciphertext = data.subarray(0xF, 0x1F) - } - } catch { /* 忽略 */ } - } - - // 计算 XOR 密钥 - let xorKey: number | null = null - let maxCount = 0 - for (const [key, count] of Object.entries(tailCounts)) { - if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } - } - - return { ciphertext, xorKey } - } - - private async _scanMemoryForAesKey( - pid: number, - ciphertext: Buffer, - onProgress?: (msg: string) => void - ): Promise { - if (!this.ensureKernel32()) return null - - // 直接用已加载的 kernel32 实例,用 uintptr 传地址 - const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) - const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) - - // RW 保护标志(只扫可写区域,速度更快) - const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY - const MEM_COMMIT = 0x1000 - const PAGE_NOACCESS = 0x01 - const PAGE_GUARD = 0x100 - const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64 - - const hProcess = this.OpenProcess(0x1F0FFF, false, pid) - if (!hProcess) return null - - try { - // 枚举 RW 内存区域 - const regions: Array<[number, number]> = [] - let addr = 0 - const mbi = Buffer.alloc(MBI_SIZE) - - while (addr < 0x7FFFFFFFFFFF) { - const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) - if (ret === 0) break - // MEMORY_BASIC_INFORMATION x64 布局: - // 0: BaseAddress (8) - // 8: AllocationBase (8) - // 16: AllocationProtect (4) + 4 padding - // 24: RegionSize (8) - // 32: State (4) - // 36: Protect (4) - // 40: Type (4) + 4 padding = 48 total - const base = Number(mbi.readBigUInt64LE(0)) - const size = Number(mbi.readBigUInt64LE(24)) - const state = mbi.readUInt32LE(32) - const protect = mbi.readUInt32LE(36) - - if (state === MEM_COMMIT && - protect !== PAGE_NOACCESS && - (protect & PAGE_GUARD) === 0 && - (protect & RW_FLAGS) !== 0 && - size <= 50 * 1024 * 1024) { - regions.push([base, size]) - } - const next = base + size - if (next <= addr) break - addr = next - } - - const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024 - onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) - - const CHUNK = 4 * 1024 * 1024 - const OVERLAP = 65 - - for (let i = 0; i < regions.length; i++) { - const [base, size] = regions[i] - if (i % 20 === 0) { - onProgress?.(`扫描进度 ${i}/${regions.length}...`) - await new Promise(r => setTimeout(r, 1)) // 让出事件循环 - } - - let offset = 0 - let trailing: Buffer | null = null - - while (offset < size) { - const chunkSize = Math.min(CHUNK, size - offset) - const buf = Buffer.alloc(chunkSize) - const bytesReadOut = [0] - const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut) - if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue } - - const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0]) - - // 搜索 ASCII 32字节密钥 - const key = this._searchAsciiKey(data, ciphertext) - if (key) { this.CloseHandle(hProcess); return key } - - // 搜索 UTF-16LE 32字节密钥 - const key16 = this._searchUtf16Key(data, ciphertext) - if (key16) { this.CloseHandle(hProcess); return key16 } - - trailing = data.subarray(Math.max(0, data.length - OVERLAP)) - offset += chunkSize - } - } - - return null - } finally { - this.CloseHandle(hProcess) - } - } - - private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { - for (let i = 0; i < data.length - 34; i++) { - if (this._isAlphaNum(data[i])) continue - let valid = true - for (let j = 1; j <= 32; j++) { - if (!this._isAlphaNum(data[i + j])) { valid = false; break } - } - if (!valid) continue - if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue - const keyBytes = data.subarray(i + 1, i + 33) - if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) - } - return null - } - - private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { - for (let i = 0; i < data.length - 65; i++) { - let valid = true - for (let j = 0; j < 32; j++) { - if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } - } - if (!valid) continue - const keyBytes = Buffer.alloc(32) - for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] - if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) - } - return null - } - - private _isAlphaNum(b: number): boolean { - return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) - } - - private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { - try { - const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - // 支持 JPEG / PNG / WEBP / WXGF / GIF - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { return false } - } -} diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts deleted file mode 100644 index 6cc46a5..0000000 --- a/electron/services/keyServiceLinux.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { app } from 'electron' -import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' -import { execFile, exec, spawn } from 'child_process' -import { promisify } from 'util' -import crypto from 'crypto' -import { createRequire } from 'module'; -const require = createRequire(__filename); - -const execFileAsync = promisify(execFile) -const execAsync = promisify(exec) - -type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } - -export class KeyServiceLinux { - private sudo: any - - constructor() { - try { - this.sudo = require('@vscode/sudo-prompt'); - } catch (e) { - console.error('Failed to load @vscode/sudo-prompt', e); - } - } - - private getHelperPath(): string { - const isPackaged = app.isPackaged - const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' - const candidates: string[] = [] - if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux')) - candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) - candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) - } else { - candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux')) - candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) - candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) - candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) - candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux')) - candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) - } - for (const p of candidates) { - if (existsSync(p)) return p - } - throw new Error('找不到 xkey_helper_linux,请检查路径') - } - - public async autoGetDbKey( - timeoutMs = 60_000, - onStatus?: (message: string, level: number) => void - ): Promise { - try { - // 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令 - const envWithPath = { - ...process.env, - PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin` - }; - - onStatus?.('正在尝试结束当前微信进程...', 0) - console.log('[Debug] 开始执行进程清理逻辑...'); - - try { - const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath }); - console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`); - } catch (err: any) { - // 命令如果没找到进程通常会返回 code 1,这也是正常的,但我们需要记录下来 - console.log(`[Debug] killall 报错或未找到进程: ${err.message}`); - - // Fallback: 尝试使用 pkill 兜底 - try { - console.log('[Debug] 尝试使用备用命令 pkill...'); - await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath }); - console.log('[Debug] pkill 执行完成'); - } catch (e: any) { - console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`); - } - } - - // 稍微等待进程完全退出 - await new Promise(r => setTimeout(r, 1000)) - - onStatus?.('正在尝试拉起微信...', 0) - - const cleanEnv = { ...process.env }; - delete cleanEnv.ELECTRON_RUN_AS_NODE; - delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE; - delete cleanEnv.APPDIR; - delete cleanEnv.APPIMAGE; - - const wechatBins = [ - 'wechat', - 'wechat-bin', - 'xwechat', - '/opt/wechat/wechat', - '/usr/bin/wechat', - '/usr/local/bin/wechat', - '/usr/bin/wechat', - '/opt/apps/com.tencent.wechat/files/wechat', - '/usr/bin/wechat-bin', - '/usr/local/bin/wechat-bin', - 'com.tencent.wechat' - ] - - for (const binName of wechatBins) { - try { - const child = spawn(binName, [], { - detached: true, - stdio: 'ignore', - env: cleanEnv - }); - - child.on('error', (err) => { - console.log(`[Debug] 拉起 ${binName} 失败:`, err.message); - }); - - child.unref(); - console.log(`[Debug] 尝试拉起 ${binName} 完毕`); - } catch (e: any) { - console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message); - } - } - - onStatus?.('等待微信进程出现...', 0) - let pid = 0 - for (let i = 0; i < 15; i++) { // 最多等 15 秒 - await new Promise(r => setTimeout(r, 1000)) - - try { - const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath }); - const pids = stdout.trim().split(/\s+/).filter(p => p); - if (pids.length > 0) { - pid = parseInt(pids[0], 10); - console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`); - break; - } - } catch (err: any) { - console.log(`[Debug] 第 ${i + 1} 秒,pidof 失败: ${err.message.split('\n')[0]}`); - - // Fallback: 使用 pgrep 兜底 - try { - const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath }); - const pids = pgrepOut.trim().split(/\s+/).filter(p => p); - if (pids.length > 0) { - pid = parseInt(pids[0], 10); - console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`); - break; - } - } catch (e: any) { - console.log(`[Debug] 第 ${i + 1} 秒,pgrep 也失败: ${e.message.split('\n')[0]}`); - } - } - } - - if (!pid) { - const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。' - onStatus?.(err, 2) - return { success: false, error: err } - } - - onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0) - - await new Promise(r => setTimeout(r, 2000)) - - return await this.getDbKey(pid, onStatus, timeoutMs) - } catch (err: any) { - console.error('[Debug] 自动获取流程彻底崩溃:', err); - const errMsg = '自动获取微信 PID 失败: ' + err.message - onStatus?.(errMsg, 2) - return { success: false, error: errMsg } - } - } - - public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise { - try { - const helperPath = this.getHelperPath() - - onStatus?.('正在扫描数据库基址...', 0) - const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()]) - const scanRes = JSON.parse(scanOut.trim()) - - if (!scanRes.success) { - const err = scanRes.result || '扫描失败,请确保微信已完全登录' - onStatus?.(err, 2) - return { success: false, error: err } - } - - const targetAddr = scanRes.target_addr - onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0) - - if (!this.sudo || typeof this.sudo.exec !== 'function') { - const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow' - onStatus?.(err, 2) - return { success: false, error: err } - } - - return await new Promise((resolve) => { - const options = { - name: 'WeFlow', - env: { - PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin` - } - } - const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000) - const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}` - let settled = false - const finish = (result: DbKeyResult) => { - if (settled) return - settled = true - clearTimeout(watchdog) - resolve(result) - } - const watchdog = setTimeout(() => { - execAsync(`kill -CONT ${pid}`).catch(() => {}) - const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。` - onStatus?.(err, 2) - finish({ success: false, error: err }) - }, timeoutMs + 30_000) - - onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0) - - this.sudo.exec(command, options, (error, stdout, stderr) => { - execAsync(`kill -CONT ${pid}`).catch(() => {}) - if (error) { - const detail = String(stderr || '').trim() - const message = detail ? `${error.message}: ${detail}` : error.message - onStatus?.('授权失败或 Hook 执行失败', 2) - finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` }) - return - } - try { - const output = String(stdout || '').trim() - if (!output) { - const detail = String(stderr || '').trim() - throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出') - } - const hookRes = JSON.parse(output) - if (hookRes.success) { - onStatus?.('密钥获取成功', 1) - finish({ success: true, key: hookRes.key }) - } else { - onStatus?.(hookRes.result, 2) - finish({ success: false, error: hookRes.result }) - } - } catch (e: any) { - onStatus?.('解析 Hook 结果失败', 2) - finish({ success: false, error: e?.message || '解析 Hook 结果失败' }) - } - }) - }) - } catch (err: any) { - onStatus?.(err.message, 2) - return { success: false, error: err.message } - } - } - - public async autoGetImageKey( - accountPath?: string, - onProgress?: (msg: string) => void, - wxid?: string - ): Promise { - try { - onProgress?.('正在初始化缓存扫描...'); - const helperPath = this.getHelperPath() - const { stdout } = await execFileAsync(helperPath, ['image_local']) - const res = JSON.parse(stdout.trim()) - if (!res.success) return { success: false, error: res.result } - - const accounts = res.data.accounts || [] - let account = accounts.find((a: any) => a.wxid === wxid) - if (!account && accounts.length > 0) account = accounts[0] - - if (account && account.keys && account.keys.length > 0) { - onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); - const keyObj = account.keys[0] - const aesKey = String(keyObj.aesKey || '') - const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey) - if (verified === true) { - onProgress?.('缓存密钥校验成功,已确认可用') - } else if (verified === false) { - onProgress?.('已从缓存计算密钥,但未通过本地模板校验') - } - return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true } - } - return { success: false, error: '未在缓存中找到匹配的图片密钥' } - } catch (err: any) { - return { success: false, error: err.message } - } - } - - private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise { - const normalizedPath = String(accountPath || '').trim() - if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null - try { - const template = await this._findTemplateData(normalizedPath, 32) - if (!template.ciphertext) return null - return this.verifyDerivedAesKey(aesKey, template.ciphertext) - } catch { - return null - } - } - - private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { - try { - if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false - const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { - return false - } - } - - public async autoGetImageKeyByMemoryScan( - accountPath: string, - onProgress?: (msg: string) => void - ): Promise { - try { - onProgress?.('正在查找模板文件...') - let result = await this._findTemplateData(accountPath, 32) - let { ciphertext, xorKey } = result - - if (ciphertext && xorKey === null) { - onProgress?.('未找到有效密钥,尝试扫描更多文件...') - result = await this._findTemplateData(accountPath, 100) - xorKey = result.xorKey - } - - if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } - if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } - - onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - - // 2. 找微信 PID - const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) - const pids = stdout.trim().split(/\s+/).filter(p => p) - if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' } - const pid = parseInt(pids[0], 10) - - onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`); - - // 3. 将 Buffer 转换为 hex 传递给 helper - const ciphertextHex = ciphertext.toString('hex') - const helperPath = this.getHelperPath() - - try { - console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`); - - const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex]) - - console.log(`[Debug] Helper stdout: ${memOut}`); - if (stderr) { - console.warn(`[Debug] Helper stderr: ${stderr}`); - } - - if (!memOut || memOut.trim() === '') { - return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' } - } - - const res = JSON.parse(memOut.trim()) - - if (res.success) { - onProgress?.('内存扫描成功'); - return { success: true, xorKey, aesKey: res.key } - } - return { success: false, error: res.result || '未知错误' } - - } catch (err: any) { - console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err); - return { - success: false, - error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}` - } - } - } catch (err: any) { - return { success: false, error: `内存扫描失败: ${err.message}` } - } - } - - private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { - const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) - - // 递归收集 *_t.dat 文件 - const collect = (dir: string, results: string[], maxFiles: number) => { - if (results.length >= maxFiles) return - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (results.length >= maxFiles) break - const full = join(dir, entry.name) - if (entry.isDirectory()) collect(full, results, maxFiles) - else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) - } - } catch { /* 忽略无权限目录 */ } - } - - const files: string[] = [] - collect(userDir, files, limit) - - // 按修改时间降序 - files.sort((a, b) => { - try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } - }) - - let ciphertext: Buffer | null = null - const tailCounts: Record = {} - - for (const f of files.slice(0, 32)) { - try { - const data = readFileSync(f) - if (data.length < 8) continue - - // 统计末尾两字节用于 XOR 密钥 - if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { - const key = `${data[data.length - 2]}_${data[data.length - 1]}` - tailCounts[key] = (tailCounts[key] ?? 0) + 1 - } - - // 提取密文(取第一个有效的) - if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { - ciphertext = data.subarray(0xF, 0x1F) - } - } catch { /* 忽略 */ } - } - - // 计算 XOR 密钥 - let xorKey: number | null = null - let maxCount = 0 - for (const [key, count] of Object.entries(tailCounts)) { - if (count > maxCount) { - maxCount = count - const [x, y] = key.split('_').map(Number) - const k = x ^ 0xFF - if (k === (y ^ 0xD9)) xorKey = k - } - } - - return { ciphertext, xorKey } - } -} diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts deleted file mode 100644 index e094918..0000000 --- a/electron/services/keyServiceMac.ts +++ /dev/null @@ -1,1483 +0,0 @@ -import { app, shell } from 'electron' -import { join, basename, dirname } from 'path' -import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs' -import { execFile, spawn } from 'child_process' -import { promisify } from 'util' -import crypto from 'crypto' -import { homedir } from 'os' - -type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } -const execFileAsync = promisify(execFile) - -export class KeyServiceMac { - private koffi: any = null - private lib: any = null - private initialized = false - - private GetDbKey: any = null - private ListWeChatProcesses: any = null - private libSystem: any = null - private machTaskSelf: any = null - private taskForPid: any = null - private machVmRegion: any = null - private machVmReadOverwrite: any = null - private machPortDeallocate: any = null - private _needsElevation = false - private restrictedFailureCount = 0 - private restrictedFailureAt = 0 - private readonly restrictedFailureWindowMs = 8 * 60_000 - - private getHelperPath(): string { - const isPackaged = app.isPackaged - const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' - const candidates: string[] = [] - - if (process.env.WX_KEY_HELPER_PATH) { - candidates.push(process.env.WX_KEY_HELPER_PATH) - } - - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper')) - candidates.push(join(process.resourcesPath, 'xkey_helper')) - } else { - const cwd = process.cwd() - candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper')) - candidates.push(join(cwd, 'resources', 'xkey_helper')) - candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper')) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - - throw new Error('xkey_helper not found') - } - - private getImageScanHelperPath(): string { - const isPackaged = app.isPackaged - const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' - const candidates: string[] = [] - - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper')) - candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper')) - candidates.push(join(process.resourcesPath, 'image_scan_helper')) - } else { - const cwd = process.cwd() - candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper')) - candidates.push(join(cwd, 'resources', 'image_scan_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper')) - candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper')) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - - throw new Error('image_scan_helper not found') - } - - private getDylibPath(): string { - const isPackaged = app.isPackaged - const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' - const candidates: string[] = [] - - if (process.env.WX_KEY_DYLIB_PATH) { - candidates.push(process.env.WX_KEY_DYLIB_PATH) - } - - if (isPackaged) { - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) - candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib')) - candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib')) - candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) - } else { - const cwd = process.cwd() - candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) - candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib')) - candidates.push(join(cwd, 'resources', 'libwx_key.dylib')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) - candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib')) - candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib')) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - - throw new Error('libwx_key.dylib not found') - } - - async initialize(): Promise { - if (this.initialized) return - - try { - this.koffi = require('koffi') - const dylibPath = this.getDylibPath() - - if (!existsSync(dylibPath)) { - throw new Error('libwx_key.dylib not found: ' + dylibPath) - } - - this.lib = this.koffi.load(dylibPath) - - this.GetDbKey = this.lib.func('const char* GetDbKey()') - this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') - - this.initialized = true - } catch (e: any) { - throw new Error('Failed to initialize KeyServiceMac: ' + e.message) - } - } - - private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> { - try { - const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status']) - const enabled = stdout.toLowerCase().includes('enabled') - return { enabled } - } catch (e: any) { - return { enabled: false, error: e.message } - } - } - - async autoGetDbKey( - timeoutMs = 60_000, - onStatus?: (message: string, level: number) => void - ): Promise { - try { - // 检测 SIP 状态 - const sipStatus = await this.checkSipStatus() - if (sipStatus.enabled) { - return { - success: false, - error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑' - } - } - - onStatus?.('正在获取数据库密钥...', 0) - onStatus?.('正在请求管理员授权并执行 helper...', 0) - let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } - try { - const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus) - parsed = this.parseDbKeyResult(elevatedResult) - console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw) - } catch (e: any) { - const msg = `${e?.message || e}` - if (msg.includes('(-128)') || msg.includes('User canceled')) { - return { success: false, error: '已取消管理员授权' } - } - throw e - } - - if (!parsed.success) { - const errorMsg = this.enrichDbKeyErrorMessage( - this.mapDbKeyErrorMessage(parsed.code, parsed.detail), - parsed.code, - parsed.detail - ) - onStatus?.(errorMsg, 2) - return { success: false, error: errorMsg } - } - - this.resetRestrictedFailureState() - onStatus?.('密钥获取成功', 1) - return { success: true, key: parsed.key } - } catch (e: any) { - console.error('[KeyServiceMac] Error:', e) - console.error('[KeyServiceMac] Stack:', e.stack) - const rawError = `${e?.message || e || ''}`.trim() - const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError) - onStatus?.(resolvedError, 2) - return { success: false, error: resolvedError } - } - } - - private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } { - const text = typeof raw === 'string' ? raw : '' - if (!text) return { success: false, code: 'UNKNOWN', raw: text } - if (!text.startsWith('ERROR:')) return { success: true, key: text, raw: text } - - const parts = text.split(':') - return { - success: false, - code: parts[1] || 'UNKNOWN', - detail: parts.slice(2).join(':') || undefined, - raw: text - } - } - - private async getDbKeyParsed( - timeoutMs: number, - onStatus?: (message: string, level: number) => void - ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { - const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) - return this.parseDbKeyResult(helperResult) - } - - private resetRestrictedFailureState(): void { - this.restrictedFailureCount = 0 - this.restrictedFailureAt = 0 - } - - private markRestrictedFailureAndGetCount(): number { - const now = Date.now() - if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) { - this.restrictedFailureCount = 0 - } - this.restrictedFailureAt = now - this.restrictedFailureCount += 1 - return this.restrictedFailureCount - } - - private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean { - const normalizedCode = String(code || '').toUpperCase() - const normalizedDetail = String(detail || '').toLowerCase() - if (!normalizedCode && !normalizedDetail) return false - - if (normalizedCode === 'SCAN_FAILED') { - return normalizedDetail.includes('sink pattern not found') - || normalizedDetail.includes('no suitable module found') - } - - if (normalizedCode === 'HOOK_FAILED') { - return normalizedDetail.includes('patch_breakpoint_failed') - || normalizedDetail.includes('thread_get_state_failed') - || normalizedDetail.includes('native hook failed') - } - - if (normalizedCode === 'ATTACH_FAILED') { - return normalizedDetail.includes('task_for_pid:5') - || normalizedDetail.includes('thread_get_state_failed') - } - - return normalizedDetail.includes('patch_breakpoint_failed') - || normalizedDetail.includes('thread_get_state_failed') - || normalizedDetail.includes('sink pattern not found') - || normalizedDetail.includes('no suitable module found') - } - - private getMacRecoveryHint(isRepeatedFailure: boolean): string { - const steps = isRepeatedFailure - ? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。' - : '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。' - return `${steps}\n请不要连续重试,以免触发微信安全模式或系统内存保护。` - } - - private simplifyDbKeyDetail(detail?: string): string { - const raw = String(detail || '') - .replace(/^WF_OK::/i, '') - .replace(/^WF_ERR::/i, '') - .replace(/\r?\n/g, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!raw) return '' - - const keys = [ - 'No suitable module found', - 'Sink pattern not found', - 'patch_breakpoint_failed', - 'thread_get_state_failed', - 'task_for_pid:5', - 'attach_wait_timeout', - 'HOOK_TIMEOUT', - 'FRIDA_TIMEOUT' - ] - for (const key of keys) { - if (raw.includes(key)) return key - } - - const stripped = raw - .replace(/\[xkey_helper\]/gi, ' ') - .replace(/\[debug\]/gi, ' ') - .replace(/\[\*\]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!stripped) return '' - return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped - } - - private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } { - const raw = String(text || '') - if (!raw) return {} - - const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/) - if (explicit) { - return { - code: explicit[1] || 'UNKNOWN', - detail: this.simplifyDbKeyDetail(explicit[2] || '') - } - } - - if (raw.includes('No suitable module found')) { - return { code: 'SCAN_FAILED', detail: 'No suitable module found' } - } - if (raw.includes('Sink pattern not found')) { - return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' } - } - if (raw.includes('patch_breakpoint_failed')) { - return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' } - } - if (raw.includes('thread_get_state_failed')) { - return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' } - } - if (raw.includes('task_for_pid:5')) { - return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' } - } - - return {} - } - - private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string { - const text = String(rawError || '').trim() - const { code, detail } = this.extractDbKeyErrorFromAnyText(text) - if (code) { - const mapped = this.mapDbKeyErrorMessage(code, detail) - return this.enrichDbKeyErrorMessage(mapped, code, detail) - } - - if (text.includes('helper timeout')) { - return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。' - } - if (text.includes('helper returned empty output') || text.includes('invalid json')) { - return '获取失败:helper 未返回可识别结果,请彻底退出微信后重启电脑再试。' - } - if (text.includes('xkey_helper not found')) { - return '获取失败:未找到 xkey_helper,请重新安装 WeFlow 后重试。' - } - return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。' - } - - private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string { - if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage - - const failureCount = this.markRestrictedFailureAndGetCount() - if (failureCount >= 2) { - return `${baseMessage}\n检测到连续失败,疑似已进入受限状态。请先彻底退出微信并重启电脑,再按下方步骤处理。\n${this.getMacRecoveryHint(true)}` - } - return `${baseMessage}\n${this.getMacRecoveryHint(false)}` - } - - private async getWeChatPid(): Promise { - try { - // 优先使用 pgrep -x 精确匹配进程名 - try { - const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat']) - const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) - if (ids.length > 0) return Math.max(...ids) - } catch { - // ignore and fallback - } - - // pgrep -f 匹配完整命令行路径(打包后 pgrep -x 可能失败时的备选) - try { - const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']) - const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) - if (ids.length > 0) return Math.max(...ids) - } catch { - // ignore and fallback to ps - } - - const { stdout } = await execFileAsync('/bin/ps', ['-A', '-o', 'pid,comm,command']) - const lines = stdout.split('\n').slice(1) - - const candidates: Array<{ pid: number; comm: string; command: string }> = [] - for (const line of lines) { - const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/) - if (!match) continue - - const pid = parseInt(match[1], 10) - const comm = match[2] - const command = match[3] - - // 打包后 command 列可能被截断或为空,同时检查 comm 列 - const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') || - command.includes('/Contents/MacOS/WeChat') || - comm === 'WeChat' - if (pathMatch) candidates.push({ pid, comm, command }) - } - - if (candidates.length === 0) throw new Error('WeChat process not found') - - const filtered = candidates.filter(p => { - const cmd = p.command - return !cmd.includes('WeChatAppEx.app/') && - !cmd.includes('/WeChatAppEx') && - !cmd.includes(' WeChatAppEx') && - !cmd.includes('crashpad_handler') && - !cmd.includes('Helper') && - p.comm !== 'WeChat Helper' - }) - if (filtered.length === 0) throw new Error('No valid WeChat main process found') - - const preferredMain = filtered.filter(p => - p.command.includes('/Contents/MacOS/WeChat') || p.comm === 'WeChat' - ) - const selectedPool = preferredMain.length > 0 ? preferredMain : filtered - const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max) - return selected.pid - } catch (e: any) { - throw new Error('Failed to get WeChat PID: ' + e.message) - } - } - - private async getDbKeyByHelper( - timeoutMs: number, - onStatus?: (message: string, level: number) => void - ): Promise { - const helperPath = this.getHelperPath() - const waitMs = Math.max(timeoutMs, 30_000) - const timeoutSec = Math.ceil(waitMs / 1000) + 30 - const pid = await this.getWeChatPid() - onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) - // 最佳努力清理同路径残留 helper(普通权限) - try { await execFileAsync('/usr/bin/pkill', ['-f', helperPath], { timeout: 2000 }) } catch { } - - return await new Promise((resolve, reject) => { - // xkey_helper 参数协议:helper [timeout_ms] - const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - let stdoutBuf = '' - let stderrBuf = '' - let settled = false - let killTimer: ReturnType | null = null - let pidNotified = false - let locatedNotified = false - let hookNotified = false - - const done = (fn: () => void) => { - if (settled) return - settled = true - if (killTimer) clearTimeout(killTimer) - fn() - } - - const processHelperLine = (line: string) => { - if (!line) return - console.log('[KeyServiceMac][helper][stderr]', line) - const pidMatch = line.match(/Selected PID=(\d+)/) - if (pidMatch && !pidNotified) { - pidNotified = true - onStatus?.(`已找到微信进程 PID=${pidMatch[1]},正在定位目标函数...`, 0) - } - if (!locatedNotified && (line.includes('strict hit=') || line.includes('sink matched by strict semantic signature'))) { - locatedNotified = true - onStatus?.('已定位到目标函数,正在安装 Hook...', 0) - } - if (line.includes('hook installed @')) { - hookNotified = true - onStatus?.('Hook 已安装,等待微信触发密钥调用...', 0) - } - if (line.includes('[MASTER] hex64=')) { - onStatus?.('检测到密钥回调,正在回填...', 0) - } - } - - child.stdout.on('data', (chunk: Buffer | string) => { - const data = chunk.toString() - stdout += data - stdoutBuf += data - const parts = stdoutBuf.split(/\r?\n/) - stdoutBuf = parts.pop()! - }) - - child.stderr.on('data', (chunk: Buffer | string) => { - const data = chunk.toString() - stderr += data - stderrBuf += data - const parts = stderrBuf.split(/\r?\n/) - stderrBuf = parts.pop()! - for (const line of parts) processHelperLine(line.trim()) - }) - - child.on('error', (err) => { - done(() => reject(err)) - }) - - child.on('close', () => { - if (stderrBuf.trim()) processHelperLine(stderrBuf.trim()) - - const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) - const last = lines[lines.length - 1] - if (!last) { - done(() => reject(new Error(stderr.trim() || 'helper returned empty output'))) - return - } - - let payload: any - try { - payload = JSON.parse(last) - } catch { - done(() => reject(new Error('helper returned invalid json: ' + last))) - return - } - - if (payload?.success === true && typeof payload?.key === 'string') { - if (!hookNotified) { - onStatus?.('Hook 已触发,正在回填密钥...', 0) - } - done(() => resolve(payload.key)) - return - } - if (typeof payload?.result === 'string') { - done(() => resolve(payload.result)) - return - } - done(() => reject(new Error('helper json missing key/result'))) - }) - - killTimer = setTimeout(() => { - try { child.kill('SIGTERM') } catch { } - done(() => reject(new Error(`helper timeout after ${waitMs}ms`))) - }, waitMs + 10_000) - }) - } - - private shellSingleQuote(text: string): string { - return `'${String(text).replace(/'/g, `'\\''`)}'` - } - - private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] { - const baseDir = dirname(primaryBinaryPath) - const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib'] - const unique: string[] = [] - for (const name of names) { - const full = join(baseDir, name) - if (!existsSync(full)) continue - if (!unique.includes(full)) unique.push(full) - } - if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) { - unique.unshift(primaryBinaryPath) - } - return unique - } - - private ensureExecutableBitsBestEffort(paths: string[]): void { - for (const p of paths) { - try { - const mode = statSync(p).mode - if ((mode & 0o111) !== 0) continue - chmodSync(p, mode | 0o111) - } catch { - // ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app) - } - } - } - - private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise { - const existing = paths.filter(p => existsSync(p)) - if (existing.length === 0) return - - const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ') - const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000)) - const scriptLines = [ - `set chmodCmd to "/bin/chmod +x ${quotedPaths}"`, - `set timeoutSec to ${timeoutSec}`, - 'with timeout of timeoutSec seconds', - 'do shell script chmodCmd with administrator privileges', - 'end timeout' - ] - - await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { - timeout: timeoutMs + 10_000 - }) - } - - private async getDbKeyByHelperElevated( - timeoutMs: number, - onStatus?: (message: string, level: number) => void - ): Promise { - const helperPath = this.getHelperPath() - const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) - this.ensureExecutableBitsBestEffort(artifactPaths) - const waitMs = Math.max(timeoutMs, 30_000) - const timeoutSec = Math.ceil(waitMs / 1000) + 30 - const pid = await this.getWeChatPid() - const chmodPart = artifactPaths.length > 0 - ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}` - : '' - const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}` - const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart - // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 - // 通过 try/on error 回传详细错误,避免只看到 "Command failed" - const scriptLines = [ - `set cmd to ${JSON.stringify(privilegedCmd)}`, - `set timeoutSec to ${timeoutSec}`, - 'try', - 'with timeout of timeoutSec seconds', - 'set outText to do shell script (cmd & " 2>&1") with administrator privileges', - 'end timeout', - 'return "WF_OK::" & outText', - 'on error errMsg number errNum partial result pr', - 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', - 'end try' - ] - let stdout = '' - try { - const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { - timeout: waitMs + 20_000 - }) - stdout = result.stdout - } catch (e: any) { - const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim() - throw new Error(msg || 'elevated helper execution failed') - } - - const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) - if (!lines.length) throw new Error('elevated helper returned empty output') - const joined = lines.join('\n') - - if (joined.startsWith('WF_ERR::')) { - const parts = joined.split('::') - const errNum = parts[1] || 'unknown' - const errMsg = parts[2] || 'unknown' - const partial = parts.slice(3).join('::') - if (errNum === '-128' || String(errMsg).includes('User canceled')) { - throw new Error('User canceled') - } - const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`) - if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}` - throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`) - } - const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined - - // 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个 - const extractJsonObjects = (s: string): any[] => { - const results: any[] = [] - const re = /\{[^{}]*\}/g - let m: RegExpExecArray | null - while ((m = re.exec(s)) !== null) { - try { results.push(JSON.parse(m[0])) } catch { } - } - return results - } - const fullOutput = normalizedOutput - const allJson = extractJsonObjects(fullOutput) - // 优先找 success=true && key 字段 - const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string') - if (successPayload) return successPayload.key - // 其次找 result 字段 - const resultPayload = allJson.find(p => typeof p?.result === 'string') - if (resultPayload) return resultPayload.result - const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput) - if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}` - throw new Error('elevated helper returned invalid output') - } - - private mapDbKeyErrorMessage(code?: string, detail?: string): string { - const normalizedDetail = this.simplifyDbKeyDetail(detail) - if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' - if (code === 'ATTACH_FAILED') { - const isDevElectron = process.execPath.includes('/node_modules/electron/') - if (normalizedDetail.includes('task_for_pid:5')) { - if (isDevElectron) { - return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。` - } - return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。' - } - if (normalizedDetail.includes('thread_get_state_failed')) { - return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。` - } - return `无法附加到进程 (${normalizedDetail || ''})` - } - if (code === 'FRIDA_FAILED') { - if (normalizedDetail.includes('FRIDA_TIMEOUT')) { - return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。' - } - return `Frida 语义定位失败 (${normalizedDetail || ''})` - } - if (code === 'HOOK_FAILED') { - if (normalizedDetail.includes('HOOK_TIMEOUT')) { - return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。' - } - if (normalizedDetail.includes('attach_wait_timeout')) { - return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' - } - if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) { - return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。` - } - return `原生 Hook 失败 (${normalizedDetail || ''})` - } - if (code === 'HOOK_TARGET_ONLY') { - return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` - } - if (code === 'SCAN_FAILED') { - if (!normalizedDetail) { - return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。' - } - if (normalizedDetail.includes('Sink pattern not found')) { - return '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。' - } - if (normalizedDetail.includes('No suitable module found')) { - return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。' - } - return `内存扫描失败:${normalizedDetail}` - } - return '未知错误' - } - - private async enableDebugPermissionWithPrompt(): Promise { - const script = [ - 'do shell script "/usr/sbin/DevToolsSecurity -enable" with administrator privileges' - ] - - try { - await execFileAsync('/usr/bin/osascript', script.flatMap(line => ['-e', line]), { - timeout: 30_000 - }) - return true - } catch (e: any) { - const msg = `${e?.stderr || ''}\n${e?.message || ''}` - const cancelled = msg.includes('User canceled') || msg.includes('(-128)') - if (!cancelled) { - console.error('[KeyServiceMac] enableDebugPermissionWithPrompt failed:', msg) - } - return false - } - } - - private async openDeveloperToolsPrivacySettings(): Promise { - const url = 'x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools' - try { - await shell.openExternal(url) - } catch (e) { - console.error('[KeyServiceMac] Failed to open settings page:', e) - } - } - - private async revealCurrentExecutableInFinder(): Promise { - try { - shell.showItemInFolder(process.execPath) - } catch (e) { - console.error('[KeyServiceMac] Failed to reveal executable in Finder:', e) - } - } - - async autoGetImageKey( - accountPath?: string, - onStatus?: (message: string) => void, - wxid?: string - ): Promise { - try { - onStatus?.('正在从缓存目录扫描图片密钥...') - const codes = this.collectKvcommCodes(accountPath) - if (codes.length === 0) { - return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } - } - - const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) - if (wxidCandidates.length === 0) { - return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' } - } - - const accountPathCandidates = this.collectAccountPathCandidates(accountPath) - - // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 - if (accountPathCandidates.length > 0) { - onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateAccountPath of accountPathCandidates) { - if (!existsSync(candidateAccountPath)) continue - const template = await this._findTemplateData(candidateAccountPath, 32) - if (!template.ciphertext) continue - - const accountDirWxid = basename(candidateAccountPath) - const orderedWxids: string[] = [] - this.pushAccountIdCandidates(orderedWxids, accountDirWxid) - for (const candidate of wxidCandidates) { - this.pushAccountIdCandidates(orderedWxids, candidate) - } - - for (const candidateWxid of orderedWxids) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue - onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey, verified: true } - } - } - } - return { - success: false, - error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。' - } - } - - // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) - const fallbackWxid = wxidCandidates[0] - const fallbackCode = codes[0] - const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) - onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) - return { success: true, xorKey, aesKey, verified: false } - } catch (e: any) { - return { success: false, error: `自动获取图片密钥失败: ${e.message}` } - } - } - - async autoGetImageKeyByMemoryScan( - userDir: string, - onProgress?: (message: string) => void - ): Promise { - try { - // 1. 查找模板文件获取密文和 XOR 密钥 - onProgress?.('正在查找模板文件...') - let result = await this._findTemplateData(userDir, 32) - let { ciphertext, xorKey } = result - - if (ciphertext && xorKey === null) { - onProgress?.('未找到有效密钥,尝试扫描更多文件...') - result = await this._findTemplateData(userDir, 100) - xorKey = result.xorKey - } - - if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } - if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } - - onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - - // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化 - const deadline = Date.now() + 60_000 - let scanCount = 0 - let lastPid: number | null = null - while (Date.now() < deadline) { - const pid = await this.findWeChatPid() - if (!pid) { - onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...') - await new Promise(r => setTimeout(r, 2000)) - continue - } - if (lastPid !== pid) { - lastPid = pid - onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) - } - scanCount++ - onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) - const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) - if (aesKey) { - onProgress?.('密钥获取成功') - return { success: true, xorKey, aesKey } - } - await new Promise(r => setTimeout(r, 5000)) - } - - return { success: false, error: '60 秒内未找到 AES 密钥' } - } catch (e: any) { - return { success: false, error: `内存扫描失败: ${e.message}` } - } - } - - private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { - const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) - - const collect = (dir: string, results: string[], maxFiles: number) => { - if (results.length >= maxFiles) return - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (results.length >= maxFiles) break - const full = join(dir, entry.name) - if (entry.isDirectory()) collect(full, results, maxFiles) - else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) - } - } catch { } - } - - const files: string[] = [] - collect(userDir, files, limit) - - files.sort((a, b) => { - try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } - }) - - let ciphertext: Buffer | null = null - const tailCounts: Record = {} - - for (const f of files.slice(0, 32)) { - try { - const data = readFileSync(f) - if (data.length < 8) continue - - if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { - const key = `${data[data.length - 2]}_${data[data.length - 1]}` - tailCounts[key] = (tailCounts[key] ?? 0) + 1 - } - - if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { - ciphertext = data.subarray(0xF, 0x1F) - } - } catch { } - } - - let xorKey: number | null = null - let maxCount = 0 - for (const [key, count] of Object.entries(tailCounts)) { - if (count > maxCount) { - maxCount = count - const [x, y] = key.split('_').map(Number) - const k = x ^ 0xFF - if (k === (y ^ 0xD9)) xorKey = k - } - } - - return { ciphertext, xorKey } - } - - private ensureMachApis(): boolean { - if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true - try { - if (!this.koffi) this.koffi = require('koffi') - this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib') - this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', []) - this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')]) - this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [ - 'uint32', - this.koffi.out('uint64*'), - this.koffi.out('uint64*'), - 'int', - 'void*', - this.koffi.out('uint32*'), - this.koffi.out('uint32*') - ]) - this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [ - 'uint32', - 'uint64', - 'uint64', - 'void*', - this.koffi.out('uint64*') - ]) - this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32']) - return true - } catch (e) { - console.error('[KeyServiceMac] 初始化 Mach API 失败:', e) - return false - } - } - - private async _scanMemoryForAesKey( - pid: number, - ciphertext: Buffer, - onProgress?: (message: string) => void - ): Promise { - // 优先通过 image_scan_helper 子进程调用 - try { - const helperPath = this.getImageScanHelperPath() - const ciphertextHex = ciphertext.toString('hex') - const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) - this.ensureExecutableBitsBestEffort(artifactPaths) - - // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) - if (!this._needsElevation) { - const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths) - if (direct.key) return direct.key - if (direct.permissionError) { - console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') - this._needsElevation = true - onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...') - } - } - - // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) - if (this._needsElevation) { - try { - await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000) - } catch (e: any) { - console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e) - } - const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths) - if (elevated.key) return elevated.key - } - } catch (e: any) { - console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message) - } - - // fallback: 直接通过 Mach API 扫描内存(Electron 进程可能没有 task_for_pid 权限) - if (!this.ensureMachApis()) return null - - const VM_PROT_READ = 0x1 - const VM_PROT_WRITE = 0x2 - const VM_REGION_BASIC_INFO_64 = 9 - const VM_REGION_BASIC_INFO_COUNT_64 = 9 - const KERN_SUCCESS = 0 - const MAX_REGION_SIZE = 50 * 1024 * 1024 - const CHUNK = 4 * 1024 * 1024 - const OVERLAP = 65 - - const selfTask = this.machTaskSelf() - const taskBuf = Buffer.alloc(4) - const attachKr = this.taskForPid(selfTask, pid, taskBuf) - const task = taskBuf.readUInt32LE(0) - if (attachKr !== KERN_SUCCESS || !task) return null - - try { - const regions: Array<[number, number]> = [] - let address = 0 - - while (address < 0x7FFFFFFFFFFF) { - const addrBuf = Buffer.alloc(8) - addrBuf.writeBigUInt64LE(BigInt(address), 0) - const sizeBuf = Buffer.alloc(8) - const infoBuf = Buffer.alloc(64) - const countBuf = Buffer.alloc(4) - countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0) - const objectBuf = Buffer.alloc(4) - - const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf) - if (kr !== KERN_SUCCESS) break - - const base = Number(addrBuf.readBigUInt64LE(0)) - const size = Number(sizeBuf.readBigUInt64LE(0)) - const protection = infoBuf.readInt32LE(0) - const objectName = objectBuf.readUInt32LE(0) - if (objectName) { - try { this.machPortDeallocate(selfTask, objectName) } catch { } - } - - if ((protection & VM_PROT_READ) !== 0 && - (protection & VM_PROT_WRITE) !== 0 && - size > 0 && - size <= MAX_REGION_SIZE) { - regions.push([base, size]) - } - - const next = base + size - if (next <= address) break - address = next - } - - const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024 - onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) - - for (let ri = 0; ri < regions.length; ri++) { - const [base, size] = regions[ri] - if (ri % 20 === 0) { - onProgress?.(`扫描进度 ${ri}/${regions.length}...`) - await new Promise(r => setTimeout(r, 1)) - } - let offset = 0 - let trailing: Buffer | null = null - - while (offset < size) { - const chunkSize = Math.min(CHUNK, size - offset) - const chunk = Buffer.alloc(chunkSize) - const outSizeBuf = Buffer.alloc(8) - const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf) - const bytesRead = Number(outSizeBuf.readBigUInt64LE(0)) - offset += chunkSize - - if (kr !== KERN_SUCCESS || bytesRead <= 0) { - trailing = null - continue - } - - const current = chunk.subarray(0, bytesRead) - const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current - const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext) - if (key) return key - // 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中) - const fallbackKey = this._searchAny16Key(data, ciphertext) - if (fallbackKey) return fallbackKey - trailing = data.subarray(Math.max(0, data.length - OVERLAP)) - } - } - return null - } finally { - try { this.machPortDeallocate(selfTask, task) } catch { } - } - } - - private _spawnScanHelper( - helperPath: string, - pid: number, - ciphertextHex: string, - elevated: boolean, - artifactPaths: string[] = [] - ): Promise<{ key: string | null; permissionError: boolean }> { - return new Promise((resolve, reject) => { - let child: ReturnType - if (elevated) { - const chmodPart = artifactPaths.length > 0 - ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && ` - : '' - const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}` - child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], - { stdio: ['ignore', 'pipe', 'pipe'] }) - } else { - child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] }) - } - const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]' - let stdout = '', stderr = '' - child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.stderr?.on('data', (chunk: Buffer) => { - stderr += chunk.toString() - console.log(tag, chunk.toString().trim()) - }) - child.on('error', reject) - child.on('close', () => { - const permissionError = !elevated && stderr.includes('task_for_pid failed') - try { - const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) - const last = lines[lines.length - 1] - if (!last) { resolve({ key: null, permissionError }); return } - const payload = JSON.parse(last) - resolve({ - key: payload?.success && payload?.aesKey ? payload.aesKey : null, - permissionError - }) - } catch { - resolve({ key: null, permissionError }) - } - }) - setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000) - }) - } - - private async findWeChatPid(): Promise { - try { - return await this.getWeChatPid() - } catch { - return null - } - } - - cleanup(): void { - this.lib = null - this.initialized = false - this.libSystem = null - this.machTaskSelf = null - this.taskForPid = null - this.machVmRegion = null - this.machVmReadOverwrite = null - this.machPortDeallocate = null - } - - private normalizeAccountId(value: string): string { - const trimmed = String(value || '').trim() - if (!trimmed) return '' - - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - return match?.[1] || trimmed - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - return suffixMatch ? suffixMatch[1] : trimmed - } - - private isIgnoredAccountName(value: string): boolean { - const lowered = String(value || '').trim().toLowerCase() - if (!lowered) return true - return lowered === 'xwechat_files' || - lowered === 'all_users' || - lowered === 'backup' || - lowered === 'wmpf' || - lowered === 'app_data' - } - - private isReasonableAccountId(value: string): boolean { - const trimmed = String(value || '').trim() - if (!trimmed) return false - if (trimmed.includes('/') || trimmed.includes('\\')) return false - return !this.isIgnoredAccountName(trimmed) - } - - private isAccountDirPath(entryPath: string): boolean { - return existsSync(join(entryPath, 'db_storage')) || - existsSync(join(entryPath, 'msg')) || - existsSync(join(entryPath, 'FileStorage', 'Image')) || - existsSync(join(entryPath, 'FileStorage', 'Image2')) - } - - private resolveXwechatRootFromPath(accountPath?: string): string | null { - const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') - if (!normalized) return null - - // 旧路径:xwechat_files - const marker = '/xwechat_files' - const markerIdx = normalized.indexOf(marker) - if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length) - - // 新路径(微信 4.0.5+):Application Support/com.tencent.xinWeChat/2.0b4.0.9 - const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/) - if (newMarkerMatch) return newMarkerMatch[1] - - return null - } - - private pushAccountIdCandidates(candidates: string[], value?: string): void { - const pushUnique = (item: string) => { - const trimmed = String(item || '').trim() - if (!trimmed || candidates.includes(trimmed)) return - candidates.push(trimmed) - } - - const raw = String(value || '').trim() - if (!this.isReasonableAccountId(raw)) return - pushUnique(raw) - const normalized = this.normalizeAccountId(raw) - if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) { - pushUnique(normalized) - } - } - - private cleanWxid(wxid: string): string { - return this.normalizeAccountId(wxid) - } - - private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { - const cleanedWxid = this.cleanWxid(wxid) - const xorKey = code & 0xFF - const dataToHash = code.toString() + cleanedWxid - const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16) - return { xorKey, aesKey } - } - - private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { - const candidates: string[] = [] - - // 1) 显式传参优先 - this.pushAccountIdCandidates(candidates, wxidParam) - - if (accountPath) { - const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') - const dirName = basename(normalized) - // 2) 当前目录名本身就是账号目录 - this.pushAccountIdCandidates(candidates, dirName) - - // 3) 从 xwechat_files 根目录枚举全部账号目录 - const root = this.resolveXwechatRootFromPath(accountPath) - if (root) { - if (existsSync(root)) { - try { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const entryPath = join(root, entry.name) - if (!this.isAccountDirPath(entryPath)) continue - this.pushAccountIdCandidates(candidates, entry.name) - } - } catch { - // ignore - } - } - } - } - - if (candidates.length === 0) candidates.push('unknown') - return candidates - } - - private collectAccountPathCandidates(accountPath?: string): string[] { - const candidates: string[] = [] - const pushUnique = (value?: string) => { - const v = String(value || '').trim() - if (!v || candidates.includes(v)) return - candidates.push(v) - } - - if (accountPath) pushUnique(accountPath) - - if (accountPath) { - const root = this.resolveXwechatRootFromPath(accountPath) - if (root) { - if (existsSync(root)) { - try { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue - const entryPath = join(root, entry.name) - if (!this.isAccountDirPath(entryPath)) continue - if (!this.isReasonableAccountId(entry.name)) continue - pushUnique(entryPath) - } - } catch { - // ignore - } - } - } - } - - return candidates - } - - private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { - try { - if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false - const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16) - const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { - return false - } - } - - private collectKvcommCodes(accountPath?: string): number[] { - const codeSet = new Set() - const pattern = /^key_(\d+)_.+\.statistic$/i - - for (const kvcommDir of this.getKvcommCandidates(accountPath)) { - if (!existsSync(kvcommDir)) continue - try { - const files = readdirSync(kvcommDir) - for (const file of files) { - const match = file.match(pattern) - if (!match) continue - const code = Number(match[1]) - if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue - codeSet.add(code) - } - } catch { - // 忽略不可读目录,继续尝试其他候选路径 - } - } - - return Array.from(codeSet) - } - - private getKvcommCandidates(accountPath?: string): string[] { - const home = homedir() - const candidates = new Set([ - // 与用户实测路径一致:Documents/xwechat_files -> Documents/app_data/net/kvcomm - join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'), - join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'), - join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'), - join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm') - ]) - - if (accountPath) { - // 规则:把路径中的 xwechat_files 替换为 app_data,然后拼 net/kvcomm - const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') - const marker = '/xwechat_files' - const idx = normalized.indexOf(marker) - if (idx >= 0) { - const base = normalized.slice(0, idx) - candidates.add(`${base}/app_data/net/kvcomm`) - } - - // 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm - const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/) - if (newMarkerMatch) { - const versionBase = newMarkerMatch[1] - candidates.add(`${versionBase}/net/kvcomm`) - // 上级目录也尝试 - const parentBase = versionBase.replace(/\/[^\/]+$/, '') - candidates.add(`${parentBase}/net/kvcomm`) - } - - let cursor = accountPath - for (let i = 0; i < 6; i++) { - candidates.add(join(cursor, 'net', 'kvcomm')) - const next = dirname(cursor) - if (next === cursor) break - cursor = next - } - } - - return Array.from(candidates) - } - - private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { - for (let i = 0; i < data.length - 34; i++) { - if (this._isAlphaNum(data[i])) continue - let valid = true - for (let j = 1; j <= 32; j++) { - if (!this._isAlphaNum(data[i + j])) { valid = false; break } - } - if (!valid) continue - if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue - const keyBytes = data.subarray(i + 1, i + 33) - if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) - } - return null - } - - private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { - for (let i = 0; i < data.length - 65; i++) { - let valid = true - for (let j = 0; j < 32; j++) { - if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } - } - if (!valid) continue - const keyBytes = Buffer.alloc(32) - for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] - if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) - } - return null - } - - private _isAlphaNum(b: number): boolean { - return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) - } - - private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { - try { - const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { - return false - } - } - - // 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率 - private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null { - for (let i = 0; i + 16 <= data.length; i++) { - const keyBytes = data.subarray(i, i + 16) - if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue - if (!this._isMostlyPrintableAscii(keyBytes)) continue - return keyBytes.toString('ascii') - } - return null - } - - private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean { - try { - const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null) - decipher.setAutoPadding(false) - const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true - if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true - if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true - if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true - if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true - return false - } catch { - return false - } - } - - private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean { - let printable = 0 - for (const b of keyBytes16) { - if (b >= 0x20 && b <= 0x7E) printable++ - } - return printable >= 14 - } -} diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts deleted file mode 100644 index 931a16e..0000000 --- a/electron/services/linuxNotificationService.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Notification } from "electron"; -import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService"; - -export interface LinuxNotificationData { - sessionId?: string; - title: string; - content: string; - avatarUrl?: string; - channel?: string; - insightRecordId?: string; - targetRoute?: string; - expireTimeout?: number; -} - -type NotificationCallback = (payload: unknown) => void; - -let notificationCallbacks: NotificationCallback[] = []; -let notificationCounter = 1; -const activeNotifications: Map = new Map(); -const closeTimers: Map = new Map(); - -function nextNotificationId(): number { - const id = notificationCounter; - notificationCounter += 1; - return id; -} - -function clearNotificationState(notificationId: number): void { - activeNotifications.delete(notificationId); - const timer = closeTimers.get(notificationId); - if (timer) { - clearTimeout(timer); - closeTimers.delete(notificationId); - } -} - -function triggerNotificationCallback(payload: unknown): void { - for (const callback of notificationCallbacks) { - try { - callback(payload); - } catch (error) { - console.error("[LinuxNotification] Callback error:", error); - } - } -} - -export async function showLinuxNotification( - data: LinuxNotificationData, -): Promise { - if (process.platform !== "linux") { - return null; - } - - if (!Notification.isSupported()) { - console.warn("[LinuxNotification] Notification API is not supported"); - return null; - } - - try { - let iconPath: string | undefined; - if (data.avatarUrl) { - iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined; - } - - const notification = new Notification({ - title: data.title, - body: data.content, - icon: iconPath, - }); - - const notificationId = nextNotificationId(); - activeNotifications.set(notificationId, notification); - - notification.on("click", () => { - if (data.channel === "ai-insight" && data.insightRecordId) { - triggerNotificationCallback({ - sessionId: data.sessionId, - channel: data.channel, - insightRecordId: data.insightRecordId, - targetRoute: data.targetRoute, - }); - return; - } - if (data.sessionId) { - triggerNotificationCallback(data.sessionId); - } - }); - - notification.on("close", () => { - clearNotificationState(notificationId); - }); - - notification.on("failed", (_, error) => { - console.error("[LinuxNotification] Notification failed:", error); - clearNotificationState(notificationId); - }); - - const expireTimeout = data.expireTimeout ?? 5000; - if (expireTimeout > 0) { - const timer = setTimeout(() => { - const currentNotification = activeNotifications.get(notificationId); - if (currentNotification) { - currentNotification.close(); - } - }, expireTimeout); - closeTimers.set(notificationId, timer); - } - - notification.show(); - - console.log( - `[LinuxNotification] Shown notification ${notificationId}: ${data.title}`, - ); - - return notificationId; - } catch (error) { - console.error("[LinuxNotification] Failed to show notification:", error); - return null; - } -} - -export async function closeLinuxNotification( - notificationId: number, -): Promise { - const notification = activeNotifications.get(notificationId); - if (!notification) return; - notification.close(); - clearNotificationState(notificationId); -} - -export async function getCapabilities(): Promise { - if (process.platform !== "linux") { - return []; - } - - if (!Notification.isSupported()) { - return []; - } - - return ["native-notification", "click"]; -} - -export function onNotificationAction(callback: NotificationCallback): void { - notificationCallbacks.push(callback); -} - -export function removeNotificationCallback( - callback: NotificationCallback, -): void { - const index = notificationCallbacks.indexOf(callback); - if (index > -1) { - notificationCallbacks.splice(index, 1); - } -} - -export async function initLinuxNotificationService(): Promise { - if (process.platform !== "linux") { - console.log("[LinuxNotification] Not on Linux, skipping init"); - return; - } - - if (!Notification.isSupported()) { - console.warn("[LinuxNotification] Notification API is not supported"); - return; - } - - const caps = await getCapabilities(); - console.log("[LinuxNotification] Service initialized with native API:", caps); -} - -export async function shutdownLinuxNotificationService(): Promise { - // 清理所有活动的通知 - for (const [id, notification] of activeNotifications) { - try { - notification.close(); - } catch {} - clearNotificationState(id); - } - - // 清理头像文件缓存 - try { - await avatarFileCache.clearCache(); - } catch {} - - console.log("[LinuxNotification] Service shutdown complete"); -} diff --git a/electron/services/messageCacheService.ts b/electron/services/messageCacheService.ts deleted file mode 100644 index 9d3079a..0000000 --- a/electron/services/messageCacheService.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { join, dirname } from 'path' -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' -import { app } from 'electron' -import { ConfigService } from './config' - -export interface SessionMessageCacheEntry { - updatedAt: number - messages: any[] -} - -export class MessageCacheService { - private readonly cacheFilePath: string - private cache: Record = {} - private readonly sessionLimit = 150 - private readonly maxSessionEntries = 48 - - constructor(cacheBasePath?: string) { - const basePath = cacheBasePath && cacheBasePath.trim().length > 0 - ? cacheBasePath - : ConfigService.getInstance().getCacheBasePath() - this.cacheFilePath = join(basePath, 'session-messages.json') - this.ensureCacheDir() - this.loadCache() - } - - private ensureCacheDir() { - const dir = dirname(this.cacheFilePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - } - - private loadCache() { - if (!existsSync(this.cacheFilePath)) return - try { - const raw = readFileSync(this.cacheFilePath, 'utf8') - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - this.cache = parsed - this.pruneSessionEntries() - } - } catch (error) { - console.error('MessageCacheService: 载入缓存失败', error) - this.cache = {} - } - } - - private pruneSessionEntries(): void { - const entries = Object.entries(this.cache || {}) - if (entries.length <= this.maxSessionEntries) return - - entries.sort((left, right) => { - const leftAt = Number(left[1]?.updatedAt || 0) - const rightAt = Number(right[1]?.updatedAt || 0) - return rightAt - leftAt - }) - - this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries)) - } - - get(sessionId: string): SessionMessageCacheEntry | undefined { - return this.cache[sessionId] - } - - set(sessionId: string, messages: any[]): void { - if (!sessionId) return - const trimmed = messages.length > this.sessionLimit - ? messages.slice(-this.sessionLimit) - : messages.slice() - this.cache[sessionId] = { - updatedAt: Date.now(), - messages: trimmed - } - this.pruneSessionEntries() - this.persist() - } - - private persist() { - try { - writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8') - } catch (error) { - console.error('MessageCacheService: 保存缓存失败', error) - } - } - - clear(): void { - this.cache = {} - try { - rmSync(this.cacheFilePath, { force: true }) - } catch (error) { - console.error('MessageCacheService: 清理缓存失败', error) - } - } -} diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts deleted file mode 100644 index 8a8c888..0000000 --- a/electron/services/messagePushService.ts +++ /dev/null @@ -1,1459 +0,0 @@ -import { ConfigService } from './config' -import { chatService, type ChatSession, type Message } from './chatService' -import { wcdbService } from './wcdbService' -import { httpService } from './httpService' -import { promises as fs } from 'fs' -import path from 'path' -import { createHash } from 'crypto' -import { pathToFileURL } from 'url' - -interface SessionBaseline { - lastTimestamp: number - unreadCount: number -} - -interface PushSessionResult { - fetched: boolean - maxFetchedTimestamp: number - incomingCandidateCount: number - observedIncomingCount: number - expectedIncomingCount: number - retry: boolean -} - -interface PushSessionOptions { - scanRecentRevokes?: boolean -} - -type MessagePushEventName = 'message.new' | 'message.revoke' - -interface MessagePushPayload { - event: MessagePushEventName - sessionId: string - sessionType: 'private' | 'group' | 'official' | 'other' - rawid: string - avatarUrl?: string - sourceName: string - groupName?: string - content: string | null - timestamp: number -} - -const PUSH_CONFIG_KEYS = new Set([ - 'messagePushEnabled', - 'messagePushFilterMode', - 'messagePushFilterList', - 'dbPath', - 'decryptKey', - 'myWxid' -]) - -class MessagePushService { - private readonly configService: ConfigService - private readonly sessionBaseline = new Map() - private readonly recentMessageKeys = new Map() - private readonly seenMessageKeys = new Map() - private readonly recentlyRevokedOriginalTokens = new Map() - private readonly seenPrimedSessions = new Set() - private readonly groupNicknameCache = new Map; updatedAt: number }>() - private readonly pushAvatarCacheDir: string - private readonly pushAvatarDataCache = new Map() - private readonly debounceMs = 350 - private readonly lookbackSeconds = 2 - private readonly recentMessageTtlMs = 10 * 60 * 1000 - private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 - private readonly messageTableRescanDelayMs = 500 - private readonly recentRevokeScanSeconds = 150 - private readonly directRevokeScanLimit = 20 - private debounceTimer: ReturnType | null = null - private messageTableRescanTimer: ReturnType | null = null - private processing = false - private rerunRequested = false - private started = false - private baselineReady = false - private messageTableScanRequested = false - private readonly pendingMessageTableNames = new Set() - - constructor() { - this.configService = ConfigService.getInstance() - this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files') - } - - start(): void { - if (this.started) return - this.started = true - void this.refreshConfiguration('startup') - } - - stop(): void { - this.started = false - this.processing = false - this.rerunRequested = false - this.resetRuntimeState() - } - - handleDbMonitorChange(type: string, json: string): void { - if (!this.started) return - if (!this.isPushEnabled()) return - - let payload: Record | null = null - try { - payload = JSON.parse(json) - } catch { - payload = null - } - - const tableName = String(payload?.table || '').trim() - const messageTableNames = this.collectMessageTableNamesFromPayload(payload) - if (this.isSessionTableChange(tableName)) { - this.scheduleSync() - return - } - - if (!tableName && messageTableNames.length === 0) { - this.scheduleSync() - return - } - - if (this.isMessageTableChange(tableName) || messageTableNames.length > 0) { - this.scheduleSync({ - scanMessageBackedSessions: true, - messageTableNames - }) - this.scheduleMessageTableRescan(messageTableNames) - } - } - - async handleConfigChanged(key: string): Promise { - if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return - if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') { - this.resetRuntimeState() - chatService.close() - } - await this.refreshConfiguration(`config:${key}`) - } - - handleConfigCleared(): void { - this.resetRuntimeState() - chatService.close() - } - - private isPushEnabled(): boolean { - return this.configService.get('messagePushEnabled') === true - } - - private resetRuntimeState(): void { - this.sessionBaseline.clear() - this.recentMessageKeys.clear() - this.seenMessageKeys.clear() - this.recentlyRevokedOriginalTokens.clear() - this.seenPrimedSessions.clear() - this.groupNicknameCache.clear() - this.baselineReady = false - this.messageTableScanRequested = false - this.pendingMessageTableNames.clear() - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - this.debounceTimer = null - } - if (this.messageTableRescanTimer) { - clearTimeout(this.messageTableRescanTimer) - this.messageTableRescanTimer = null - } - } - - private async refreshConfiguration(reason: string): Promise { - if (!this.isPushEnabled()) { - this.resetRuntimeState() - return - } - - const connectResult = await chatService.connect() - if (!connectResult.success) { - console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error) - return - } - - await this.bootstrapBaseline() - } - - private async bootstrapBaseline(): Promise { - const sessionsResult = await chatService.getSessions() - if (!sessionsResult.success || !sessionsResult.sessions) { - return - } - this.setBaseline(sessionsResult.sessions as ChatSession[]) - this.baselineReady = true - } - - private scheduleSync(options: { scanMessageBackedSessions?: boolean; messageTableNames?: string[] } = {}): void { - if (options.scanMessageBackedSessions) { - this.messageTableScanRequested = true - } - for (const tableName of options.messageTableNames || []) { - const normalized = String(tableName || '').trim() - if (normalized) this.pendingMessageTableNames.add(normalized) - } - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer) - } - - this.debounceTimer = setTimeout(() => { - this.debounceTimer = null - void this.flushPendingChanges() - }, this.debounceMs) - } - - private scheduleMessageTableRescan(messageTableNames: string[]): void { - if (this.messageTableRescanTimer) { - clearTimeout(this.messageTableRescanTimer) - } - - const tableNames = [...messageTableNames] - this.messageTableRescanTimer = setTimeout(() => { - this.messageTableRescanTimer = null - if (!this.started || !this.isPushEnabled()) return - this.scheduleSync({ - scanMessageBackedSessions: true, - messageTableNames: tableNames - }) - }, this.messageTableRescanDelayMs) - } - - private async flushPendingChanges(): Promise { - if (this.processing) { - this.rerunRequested = true - return - } - - this.processing = true - try { - if (!this.isPushEnabled()) return - const scanMessageBackedSessions = this.messageTableScanRequested - this.messageTableScanRequested = false - const pendingMessageTableNames = Array.from(this.pendingMessageTableNames) - this.pendingMessageTableNames.clear() - - const connectResult = await chatService.connect() - if (!connectResult.success) { - console.warn('[MessagePushService] Sync connect failed:', connectResult.error) - return - } - - const sessionsResult = await chatService.getSessions() - if (!sessionsResult.success || !sessionsResult.sessions) { - return - } - - const sessions = sessionsResult.sessions as ChatSession[] - if (!this.baselineReady) { - this.setBaseline(sessions) - this.baselineReady = true - return - } - - const previousBaseline = new Map(this.sessionBaseline) - const messageTableTargetSessionIds = this.resolveMessageTableTargetSessionIds(sessions, pendingMessageTableNames) - - const candidates = sessions.filter((session) => { - const sessionId = String(session.username || '').trim() - const previous = previousBaseline.get(session.username) - if (sessionId && messageTableTargetSessionIds.has(sessionId)) { - return true - } - if (this.shouldInspectSession(previous, session)) { - return true - } - return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session) - }) - const candidateIds = new Set() - for (const session of candidates) { - const sessionId = String(session.username || '').trim() - if (sessionId) candidateIds.add(sessionId) - const previous = previousBaseline.get(session.username) || this.sessionBaseline.get(session.username) - const scanRecentRevokes = this.hasUnreadCountDecreased(previous, session) || - (this.hasUnreadCountChanged(previous, session) && this.isRevokeSessionSummary(session)) || - (Boolean(sessionId) && messageTableTargetSessionIds.has(sessionId)) - const result = await this.pushSessionMessages( - session, - previous, - { scanRecentRevokes } - ) - this.updateInspectedBaseline(session, previousBaseline.get(session.username), result) - if (result.retry) { - this.rerunRequested = true - } - } - - for (const session of sessions) { - const sessionId = String(session.username || '').trim() - if (!sessionId || candidateIds.has(sessionId)) continue - this.updateObservedBaseline(session, previousBaseline.get(sessionId)) - } - } finally { - this.processing = false - if (this.rerunRequested) { - this.rerunRequested = false - this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested }) - } - } - } - - private setBaseline(sessions: ChatSession[]): void { - const previousBaseline = new Map(this.sessionBaseline) - const nextBaseline = new Map() - const nowSeconds = Math.floor(Date.now() / 1000) - this.sessionBaseline.clear() - for (const session of sessions) { - const username = String(session.username || '').trim() - if (!username) continue - const previous = previousBaseline.get(username) - const sessionTimestamp = Number(session.lastTimestamp || 0) - const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds - nextBaseline.set(username, { - lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp), - unreadCount: Number(session.unreadCount || 0) - }) - } - for (const [username, baseline] of nextBaseline.entries()) { - this.sessionBaseline.set(username, baseline) - } - } - - private updateObservedBaseline(session: ChatSession, previous: SessionBaseline | undefined): void { - const username = String(session.username || '').trim() - if (!username) return - - const sessionTimestamp = Number(session.lastTimestamp || 0) - const previousTimestamp = Number(previous?.lastTimestamp || 0) - this.sessionBaseline.set(username, { - lastTimestamp: Math.max(sessionTimestamp, previousTimestamp), - unreadCount: Number(session.unreadCount ?? previous?.unreadCount ?? 0) - }) - } - - private updateInspectedBaseline( - session: ChatSession, - previous: SessionBaseline | undefined, - result: PushSessionResult - ): void { - const username = String(session.username || '').trim() - if (!username) return - - const previousTimestamp = Number(previous?.lastTimestamp || 0) - const current = this.sessionBaseline.get(username) || previous || { lastTimestamp: 0, unreadCount: 0 } - const nextTimestamp = result.retry - ? previousTimestamp - : Math.max(previousTimestamp, current.lastTimestamp, result.maxFetchedTimestamp) - - this.sessionBaseline.set(username, { - lastTimestamp: nextTimestamp, - unreadCount: result.retry - ? Number(previous?.unreadCount || 0) - : Number(session.unreadCount || 0) - }) - } - - private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { - const sessionId = String(session.username || '').trim() - if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { - return false - } - - const lastTimestamp = Number(session.lastTimestamp || 0) - const unreadCount = Number(session.unreadCount || 0) - - if (!previous) { - return unreadCount > 0 && lastTimestamp > 0 - } - - if (this.isRevokeSessionSummary(session) && lastTimestamp >= previous.lastTimestamp) { - return true - } - - return lastTimestamp > previous.lastTimestamp || unreadCount !== previous.unreadCount - } - - private hasUnreadCountChanged(previous: SessionBaseline | undefined, session: ChatSession): boolean { - if (!previous) return false - return Number(session.unreadCount || 0) !== Number(previous.unreadCount || 0) - } - - private hasUnreadCountDecreased(previous: SessionBaseline | undefined, session: ChatSession): boolean { - if (!previous) return false - return Number(session.unreadCount || 0) < Number(previous.unreadCount || 0) - } - - private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { - const sessionId = String(session.username || '').trim() - if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { - return false - } - - const sessionType = this.getSessionType(sessionId, session) - if (sessionType === 'private' && !this.isRevokeSessionSummary(session)) { - return false - } - - return Boolean(previous) || Number(session.lastTimestamp || 0) > 0 - } - - private async pushSessionMessages( - session: ChatSession, - previous: SessionBaseline | undefined, - options: PushSessionOptions = {} - ): Promise { - const previousTimestamp = Math.max(0, Number(previous?.lastTimestamp || 0)) - const previousUnreadCount = Math.max(0, Number(previous?.unreadCount || 0)) - const currentUnreadCount = Math.max(0, Number(session.unreadCount || 0)) - const expectedIncomingCount = previous - ? Math.max(0, currentUnreadCount - previousUnreadCount) - : 0 - const since = previous - ? Math.max(0, previousTimestamp - this.lookbackSeconds) - : 0 - const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) - const fetchedMessages = newMessagesResult.success && Array.isArray(newMessagesResult.messages) - ? newMessagesResult.messages - : [] - if (fetchedMessages.length === 0 && !options.scanRecentRevokes) { - return { - fetched: false, - maxFetchedTimestamp: previousTimestamp, - incomingCandidateCount: 0, - observedIncomingCount: 0, - expectedIncomingCount, - retry: expectedIncomingCount > 0 - } - } - - const sessionId = String(session.username || '').trim() - const maxFetchedTimestamp = fetchedMessages.reduce((max, message) => { - const createTime = Number(message.createTime || 0) - return Number.isFinite(createTime) && createTime > max ? createTime : max - }, previousTimestamp) - const seenPrimed = sessionId ? this.seenPrimedSessions.has(sessionId) : false - const sameTimestampIncoming: Message[] = [] - const candidateMessages: Message[] = [] - let observedIncomingCount = 0 - - for (const message of fetchedMessages) { - const messageKey = String(message.messageKey || '').trim() - if (!messageKey) continue - const createTime = Number(message.createTime || 0) - const seen = this.isSeenMessage(messageKey) - const recent = this.isRecentMessage(messageKey) - const revokeMessage = this.isRevokeSystemMessage(message) - - if (message.isSend !== 1) { - if (!previous || createTime > previousTimestamp || (seenPrimed && createTime === previousTimestamp)) { - observedIncomingCount += 1 - } - } - - if (previous && !seenPrimed && createTime < previousTimestamp) { - if (revokeMessage && !recent) { - candidateMessages.push(message) - continue - } - this.rememberSeenMessageKey(messageKey) - continue - } - - if (seen || recent) { - if (seen && !recent && revokeMessage) { - candidateMessages.push(message) - } - continue - } - if (message.isSend === 1) continue - if (previous && !seenPrimed && createTime === previousTimestamp) { - if (revokeMessage) { - candidateMessages.push(message) - } else { - sameTimestampIncoming.push(message) - } - continue - } - - candidateMessages.push(message) - } - - const futureIncomingCount = candidateMessages.filter((message) => { - const createTime = Number(message.createTime || 0) - return !previous || createTime > previousTimestamp || seenPrimed - }).length - const sameTimestampAllowance = previous && !seenPrimed - ? Math.max(0, expectedIncomingCount - futureIncomingCount) - : 0 - const selectedSameTimestamp = sameTimestampAllowance > 0 - ? sameTimestampIncoming.slice(-sameTimestampAllowance) - : [] - const messagesToPush = [...selectedSameTimestamp, ...candidateMessages] - const suppressedNormalMessageKeys = this.collectSuppressedNormalMessageKeys(messagesToPush, fetchedMessages) - const incomingCandidateCount = messagesToPush.length - - for (const message of messagesToPush) { - const messageKey = String(message.messageKey || '').trim() - if (!messageKey) continue - if (!this.isRevokeSystemMessage(message) && suppressedNormalMessageKeys.has(messageKey)) { - this.rememberMessageKey(messageKey) - continue - } - if (!this.isRevokeSystemMessage(message) && this.isRecentlyRevokedOriginal(session.username, message)) { - this.rememberMessageKey(messageKey) - this.rememberSeenMessageKey(messageKey) - continue - } - const payload = this.isRevokeSystemMessage(message) - ? await this.buildRevokePayload(session, message, fetchedMessages) - : await this.buildPayload(session, message) - if (!payload) continue - if (!this.shouldPushPayload(payload)) continue - - httpService.broadcastMessagePush(payload) - this.rememberMessageKey(messageKey) - this.bumpSessionBaseline(session.username, message) - } - - for (const message of fetchedMessages) { - const messageKey = String(message.messageKey || '').trim() - if (messageKey) this.rememberSeenMessageKey(messageKey) - } - if (sessionId) this.seenPrimedSessions.add(sessionId) - - const recentRevokeResult = options.scanRecentRevokes - ? await this.pushRecentRevokeMessages(session, previous, fetchedMessages) - : { pushedCount: 0, maxPushedTimestamp: 0 } - - return { - fetched: true, - maxFetchedTimestamp: Math.max(maxFetchedTimestamp, recentRevokeResult.maxPushedTimestamp), - incomingCandidateCount: incomingCandidateCount + recentRevokeResult.pushedCount, - observedIncomingCount, - expectedIncomingCount, - retry: expectedIncomingCount > 0 && observedIncomingCount < expectedIncomingCount - } - } - - private async pushRecentRevokeMessages( - session: ChatSession, - previous: SessionBaseline | undefined, - contextMessages: Message[] - ): Promise<{ pushedCount: number; maxPushedTimestamp: number }> { - const sessionId = String(session.username || '').trim() - if (!sessionId) return { pushedCount: 0, maxPushedTimestamp: 0 } - - const since = this.getRecentRevokeScanSince(session, previous) - const revokeMessages = await this.getRecentRevokeMessagesFromTables(sessionId, since) - if (revokeMessages.length === 0) { - return { pushedCount: 0, maxPushedTimestamp: 0 } - } - - const mergedMessages = this.mergeMessagesForRevokeLookup(contextMessages, revokeMessages) - let pushedCount = 0 - let maxPushedTimestamp = 0 - - for (const message of revokeMessages) { - const messageKey = String(message.messageKey || '').trim() - if (!messageKey || !this.isRevokeSystemMessage(message)) continue - if (this.isRecentMessage(messageKey)) continue - - const payload = await this.buildRevokePayload(session, message, mergedMessages) - if (!payload) continue - if (!this.shouldPushPayload(payload)) continue - - httpService.broadcastMessagePush(payload) - this.rememberMessageKey(messageKey) - this.rememberSeenMessageKey(messageKey) - this.bumpSessionBaseline(sessionId, message) - pushedCount += 1 - - const createTime = Number(message.createTime || 0) - if (Number.isFinite(createTime) && createTime > maxPushedTimestamp) { - maxPushedTimestamp = createTime - } - } - - return { pushedCount, maxPushedTimestamp } - } - - private getRecentRevokeScanSince(session: ChatSession, previous: SessionBaseline | undefined): number { - const nowSeconds = Math.floor(Date.now() / 1000) - const anchor = Math.max( - nowSeconds, - Number(session.lastTimestamp || 0), - Number(previous?.lastTimestamp || 0) - ) - return Math.max(0, anchor - this.recentRevokeScanSeconds) - } - - private async getRecentRevokeMessagesFromTables(sessionId: string, since: number): Promise { - const tables = await this.getCandidateMessageTables(sessionId, since) - if (tables.length === 0) return [] - - const messages: Message[] = [] - const sinceSeconds = this.toSafeSqlInteger(since) - for (const table of tables) { - const sql = [ - `SELECT *, '${this.escapeSqlString(table.dbPath)}' AS _db_path, '${this.escapeSqlString(table.tableName)}' AS table_name`, - `FROM ${this.quoteSqlIdentifier(table.tableName)}`, - `WHERE create_time >= ${sinceSeconds}`, - `AND (local_type IN (10000, 10002) OR message_content LIKE '%撤回%' OR message_content LIKE '%revokemsg%' OR message_content LIKE '%[])) - } - - return messages - .filter((message) => this.isRevokeSystemMessage(message)) - .sort((left, right) => this.compareMessagePosition(left, right)) - } - - private async getRecentRevokeContextMessages(sessionId: string, since: number): Promise { - const tables = await this.getCandidateMessageTables(sessionId, since) - if (tables.length === 0) return [] - - const messages: Message[] = [] - const sinceSeconds = this.toSafeSqlInteger(since) - for (const table of tables) { - const sql = [ - `SELECT *, '${this.escapeSqlString(table.dbPath)}' AS _db_path, '${this.escapeSqlString(table.tableName)}' AS table_name`, - `FROM ${this.quoteSqlIdentifier(table.tableName)}`, - `WHERE create_time >= ${sinceSeconds}`, - `ORDER BY create_time ASC, sort_seq ASC, local_id ASC`, - `LIMIT ${this.directRevokeScanLimit * 4}` - ].join(' ') - const result = await wcdbService.execQuery('message', table.dbPath, sql) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue - messages.push(...chatService.mapRowsToMessagesForApi(result.rows as Record[])) - } - - return messages.sort((left, right) => this.compareMessagePosition(left, right)) - } - - private async findMessageByServerIdDirect( - sessionId: string, - revokeMessage: Message, - serverId: string - ): Promise { - const normalizedServerId = this.normalizeMessageIdToken(serverId) - if (!normalizedServerId) return undefined - - const source = this.parseMessageKeySource(revokeMessage.messageKey) - const tables = source - ? [source] - : await this.getCandidateMessageTables(sessionId, Math.max(0, Number(revokeMessage.createTime || 0) - 5 * 60)) - const revokeLocalId = Number(revokeMessage.localId || 0) - - for (const table of tables) { - const serverPredicate = this.buildServerIdPredicate('server_id', normalizedServerId) - const localFilter = Number.isFinite(revokeLocalId) && revokeLocalId > 0 - ? `AND local_id <> ${this.toSafeSqlInteger(revokeLocalId)}` - : '' - const sql = [ - `SELECT *, '${this.escapeSqlString(table.dbPath)}' AS _db_path, '${this.escapeSqlString(table.tableName)}' AS table_name`, - `FROM ${this.quoteSqlIdentifier(table.tableName)}`, - `WHERE ${serverPredicate}`, - localFilter, - `AND local_type NOT IN (10000, 10002)`, - `ORDER BY local_id ASC`, - `LIMIT 1` - ].filter(Boolean).join(' ') - const result = await wcdbService.execQuery('message', table.dbPath, sql) - if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue - const [message] = chatService.mapRowsToMessagesForApi(result.rows as Record[]) - if (message && !this.isRevokeSystemMessage(message)) return message - } - - return undefined - } - - private async getCandidateMessageTables( - sessionId: string, - since: number - ): Promise> { - const result = await wcdbService.getMessageTableStats(sessionId) - if (!result.success || !Array.isArray(result.tables)) return [] - - const sinceSeconds = Math.max(0, Number(since || 0)) - return result.tables - .map((table) => ({ - dbPath: String(table?.db_path || table?.dbPath || '').trim(), - tableName: String(table?.table_name || table?.tableName || '').trim(), - lastTime: Number(table?.last_time || table?.lastTime || 0) - })) - .filter((table) => table.dbPath && table.tableName && (!sinceSeconds || table.lastTime >= sinceSeconds)) - .sort((left, right) => right.lastTime - left.lastTime) - } - - private mergeMessagesForRevokeLookup(primary: Message[], secondary: Message[]): Message[] { - const merged: Message[] = [] - const keys = new Set() - for (const message of [...primary, ...secondary]) { - const key = String(message.messageKey || '').trim() - if (key) { - if (keys.has(key)) continue - keys.add(key) - } - merged.push(message) - } - return merged - } - - private async buildPayload(session: ChatSession, message: Message): Promise { - const sessionId = String(session.username || '').trim() - const messageKey = String(message.messageKey || '').trim() - if (!sessionId || !messageKey) return null - - const isGroup = sessionId.endsWith('@chatroom') - const sessionType = this.getSessionType(sessionId, session) - const content = this.getMessageDisplayContent(message) - const rawid = this.getMessageRawId(message) - - const createTime = Number(message.createTime || 0) - - if (isGroup) { - const groupInfo = await chatService.getContactAvatar(sessionId) - const groupName = session.displayName || groupInfo?.displayName || sessionId - const sourceName = await this.resolveGroupSourceName(sessionId, message, session) - const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl) - return { - event: 'message.new', - sessionId, - sessionType, - rawid, - avatarUrl, - groupName, - sourceName, - content, - timestamp: createTime - } - } - - const contactInfo = await chatService.getContactAvatar(sessionId) - const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl) - return { - event: 'message.new', - sessionId, - sessionType, - rawid, - avatarUrl, - sourceName: session.displayName || contactInfo?.displayName || sessionId, - content, - timestamp: createTime - } - } - - private isRevokeSystemMessage(message: Message): boolean { - const localType = Number(message.localType || 0) - const content = `${message.rawContent || ''}\n${message.parsedContent || ''}` - if (content.includes('revokemsg') || content.includes(' { - const fromFetched = this.findRevokedOriginalInMessages(fetchedMessages, revokeMessage, revokedMessageId) - if (fromFetched) return fromFetched - - const createTime = Number(revokeMessage.createTime || 0) - if (!Number.isFinite(createTime) || createTime <= 0) return undefined - - if (revokedMessageId) { - const directMessage = await this.findMessageByServerIdDirect(sessionId, revokeMessage, revokedMessageId) - if (directMessage) return directMessage - } - - const lookupMessages = await this.getRecentRevokeContextMessages(sessionId, Math.max(0, createTime - 5 * 60)) - if (lookupMessages.length === 0) return undefined - return this.findRevokedOriginalInMessages(lookupMessages, revokeMessage, revokedMessageId) - } - - private collectSuppressedNormalMessageKeys(messagesToPush: Message[], fetchedMessages: Message[]): Set { - const suppressed = new Set() - const pushKeySet = new Set(messagesToPush.map((message) => String(message.messageKey || '').trim()).filter(Boolean)) - for (const message of messagesToPush) { - if (!this.isRevokeSystemMessage(message)) continue - const originalMessage = this.findRevokedOriginalInMessages(fetchedMessages, message, this.extractRevokedMessageId(message)) - const originalKey = String(originalMessage?.messageKey || '').trim() - if (originalKey && pushKeySet.has(originalKey)) { - suppressed.add(originalKey) - } - } - return suppressed - } - - private findRevokedOriginalInMessages( - messages: Message[], - revokeMessage: Message, - revokedMessageId?: string - ): Message | undefined { - if (revokedMessageId) { - const byPlatformId = this.findMessageByPlatformId(messages, revokedMessageId, revokeMessage) - if (byPlatformId) return byPlatformId - } - return this.findNearestMessageBeforeRevoke(messages, revokeMessage) - } - - private findMessageByPlatformId(messages: Message[], revokedMessageId: string, revokeMessage: Message): Message | undefined { - const normalizedTarget = this.normalizeMessageIdToken(revokedMessageId) - if (!normalizedTarget) return undefined - - for (const message of messages) { - if (message.messageKey === revokeMessage.messageKey) continue - if (this.isRevokeSystemMessage(message)) continue - if (this.getMessageIdTokens(message).has(normalizedTarget)) { - return message - } - } - return undefined - } - - private findNearestMessageBeforeRevoke(messages: Message[], revokeMessage: Message): Message | undefined { - const revokeCreateTime = Number(revokeMessage.createTime || 0) - const revokeSortSeq = Number(revokeMessage.sortSeq || 0) - const revokeLocalId = Number(revokeMessage.localId || 0) - - let best: Message | undefined - for (const message of messages) { - if (message.messageKey === revokeMessage.messageKey) continue - if (message.isSend === 1) continue - if (this.isRevokeSystemMessage(message)) continue - - const createTime = Number(message.createTime || 0) - const sortSeq = Number(message.sortSeq || 0) - const localId = Number(message.localId || 0) - if (revokeCreateTime > 0 && createTime > revokeCreateTime) continue - if (revokeCreateTime > 0 && createTime === revokeCreateTime) { - if (revokeSortSeq > 0 && sortSeq > revokeSortSeq) continue - if (revokeSortSeq <= 0 && revokeLocalId > 0 && localId > revokeLocalId) continue - } - - if (!best || this.compareMessagePosition(message, best) > 0) { - best = message - } - } - return best - } - - private compareMessagePosition(left: Message, right: Message): number { - const leftCreateTime = Number(left.createTime || 0) - const rightCreateTime = Number(right.createTime || 0) - if (leftCreateTime !== rightCreateTime) return leftCreateTime - rightCreateTime - - const leftSortSeq = Number(left.sortSeq || 0) - const rightSortSeq = Number(right.sortSeq || 0) - if (leftSortSeq !== rightSortSeq) return leftSortSeq - rightSortSeq - - const leftLocalId = Number(left.localId || 0) - const rightLocalId = Number(right.localId || 0) - if (leftLocalId !== rightLocalId) return leftLocalId - rightLocalId - - return String(left.messageKey || '').localeCompare(String(right.messageKey || '')) - } - - private getMessageIdTokens(message: Message): Set { - const tokens = new Set() - const add = (value: unknown) => { - const normalized = this.normalizeMessageIdToken(value) - if (normalized) tokens.add(normalized) - } - add(message.serverIdRaw) - add(message.serverId) - add(message.localId) - const content = String(message.rawContent || '') - add(this.extractXmlValue(content, 'newmsgid')) - add(this.extractXmlValue(content, 'msgid')) - add(this.extractXmlValue(content, 'oldmsgid')) - add(this.extractXmlValue(content, 'svrid')) - return tokens - } - - private extractRevokedMessageId(message: Message): string | undefined { - const content = String(message.rawContent || message.parsedContent || '') - const candidates = [ - this.extractXmlValue(content, 'newmsgid'), - this.extractXmlValue(content, 'msgid'), - this.extractXmlValue(content, 'oldmsgid'), - this.extractXmlValue(content, 'svrid'), - message.serverIdRaw, - message.serverId - ] - for (const candidate of candidates) { - const normalized = this.normalizeMessageIdToken(candidate) - if (normalized) return normalized - } - return undefined - } - - private extractRevokerUsername(message: Message): string | undefined { - const content = String(message.rawContent || '') - const candidates = [ - this.extractXmlValue(content, 'fromusername'), - this.extractXmlValue(content, 'session'), - message.senderUsername - ] - for (const candidate of candidates) { - const normalized = String(candidate || '').trim() - if (normalized) return normalized - } - return undefined - } - - private getRevokeFallbackContent(message: Message): string | null { - const content = String(message.rawContent || message.parsedContent || '') - const replacemsg = this.extractXmlValue(content, 'replacemsg') - if (replacemsg && !replacemsg.includes('撤回了一条消息')) return replacemsg - return null - } - - private extractXmlValue(xml: string, tagName: string): string { - const decoded = this.decodeBasicXmlEntities(String(xml || '')) - const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i') - const match = regex.exec(decoded) - if (!match) return '' - return match[1].replace(//g, '').trim() - } - - private decodeBasicXmlEntities(value: string): string { - return value - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') - } - - private normalizeMessageIdToken(value: unknown): string { - const raw = String(value ?? '').trim() - if (!raw) return '' - const numeric = /^-?\d+$/.test(raw) ? raw.replace(/^-/, '').replace(/^0+(?=\d)/, '') : raw - return numeric === '0' ? '' : numeric - } - - private parseMessageKeySource(messageKey?: string): { dbPath: string; tableName: string } | null { - const raw = String(messageKey || '').trim() - if (!raw) return null - - const parts = raw.split(':') - if (parts.length < 3) return null - parts.pop() - const tableName = String(parts.pop() || '').trim() - const encodedDbPath = parts.join(':') - if (!tableName || !encodedDbPath) return null - - try { - const dbPath = decodeURIComponent(encodedDbPath) - return dbPath ? { dbPath, tableName } : null - } catch { - return { dbPath: encodedDbPath, tableName } - } - } - - private quoteSqlIdentifier(identifier: string): string { - return `"${String(identifier || '').replace(/"/g, '""')}"` - } - - private escapeSqlString(value: string): string { - return String(value || '').replace(/'/g, "''") - } - - private toSafeSqlInteger(value: unknown): number { - const numeric = Number(value) - if (!Number.isFinite(numeric)) return 0 - return Math.max(0, Math.floor(numeric)) - } - - private buildServerIdPredicate(columnName: string, serverId: string): string { - const column = this.quoteSqlIdentifier(columnName) - const escaped = this.escapeSqlString(serverId) - if (/^\d+$/.test(serverId)) { - return `(${column} = ${serverId} OR CAST(${column} AS TEXT) = '${escaped}')` - } - return `CAST(${column} AS TEXT) = '${escaped}'` - } - - private async buildRevokePayload( - session: ChatSession, - message: Message, - fetchedMessages: Message[] - ): Promise { - const sessionId = String(session.username || '').trim() - const messageKey = String(message.messageKey || '').trim() - if (!sessionId || !messageKey) return null - if (this.isSelfRevokeMessage(message)) return null - - const revokedMessageId = this.extractRevokedMessageId(message) - const originalMessage = await this.findRevokedOriginalMessage(sessionId, message, fetchedMessages, revokedMessageId) - const rawid = this.getDisplayRawId(originalMessage, revokedMessageId, message) - const originalContent = originalMessage - ? this.getMessageDisplayContent(originalMessage) - : this.getRevokeFallbackContent(message) - const safeContent = String(originalContent || '未知内容').trim() || '未知内容' - const content = `对方撤回了一条消息(rawid:${rawid}) 内容为“${safeContent}”` - this.rememberRecentlyRevokedOriginalTokens(sessionId, originalMessage, revokedMessageId, message) - const isGroup = sessionId.endsWith('@chatroom') - const sessionType = this.getSessionType(sessionId, session) - const createTime = Number(message.createTime || 0) - - if (isGroup) { - const groupInfo = await chatService.getContactAvatar(sessionId) - const groupName = session.displayName || groupInfo?.displayName || sessionId - const revokerUsername = this.extractRevokerUsername(message) - const sourceMessage = revokerUsername ? { ...message, senderUsername: revokerUsername } : message - const sourceName = await this.resolveGroupSourceName(sessionId, sourceMessage, session) - const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl) - return { - event: 'message.revoke', - sessionId, - sessionType, - rawid, - avatarUrl, - groupName, - sourceName, - content, - timestamp: createTime - } - } - - const contactInfo = await chatService.getContactAvatar(sessionId) - const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl) - return { - event: 'message.revoke', - sessionId, - sessionType, - rawid, - avatarUrl, - sourceName: session.displayName || contactInfo?.displayName || sessionId, - content, - timestamp: createTime - } - } - - private getMessageRawId(message: Message): string { - return String(message.serverIdRaw || '').trim() - } - - private getDisplayRawId(originalMessage?: Message, revokedMessageId?: string, revokeMessage?: Message): string { - const candidates = originalMessage - ? [originalMessage.serverIdRaw, revokedMessageId] - : [revokedMessageId, revokeMessage?.serverIdRaw] - for (const candidate of candidates) { - const normalized = this.normalizeMessageIdToken(candidate) - if (normalized) return normalized - } - return '未知' - } - - private rememberRecentlyRevokedOriginalTokens( - sessionId: string, - originalMessage?: Message, - revokedMessageId?: string, - revokeMessage?: Message - ): void { - const keyPrefix = String(sessionId || '').trim() - if (!keyPrefix) return - - this.pruneRecentlyRevokedOriginalTokens() - const tokens = new Set() - const add = (value: unknown) => { - const normalized = this.normalizeMessageIdToken(value) - if (normalized) tokens.add(normalized) - } - - if (originalMessage) { - add(originalMessage.serverIdRaw) - add(originalMessage.serverId) - } - add(revokedMessageId) - add(revokeMessage?.serverIdRaw) - add(revokeMessage?.serverId) - - const now = Date.now() - for (const token of tokens) { - this.recentlyRevokedOriginalTokens.set(`${keyPrefix}\u0000${token}`, now) - } - } - - private isRecentlyRevokedOriginal(sessionId: string, message: Message): boolean { - const keyPrefix = String(sessionId || '').trim() - if (!keyPrefix) return false - - this.pruneRecentlyRevokedOriginalTokens() - for (const token of this.getMessageIdTokens(message)) { - if (this.recentlyRevokedOriginalTokens.has(`${keyPrefix}\u0000${token}`)) { - return true - } - } - return false - } - - private pruneRecentlyRevokedOriginalTokens(): void { - const now = Date.now() - for (const [key, timestamp] of this.recentlyRevokedOriginalTokens.entries()) { - if (now - timestamp > this.recentMessageTtlMs) { - this.recentlyRevokedOriginalTokens.delete(key) - } - } - } - - private async normalizePushAvatarUrl(avatarUrl?: string): Promise { - const normalized = String(avatarUrl || '').trim() - if (!normalized) return undefined - if (!normalized.startsWith('data:image/')) { - return normalized - } - - const cached = this.pushAvatarDataCache.get(normalized) - if (cached) return cached - - const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized) - if (!match) return undefined - - try { - const mimeType = match[1].toLowerCase() - const base64Data = match[2] - const imageBuffer = Buffer.from(base64Data, 'base64') - if (!imageBuffer.length) return undefined - - const ext = this.getImageExtFromMime(mimeType) - const hash = createHash('sha1').update(normalized).digest('hex') - const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`) - - await fs.mkdir(this.pushAvatarCacheDir, { recursive: true }) - try { - await fs.access(filePath) - } catch { - await fs.writeFile(filePath, imageBuffer) - } - - const fileUrl = pathToFileURL(filePath).toString() - this.pushAvatarDataCache.set(normalized, fileUrl) - return fileUrl - } catch { - return undefined - } - } - - private getImageExtFromMime(mimeType: string): string { - if (mimeType === 'image/png') return 'png' - if (mimeType === 'image/gif') return 'gif' - if (mimeType === 'image/webp') return 'webp' - return 'jpg' - } - - private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { - if (sessionId.endsWith('@chatroom')) { - return 'group' - } - if (sessionId.startsWith('gh_') || session.type === 'official') { - return 'official' - } - if (session.type === 'friend') { - return 'private' - } - return 'other' - } - - private shouldPushPayload(payload: MessagePushPayload): boolean { - const sessionId = String(payload.sessionId || '').trim() - const filterMode = this.getMessagePushFilterMode() - if (filterMode === 'all') { - return true - } - - const filterList = this.getMessagePushFilterList() - const listed = filterList.has(sessionId) - if (filterMode === 'whitelist') { - return listed - } - return !listed - } - - private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' { - const value = this.configService.get('messagePushFilterMode') - if (value === 'whitelist' || value === 'blacklist') return value - return 'all' - } - - private getMessagePushFilterList(): Set { - const value = this.configService.get('messagePushFilterList') - if (!Array.isArray(value)) return new Set() - return new Set(value.map((item) => String(item || '').trim()).filter(Boolean)) - } - - private collectMessageTableNamesFromPayload(payload: Record | null): string[] { - const tableNames = new Set() - const visit = (value: unknown, keyHint = '') => { - if (value === null || value === undefined) return - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) return - const key = keyHint.toLowerCase() - if (key.includes('table') && this.isMessageTableChange(trimmed)) { - tableNames.add(trimmed) - return - } - for (const match of trimmed.matchAll(/\b(?:msg|message)_[a-z0-9_]+/gi)) { - const tableName = String(match[0] || '').trim() - if (tableName && this.isMessageTableChange(tableName)) tableNames.add(tableName) - } - return - } - if (Array.isArray(value)) { - for (const item of value) visit(item, keyHint) - return - } - if (typeof value !== 'object') return - - for (const [key, nested] of Object.entries(value as Record)) { - visit(nested, key) - } - } - - visit(payload) - return Array.from(tableNames) - } - - private isSessionTableChange(tableName: string): boolean { - return String(tableName || '').trim().toLowerCase() === 'session' - } - - private isMessageTableChange(tableName: string): boolean { - const normalized = String(tableName || '').trim().toLowerCase() - if (!normalized) return false - return normalized === 'message' || - normalized === 'msg' || - normalized.startsWith('message_') || - normalized.startsWith('msg_') || - normalized.includes('message') - } - - private resolveMessageTableTargetSessionIds(sessions: ChatSession[], tableNames: string[]): Set { - const targets = new Set() - if (!Array.isArray(tableNames) || tableNames.length === 0) return targets - - const fullHashLookup = new Map() - const shortHashLookup = new Map() - for (const session of sessions) { - const sessionId = String(session.username || '').trim() - if (!sessionId) continue - const fullHash = createHash('md5').update(sessionId).digest('hex').toLowerCase() - fullHashLookup.set(fullHash, sessionId) - const shortHash = fullHash.slice(0, 16) - const existing = shortHashLookup.get(shortHash) - if (existing === undefined) { - shortHashLookup.set(shortHash, sessionId) - } else if (existing !== sessionId) { - shortHashLookup.set(shortHash, null) - } - } - - for (const tableName of tableNames) { - const matched = this.matchSessionIdByMessageTableName(tableName, fullHashLookup, shortHashLookup) - if (matched) targets.add(matched) - } - return targets - } - - private matchSessionIdByMessageTableName( - tableName: string, - fullHashLookup: Map, - shortHashLookup: Map - ): string | null { - const normalized = String(tableName || '').trim().toLowerCase() - if (!normalized) return null - - const suffix = normalized.startsWith('msg_') ? normalized.slice(4) : '' - if (suffix) { - const directFull = fullHashLookup.get(suffix) - if (directFull) return directFull - - if (suffix.length >= 16) { - const directShort = shortHashLookup.get(suffix.slice(0, 16)) - if (typeof directShort === 'string') return directShort - } - } - - const hashMatch = /[a-f0-9]{32}|[a-f0-9]{16}/i.exec(normalized) - if (!hashMatch?.[0]) return null - const hash = hashMatch[0].toLowerCase() - if (hash.length >= 32) { - const full = fullHashLookup.get(hash) - if (full) return full - } - const short = shortHashLookup.get(hash.slice(0, 16)) - return typeof short === 'string' ? short : null - } - - private bumpSessionBaseline(sessionId: string, message: Message): void { - const key = String(sessionId || '').trim() - if (!key) return - - const createTime = Number(message.createTime || 0) - if (!Number.isFinite(createTime) || createTime <= 0) return - - const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 } - if (createTime > current.lastTimestamp) { - this.sessionBaseline.set(key, { - ...current, - lastTimestamp: createTime - }) - } - } - - private getMessageDisplayContent(message: Message): string | null { - const normalizeTextContent = (value: string | null | undefined): string | null => { - const text = String(value || '') - if (!text) return null - return text.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '').trim() - } - - const cleanOfficialPrefix = (value: string | null): string | null => { - if (!value) return value - return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value - } - switch (Number(message.localType || 0)) { - case 1: - return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent)) - case 3: - return '[图片]' - case 34: - return '[语音]' - case 43: - return '[视频]' - case 47: - return '[表情]' - case 42: - return cleanOfficialPrefix(message.cardNickname || '[名片]') - case 48: - return '[位置]' - case 49: - return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]') - default: - return cleanOfficialPrefix(normalizeTextContent(message.parsedContent || message.rawContent) || null) - } - } - - private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise { - const senderUsername = String(message.senderUsername || '').trim() - if (!senderUsername) { - return session.lastSenderDisplayName || '未知发送者' - } - - const groupNicknames = await this.getGroupNicknames(chatroomId) - const senderKey = senderUsername.toLowerCase() - const nickname = groupNicknames[senderKey] - - if (nickname) { - return nickname - } - - const contactInfo = await chatService.getContactAvatar(senderUsername) - return contactInfo?.displayName || senderUsername - } - - private async getGroupNicknames(chatroomId: string): Promise> { - const cacheKey = String(chatroomId || '').trim() - if (!cacheKey) return {} - - const cached = this.groupNicknameCache.get(cacheKey) - if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) { - return cached.nicknames - } - - const result = await wcdbService.getGroupNicknames(cacheKey) - const nicknames = result.success && result.nicknames - ? this.sanitizeGroupNicknames(result.nicknames) - : {} - this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() }) - return nicknames - } - - private sanitizeGroupNicknames(nicknames: Record): Record { - const buckets = new Map>() - for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) { - const memberId = String(memberIdRaw || '').trim().toLowerCase() - const nickname = String(nicknameRaw || '').trim() - if (!memberId || !nickname) continue - const slot = buckets.get(memberId) - if (slot) { - slot.add(nickname) - } else { - buckets.set(memberId, new Set([nickname])) - } - } - - const trusted: Record = {} - for (const [memberId, nicknameSet] of buckets.entries()) { - if (nicknameSet.size !== 1) continue - trusted[memberId] = Array.from(nicknameSet)[0] - } - return trusted - } - - private isRecentMessage(messageKey: string): boolean { - this.pruneRecentMessageKeys() - const timestamp = this.recentMessageKeys.get(messageKey) - return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs - } - - private rememberMessageKey(messageKey: string): void { - this.recentMessageKeys.set(messageKey, Date.now()) - this.pruneRecentMessageKeys() - } - - private isSeenMessage(messageKey: string): boolean { - this.pruneSeenMessageKeys() - const timestamp = this.seenMessageKeys.get(messageKey) - return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs - } - - private rememberSeenMessageKey(messageKey: string): void { - this.seenMessageKeys.set(messageKey, Date.now()) - this.pruneSeenMessageKeys() - } - - private pruneRecentMessageKeys(): void { - const now = Date.now() - for (const [key, timestamp] of this.recentMessageKeys.entries()) { - if (now - timestamp > this.recentMessageTtlMs) { - this.recentMessageKeys.delete(key) - } - } - } - - private pruneSeenMessageKeys(): void { - const now = Date.now() - for (const [key, timestamp] of this.seenMessageKeys.entries()) { - if (now - timestamp > this.recentMessageTtlMs) { - this.seenMessageKeys.delete(key) - } - } - } - -} - -export const messagePushService = new MessagePushService() diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts deleted file mode 100644 index 3a78137..0000000 --- a/electron/services/nativeImageDecrypt.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { existsSync } from 'fs' -import { join } from 'path' - -type NativeDecryptResult = { - data: Buffer - ext: string - isWxgf?: boolean - is_wxgf?: boolean - version?: number - aesSize?: number - aes_size?: number - xorSize?: number - xor_size?: number - rawSize?: number - raw_size?: number - flag?: number -} - -export type NativeDatMeta = { - version?: number - aesSize?: number - aes_size?: number - xorSize?: number - xor_size?: number - rawSize?: number - raw_size?: number - flag?: number -} - -type NativeAddon = { - decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult - encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer -} - -let cachedAddon: NativeAddon | null | undefined - -function shouldEnableNative(): boolean { - return process.env.WEFLOW_IMAGE_NATIVE !== '0' -} - -function expandAsarCandidates(filePath: string): string[] { - if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) { - return [filePath] - } - return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath] -} - -function getPlatformDir(): string { - if (process.platform === 'win32') return 'win32' - if (process.platform === 'darwin') return 'macos' - if (process.platform === 'linux') return 'linux' - return process.platform -} - -function getArchDir(): string { - if (process.arch === 'x64') return 'x64' - if (process.arch === 'arm64') return 'arm64' - return process.arch -} - -function getAddonCandidates(): string[] { - const platformDir = getPlatformDir() - const archDir = getArchDir() - const cwd = process.cwd() - const fileNames = [ - `weflow-image-native-${platformDir}-${archDir}.node` - ] - const roots = [ - join(cwd, 'resources', 'wedecrypt', platformDir, archDir), - ...(process.resourcesPath - ? [ - join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir), - join(process.resourcesPath, 'wedecrypt', platformDir, archDir) - ] - : []) - ] - const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name))) - return Array.from(new Set(candidates.flatMap(expandAsarCandidates))) -} - -function loadAddon(): NativeAddon | null { - if (!shouldEnableNative()) return null - if (cachedAddon !== undefined) return cachedAddon - - for (const candidate of getAddonCandidates()) { - if (!existsSync(candidate)) continue - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const addon = require(candidate) as NativeAddon - if (addon && typeof addon.decryptDatNative === 'function') { - cachedAddon = addon - return addon - } - } catch { - // try next candidate - } - } - - cachedAddon = null - return null -} - -export function nativeAddonLocation(): string | null { - for (const candidate of getAddonCandidates()) { - if (existsSync(candidate)) return candidate - } - return null -} - -export function decryptDatViaNative( - inputPath: string, - xorKey: number, - aesKey?: string -): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null { - const addon = loadAddon() - if (!addon) return null - - try { - const result = addon.decryptDatNative(inputPath, xorKey, aesKey) - const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf) - if (!result || !Buffer.isBuffer(result.data)) return null - const rawExt = typeof result.ext === 'string' && result.ext.trim() - ? result.ext.trim().toLowerCase() - : '' - const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : '' - const meta: NativeDatMeta = { - version: result.version, - aes_size: result.aes_size ?? result.aesSize, - xor_size: result.xor_size ?? result.xorSize, - raw_size: result.raw_size ?? result.rawSize, - flag: result.flag - } - return { data: result.data, ext, isWxgf, meta } - } catch { - return null - } -} - -export function encryptDatViaNative( - inputPath: string, - xorKey: number, - aesKey?: string, - meta?: NativeDatMeta -): Buffer | null { - const addon = loadAddon() - if (!addon || typeof addon.encryptDatNative !== 'function') return null - - try { - const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta) - return Buffer.isBuffer(result) ? result : null - } catch { - return null - } -} diff --git a/electron/services/sessionStatsCacheService.ts b/electron/services/sessionStatsCacheService.ts deleted file mode 100644 index 147930d..0000000 --- a/electron/services/sessionStatsCacheService.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { join, dirname } from 'path' -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' -import { ConfigService } from './config' - -const CACHE_VERSION = 2 -const MAX_SESSION_ENTRIES_PER_SCOPE = 2000 -const MAX_SCOPE_ENTRIES = 12 - -export interface SessionStatsCacheStats { - totalMessages: number - voiceMessages: number - imageMessages: number - videoMessages: number - emojiMessages: number - transferMessages: number - redPacketMessages: number - callMessages: number - firstTimestamp?: number - lastTimestamp?: number - privateMutualGroups?: number - groupMemberCount?: number - groupMyMessages?: number - groupActiveSpeakers?: number - groupMutualFriends?: number -} - -export interface SessionStatsCacheEntry { - updatedAt: number - includeRelations: boolean - stats: SessionStatsCacheStats -} - -interface SessionStatsScopeMap { - [sessionId: string]: SessionStatsCacheEntry -} - -interface SessionStatsCacheStore { - version: number - scopes: Record -} - -function toNonNegativeInt(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) return undefined - return Math.max(0, Math.floor(value)) -} - -function normalizeStats(raw: unknown): SessionStatsCacheStats | null { - if (!raw || typeof raw !== 'object') return null - const source = raw as Record - - const totalMessages = toNonNegativeInt(source.totalMessages) - const voiceMessages = toNonNegativeInt(source.voiceMessages) - const imageMessages = toNonNegativeInt(source.imageMessages) - const videoMessages = toNonNegativeInt(source.videoMessages) - const emojiMessages = toNonNegativeInt(source.emojiMessages) - const transferMessages = toNonNegativeInt(source.transferMessages) - const redPacketMessages = toNonNegativeInt(source.redPacketMessages) - const callMessages = toNonNegativeInt(source.callMessages) - - if ( - totalMessages === undefined || - voiceMessages === undefined || - imageMessages === undefined || - videoMessages === undefined || - emojiMessages === undefined || - transferMessages === undefined || - redPacketMessages === undefined || - callMessages === undefined - ) { - return null - } - - const normalized: SessionStatsCacheStats = { - totalMessages, - voiceMessages, - imageMessages, - videoMessages, - emojiMessages, - transferMessages, - redPacketMessages, - callMessages - } - - const firstTimestamp = toNonNegativeInt(source.firstTimestamp) - if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp - - const lastTimestamp = toNonNegativeInt(source.lastTimestamp) - if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp - - const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups) - if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups - - const groupMemberCount = toNonNegativeInt(source.groupMemberCount) - if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount - - const groupMyMessages = toNonNegativeInt(source.groupMyMessages) - if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages - - const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers) - if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers - - const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends) - if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends - - return normalized -} - -function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null { - if (!raw || typeof raw !== 'object') return null - const source = raw as Record - const updatedAt = toNonNegativeInt(source.updatedAt) - const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false - const stats = normalizeStats(source.stats) - - if (updatedAt === undefined || !stats) { - return null - } - - return { - updatedAt, - includeRelations, - stats - } -} - -export class SessionStatsCacheService { - private readonly cacheFilePath: string - private store: SessionStatsCacheStore = { - version: CACHE_VERSION, - scopes: {} - } - - constructor(cacheBasePath?: string) { - const basePath = cacheBasePath && cacheBasePath.trim().length > 0 - ? cacheBasePath - : ConfigService.getInstance().getCacheBasePath() - this.cacheFilePath = join(basePath, 'session-stats.json') - this.ensureCacheDir() - this.load() - } - - private ensureCacheDir(): void { - const dir = dirname(this.cacheFilePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - } - - private load(): void { - if (!existsSync(this.cacheFilePath)) return - try { - const raw = readFileSync(this.cacheFilePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const payload = parsed as Record - const version = Number(payload.version) - if (!Number.isFinite(version) || version !== CACHE_VERSION) { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - const scopesRaw = payload.scopes - if (!scopesRaw || typeof scopesRaw !== 'object') { - this.store = { version: CACHE_VERSION, scopes: {} } - return - } - - const scopes: Record = {} - for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { - if (!scopeValue || typeof scopeValue !== 'object') continue - const normalizedScope: SessionStatsScopeMap = {} - for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record)) { - const entry = normalizeEntry(entryRaw) - if (!entry) continue - normalizedScope[sessionId] = entry - } - if (Object.keys(normalizedScope).length > 0) { - scopes[scopeKey] = normalizedScope - } - } - - this.store = { - version: CACHE_VERSION, - scopes - } - } catch (error) { - console.error('SessionStatsCacheService: 载入缓存失败', error) - this.store = { version: CACHE_VERSION, scopes: {} } - } - } - - get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined { - if (!scopeKey || !sessionId) return undefined - const scope = this.store.scopes[scopeKey] - if (!scope) return undefined - const entry = normalizeEntry(scope[sessionId]) - if (!entry) { - delete scope[sessionId] - if (Object.keys(scope).length === 0) { - delete this.store.scopes[scopeKey] - } - this.persist() - return undefined - } - return entry - } - - set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void { - if (!scopeKey || !sessionId) return - const normalized = normalizeEntry(entry) - if (!normalized) return - - if (!this.store.scopes[scopeKey]) { - this.store.scopes[scopeKey] = {} - } - this.store.scopes[scopeKey][sessionId] = normalized - - this.trimScope(scopeKey) - this.trimScopes() - this.persist() - } - - delete(scopeKey: string, sessionId: string): void { - if (!scopeKey || !sessionId) return - const scope = this.store.scopes[scopeKey] - if (!scope) return - if (!(sessionId in scope)) return - - delete scope[sessionId] - if (Object.keys(scope).length === 0) { - delete this.store.scopes[scopeKey] - } - this.persist() - } - - clearScope(scopeKey: string): void { - if (!scopeKey) return - if (!this.store.scopes[scopeKey]) return - delete this.store.scopes[scopeKey] - this.persist() - } - - clearAll(): void { - this.store = { version: CACHE_VERSION, scopes: {} } - try { - rmSync(this.cacheFilePath, { force: true }) - } catch (error) { - console.error('SessionStatsCacheService: 清理缓存失败', error) - } - } - - private trimScope(scopeKey: string): void { - const scope = this.store.scopes[scopeKey] - if (!scope) return - const entries = Object.entries(scope) - if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return - - entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) - const trimmed: SessionStatsScopeMap = {} - for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) { - trimmed[sessionId] = entry - } - this.store.scopes[scopeKey] = trimmed - } - - private trimScopes(): void { - const scopeEntries = Object.entries(this.store.scopes) - if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return - - scopeEntries.sort((a, b) => { - const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) - const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) - return bUpdatedAt - aUpdatedAt - }) - - const trimmedScopes: Record = {} - for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { - trimmedScopes[scopeKey] = scopeMap - } - this.store.scopes = trimmedScopes - } - - private persist(): void { - try { - writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') - } catch (error) { - console.error('SessionStatsCacheService: 保存缓存失败', error) - } - } -} diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts deleted file mode 100644 index 5721f14..0000000 --- a/electron/services/snsService.ts +++ /dev/null @@ -1,2504 +0,0 @@ -import { wcdbService } from './wcdbService' -import { ConfigService } from './config' -import { ContactCacheService } from './contactCacheService' -import { app } from 'electron' -import { existsSync, mkdirSync, unlinkSync } from 'fs' -import { readFile, writeFile, mkdir } from 'fs/promises' -import { basename, join } from 'path' -import crypto from 'crypto' -import { WasmService } from './wasmService' -import zlib from 'zlib' - -export interface SnsLivePhoto { - url: string - thumb: string - md5?: string - token?: string - thumbToken?: string - key?: string - encIdx?: string -} - -export interface SnsMedia { - url: string - thumb: string - md5?: string - token?: string - thumbToken?: string - key?: string - encIdx?: string - livePhoto?: SnsLivePhoto -} - -export interface SnsLocation { - latitude?: number - longitude?: number - city?: string - country?: string - poiName?: string - poiAddress?: string - poiAddressName?: string - label?: string -} - -export interface SnsPost { - id: string - tid?: string // 数据库主键(雪花 ID),用于精确删除 - username: string - nickname: string - avatarUrl?: string - createTime: number - contentDesc: string - type?: number - media: SnsMedia[] - likes: string[] - comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] - location?: SnsLocation - rawXml?: string - linkTitle?: string - linkUrl?: string -} - -interface SnsContactIdentity { - username: string - wxid: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string -} - -interface ParsedLikeUser { - username?: string - nickname?: string -} - -interface ParsedCommentItem { - id: string - nickname: string - username?: string - content: string - refCommentId: string - refUsername?: string - refNickname?: string - emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] -} - -interface ArkmeLikeDetail { - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - source: 'xml' | 'legacy' -} - -interface ArkmeCommentDetail { - id: string - nickname: string - username?: string - wxid?: string - alias?: string - wechatId?: string - remark?: string - nickName?: string - displayName: string - content: string - refCommentId: string - refNickname?: string - refUsername?: string - refWxid?: string - refAlias?: string - refWechatId?: string - refRemark?: string - refNickName?: string - refDisplayName?: string - emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] - source: 'xml' | 'legacy' -} - - - -const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { - if (!url) return url - - let fixedUrl = url.replace('http://', 'https://') - - // 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0 - if (!isVideo) { - const [pathPart, queryPart] = fixedUrl.split('?') - const fixedPath = pathPart.replace(/\/(150|200|480)($|\?)/, '/0$2') - fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath - } - - // 如果没有提供新token,直接返回 - if (!token) return fixedUrl - - // 移除已有的token和idx参数 - const [pathPart, queryPart] = fixedUrl.split('?') - if (queryPart) { - const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx=')) - fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart - } - - // 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数 - if (isVideo) { - const urlParts = fixedUrl.split('?') - const baseUrl = urlParts[0] - const existingParams = urlParts[1] ? `&${urlParts[1]}` : '' - return `${baseUrl}?token=${token}&idx=1${existingParams}` - } - - const connector = fixedUrl.includes('?') ? '&' : '?' - return `${fixedUrl}${connector}token=${token}&idx=1` -} - -const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { - if (!buf || buf.length < 4) return fallback - - // JPEG - if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' - - // PNG - if ( - buf.length >= 8 && - buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && - buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a - ) return 'image/png' - - // GIF - if (buf.length >= 6) { - const sig = buf.subarray(0, 6).toString('ascii') - if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif' - } - - // WebP - if ( - buf.length >= 12 && - buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && - buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 - ) return 'image/webp' - - // BMP - if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp' - - // ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4 - if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { - const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() - if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif' - if ( - ftypWindow.includes('heic') || ftypWindow.includes('heix') || - ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || - ftypWindow.includes('mif1') || ftypWindow.includes('msf1') - ) return 'image/heic' - return 'video/mp4' - } - - // Fallback logic for video - if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4' - - return fallback -} - -export const isVideoUrl = (url: string) => { - if (!url) return false - // 排除 vweixinthumb 域名 (缩略图) - if (url.includes('vweixinthumb')) return false - return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4') -} - -import { Isaac64 } from './isaac64' - -const extractVideoKey = (xml: string): string | undefined => { - if (!xml) return undefined - // 匹配 - const match = xml.match(/([\s\S]*?)<\/CommentUserList>/i) - if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentUserList>/i) - if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/commentList>/i) - if (!listMatch) listMatch = xml.match(/([\s\S]*?)<\/comment_user_list>/i) - if (!listMatch) return comments - - const listXml = listMatch[1] - const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi - let m: RegExpExecArray | null - - while ((m = itemRegex.exec(listXml)) !== null) { - const c = m[1] - - const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i) - const usernameMatch = c.match(/([^<]*)<\/username>/i) - let nicknameMatch = c.match(/([^<]*)<\/nickname>/i) - if (!nicknameMatch) nicknameMatch = c.match(/([^<]*)<\/nickName>/i) - const contentMatch = c.match(/([^<]*)<\/content>/i) - const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i) - const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i) - const refUserMatch = c.match(/([^<]*)<\/ref_username>/i) - - // 解析表情包 - const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = [] - const emojiRegex = /([\s\S]*?)<\/emojiinfo>/gi - let em: RegExpExecArray | null - while ((em = emojiRegex.exec(c)) !== null) { - const ex = em[1] - const externUrl = ex.match(/([^<]*)<\/extern_url>/i) - const cdnUrl = ex.match(/([^<]*)<\/cdn_url>/i) - const plainUrl = ex.match(/([^<]*)<\/url>/i) - const urlMatch = externUrl || cdnUrl || plainUrl - const md5Match = ex.match(/([^<]*)<\/md5>/i) - const wMatch = ex.match(/([^<]*)<\/width>/i) - const hMatch = ex.match(/([^<]*)<\/height>/i) - const encMatch = ex.match(/([^<]*)<\/encrypt_url>/i) - const aesMatch = ex.match(/([^<]*)<\/aes_key>/i) - - const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : '' - const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined - const aesKey = aesMatch ? aesMatch[1].trim() : undefined - - if (url || encryptUrl) { - emojis.push({ - url, - md5: md5Match ? md5Match[1].trim() : '', - width: wMatch ? parseInt(wMatch[1]) : 0, - height: hMatch ? parseInt(hMatch[1]) : 0, - encryptUrl, - aesKey - }) - } - } - - if (nicknameMatch && (contentMatch || emojis.length > 0)) { - const refId = refIdMatch ? refIdMatch[1].trim() : '' - comments.push({ - id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`, - nickname: nicknameMatch[1].trim(), - username: usernameMatch ? usernameMatch[1].trim() : undefined, - content: contentMatch ? contentMatch[1].trim() : '', - refCommentId: refId === '0' ? '' : refId, - refUsername: refUserMatch ? refUserMatch[1].trim() : undefined, - refNickname: refNickMatch ? refNickMatch[1].trim() : undefined, - emojis: emojis.length > 0 ? emojis : undefined - }) - } - } - - // 二次解析:通过 refUsername 补全 refNickname - const userMap = new Map() - for (const c of comments) { - if (c.username && c.nickname) userMap.set(c.username, c.nickname) - } - for (const c of comments) { - if (!c.refNickname && c.refUsername && c.refCommentId) { - c.refNickname = userMap.get(c.refUsername) - } - } - } catch (e) { - console.error('[SnsService] parseCommentsFromXml 失败:', e) - } - - return comments -} - -const decodeXmlText = (text: string): string => { - if (!text) return '' - return text - .replace(//g, '$1') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/'/gi, "'") -} - -class SnsService { - private configService: ConfigService - private contactCache: ContactCacheService - private imageCache = new Map() - private imageCacheMeta = new Map() - private readonly imageCacheTtlMs = 15 * 60 * 1000 - private readonly imageCacheMaxEntries = 120 - private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null - private userPostCountsCache: { counts: Record; updatedAt: number } | null = null - private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 - private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000 - private lastTimelineFallbackAt = 0 - private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 - - constructor() { - this.configService = new ConfigService() - this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) - } - - clearMemoryCache(): void { - this.imageCache.clear() - this.imageCacheMeta.clear() - } - - private pruneImageCache(now: number = Date.now()): void { - for (const [key, updatedAt] of this.imageCacheMeta.entries()) { - if (now - updatedAt > this.imageCacheTtlMs) { - this.imageCacheMeta.delete(key) - this.imageCache.delete(key) - } - } - - while (this.imageCache.size > this.imageCacheMaxEntries) { - const oldestKey = this.imageCache.keys().next().value as string | undefined - if (!oldestKey) break - this.imageCache.delete(oldestKey) - this.imageCacheMeta.delete(oldestKey) - } - } - - private rememberImageCache(cacheKey: string, dataUrl: string): void { - if (!cacheKey || !dataUrl) return - const now = Date.now() - if (this.imageCache.has(cacheKey)) { - this.imageCache.delete(cacheKey) - } - this.imageCache.set(cacheKey, dataUrl) - this.imageCacheMeta.set(cacheKey, now) - this.pruneImageCache(now) - } - - private toOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined - } - - private async resolveContactIdentity( - username: string, - identityCache: Map> - ): Promise { - const normalized = String(username || '').trim() - if (!normalized) return null - - let pending = identityCache.get(normalized) - if (!pending) { - pending = (async () => { - const cached = this.contactCache.get(normalized) - let alias: string | undefined - let remark: string | undefined - let nickName: string | undefined - - try { - const contactResult = await wcdbService.getContact(normalized) - if (contactResult.success && contactResult.contact) { - const contact = contactResult.contact - alias = this.toOptionalString(contact.alias ?? contact.Alias) - remark = this.toOptionalString(contact.remark ?? contact.Remark) - nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName) - } - } catch { - // 联系人补全失败不影响导出 - } - - const displayName = remark || nickName || alias || cached?.displayName || normalized - return { - username: normalized, - wxid: normalized, - alias, - wechatId: alias, - remark, - nickName, - displayName - } - })() - identityCache.set(normalized, pending) - } - - return pending - } - - private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] { - if (!xml) return [] - const likes: ParsedLikeUser[] = [] - try { - let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) - if (!likeListMatch) return likes - - const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi - let m: RegExpExecArray | null - while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { - const block = m[1] - const username = this.toOptionalString(block.match(/([^<]*)<\/username>/i)?.[1]) - const nickname = this.toOptionalString( - block.match(/([^<]*)<\/nickname>/i)?.[1] - || block.match(/([^<]*)<\/nickName>/i)?.[1] - ) - if (username || nickname) { - likes.push({ username, nickname }) - } - } - } catch (e) { - console.error('[SnsService] 解析点赞用户失败:', e) - } - return likes - } - - private async buildArkmeInteractionDetails( - post: SnsPost, - identityCache: Map> - ): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> { - const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '') - const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0 - ? xmlLikes - : (post.likes || []).map((nickname) => ({ nickname })) - const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy' - const likesDetail: ArkmeLikeDetail[] = [] - const likeSeen = new Set() - - for (const like of likeCandidates) { - const identity = like.username - ? await this.resolveContactIdentity(like.username, identityCache) - : null - const nickname = like.nickname || identity?.displayName || like.username || '' - const username = identity?.username || like.username - const key = `${username || ''}|${nickname}` - if (likeSeen.has(key)) continue - likeSeen.add(key) - likesDetail.push({ - nickname, - username, - wxid: username, - alias: identity?.alias, - wechatId: identity?.wechatId, - remark: identity?.remark, - nickName: identity?.nickName, - displayName: identity?.displayName || nickname || username || '', - source: likeSource - }) - } - - const xmlComments = parseCommentsFromXml(post.rawXml || '') - const commentMap = new Map() - for (const comment of post.comments || []) { - if (comment.id) commentMap.set(comment.id, comment) - } - - const commentsBase: ParsedCommentItem[] = xmlComments.length > 0 - ? xmlComments.map((comment) => { - const fallback = comment.id ? commentMap.get(comment.id) : undefined - return { - id: comment.id || fallback?.id || '', - nickname: comment.nickname || fallback?.nickname || '', - username: comment.username, - content: comment.content || fallback?.content || '', - refCommentId: comment.refCommentId || fallback?.refCommentId || '', - refUsername: comment.refUsername, - refNickname: comment.refNickname || fallback?.refNickname, - emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis - } - }) - : (post.comments || []).map((comment) => ({ - id: comment.id || '', - nickname: comment.nickname || '', - content: comment.content || '', - refCommentId: comment.refCommentId || '', - refNickname: comment.refNickname, - emojis: comment.emojis - })) - - if (xmlComments.length > 0) { - const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean)) - for (const comment of post.comments || []) { - if (comment.id && mappedIds.has(comment.id)) continue - commentsBase.push({ - id: comment.id || '', - nickname: comment.nickname || '', - content: comment.content || '', - refCommentId: comment.refCommentId || '', - refNickname: comment.refNickname, - emojis: comment.emojis - }) - } - } - - const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy' - const commentsDetail: ArkmeCommentDetail[] = [] - - for (const comment of commentsBase) { - const actor = comment.username - ? await this.resolveContactIdentity(comment.username, identityCache) - : null - const refActor = comment.refUsername - ? await this.resolveContactIdentity(comment.refUsername, identityCache) - : null - const nickname = comment.nickname || actor?.displayName || comment.username || '' - const username = actor?.username || comment.username - const refUsername = refActor?.username || comment.refUsername - commentsDetail.push({ - id: comment.id || '', - nickname, - username, - wxid: username, - alias: actor?.alias, - wechatId: actor?.wechatId, - remark: actor?.remark, - nickName: actor?.nickName, - displayName: actor?.displayName || nickname || username || '', - content: comment.content || '', - refCommentId: comment.refCommentId || '', - refNickname: comment.refNickname || refActor?.displayName, - refUsername, - refWxid: refUsername, - refAlias: refActor?.alias, - refWechatId: refActor?.wechatId, - refRemark: refActor?.remark, - refNickName: refActor?.nickName, - refDisplayName: refActor?.displayName, - emojis: comment.emojis, - source: commentSource - }) - } - - return { likesDetail, commentsDetail } - } - - private parseCountValue(row: any): number { - if (!row || typeof row !== 'object') return 0 - const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] - const num = Number(raw) - return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 - } - - private pickTimelineUsername(post: any): string { - const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' - if (typeof raw !== 'string') return '' - return raw.trim() - } - - private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise { - const pageSize = 500 - const uniqueUsers = new Set() - let offset = 0 - - for (let round = 0; round < maxRounds; round++) { - const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) - if (!result.success || !Array.isArray(result.timeline)) { - throw new Error(result.error || '获取朋友圈发布者失败') - } - - const rows = result.timeline - if (rows.length === 0) break - - for (const row of rows) { - const username = this.pickTimelineUsername(row) - if (username) uniqueUsers.add(username) - } - - if (rows.length < pageSize) break - offset += rows.length - } - - return Array.from(uniqueUsers) - } - - private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { - const pageSize = 500 - const uniqueUsers = new Set() - let totalPosts = 0 - let myPosts = 0 - let offset = 0 - const normalizedMyWxid = this.toOptionalString(myWxid) - - for (let round = 0; round < 2000; round++) { - const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) - if (!result.success || !Array.isArray(result.timeline)) { - throw new Error(result.error || '获取朋友圈统计失败') - } - - const rows = result.timeline - if (rows.length === 0) break - - totalPosts += rows.length - for (const row of rows) { - const username = this.pickTimelineUsername(row) - if (username) uniqueUsers.add(username) - if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1 - } - - if (rows.length < pageSize) break - offset += rows.length - } - - return { - totalPosts, - totalFriends: uniqueUsers.size, - myPosts: normalizedMyWxid ? myPosts : null - } - } - - private parseLikesFromXml(xml: string): string[] { - if (!xml) return [] - const likes: string[] = [] - try { - let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) - if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) - if (!likeListMatch) return likes - - const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi - let m: RegExpExecArray | null - while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { - let nick = m[1].match(/([^<]*)<\/nickname>/i) - if (!nick) nick = m[1].match(/([^<]*)<\/nickName>/i) - if (nick) likes.push(nick[1].trim()) - } - } catch (e) { - console.error('[SnsService] 解析点赞失败:', e) - } - return likes - } - - private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } { - if (!xml) return { media: [] } - const media: SnsMedia[] = [] - let videoKey: string | undefined - try { - const encMatch = xml.match(/([\s\S]*?)<\/media>/gi - let mediaMatch: RegExpExecArray | null - while ((mediaMatch = mediaRegex.exec(xml)) !== null) { - const mx = mediaMatch[1] - const urlMatch = mx.match(/]*>([^<]+)<\/url>/i) - const urlTagMatch = mx.match(/]*)>/i) - const thumbMatch = mx.match(/]*>([^<]+)<\/thumb>/i) - const thumbTagMatch = mx.match(/]*)>/i) - - let urlToken: string | undefined, urlKey: string | undefined - let urlMd5: string | undefined, urlEncIdx: string | undefined - if (urlTagMatch?.[1]) { - const a = urlTagMatch[1] - urlToken = a.match(/token="([^"]+)"/i)?.[1] - urlKey = a.match(/key="([^"]+)"/i)?.[1] - urlMd5 = a.match(/md5="([^"]+)"/i)?.[1] - urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] - } - let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined - if (thumbTagMatch?.[1]) { - const a = thumbTagMatch[1] - thumbToken = a.match(/token="([^"]+)"/i)?.[1] - thumbKey = a.match(/key="([^"]+)"/i)?.[1] - thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] - } - - const item: SnsMedia = { - url: urlMatch ? urlMatch[1].trim() : '', - thumb: thumbMatch ? thumbMatch[1].trim() : '', - token: urlToken || thumbToken, - thumbToken: thumbToken, - key: urlKey || thumbKey, - md5: urlMd5, - encIdx: urlEncIdx || thumbEncIdx - } - - const livePhotoMatch = mx.match(/([\s\S]*?)<\/livePhoto>/i) - if (livePhotoMatch) { - const lx = livePhotoMatch[1] - const lpUrl = lx.match(/]*>([^<]+)<\/url>/i) - const lpUrlTag = lx.match(/]*)>/i) - const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) - const lpThumbTag = lx.match(/]*)>/i) - let lpUrlToken: string | undefined, lpThumbToken: string | undefined - let lpKey: string | undefined, lpEncIdx: string | undefined - if (lpUrlTag?.[1]) { - const a = lpUrlTag[1] - lpUrlToken = a.match(/token="([^"]+)"/i)?.[1] - lpKey = a.match(/key="([^"]+)"/i)?.[1] - lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] - } - if (lpThumbTag?.[1]) { - const a = lpThumbTag[1] - lpThumbToken = a.match(/token="([^"]+)"/i)?.[1] - if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1] - } - item.livePhoto = { - url: lpUrl ? lpUrl[1].trim() : '', - thumb: lpThumb ? lpThumb[1].trim() : '', - token: lpUrlToken || lpThumbToken, - thumbToken: lpThumbToken, - key: lpKey, - encIdx: lpEncIdx - } - } - media.push(item) - } - } catch (e) { - console.error('[SnsService] 解析媒体 XML 失败:', e) - } - return { media, videoKey } - } - - private toOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - if (!trimmed) return undefined - const parsed = Number.parseFloat(trimmed) - return Number.isFinite(parsed) ? parsed : undefined - } - - private normalizeLocation(input: unknown): SnsLocation | undefined { - if (!input || typeof input !== 'object') return undefined - - const row = input as Record - const normalizeText = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined - return this.toOptionalString(decodeXmlText(value)) - } - - const location: SnsLocation = {} - const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x) - const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y) - const city = normalizeText(row.city) - const country = normalizeText(row.country) - const poiName = normalizeText(row.poiName ?? row.poiname) - const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress) - const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname) - const label = normalizeText(row.label) - - if (latitude !== undefined) location.latitude = latitude - if (longitude !== undefined) location.longitude = longitude - if (city) location.city = city - if (country) location.country = country - if (poiName) location.poiName = poiName - if (poiAddress) location.poiAddress = poiAddress - if (poiAddressName) location.poiAddressName = poiAddressName - if (label) location.label = label - - return Object.keys(location).length > 0 ? location : undefined - } - - private parseLocationFromXml(xml: string): SnsLocation | undefined { - if (!xml) return undefined - - try { - const locationTagMatch = xml.match(/]*)>/i) - const locationAttrs = locationTagMatch?.[1] || '' - const readAttr = (name: string): string | undefined => { - if (!locationAttrs) return undefined - const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i')) - if (!match?.[1]) return undefined - return this.toOptionalString(decodeXmlText(match[1])) - } - const readTag = (name: string): string | undefined => { - const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')) - if (!match?.[1]) return undefined - return this.toOptionalString(decodeXmlText(match[1])) - } - - const location: SnsLocation = {} - const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x')) - const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y')) - const city = readAttr('city') || readTag('city') - const country = readAttr('country') || readTag('country') - const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname') - const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress') - const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname') - const label = readAttr('label') || readTag('label') - - if (latitude !== undefined) location.latitude = latitude - if (longitude !== undefined) location.longitude = longitude - if (city) location.city = city - if (country) location.country = country - if (poiName) location.poiName = poiName - if (poiAddress) location.poiAddress = poiAddress - if (poiAddressName) location.poiAddressName = poiAddressName - if (label) location.label = label - - return Object.keys(location).length > 0 ? location : undefined - } catch (e) { - console.error('[SnsService] 解析位置 XML 失败:', e) - return undefined - } - } - - private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined { - if (!primary && !fallback) return undefined - - const merged: SnsLocation = {} - const setValue = (key: K, value: SnsLocation[K] | undefined) => { - if (value !== undefined) merged[key] = value - } - - setValue('latitude', primary?.latitude ?? fallback?.latitude) - setValue('longitude', primary?.longitude ?? fallback?.longitude) - setValue('city', primary?.city ?? fallback?.city) - setValue('country', primary?.country ?? fallback?.country) - setValue('poiName', primary?.poiName ?? fallback?.poiName) - setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress) - setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName) - setValue('label', primary?.label ?? fallback?.label) - - return Object.keys(merged).length > 0 ? merged : undefined - } - - private getSnsCacheDir(): string { - const configuredCachePath = String(this.configService.get('cachePath') || '').trim() - const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') - const snsCacheDir = join(baseDir, 'sns_cache') - if (!existsSync(snsCacheDir)) { - mkdirSync(snsCacheDir, { recursive: true }) - } - return snsCacheDir - } - - private getEmojiCacheDir(): string { - const configuredCachePath = String(this.configService.get('cachePath') || '').trim() - const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow') - const emojiDir = join(baseDir, 'Emojis') - if (!existsSync(emojiDir)) { - mkdirSync(emojiDir, { recursive: true }) - } - return emojiDir - } - - private getCacheFilePath(url: string): string { - const hash = crypto.createHash('md5').update(url).digest('hex') - const ext = isVideoUrl(url) ? '.mp4' : '.jpg' - return join(this.getSnsCacheDir(), `${hash}${ext}`) - } - - async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - const result = await wcdbService.getSnsUsernames() - if (!result.success) { - return { success: false, error: result.error || '获取朋友圈联系人失败' } - } - const directUsernames = Array.isArray(result.usernames) ? result.usernames : [] - if (directUsernames.length > 0) { - return { success: true, usernames: directUsernames } - } - - // 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。 - try { - const timelineUsers = await this.collectSnsUsernamesFromTimeline() - if (timelineUsers.length > 0) { - return { success: true, usernames: timelineUsers } - } - } catch { - // 忽略回退错误,保持与原行为一致返回空数组 - } - - return { success: true, usernames: directUsernames } - } - - private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { - const normalizedMyWxid = this.toOptionalString(myWxid) - const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined) - if (!result.success || !result.data) { - return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null } - } - return { - totalPosts: Number(result.data.totalPosts || 0), - totalFriends: Number(result.data.totalFriends || 0), - myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0) - } - } - - async getExportStats(options?: { - allowTimelineFallback?: boolean - preferCache?: boolean - }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { - const allowTimelineFallback = options?.allowTimelineFallback ?? true - const preferCache = options?.preferCache ?? false - const now = Date.now() - const myWxid = this.toOptionalString(this.configService.getMyWxidCleaned()) - - try { - if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { - return { - success: true, - data: { - totalPosts: this.exportStatsCache.totalPosts, - totalFriends: this.exportStatsCache.totalFriends, - myPosts: this.exportStatsCache.myPosts - } - } - } - - let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid) - let fallbackAttempted = false - let fallbackError = '' - - // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 - if ( - allowTimelineFallback && - (totalPosts <= 0 || totalFriends <= 0) && - now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs - ) { - fallbackAttempted = true - try { - const timelineStats = await this.getExportStatsFromTimeline(myWxid) - this.lastTimelineFallbackAt = Date.now() - if (timelineStats.totalPosts > 0) { - totalPosts = timelineStats.totalPosts - } - if (timelineStats.totalFriends > 0) { - totalFriends = timelineStats.totalFriends - } - if (timelineStats.myPosts !== null) { - myPosts = timelineStats.myPosts - } - } catch (error) { - fallbackError = String(error) - console.error('[SnsService] getExportStats timeline fallback failed:', error) - } - } - - const normalizedStats = { - totalPosts: Math.max(0, Number(totalPosts || 0)), - totalFriends: Math.max(0, Number(totalFriends || 0)), - myPosts: myWxid - ? (myPosts === null ? null : Math.max(0, Number(myPosts || 0))) - : null - } - const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0 - const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0) - - // 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。 - if (!computedHasData && cacheHasData && this.exportStatsCache) { - return { - success: true, - data: { - totalPosts: this.exportStatsCache.totalPosts, - totalFriends: this.exportStatsCache.totalFriends, - myPosts: this.exportStatsCache.myPosts - } - } - } - - // 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0)。 - if (!computedHasData && fallbackAttempted && fallbackError) { - return { success: false, error: fallbackError } - } - - this.exportStatsCache = { - totalPosts: normalizedStats.totalPosts, - totalFriends: normalizedStats.totalFriends, - myPosts: normalizedStats.myPosts, - updatedAt: Date.now() - } - - return { success: true, data: normalizedStats } - } catch (e) { - if (this.exportStatsCache) { - return { - success: true, - data: { - totalPosts: this.exportStatsCache.totalPosts, - totalFriends: this.exportStatsCache.totalFriends, - myPosts: this.exportStatsCache.myPosts - } - } - } - return { success: false, error: String(e) } - } - } - - async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { - return this.getExportStats({ - allowTimelineFallback: false, - preferCache: true - }) - } - - private async getUserPostCountsFromTimeline(): Promise> { - const pageSize = 500 - const counts: Record = {} - let offset = 0 - - for (let round = 0; round < 2000; round++) { - const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) - if (!result.success || !Array.isArray(result.timeline)) { - throw new Error(result.error || '获取朋友圈用户总条数失败') - } - - const rows = result.timeline - if (rows.length === 0) break - - for (const row of rows) { - const username = this.pickTimelineUsername(row) - if (!username) continue - counts[username] = (counts[username] || 0) + 1 - } - - if (rows.length < pageSize) break - offset += rows.length - } - - return counts - } - - async getUserPostCounts(options?: { - preferCache?: boolean - }): Promise<{ success: boolean; counts?: Record; error?: string }> { - const preferCache = options?.preferCache ?? true - const now = Date.now() - - try { - if ( - preferCache && - this.userPostCountsCache && - now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs - ) { - return { success: true, counts: this.userPostCountsCache.counts } - } - - const counts = await this.getUserPostCountsFromTimeline() - this.userPostCountsCache = { - counts, - updatedAt: Date.now() - } - return { success: true, counts } - } catch (error) { - console.error('[SnsService] getUserPostCounts failed:', error) - if (this.userPostCountsCache) { - return { success: true, counts: this.userPostCountsCache.counts } - } - return { success: false, error: String(error) } - } - } - - async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { - const normalizedUsername = this.toOptionalString(username) - if (!normalizedUsername) { - return { success: false, error: '用户名不能为空' } - } - - const countsResult = await this.getUserPostCounts({ preferCache: true }) - if (countsResult.success) { - const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 - return { - success: true, - data: { - username: normalizedUsername, - totalPosts: Math.max(0, Number(totalPosts || 0)) - } - } - } - - return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } - } - - // 安装朋友圈删除拦截 - async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { - return wcdbService.installSnsBlockDeleteTrigger() - } - - // 卸载朋友圈删除拦截 - async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { - return wcdbService.uninstallSnsBlockDeleteTrigger() - } - - // 查询朋友圈删除拦截是否已安装 - async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { - return wcdbService.checkSnsBlockDeleteTrigger() - } - - // 从数据库直接删除朋友圈记录 - async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - const result = await wcdbService.deleteSnsPost(postId) - if (result.success) { - this.userPostCountsCache = null - this.exportStatsCache = null - } - return result - } - - /** - * 补全数据服务返回的评论中缺失的 refNickname - *数据服务返回的 refCommentId 是被回复评论的 cmtid - * 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增 - */ - private fixCommentRefs(comments: any[]): any[] { - if (!comments || comments.length === 0) return [] - - //数据服务现在返回完整的评论数据(含 emojis、refNickname) - // 此处做最终的格式化和兜底补全 - const idToNickname = new Map() - comments.forEach((c, idx) => { - if (c.id) idToNickname.set(c.id, c.nickname || '') - // 兜底:按索引映射(部分旧数据 id 可能为空) - idToNickname.set(String(idx + 1), c.nickname || '') - }) - - return comments.map((c) => { - const refId = c.refCommentId - let refNickname = c.refNickname || '' - - if (refId && refId !== '0' && refId !== '' && !refNickname) { - refNickname = idToNickname.get(refId) || '' - } - - // 处理 emojis:过滤掉空的 url 和 encryptUrl - const emojis = (c.emojis || []) - .filter((e: any) => e.url || e.encryptUrl) - .map((e: any) => ({ - url: (e.url || '').replace(/&/g, '&'), - md5: e.md5 || '', - width: e.width || 0, - height: e.height || 0, - encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined, - aesKey: e.aesKey || undefined - })) - - return { - id: c.id || '', - nickname: c.nickname || '', - content: c.content || '', - refCommentId: (refId === '0') ? '' : (refId || ''), - refNickname, - emojis: emojis.length > 0 ? emojis : undefined - } - }) - } - - async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) - if (!result.success || !result.timeline || result.timeline.length === 0) return result - - const enrichedTimeline = result.timeline.map((post: any) => { - const contact = this.contactCache.get(post.username) - const isVideoPost = post.type === 15 - const rawXml = post.rawXml || '' - const videoKey = extractVideoKey(rawXml) - const location = this.mergeLocation( - this.normalizeLocation((post as { location?: unknown }).location), - this.parseLocationFromXml(rawXml) - ) - - const fixedMedia = (post.media || []).map((m: any) => ({ - url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false), - md5: m.md5, - token: m.token, - thumbToken: m.thumbToken, - key: isVideoPost ? (videoKey || m.key) : m.key, - encIdx: m.encIdx || m.enc_idx, - livePhoto: m.livePhoto ? { - ...m.livePhoto, - url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false), - token: m.livePhoto.token, - thumbToken: m.livePhoto.thumbToken, - key: videoKey || m.livePhoto.key || m.key, - encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx - } : undefined - })) - - //数据服务已返回完整评论数据(含 emojis、refNickname) - // 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析 - const dllComments: any[] = post.comments || [] - const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) - - let finalComments: any[] - if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { - //数据服务数据完整,直接使用 - finalComments = this.fixCommentRefs(dllComments) - } else if (rawXml) { - // 回退:从 rawXml 重新解析(兼容旧版 DLL) - const xmlComments = parseCommentsFromXml(rawXml) - finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) - } else { - finalComments = this.fixCommentRefs(dllComments) - } - - return { - ...post, - avatarUrl: contact?.avatarUrl, - nickname: post.nickname || contact?.displayName || post.username, - media: fixedMedia, - comments: finalComments, - location - } - }) - - return { ...result, timeline: enrichedTimeline } - } - - async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { - return new Promise((resolve) => { - try { - const https = require('https') - const urlObj = new URL(url) - - const options = { - hostname: urlObj.hostname, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351', - 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Connection': 'keep-alive', - 'Range': 'bytes=0-10' - } - } - - const req = https.request(options, (res: any) => { - resolve({ - success: true, - status: res.statusCode, - headers: { - 'x-enc': res.headers['x-enc'], - 'x-time': res.headers['x-time'], - 'content-length': res.headers['content-length'], - 'content-type': res.headers['content-type'] - } - }) - req.destroy() - }) - - req.on('error', (e: any) => resolve({ success: false, error: e.message })) - req.end() - } catch (e: any) { - resolve({ success: false, error: e.message }) - } - }) - } - - - - async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> { - if (!url) return { success: false, error: 'url 不能为空' } - const cacheKey = `${url}|${key ?? ''}` - - const cachedDataUrl = this.imageCache.get(cacheKey) || '' - if (cachedDataUrl) { - const cachedAt = this.imageCacheMeta.get(cacheKey) || 0 - if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) { - const base64Part = cachedDataUrl.split(',')[1] || '' - if (base64Part) { - try { - const cachedBuf = Buffer.from(base64Part, 'base64') - if (detectImageMime(cachedBuf, '').startsWith('image/')) { - this.imageCache.delete(cacheKey) - this.imageCache.set(cacheKey, cachedDataUrl) - this.imageCacheMeta.set(cacheKey, Date.now()) - return { success: true, dataUrl: cachedDataUrl } - } - } catch { - // ignore and fall through to refetch - } - } - } - this.imageCache.delete(cacheKey) - this.imageCacheMeta.delete(cacheKey) - } - - const result = await this.fetchAndDecryptImage(url, key) - if (result.success) { - // 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议) - if (result.contentType?.startsWith('video/')) { - // Return cachePath directly for video - // 注意:fetchAndDecryptImage 需要修改以返回 cachePath - return { success: true, videoPath: result.cachePath } - } - - if (result.data && result.contentType) { - if (!detectImageMime(result.data, '').startsWith('image/')) { - return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' } - } - const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` - this.rememberImageCache(cacheKey, dataUrl) - return { success: true, dataUrl } - } - } - return { success: false, error: result.error } - } - - async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { - return this.fetchAndDecryptImage(url, key) - } - - /** - * 导出朋友圈动态 - * 支持筛选条件(用户名、关键词)和媒体文件导出 - */ - async exportTimeline(options: { - outputDir: string - format: 'json' | 'html' | 'arkmejson' - usernames?: string[] - keyword?: string - exportMedia?: boolean - exportImages?: boolean - exportLivePhotos?: boolean - exportVideos?: boolean - startTime?: number - endTime?: number - }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { - shouldPause?: () => boolean - shouldStop?: () => boolean - recordCreatedFile?: (filePath: string) => void - recordCreatedDir?: (dirPath: string) => void - }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { - const { outputDir, format, usernames, keyword, startTime, endTime } = options - const hasExplicitMediaSelection = - typeof options.exportImages === 'boolean' || - typeof options.exportLivePhotos === 'boolean' || - typeof options.exportVideos === 'boolean' - const shouldExportImages = hasExplicitMediaSelection - ? options.exportImages === true - : options.exportMedia === true - const shouldExportLivePhotos = hasExplicitMediaSelection - ? options.exportLivePhotos === true - : options.exportMedia === true - const shouldExportVideos = hasExplicitMediaSelection - ? options.exportVideos === true - : options.exportMedia === true - const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos - const getControlState = (): 'paused' | 'stopped' | null => { - if (control?.shouldStop?.()) return 'stopped' - if (control?.shouldPause?.()) return 'paused' - return null - } - const ensureExportDir = (dirPath: string) => { - const existed = existsSync(dirPath) - if (!existed) { - mkdirSync(dirPath, { recursive: true }) - control?.recordCreatedDir?.(dirPath) - } - } - const recordCreatedFileBeforeWrite = (filePath: string) => { - if (!existsSync(filePath)) { - control?.recordCreatedFile?.(filePath) - } - } - const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => ( - state === 'stopped' - ? { success: true, stopped: true, filePath: '', postCount, mediaCount } - : { success: true, paused: true, filePath: '', postCount, mediaCount } - ) - - try { - // 确保输出目录存在 - ensureExportDir(outputDir) - - // 1. 分页加载全部帖子 - const allPosts: SnsPost[] = [] - const pageSize = 50 - let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界 - let hasMore = true - - progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) - - while (hasMore) { - const controlState = getControlState() - if (controlState) { - return buildInterruptedResult(controlState, allPosts.length, 0) - } - const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) - if (result.success && result.timeline && result.timeline.length > 0) { - allPosts.push(...result.timeline) - // 下一页的 endTs 为当前最后一条帖子的时间 - 1 - const lastTs = result.timeline[result.timeline.length - 1].createTime - 1 - endTs = lastTs - hasMore = result.timeline.length >= pageSize - // 如果已经低于 startTime,提前终止 - if (startTime && lastTs < startTime) { - hasMore = false - } - progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` }) - } else { - hasMore = false - } - } - - if (allPosts.length === 0) { - return { success: true, filePath: '', postCount: 0, mediaCount: 0 } - } - - progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` }) - - // 2. 如果需要导出媒体,创建 media 子目录并下载 - let mediaCount = 0 - const mediaDir = join(outputDir, 'media') - - if (shouldExportMedia) { - ensureExportDir(mediaDir) - - // 收集所有媒体下载任务 - const mediaTasks: Array<{ - kind: 'image' | 'video' | 'livephoto' - media: SnsMedia - url: string - key?: string - postId: string - mi: number - }> = [] - for (const post of allPosts) { - post.media.forEach((media, mi) => { - const isVideo = isVideoUrl(media.url) - if (shouldExportImages && !isVideo && media.url) { - mediaTasks.push({ - kind: 'image', - media, - url: media.url, - key: media.key, - postId: post.id, - mi - }) - } - if (shouldExportVideos && isVideo && media.url) { - mediaTasks.push({ - kind: 'video', - media, - url: media.url, - key: media.key, - postId: post.id, - mi - }) - } - if (shouldExportLivePhotos && media.livePhoto?.url) { - mediaTasks.push({ - kind: 'livephoto', - media, - url: media.livePhoto.url, - key: media.livePhoto.key || media.key, - postId: post.id, - mi - }) - } - }) - } - - // 并发下载(5路) - let done = 0 - const concurrency = 5 - const runTask = async (task: typeof mediaTasks[0]) => { - const { media, postId, mi } = task - try { - const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url) - const ext = isVideo ? 'mp4' : 'jpg' - const suffix = task.kind === 'livephoto' ? '_live' : '' - const fileName = `${postId}_${mi}${suffix}.${ext}` - const filePath = join(mediaDir, fileName) - - if (existsSync(filePath)) { - if (task.kind === 'livephoto') { - if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` - } else { - ;(media as any).localPath = `media/${fileName}` - } - mediaCount++ - } else { - const result = await this.fetchAndDecryptImage(task.url, task.key) - if (result.success && result.data) { - recordCreatedFileBeforeWrite(filePath) - await writeFile(filePath, result.data) - if (task.kind === 'livephoto') { - if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` - } else { - ;(media as any).localPath = `media/${fileName}` - } - mediaCount++ - } else if (result.success && result.cachePath) { - const cachedData = await readFile(result.cachePath) - recordCreatedFileBeforeWrite(filePath) - await writeFile(filePath, cachedData) - if (task.kind === 'livephoto') { - if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` - } else { - ;(media as any).localPath = `media/${fileName}` - } - mediaCount++ - } - } - } catch (e) { - console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e) - } - done++ - progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) - } - - // 控制并发的执行器 - const queue = [...mediaTasks] - const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { - while (queue.length > 0) { - const controlState = getControlState() - if (controlState) return controlState - const task = queue.shift()! - await runTask(task) - } - return null - }) - const workerResults = await Promise.all(workers) - const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped') - if (interruptedState) { - return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) - } - } - - // 2.5 下载头像 - const avatarMap = new Map() - if (format === 'html') { - ensureExportDir(mediaDir) - const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()] - let avatarDone = 0 - const avatarQueue = [...uniqueUsers] - const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { - while (avatarQueue.length > 0) { - const controlState = getControlState() - if (controlState) return controlState - const post = avatarQueue.shift()! - try { - const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` - const filePath = join(mediaDir, fileName) - if (existsSync(filePath)) { - avatarMap.set(post.username, `media/${fileName}`) - } else { - const result = await this.fetchAndDecryptImage(post.avatarUrl!) - if (result.success && result.data) { - recordCreatedFileBeforeWrite(filePath) - await writeFile(filePath, result.data) - avatarMap.set(post.username, `media/${fileName}`) - } - } - } catch (e) { /* 头像下载失败不影响导出 */ } - avatarDone++ - progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) - } - return null - }) - const avatarWorkerResults = await Promise.all(avatarWorkers) - const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped') - if (interruptedState) { - return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) - } - } - - // 3. 生成输出文件 - const finalControlState = getControlState() - if (finalControlState) { - return buildInterruptedResult(finalControlState, allPosts.length, mediaCount) - } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) - let outputFilePath: string - - if (format === 'json') { - outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) - const exportData = { - exportTime: new Date().toISOString(), - totalPosts: allPosts.length, - filters: { - usernames: usernames || [], - keyword: keyword || '' - }, - posts: allPosts.map(p => ({ - id: p.id, - username: p.username, - nickname: p.nickname, - createTime: p.createTime, - createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'), - contentDesc: p.contentDesc, - type: p.type, - media: p.media.map(m => ({ - url: m.url, - thumb: m.thumb, - localPath: (m as any).localPath || undefined - })), - likes: p.likes, - comments: p.comments, - location: p.location, - linkTitle: (p as any).linkTitle, - linkUrl: (p as any).linkUrl - })) - } - recordCreatedFileBeforeWrite(outputFilePath) - await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') - } else if (format === 'arkmejson') { - outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) - progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' }) - - const identityCache = new Map>() - const posts: any[] = [] - let built = 0 - - for (const post of allPosts) { - const controlState = getControlState() - if (controlState) { - return buildInterruptedResult(controlState, allPosts.length, mediaCount) - } - - const authorIdentity = await this.resolveContactIdentity(post.username, identityCache) - const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache) - - posts.push({ - id: post.id, - username: post.username, - nickname: post.nickname, - author: authorIdentity - ? { - ...authorIdentity - } - : { - username: post.username, - wxid: post.username, - displayName: post.nickname || post.username - }, - createTime: post.createTime, - createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'), - contentDesc: post.contentDesc, - type: post.type, - media: post.media.map(m => ({ - url: m.url, - thumb: m.thumb, - localPath: (m as any).localPath || undefined, - livePhoto: m.livePhoto ? { - url: m.livePhoto.url, - thumb: m.livePhoto.thumb, - localPath: (m.livePhoto as any).localPath || undefined - } : undefined - })), - likes: post.likes, - comments: post.comments, - location: post.location, - likesDetail, - commentsDetail, - linkTitle: (post as any).linkTitle, - linkUrl: (post as any).linkUrl - }) - - built++ - if (built % 20 === 0 || built === allPosts.length) { - progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` }) - } - } - - const ownerWxid = this.toOptionalString(this.configService.get('myWxid')) - const ownerIdentity = ownerWxid - ? await this.resolveContactIdentity(ownerWxid, identityCache) - : null - const recordOwner = ownerIdentity - ? { ...ownerIdentity } - : ownerWxid - ? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid } - : { username: '', wxid: '', displayName: '' } - - const exportData = { - exportTime: new Date().toISOString(), - format: 'arkmejson', - schemaVersion: '1.0.0', - recordOwner, - mediaSelection: { - images: shouldExportImages, - livePhotos: shouldExportLivePhotos, - videos: shouldExportVideos - }, - totalPosts: allPosts.length, - filters: { - usernames: usernames || [], - keyword: keyword || '' - }, - posts - } - recordCreatedFileBeforeWrite(outputFilePath) - await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') - } else { - // HTML 格式 - outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) - const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap) - recordCreatedFileBeforeWrite(outputFilePath) - await writeFile(outputFilePath, html, 'utf-8') - } - - progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' }) - - return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount } - } catch (e: any) { - console.error('[SnsExport] 导出失败:', e) - return { success: false, error: e.message || String(e) } - } - } - - /** - * 生成朋友圈 HTML 导出文件 - */ - private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map): string { - const escapeHtml = (str: string) => str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/\n/g, '
') - - const formatTime = (ts: number) => { - const d = new Date(ts * 1000) - const now = new Date() - const isCurrentYear = d.getFullYear() === now.getFullYear() - const pad = (n: number) => String(n).padStart(2, '0') - const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}` - const m = d.getMonth() + 1, day = d.getDate() - return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}` - } - - // 生成头像首字母 - const avatarLetter = (name: string) => { - const ch = name.charAt(0) - return escapeHtml(ch || '?') - } - const normalizeLocationText = (value?: string): string => ( - decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim() - ) - const resolveLocationText = (location?: SnsLocation): string => { - if (!location) return '' - const primaryCandidates = [ - normalizeLocationText(location.poiName), - normalizeLocationText(location.poiAddressName), - normalizeLocationText(location.label), - normalizeLocationText(location.poiAddress) - ].filter(Boolean) - const primary = primaryCandidates[0] || '' - const region = [ - normalizeLocationText(location.country), - normalizeLocationText(location.city) - ].filter(Boolean).join(' ') - if (primary && region && !primary.includes(region)) { - return `${primary} · ${region}` - } - return primary || region - } - - let filterInfo = '' - if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` - if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人` - - const postsHtml = posts.map(post => { - const mediaCount = post.media.length - const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3' - - const mediaHtml = post.media.map((m, mi) => { - const localPath = (m as any).localPath - if (localPath) { - if (isVideoUrl(m.url)) { - return `
` - } - return `
` - } - return `` - }).join('') - - const linkHtml = post.linkTitle && post.linkUrl - ? `${escapeHtml(post.linkTitle)}` - : '' - const locationText = resolveLocationText(post.location) - const locationHtml = locationText - ? `
📍${escapeHtml(locationText)}
` - : '' - - const likesHtml = post.likes.length > 0 - ? `
` - : '' - - const commentsHtml = post.comments.length > 0 - ? `
${post.comments.map(c => { - const ref = c.refNickname ? `回复${escapeHtml(c.refNickname)}` : '' - return `
${escapeHtml(c.nickname)}${ref}:${escapeHtml(c.content)}
` - }).join('')}
` - : '' - - const avatarSrc = avatarMap?.get(post.username) - const avatarHtml = avatarSrc - ? `
` - : `
${avatarLetter(post.nickname)}
` - - return `
-${avatarHtml} -
-
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
-${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} -${locationHtml} -${mediaHtml ? `
${mediaHtml}
` : ''} -${linkHtml} -${likesHtml} -${commentsHtml} -
` - }).join('\n') - - return ` - - - - -朋友圈导出 - - - -
-

朋友圈

共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}
- ${postsHtml} -
由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}
-
-
- - - -` - } - - private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { - if (!url) return { success: false, error: 'url 不能为空' } - - const isVideo = isVideoUrl(url) - const cachePath = this.getCacheFilePath(url) - - // 1. 优先尝试从当前缓存目录读取 - if (existsSync(cachePath)) { - try { - // 对于视频,不读取整个文件到内存,只确认存在即可 - if (isVideo) { - return { success: true, cachePath, contentType: 'video/mp4' } - } - - const data = await readFile(cachePath) - if (!detectImageMime(data, '').startsWith('image/')) { - // 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。 - try { unlinkSync(cachePath) } catch { } - } else { - const contentType = detectImageMime(data) - return { success: true, data, contentType, cachePath } - } - } catch (e) { - console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e) - } - } - - if (isVideo) { - // 视频专用下载逻辑 (下载 -> 解密 -> 缓存) - return new Promise(async (resolve) => { - const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`) - - try { - const https = require('https') - const urlObj = new URL(url) - const fs = require('fs') - - const fileStream = fs.createWriteStream(tmpPath) - - const options = { - hostname: urlObj.hostname, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { - 'User-Agent': 'MicroMessenger Client', - 'Accept': '*/*', - // 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂 - 'Connection': 'keep-alive' - } - } - - const req = https.request(options, (res: any) => { - if (res.statusCode !== 200 && res.statusCode !== 206) { - fileStream.close() - fs.unlink(tmpPath, () => { }) // 删除临时文件 - resolve({ success: false, error: `HTTP ${res.statusCode}` }) - return - } - - res.pipe(fileStream) - fileStream.on('finish', async () => { - fileStream.close() - - try { - const encryptedBuffer = await readFile(tmpPath) - const raw = encryptedBuffer // 引用,方便后续操作 - - - if (key && String(key).trim().length > 0) { - try { - const keyText = String(key).trim() - let keystream: Buffer - - try { - const wasmService = WasmService.getInstance() - // 只需要前 128KB (131072 bytes) 用于解密头部 - keystream = await wasmService.getKeystream(keyText, 131072) - } catch (wasmErr) { - // 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64 - const isaac = new Isaac64(keyText) - keystream = isaac.generateKeystreamBE(131072) - } - - const decryptLen = Math.min(keystream.length, raw.length) - - // XOR 解密 - for (let i = 0; i < decryptLen; i++) { - raw[i] ^= keystream[i] - } - - // 验证 MP4 签名 ('ftyp' at offset 4) - const ftyp = raw.subarray(4, 8).toString('ascii') - if (ftyp !== 'ftyp') { - // 可以在此处记录解密可能失败的标记,但不打印详细 hex - } - } catch (err) { - console.error(`[SnsService] 视频解密出错: ${err}`) - } - } - - // 写入最终缓存 (覆盖) - await writeFile(cachePath, raw) - - // 删除临时文件 - try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { } - - resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath }) - } catch (e: any) { - console.error(`[SnsService] 视频处理失败:`, e) - resolve({ success: false, error: e.message }) - } - }) - }) - - req.on('error', (e: any) => { - fs.unlink(tmpPath, () => { }) - resolve({ success: false, error: e.message }) - }) - - req.setTimeout(15000, () => { - req.destroy() - fs.unlink(tmpPath, () => { }) - resolve({ success: false, error: '请求超时' }) - }) - - req.end() - - } catch (e: any) { - resolve({ success: false, error: e.message }) - } - }) - } - - // 图片逻辑 (保持流式处理) - return new Promise((resolve) => { - try { - const https = require('https') - const zlib = require('zlib') - const urlObj = new URL(url) - - console.log(`[SnsService] 开始下载图片: url=${url.substring(0, 100)}..., key=${key || 'undefined'}`) - - const options = { - hostname: urlObj.hostname, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { - 'User-Agent': 'MicroMessenger Client', - 'Accept': '*/*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'zh-CN,zh;q=0.9', - 'Connection': 'keep-alive' - } - } - - const req = https.request(options, (res: any) => { - console.log(`[SnsService] CDN 响应: statusCode=${res.statusCode}, x-enc=${res.headers['x-enc']}, content-type=${res.headers['content-type']}`) - if (res.statusCode !== 200 && res.statusCode !== 206) { - console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`) - resolve({ success: false, error: `HTTP ${res.statusCode}` }) - return - } - - const chunks: Buffer[] = [] - let stream = res - - const encoding = res.headers['content-encoding'] - if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip()) - else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate()) - else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress()) - - stream.on('data', (chunk: Buffer) => chunks.push(chunk)) - stream.on('end', async () => { - const raw = Buffer.concat(chunks) - const xEnc = String(res.headers['x-enc'] || '').trim() - - let decoded = raw - const rawMagicMime = detectImageMime(raw, '') - console.log(`[SnsService] 原始数据: size=${raw.length}, mime=${rawMagicMime}, xEnc=${xEnc}`) - - // 图片逻辑 - const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 - console.log(`[SnsService] 解密判断: shouldDecrypt=${shouldDecrypt}, key=${key || 'undefined'}`) - if (shouldDecrypt) { - try { - const keyStr = String(key).trim() - if (/^\d+$/.test(keyStr)) { - // 使用 WASM 版本的 Isaac64 解密图片 - // 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream - const wasmService = WasmService.getInstance() - const keystream = await wasmService.getKeystream(keyStr, raw.length) - - const decrypted = Buffer.allocUnsafe(raw.length) - for (let i = 0; i < raw.length; i++) { - decrypted[i] = raw[i] ^ keystream[i] - } - - const decryptedMagicMime = detectImageMime(decrypted, '') - console.log(`[SnsService] 解密后: mime=${decryptedMagicMime}`) - if (decryptedMagicMime.startsWith('image/')) { - decoded = decrypted - } else if (!rawMagicMime.startsWith('image/')) { - decoded = decrypted - } - } - } catch (e) { - console.error('[SnsService] TS Decrypt Error:', e) - } - } - - const decodedMagicMime = detectImageMime(decoded, '') - console.log(`[SnsService] 最终结果: mime=${decodedMagicMime}, isImage=${decodedMagicMime.startsWith('image/')}`) - if (!decodedMagicMime.startsWith('image/')) { - console.error(`[SnsService] 图片解密失败: 原始mime=${rawMagicMime}, 解密后mime=${decodedMagicMime}, key=${key}`) - resolve({ success: false, error: '图片解密失败:无法识别图片格式' }) - return - } - - // 写入磁盘缓存 - try { - await writeFile(cachePath, decoded) - } catch (e) { - console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e) - } - - const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string) - resolve({ success: true, data: decoded, contentType, cachePath }) - }) - stream.on('error', (e: any) => resolve({ success: false, error: e.message })) - }) - - req.on('error', (e: any) => resolve({ success: false, error: e.message })) - req.setTimeout(15000, () => { - req.destroy() - resolve({ success: false, error: '请求超时' }) - }) - req.end() - } catch (e: any) { - resolve({ success: false, error: e.message }) - } - }) - } - - /** 判断 buffer 是否为有效图片头 */ - private isValidImageBuffer(buf: Buffer): boolean { - if (!buf || buf.length < 12) return false - if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true - if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true - if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true - if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 - && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true - if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { - const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() - if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true - if ( - ftypWindow.includes('heic') || ftypWindow.includes('heix') || - ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || - ftypWindow.includes('mif1') || ftypWindow.includes('msf1') - ) return true - } - return false - } - - /** 根据图片头返回扩展名 */ - private getImageExtFromBuffer(buf: Buffer): string { - if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif' - if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png' - if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg' - if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 - && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return '.webp' - return '.gif' - } - - /** 构建多种密钥派生方式 */ - private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] { - const keyTries: { name: string; key: Buffer }[] = [] - const hexStr = aesKey.replace(/\s/g, '') - if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) { - try { - const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex') - if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf }) - } catch { } - const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8') - if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey }) - } - if (aesKey.length >= 16) { - keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) }) - } - keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() }) - try { - const b64Buf = Buffer.from(aesKey, 'base64') - if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) }) - } catch { } - return keyTries - } - - /** 构建多种 GCM 数据布局 */ - private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] { - const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = [] - // 格式 A:GcmData 块格式 - if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) { - const payloadSize = encData.readUInt32LE(10) - if (payloadSize > 16 && 63 + payloadSize <= encData.length) { - const nonce = encData.subarray(19, 31) - const payload = encData.subarray(63, 63 + payloadSize) - layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) }) - } - } - // 格式 B:尾部 [ciphertext][nonce 12B][tag 16B] - if (encData.length > 28) { - layouts.push({ - ciphertext: encData.subarray(0, encData.length - 28), - nonce: encData.subarray(encData.length - 28, encData.length - 16), - tag: encData.subarray(encData.length - 16) - }) - } - // 格式 C:前置 [nonce 12B][ciphertext][tag 16B] - if (encData.length > 28) { - layouts.push({ - nonce: encData.subarray(0, 12), - ciphertext: encData.subarray(12, encData.length - 16), - tag: encData.subarray(encData.length - 16) - }) - } - // 格式 D:零 nonce - if (encData.length > 16) { - layouts.push({ - nonce: Buffer.alloc(12, 0), - ciphertext: encData.subarray(0, encData.length - 16), - tag: encData.subarray(encData.length - 16) - }) - } - // 格式 E:[nonce 12B][tag 16B][ciphertext] - if (encData.length > 28) { - layouts.push({ - nonce: encData.subarray(0, 12), - tag: encData.subarray(12, 28), - ciphertext: encData.subarray(28) - }) - } - return layouts - } - - /** 尝试 AES-GCM 解密 */ - private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null { - try { - const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm' - const decipher = crypto.createDecipheriv(algo, key, nonce) - decipher.setAuthTag(tag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - if (this.isValidImageBuffer(decrypted)) return decrypted - for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) { - try { - const d = fn(decrypted) - if (this.isValidImageBuffer(d)) return d - } catch { } - } - return decrypted - } catch { - return null - } - } - - /** - * 解密表情数据(多种算法 + 多种密钥派生) - * 移植自 ciphertalk 的逆向实现 - */ - private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null { - if (encData.length <= 16) return null - - const keyTries = this.buildKeyTries(aesKey) - const tag = encData.subarray(encData.length - 16) - const ciphertext = encData.subarray(0, encData.length - 16) - - // 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B] - if (encData.length > 28) { - const nonceTail = encData.subarray(encData.length - 28, encData.length - 16) - const tagTail = encData.subarray(encData.length - 16) - const cipherTail = encData.subarray(0, encData.length - 28) - for (const { key } of keyTries) { - if (key.length !== 16 && key.length !== 32) continue - const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail) - if (result) return result - } - } - - // 次优先级:nonce = key 前 12 字节 - for (const { key } of keyTries) { - if (key.length !== 16 && key.length !== 32) continue - const nonce = key.subarray(0, 12) - const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag) - if (result) return result - } - - // 其他 GCM 布局 - const layouts = this.buildGcmLayouts(encData) - for (const layout of layouts) { - for (const { key } of keyTries) { - if (key.length !== 16 && key.length !== 32) continue - const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag) - if (result) return result - } - } - - // 回退:AES-128-CBC / AES-128-ECB - for (const { key } of keyTries) { - if (key.length !== 16) continue - // CBC:IV = key - if (encData.length >= 16 && encData.length % 16 === 0) { - try { - const dec = crypto.createDecipheriv('aes-128-cbc', key, key) - dec.setAutoPadding(true) - const result = Buffer.concat([dec.update(encData), dec.final()]) - if (this.isValidImageBuffer(result)) return result - for (const fn of [zlib.inflateSync, zlib.gunzipSync]) { - try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { } - } - } catch { } - } - // CBC:前 16 字节作为 IV - if (encData.length > 32) { - try { - const iv = encData.subarray(0, 16) - const dec = crypto.createDecipheriv('aes-128-cbc', key, iv) - dec.setAutoPadding(true) - const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()]) - if (this.isValidImageBuffer(result)) return result - } catch { } - } - // ECB - try { - const dec = crypto.createDecipheriv('aes-128-ecb', key, null) - dec.setAutoPadding(true) - const result = Buffer.concat([dec.update(encData), dec.final()]) - if (this.isValidImageBuffer(result)) return result - } catch { } - } - - return null - } - - /** 下载原始数据到本地临时文件,支持重定向 */ - private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise { - return new Promise((resolve) => { - try { - const fs = require('fs') - const https = require('https') - const http = require('http') - let fixedUrl = targetUrl.replace(/&/g, '&') - const urlObj = new URL(fixedUrl) - const protocol = fixedUrl.startsWith('https') ? https : http - - const options = { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)', - 'Accept': '*/*', - 'Connection': 'keep-alive' - }, - rejectUnauthorized: false, - timeout: 15000 - } - - const request = protocol.get(fixedUrl, options, (response: any) => { - // 处理重定向 - if ([301, 302, 303, 307].includes(response.statusCode)) { - const redirectUrl = response.headers.location - if (redirectUrl) { - const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}` - this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve) - return - } - } - if (response.statusCode !== 200) { resolve(null); return } - - const chunks: Buffer[] = [] - response.on('data', (chunk: Buffer) => chunks.push(chunk)) - response.on('end', () => { - const buffer = Buffer.concat(chunks) - if (buffer.length === 0) { resolve(null); return } - const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin' - const filePath = join(cacheDir, `${cacheKey}${ext}`) - try { - fs.writeFileSync(filePath, buffer) - resolve(filePath) - } catch { resolve(null) } - }) - response.on('error', () => resolve(null)) - }) - request.on('error', () => resolve(null)) - request.setTimeout(15000, () => { request.destroy(); resolve(null) }) - } catch { resolve(null) } - }) - } - - /** - * 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk) - */ - async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { - if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' } - - const fs = require('fs') - const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex') - const emojiDir = this.getEmojiCacheDir() - - // 检查本地缓存 - for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) { - const filePath = join(emojiDir, `${cacheKey}${ext}`) - if (existsSync(filePath)) return { success: true, localPath: filePath } - } - - // 保存解密后的图片 - const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => { - const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif' - const filePath = join(emojiDir, `${cacheKey}${ext}`) - try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } } - catch { return { success: false } } - } - - // 1. 优先:encryptUrl + aesKey - if (encryptUrl && aesKey) { - const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir) - if (encResult) { - const encData = fs.readFileSync(encResult) - if (this.isValidImageBuffer(encData)) { - const ext = this.getImageExtFromBuffer(encData) - const filePath = join(emojiDir, `${cacheKey}${ext}`) - fs.writeFileSync(filePath, encData) - try { fs.unlinkSync(encResult) } catch { } - return { success: true, localPath: filePath } - } - const decrypted = this.decryptEmojiAes(encData, aesKey) - if (decrypted) { - try { fs.unlinkSync(encResult) } catch { } - return saveDecrypted(decrypted) - } - try { fs.unlinkSync(encResult) } catch { } - } - } - - // 2. 直接下载 url - if (url) { - const result = await this.doDownloadRaw(url, cacheKey, emojiDir) - if (result) { - const buf = fs.readFileSync(result) - if (this.isValidImageBuffer(buf)) return { success: true, localPath: result } - // 用 aesKey 解密 - if (aesKey) { - const decrypted = this.decryptEmojiAes(buf, aesKey) - if (decrypted) { - try { fs.unlinkSync(result) } catch { } - return saveDecrypted(decrypted) - } - } - try { fs.unlinkSync(result) } catch { } - } - } - - return { success: false, error: '下载表情包失败' } - } -} - -export const snsService = new SnsService() diff --git a/electron/services/social/weiboService.ts b/electron/services/social/weiboService.ts deleted file mode 100644 index 30a9a5f..0000000 --- a/electron/services/social/weiboService.ts +++ /dev/null @@ -1,367 +0,0 @@ -import https from 'https' -import { createHash } from 'crypto' -import { URL } from 'url' - -const WEIBO_TIMEOUT_MS = 10_000 -const WEIBO_MAX_POSTS = 5 -const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000 -const WEIBO_USER_AGENT = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36' -const WEIBO_MOBILE_USER_AGENT = - 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' - -interface BrowserCookieEntry { - domain?: string - name?: string - value?: string -} - -interface WeiboUserInfo { - id?: number | string - screen_name?: string -} - -interface WeiboWaterFallItem { - id?: number | string - idstr?: string - mblogid?: string - created_at?: string - text_raw?: string - isLongText?: boolean - user?: WeiboUserInfo - retweeted_status?: WeiboWaterFallItem -} - -interface WeiboWaterFallResponse { - ok?: number - data?: { - list?: WeiboWaterFallItem[] - next_cursor?: string - } -} - -interface WeiboStatusShowResponse { - id?: number | string - idstr?: string - mblogid?: string - created_at?: string - text_raw?: string - user?: WeiboUserInfo - retweeted_status?: WeiboWaterFallItem -} - -interface MWeiboCard { - mblog?: WeiboWaterFallItem - card_group?: MWeiboCard[] -} - -interface MWeiboContainerResponse { - ok?: number - data?: { - cards?: MWeiboCard[] - } -} - -export interface WeiboRecentPost { - id: string - createdAt: string - url: string - text: string - screenName?: string -} - -interface CachedRecentPosts { - expiresAt: number - posts: WeiboRecentPost[] -} - -function requestJson(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise { - return new Promise((resolve, reject) => { - let urlObj: URL - try { - urlObj = new URL(url) - } catch { - reject(new Error(`无效的微博请求地址:${url}`)) - return - } - - const headers: Record = { - Accept: 'application/json, text/plain, */*', - Referer: options.referer || 'https://weibo.com', - 'User-Agent': options.userAgent || WEIBO_USER_AGENT, - 'X-Requested-With': 'XMLHttpRequest' - } - if (options.cookie) { - headers.Cookie = options.cookie - } - - const req = https.request( - { - hostname: urlObj.hostname, - port: urlObj.port || 443, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers - }, - (res) => { - let raw = '' - res.setEncoding('utf8') - res.on('data', (chunk) => { - raw += chunk - }) - res.on('end', () => { - const statusCode = res.statusCode || 0 - if (statusCode < 200 || statusCode >= 300) { - reject(new Error(`微博接口返回异常状态码 ${statusCode}`)) - return - } - try { - resolve(JSON.parse(raw) as T) - } catch { - reject(new Error('微博接口返回了非 JSON 响应')) - } - }) - } - ) - - req.setTimeout(WEIBO_TIMEOUT_MS, () => { - req.destroy() - reject(new Error('微博请求超时')) - }) - - req.on('error', reject) - req.end() - }) -} - -function normalizeCookieArray(entries: BrowserCookieEntry[]): string { - const picked = new Map() - - for (const entry of entries) { - const name = String(entry?.name || '').trim() - const value = String(entry?.value || '').trim() - const domain = String(entry?.domain || '').trim().toLowerCase() - - if (!name || !value) continue - if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue - - picked.set(name, value) - } - - return Array.from(picked.entries()) - .map(([name, value]) => `${name}=${value}`) - .join('; ') -} - -export function normalizeWeiboCookieInput(rawInput: string): string { - const trimmed = String(rawInput || '').trim() - if (!trimmed) return '' - - try { - const parsed = JSON.parse(trimmed) as unknown - if (Array.isArray(parsed)) { - const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[]) - if (normalized) return normalized - throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项') - } - } catch (error) { - if (!(error instanceof SyntaxError)) { - throw error - } - } - - return trimmed.replace(/^Cookie:\s*/i, '').trim() -} - -function normalizeWeiboUid(input: string): string { - const trimmed = String(input || '').trim() - const directMatch = trimmed.match(/^\d{5,}$/) - if (directMatch) return directMatch[0] - - const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i) - if (linkMatch) return linkMatch[1] - - throw new Error('请输入有效的微博 UID(纯数字)') -} - -function sanitizeWeiboText(text: string): string { - return String(text || '') - .replace(/\u200b|\u200c|\u200d|\ufeff/g, '') - .replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ') - .replace(/ +/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - -function mergeRetweetText(item: Pick): string { - const baseText = sanitizeWeiboText(item.text_raw || '') - const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '') - if (!retweetText) return baseText - if (!baseText || baseText === '转发微博') return `转发:${retweetText}` - return `${baseText}\n\n转发内容:${retweetText}` -} - -function buildCacheKey(uid: string, count: number, cookie: string): string { - const cookieHash = createHash('sha1').update(cookie).digest('hex') - return `${uid}:${count}:${cookieHash}` -} - -class WeiboService { - private recentPostsCache = new Map() - - clearCache(): void { - this.recentPostsCache.clear() - } - - async validateUid( - uidInput: string, - cookieInput: string - ): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> { - try { - const uid = normalizeWeiboUid(uidInput) - const cookie = normalizeWeiboCookieInput(cookieInput) - if (!cookie) { - return { success: true, uid } - } - - const timeline = await this.fetchTimeline(uid, cookie) - const firstItem = timeline.data?.list?.[0] - if (!firstItem) { - return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' } - } - - return { - success: true, - uid, - screenName: firstItem.user?.screen_name - } - } catch (error) { - return { - success: false, - error: (error as Error).message || '微博 UID 校验失败' - } - } - } - - async fetchRecentPosts( - uidInput: string, - cookieInput: string, - requestedCount: number - ): Promise { - const uid = normalizeWeiboUid(uidInput) - const cookie = normalizeWeiboCookieInput(cookieInput) - const hasCookie = Boolean(cookie) - - const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0))) - const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__') - const cached = this.recentPostsCache.get(cacheKey) - const now = Date.now() - - if (cached && cached.expiresAt > now) { - return cached.posts - } - - const rawItems = hasCookie - ? (await this.fetchTimeline(uid, cookie)).data?.list || [] - : await this.fetchMobileTimeline(uid) - const posts: WeiboRecentPost[] = [] - - for (const item of rawItems) { - if (posts.length >= count) break - - const id = String(item.idstr || item.id || '').trim() - if (!id) continue - - let text = mergeRetweetText(item) - if (item.isLongText && hasCookie) { - try { - const detail = await this.fetchDetail(id, cookie) - text = mergeRetweetText(detail) - } catch { - // 长文补抓失败时回退到列表摘要 - } - } - - text = sanitizeWeiboText(text) - if (!text) continue - - posts.push({ - id, - createdAt: String(item.created_at || ''), - url: `https://m.weibo.cn/detail/${id}`, - text, - screenName: item.user?.screen_name - }) - } - - this.recentPostsCache.set(cacheKey, { - expiresAt: now + WEIBO_CACHE_TTL_MS, - posts - }) - - return posts - } - - private fetchTimeline(uid: string, cookie: string): Promise { - return requestJson( - `https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`, - { - cookie, - referer: `https://weibo.com/u/${encodeURIComponent(uid)}` - } - ).then((response) => { - if (response.ok !== 1 || !Array.isArray(response.data?.list)) { - throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效') - } - return response - }) - } - - private fetchMobileTimeline(uid: string): Promise { - const containerid = `107603${uid}` - return requestJson( - `https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`, - { - referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`, - userAgent: WEIBO_MOBILE_USER_AGENT - } - ).then((response) => { - if (response.ok !== 1 || !Array.isArray(response.data?.cards)) { - throw new Error('微博时间线获取失败,请稍后重试') - } - - const rows: WeiboWaterFallItem[] = [] - for (const card of response.data.cards) { - if (card?.mblog) rows.push(card.mblog) - if (Array.isArray(card?.card_group)) { - for (const subCard of card.card_group) { - if (subCard?.mblog) rows.push(subCard.mblog) - } - } - } - - if (rows.length === 0) { - throw new Error('该微博账号暂无可读取的近期公开内容') - } - - return rows - }) - } - - private fetchDetail(id: string, cookie: string): Promise { - return requestJson( - `https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`, - { - cookie, - referer: `https://weibo.com/detail/${encodeURIComponent(id)}` - } - ).then((response) => { - if (!response || (!response.id && !response.idstr)) { - throw new Error('微博详情获取失败') - } - return response - }) - } -} - -export const weiboService = new WeiboService() diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts deleted file mode 100644 index d562705..0000000 --- a/electron/services/videoService.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' -import { pathToFileURL } from 'url' -import { app } from 'electron' -import { ConfigService } from './config' -import { wcdbService } from './wcdbService' - -export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) - coverUrl?: string // 封面 data URL - thumbUrl?: string // 缩略图 data URL - exists: boolean -} - -interface TimedCacheEntry { - value: T - expiresAt: number -} - -interface VideoIndexEntry { - videoPath?: string - coverPath?: string - thumbPath?: string -} - -type PosterFormat = 'dataUrl' | 'fileUrl' - -class VideoService { - private configService: ConfigService - private hardlinkResolveCache = new Map>() - private videoInfoCache = new Map>() - private videoDirIndexCache = new Map>>() - private pendingVideoInfo = new Map>() - private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 - private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 - private readonly videoIndexCacheTtlMs = 90 * 1000 - private readonly maxCacheEntries = 2000 - private readonly maxIndexEntries = 6 - - constructor() { - this.configService = new ConfigService() - } - - private log(message: string, meta?: Record): void { - try { - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logDir = join(app.getPath('userData'), 'logs') - if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) - appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') - } catch { } - } - - private readTimedCache(cache: Map>, key: string): T | undefined { - const hit = cache.get(key) - if (!hit) return undefined - if (hit.expiresAt <= Date.now()) { - cache.delete(key) - return undefined - } - return hit.value - } - - private writeTimedCache( - cache: Map>, - key: string, - value: T, - ttlMs: number, - maxEntries: number - ): void { - cache.set(key, { value, expiresAt: Date.now() + ttlMs }) - if (cache.size <= maxEntries) return - - const now = Date.now() - for (const [cacheKey, entry] of cache) { - if (entry.expiresAt <= now) { - cache.delete(cacheKey) - } - } - - while (cache.size > maxEntries) { - const oldestKey = cache.keys().next().value as string | undefined - if (!oldestKey) break - cache.delete(oldestKey) - } - } - - /** - * 获取数据库根目录 - */ - private getDbPath(): string { - return this.configService.get('dbPath') || '' - } - - /** - * 获取当前用户的wxid - */ - private getMyWxid(): string { - return this.configService.getMyWxidCleaned() || '' - } - - /** - * 清理 wxid 目录名(去掉后缀) - */ - private cleanWxid(wxid: string): string { - const trimmed = wxid.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 getScopeKey(dbPath: string, wxid: string): string { - return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase() - } - - private resolveVideoBaseDir(dbPath: string, wxid: string): string { - const cleanedWxid = this.cleanWxid(wxid) - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - if (dbPathContainsWxid) { - return join(dbPath, 'msg', 'video') - } - - // 使用 ConfigService 的统一账号目录解析 - const accountDir = this.configService.getAccountDir(dbPath, wxid) - if (accountDir) { - return join(accountDir, 'msg', 'video') - } - - // 回退到原始逻辑 - return join(dbPath, wxid, 'msg', 'video') - } - - private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] { - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - - if (dbPathContainsWxid) { - return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')] - } - - // 使用 ConfigService 的统一账号目录解析 - const accountDir = this.configService.getAccountDir(dbPath, wxid) - if (accountDir) { - return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')] - } - - // 回退到原始逻辑 - return [ - join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), - join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') - ] - } - - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 使用 wcdb 专属接口查询加密的 hardlink.db - */ - private async resolveVideoHardlinks( - md5List: string[], - dbPath: string, - wxid: string, - cleanedWxid: string - ): Promise> { - const scopeKey = this.getScopeKey(dbPath, wxid) - const normalizedList = Array.from( - new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) - ) - const resolvedMap = new Map() - const unresolvedSet = new Set(normalizedList) - - for (const md5 of normalizedList) { - const cacheKey = `${scopeKey}|${md5}` - const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey) - if (cached === undefined) continue - if (cached) resolvedMap.set(md5, cached) - unresolvedSet.delete(md5) - } - - if (unresolvedSet.size === 0) return resolvedMap - - const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid) - for (const p of encryptedDbPaths) { - if (!existsSync(p) || unresolvedSet.size === 0) continue - const unresolved = Array.from(unresolvedSet) - const requests = unresolved.map((md5) => ({ md5, dbPath: p })) - try { - const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests) - if (batchResult.success && Array.isArray(batchResult.rows)) { - for (const row of batchResult.rows) { - const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1 - const inputMd5 = index >= 0 && index < requests.length - ? requests[index].md5 - : String(row?.md5 || '').trim().toLowerCase() - if (!inputMd5) continue - const resolvedMd5 = row?.success && row?.data?.resolved_md5 - ? String(row.data.resolved_md5).trim().toLowerCase() - : '' - if (!resolvedMd5) continue - const cacheKey = `${scopeKey}|${inputMd5}` - this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) - resolvedMap.set(inputMd5, resolvedMd5) - unresolvedSet.delete(inputMd5) - } - } else { - // 兼容不支持批量接口的版本,回退单条请求。 - for (const req of requests) { - try { - const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath) - const resolvedMd5 = single.success && single.data?.resolved_md5 - ? String(single.data.resolved_md5).trim().toLowerCase() - : '' - if (!resolvedMd5) continue - const cacheKey = `${scopeKey}|${req.md5}` - this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) - resolvedMap.set(req.md5, resolvedMd5) - unresolvedSet.delete(req.md5) - } catch { } - } - } - } catch (e) { - this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) }) - } - } - - for (const md5 of unresolvedSet) { - const cacheKey = `${scopeKey}|${md5}` - this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries) - } - - return resolvedMap - } - - private async queryVideoFileName(md5: string): Promise { - const normalizedMd5 = String(md5 || '').trim().toLowerCase() - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - - this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath }) - - if (!normalizedMd5 || !wxid || !dbPath) { - this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath }) - return undefined - } - - const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid) - const resolved = resolvedMap.get(normalizedMd5) - if (resolved) { - this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved }) - return resolved - } - return undefined - } - - async preloadVideoHardlinkMd5s(md5List: string[]): Promise { - // 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。 - // 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。 - void md5List - } - - private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined { - try { - if (!filePath || !existsSync(filePath)) return undefined - if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString() - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined - } - } - - private getOrBuildVideoIndex(videoBaseDir: string): Map { - const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir) - if (cached) return cached - - const index = new Map() - const ensureEntry = (key: string): VideoIndexEntry => { - let entry = index.get(key) - if (!entry) { - entry = {} - index.set(key, entry) - } - return entry - } - - try { - const yearMonthDirs = readdirSync(videoBaseDir) - .filter((dir) => { - const dirPath = join(videoBaseDir, dir) - try { - return statSync(dirPath).isDirectory() - } catch { - return false - } - }) - .sort((a, b) => b.localeCompare(a)) - - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - let files: string[] = [] - try { - files = readdirSync(dirPath) - } catch { - continue - } - - for (const file of files) { - const lower = file.toLowerCase() - const fullPath = join(dirPath, file) - - if (lower.endsWith('.mp4')) { - const md5 = lower.slice(0, -4) - const entry = ensureEntry(md5) - if (!entry.videoPath) entry.videoPath = fullPath - if (md5.endsWith('_raw')) { - const baseMd5 = md5.replace(/_raw$/, '') - const baseEntry = ensureEntry(baseMd5) - if (!baseEntry.videoPath) baseEntry.videoPath = fullPath - } - continue - } - - if (!lower.endsWith('.jpg')) continue - const jpgBase = lower.slice(0, -4) - if (jpgBase.endsWith('_thumb')) { - const baseMd5 = jpgBase.slice(0, -6) - const entry = ensureEntry(baseMd5) - if (!entry.thumbPath) entry.thumbPath = fullPath - } else { - const entry = ensureEntry(jpgBase) - if (!entry.coverPath) entry.coverPath = fullPath - } - } - } - - for (const [key, entry] of index) { - if (!key.endsWith('_raw')) continue - const baseKey = key.replace(/_raw$/, '') - const baseEntry = index.get(baseKey) - if (!baseEntry) continue - if (!entry.coverPath) entry.coverPath = baseEntry.coverPath - if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath - } - } catch (e) { - this.log('构建视频索引失败', { videoBaseDir, error: String(e) }) - } - - this.writeTimedCache( - this.videoDirIndexCache, - videoBaseDir, - index, - this.videoIndexCacheTtlMs, - this.maxIndexEntries - ) - return index - } - - private getVideoInfoFromIndex( - index: Map, - md5: string, - includePoster = true, - posterFormat: PosterFormat = 'dataUrl' - ): VideoInfo | null { - const normalizedMd5 = String(md5 || '').trim().toLowerCase() - if (!normalizedMd5) return null - - const candidates = [normalizedMd5] - const baseMd5 = normalizedMd5.replace(/_raw$/, '') - if (baseMd5 !== normalizedMd5) { - candidates.push(baseMd5) - } else { - candidates.push(`${normalizedMd5}_raw`) - } - - for (const key of candidates) { - const entry = index.get(key) - if (!entry?.videoPath) continue - if (!existsSync(entry.videoPath)) continue - if (!includePoster) { - return { - videoUrl: entry.videoPath, - exists: true - } - } - return { - videoUrl: entry.videoPath, - coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat), - thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat), - exists: true - } - } - - return null - } - - private normalizeVideoLookupKey(value: string): string { - let text = String(value || '').trim().toLowerCase() - if (!text) return '' - text = text.replace(/^.*[\\/]/, '') - text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') - text = text.replace(/_thumb$/, '') - const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) - if (direct) { - const suffix = /_raw$/i.test(text) ? '_raw' : '' - return `${direct[1].toLowerCase()}${suffix}` - } - const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) - if (preferred32?.[1]) return preferred32[1].toLowerCase() - const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) - return String(fallback?.[1] || '').toLowerCase() - } - - private fallbackScanVideo( - videoBaseDir: string, - realVideoMd5: string, - includePoster = true, - posterFormat: PosterFormat = 'dataUrl' - ): VideoInfo | null { - try { - const yearMonthDirs = readdirSync(videoBaseDir) - .filter((dir) => { - const dirPath = join(videoBaseDir, dir) - try { - return statSync(dirPath).isDirectory() - } catch { - return false - } - }) - .sort((a, b) => b.localeCompare(a)) - - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - if (!existsSync(videoPath)) continue - if (!includePoster) { - return { - videoUrl: videoPath, - exists: true - } - } - const baseMd5 = realVideoMd5.replace(/_raw$/, '') - const coverPath = join(dirPath, `${baseMd5}.jpg`) - const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) - return { - videoUrl: videoPath, - coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat), - thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat), - exists: true - } - } - } catch (e) { - this.log('fallback 扫描视频目录失败', { error: String(e) }) - } - return null - } - - private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise { - void posterFormat - if (!includePoster) return info - return info - } - - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise { - const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() - const includePoster = options?.includePoster !== false - const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl' - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid }) - - if (!dbPath || !wxid || !normalizedMd5) { - this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 }) - return { exists: false } - } - - const scopeKey = this.getScopeKey(dbPath, wxid) - const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}` - - const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) - if (cachedInfo) return cachedInfo - - const pending = this.pendingVideoInfo.get(cacheKey) - if (pending) return pending - - const task = (async (): Promise => { - const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5 - const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) - - if (!existsSync(videoBaseDir)) { - const miss = { exists: false } - this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) - return miss - } - - const index = this.getOrBuildVideoIndex(videoBaseDir) - const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat) - if (indexed) { - const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat) - this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries) - return withPoster - } - - const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat) - if (fallback) { - const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat) - this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries) - return withPoster - } - - const miss = { exists: false } - this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) - this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 }) - return miss - })() - - this.pendingVideoInfo.set(cacheKey, task) - try { - return await task - } finally { - this.pendingVideoInfo.delete(cacheKey) - } - } - - /** - * 根据消息内容解析视频MD5 - */ - parseVideoMd5(content: string): string | undefined { - if (!content) return undefined - - // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 - this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) - - try { - // 收集所有 md5 相关属性,方便对比 - const allMd5Attrs: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5Attrs.push(match[0]) - } - this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) - - // 方法1:从 提取(收到的视频) - const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMd5Match) { - this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) - return videoMsgMd5Match[1].toLowerCase() - } - - // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) - const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Match) { - this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) - return rawMd5Match[1].toLowerCase() - } - - // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) - const attrMatch = /(?...
标签 - const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5TagMatch) { - this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) - return md5TagMatch[1].toLowerCase() - } - - // 方法5:兜底取 rawmd5 属性(任意位置) - const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Fallback) { - this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) - return rawMd5Fallback[1].toLowerCase() - } - - this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) - } catch (e) { - this.log('parseVideoMd5 异常', { error: String(e) }) - } - - return undefined - } -} - -export const videoService = new VideoService() diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts deleted file mode 100644 index 5cc7804..0000000 --- a/electron/services/voiceTranscribeService.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { app } from 'electron' -import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs' -import { join } from 'path' -import * as https from 'https' -import * as http from 'http' -import { ConfigService } from './config' - -// Sherpa-onnx 类型定义 -type OfflineRecognizer = any -type OfflineStream = any - -type ModelInfo = { - name: string - files: { - model: string - tokens: string - } - sizeBytes: number - sizeLabel: string -} - -type DownloadProgress = { - modelName: string - downloadedBytes: number - totalBytes?: number - percent?: number - speed?: number -} - -const SENSEVOICE_MODEL: ModelInfo = { - name: 'SenseVoiceSmall', - files: { - model: 'model.int8.onnx', - tokens: 'tokens.txt' - }, - sizeBytes: 245_000_000, - sizeLabel: '245 MB' -} - -const MODEL_DOWNLOAD_URLS = { - model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx', - tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt' -} - -export class VoiceTranscribeService { - private configService = new ConfigService() - private downloadTasks = new Map>() - private recognizer: OfflineRecognizer | null = null - private isInitializing = false - - private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env } - const platform = process.platform === 'win32' ? 'win' : process.platform - const platformPkg = `sherpa-onnx-${platform}-${process.arch}` - const candidates = [ - join(__dirname, '..', 'node_modules', platformPkg), - join(__dirname, 'node_modules', platformPkg), - join(process.cwd(), 'node_modules', platformPkg), - process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' - ].filter((item): item is string => Boolean(item) && existsSync(item)) - - if (process.platform === 'darwin') { - const key = 'DYLD_LIBRARY_PATH' - const existing = env[key] || '' - const merged = [...candidates, ...existing.split(':').filter(Boolean)] - env[key] = Array.from(new Set(merged)).join(':') - if (candidates.length === 0) { - console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) - } - } else if (process.platform === 'linux') { - const key = 'LD_LIBRARY_PATH' - const existing = env[key] || '' - const merged = [...candidates, ...existing.split(':').filter(Boolean)] - env[key] = Array.from(new Set(merged)).join(':') - if (candidates.length === 0) { - console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) - } - } else if (process.platform === 'win32') { - // Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖 - const existing = env['PATH'] || '' - const merged = [...candidates, ...existing.split(';').filter(Boolean)] - env['PATH'] = Array.from(new Set(merged)).join(';') - if (candidates.length === 0) { - console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) - } - } - - return env - } - - private resolveModelDir(): string { - const configured = this.configService.get('whisperModelDir') as string | undefined - if (configured) return configured - return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice') - } - - private resolveModelPath(fileName: string): string { - return join(this.resolveModelDir(), fileName) - } - - /** - * 检查模型状态 - */ - async getModelStatus(): Promise<{ - success: boolean - exists?: boolean - modelPath?: string - tokensPath?: string - sizeBytes?: number - error?: string - }> { - try { - const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model) - const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens) - const modelExists = existsSync(modelPath) - const tokensExists = existsSync(tokensPath) - const exists = modelExists && tokensExists - - if (!exists) { - return { success: true, exists: false, modelPath, tokensPath } - } - - const modelSize = statSync(modelPath).size - const tokensSize = statSync(tokensPath).size - const totalSize = modelSize + tokensSize - - return { - success: true, - exists: true, - modelPath, - tokensPath, - sizeBytes: totalSize - } - } catch (error) { - return { success: false, error: String(error) } - } - } - - /** - * 下载模型文件 - */ - async downloadModel( - onProgress?: (progress: DownloadProgress) => void - ): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> { - const cacheKey = 'sensevoice' - const pending = this.downloadTasks.get(cacheKey) - if (pending) return pending - - const task = (async () => { - try { - const modelDir = this.resolveModelDir() - if (!existsSync(modelDir)) { - mkdirSync(modelDir, { recursive: true }) - } - - const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model) - const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens) - - // 初始进度 - onProgress?.({ - modelName: SENSEVOICE_MODEL.name, - downloadedBytes: 0, - totalBytes: SENSEVOICE_MODEL.sizeBytes, - percent: 0 - }) - - // 下载模型文件 (80% 权重) - console.info('[VoiceTranscribe] 开始下载模型文件...') - await this.downloadToFile( - MODEL_DOWNLOAD_URLS.model, - modelPath, - 'model', - (downloaded, total, speed) => { - const percent = total ? (downloaded / total) * 80 : 0 - onProgress?.({ - modelName: SENSEVOICE_MODEL.name, - downloadedBytes: downloaded, - totalBytes: SENSEVOICE_MODEL.sizeBytes, - percent, - speed - }) - } - ) - - // 下载 tokens 文件 (20% 权重) - console.info('[VoiceTranscribe] 开始下载 tokens 文件...') - await this.downloadToFile( - MODEL_DOWNLOAD_URLS.tokens, - tokensPath, - 'tokens', - (downloaded, total, speed) => { - const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0 - const percent = total ? 80 + (downloaded / total) * 20 : 80 - onProgress?.({ - modelName: SENSEVOICE_MODEL.name, - downloadedBytes: modelSize + downloaded, - totalBytes: SENSEVOICE_MODEL.sizeBytes, - percent, - speed - }) - } - ) - - console.info('[VoiceTranscribe] 模型下载完成') - return { success: true, modelPath, tokensPath } - } catch (error) { - const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model) - const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens) - try { - if (existsSync(modelPath)) unlinkSync(modelPath) - if (existsSync(tokensPath)) unlinkSync(tokensPath) - } catch { } - return { success: false, error: String(error) } - } finally { - this.downloadTasks.delete(cacheKey) - } - })() - - this.downloadTasks.set(cacheKey, task) - return task - } - - /** - * 转写 WAV 音频数据 - */ - async transcribeWavBuffer( - wavData: Buffer, - onPartial?: (text: string) => void, - languages?: string[] - ): Promise<{ success: boolean; transcript?: string; error?: string }> { - return new Promise((resolve) => { - try { - const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model) - const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens) - - if (!existsSync(modelPath) || !existsSync(tokensPath)) { - resolve({ success: false, error: '模型文件不存在,请先下载模型' }) - return - } - - let supportedLanguages = languages - if (!supportedLanguages || supportedLanguages.length === 0) { - supportedLanguages = this.configService.get('transcribeLanguages') - if (!supportedLanguages || supportedLanguages.length === 0) { - supportedLanguages = ['zh', 'yue'] - } - } - - const { fork } = require('child_process') - const workerPath = join(__dirname, 'transcribeWorker.js') - - const worker = fork(workerPath, [], { - env: this.buildTranscribeWorkerEnv(), - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - serialization: 'advanced' - }) - worker.send({ - modelPath, - tokensPath, - wavData, - sampleRate: 16000, - languages: supportedLanguages - }) - - let finalTranscript = '' - - worker.on('message', (msg: any) => { - if (msg.type === 'partial') { - onPartial?.(msg.text) - } else if (msg.type === 'final') { - finalTranscript = msg.text - resolve({ success: true, transcript: finalTranscript }) - worker.disconnect() - worker.kill() - } else if (msg.type === 'error') { - console.error('[VoiceTranscribe] Worker 错误:', msg.error) - resolve({ success: false, error: msg.error }) - worker.disconnect() - worker.kill() - } - }) - - worker.on('error', (err: Error) => resolve({ success: false, error: String(err) })) - worker.on('exit', (code: number | null, signal: string | null) => { - if (code === null || signal === 'SIGSEGV') { - - console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`); - resolve({ - success: false, - error: 'SEGFAULT_ERROR' - }); - return; - } - - if (code !== 0) { - resolve({ success: false, error: `Worker exited with code ${code}` }); - } - }) - - } catch (error) { - resolve({ success: false, error: String(error) }) - } - }) - } - - /** - * 下载文件 (支持多线程) - */ - private async downloadToFile( - url: string, - targetPath: string, - fileName: string, - onProgress?: (downloaded: number, total?: number, speed?: number) => void - ): Promise { - if (existsSync(targetPath)) { - unlinkSync(targetPath) - } - - console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`) - - // 1. 探测支持情况 - let probeResult - try { - probeResult = await this.probeUrl(url) - } catch (err) { - console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err) - return this.downloadSingleThread(url, targetPath, fileName, onProgress) - } - - const { totalSize, acceptRanges, finalUrl } = probeResult - - // 如果文件太小 (< 2MB) 或者不支持 Range,使用单线程 - if (totalSize < 2 * 1024 * 1024 || !acceptRanges) { - return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress) - } - - console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`) - - const threadCount = 4 - const chunkSize = Math.ceil(totalSize / threadCount) - const fd = openSync(targetPath, 'w') - - let downloadedTotal = 0 - let lastDownloaded = 0 - let lastTime = Date.now() - let speed = 0 - - const speedInterval = setInterval(() => { - const now = Date.now() - const duration = (now - lastTime) / 1000 - if (duration > 0) { - speed = (downloadedTotal - lastDownloaded) / duration - lastDownloaded = downloadedTotal - lastTime = now - onProgress?.(downloadedTotal, totalSize, speed) - } - }, 1000) - - try { - const promises = [] - for (let i = 0; i < threadCount; i++) { - const start = i * chunkSize - const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1 - - promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => { - downloadedTotal += bytes - })) - } - - await Promise.all(promises) - // Final progress update - onProgress?.(totalSize, totalSize, 0) - console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`) - } catch (err) { - console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err) - throw err - } finally { - clearInterval(speedInterval) - closeSync(fd) - } - } - - private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> { - return new Promise((resolve, reject) => { - const protocol = url.startsWith('https') ? https : http - const options = { - method: 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://modelscope.cn/', - 'Range': 'bytes=0-0' - } - } - - const req = protocol.get(url, options, (res) => { - if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) { - const location = res.headers.location - if (location && remainingRedirects > 0) { - const nextUrl = new URL(location, url).href - this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject) - return - } - } - - if (res.statusCode !== 206 && res.statusCode !== 200) { - reject(new Error(`Probe failed: HTTP ${res.statusCode}`)) - return - } - - const contentRange = res.headers['content-range'] - let totalSize = 0 - if (contentRange) { - const parts = contentRange.split('/') - totalSize = parseInt(parts[parts.length - 1], 10) - } else { - totalSize = parseInt(res.headers['content-length'] || '0', 10) - } - - const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange - resolve({ totalSize, acceptRanges, finalUrl: url }) - res.destroy() - }) - req.on('error', reject) - }) - } - - private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise { - return new Promise((resolve, reject) => { - const protocol = url.startsWith('https') ? https : http - const options = { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://modelscope.cn/', - 'Range': `bytes=${start}-${end}` - } - } - - const req = protocol.get(url, options, (res) => { - if (res.statusCode !== 206) { - reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`)) - return - } - - let currentOffset = start - res.on('data', (chunk: Buffer) => { - try { - writeSync(fd, chunk, 0, chunk.length, currentOffset) - currentOffset += chunk.length - onData(chunk.length) - } catch (err) { - reject(err) - res.destroy() - } - }) - - res.on('end', () => resolve()) - res.on('error', reject) - }) - req.on('error', reject) - }) - } - - private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise { - return new Promise((resolve, reject) => { - const protocol = url.startsWith('https') ? https : http - const options = { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://modelscope.cn/' - } - } - - const request = protocol.get(url, options, (response) => { - if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) { - const location = response.headers.location - if (location && remainingRedirects > 0) { - const nextUrl = new URL(location, url).href - this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject) - return - } - } - - if (response.statusCode !== 200) { - reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`)) - return - } - - const totalBytes = Number(response.headers['content-length'] || 0) || undefined - let downloadedBytes = 0 - let lastDownloaded = 0 - let lastTime = Date.now() - let speed = 0 - - const speedInterval = setInterval(() => { - const now = Date.now() - const duration = (now - lastTime) / 1000 - if (duration > 0) { - speed = (downloadedBytes - lastDownloaded) / duration - lastDownloaded = downloadedBytes - lastTime = now - onProgress?.(downloadedBytes, totalBytes, speed) - } - }, 1000) - - const writer = createWriteStream(targetPath) - response.on('data', (chunk) => { - downloadedBytes += chunk.length - }) - - writer.on('finish', () => { - clearInterval(speedInterval) - writer.close() - resolve() - }) - - writer.on('error', (err) => { - clearInterval(speedInterval) - // 确保在错误情况下也关闭文件句柄 - writer.destroy() - reject(err) - }) - - response.on('error', (err) => { - clearInterval(speedInterval) - // 确保在响应错误时也关闭文件句柄 - writer.destroy() - reject(err) - }) - - response.pipe(writer) - }) - request.on('error', reject) - }) - } - - dispose() { - if (this.recognizer) { - this.recognizer = null - } - } -} - -export const voiceTranscribeService = new VoiceTranscribeService() diff --git a/electron/services/wasmService.ts b/electron/services/wasmService.ts deleted file mode 100644 index 2a5a1ed..0000000 --- a/electron/services/wasmService.ts +++ /dev/null @@ -1,180 +0,0 @@ - -import path from 'path'; -import fs from 'fs'; -import vm from 'vm'; - -let app: any; -try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - app = require('electron').app; -} catch (e) { - app = { isPackaged: false }; -} - -// This service handles the loading and execution of the WeChat WASM module -// to generate the correct Isaac64 keystream for video decryption. -export class WasmService { - private static instance: WasmService; - private module: any = null; - private wasmLoaded = false; - private initPromise: Promise | null = null; - private capturedKeystream: Uint8Array | null = null; - - private constructor() { } - - public static getInstance(): WasmService { - if (!WasmService.instance) { - WasmService.instance = new WasmService(); - } - return WasmService.instance; - } - - private async init(): Promise { - if (this.wasmLoaded) return; - if (this.initPromise) return this.initPromise; - - this.initPromise = new Promise((resolve, reject) => { - try { - // For dev, files are in electron/assets/wasm - // __dirname in dev (from dist-electron) is .../dist-electron - // So we need to go up one level and then into electron/assets/wasm - const isDev = !app.isPackaged; - const basePath = isDev - ? path.join(__dirname, '../electron/assets/wasm') - : path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build - - const wasmPath = path.join(basePath, 'wasm_video_decode.wasm'); - const jsPath = path.join(basePath, 'wasm_video_decode.js'); - - - if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) { - throw new Error(`WASM files not found at ${basePath}`); - } - - const wasmBinary = fs.readFileSync(wasmPath); - - // Emulate Emscripten environment - // We must use 'any' for global mocking - const mockGlobal: any = { - console: console, - Buffer: Buffer, - Uint8Array: Uint8Array, - Int8Array: Int8Array, - Uint16Array: Uint16Array, - Int16Array: Int16Array, - Uint32Array: Uint32Array, - Int32Array: Int32Array, - Float32Array: Float32Array, - Float64Array: Float64Array, - BigInt64Array: BigInt64Array, - BigUint64Array: BigUint64Array, - Array: Array, - Object: Object, - Function: Function, - String: String, - Number: Number, - Boolean: Boolean, - Error: Error, - Promise: Promise, - require: require, - process: process, - setTimeout: setTimeout, - clearTimeout: clearTimeout, - setInterval: setInterval, - clearInterval: clearInterval, - }; - - // Define Module - mockGlobal.Module = { - onRuntimeInitialized: () => { - this.wasmLoaded = true; - resolve(); - }, - wasmBinary: wasmBinary, - print: (text: string) => console.log('[WASM stdout]', text), - printErr: (text: string) => console.error('[WASM stderr]', text) - }; - - // Define necessary globals for Emscripten loader - mockGlobal.self = mockGlobal; - mockGlobal.self.location = { href: jsPath }; - mockGlobal.WorkerGlobalScope = function () { }; - mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock? - - // Define the callback function that WASM calls to return data - // The WASM module calls `wasm_isaac_generate(ptr, size)` - mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => { - // console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`); - const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size); - // Copy the data because WASM memory might change or be invalidated - this.capturedKeystream = new Uint8Array(buffer); - }; - - // Execute the loader script in the context - const jsContent = fs.readFileSync(jsPath, 'utf8'); - const script = new vm.Script(jsContent, { filename: jsPath }); - - // create context - const context = vm.createContext(mockGlobal); - script.runInContext(context); - - // Store reference to module - this.module = mockGlobal.Module; - - } catch (error) { - console.error('[WasmService] Failed to initialize WASM:', error); - reject(error); - } - }); - - return this.initPromise; - } - - public async getKeystream(key: string, size: number = 131072): Promise { - // ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8, - // the global reverse() will cause a shift in alignment. - const alignSize = Math.ceil(size / 8) * 8; - const buffer = await this.getRawKeystream(key, alignSize); - - // Reverse the entire aligned buffer - const reversed = new Uint8Array(buffer); - reversed.reverse(); - - // Return exactly the requested size from the beginning of the reversed stream. - // Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block. - return Buffer.from(reversed).subarray(0, size); - } - - public async getRawKeystream(key: string, size: number = 131072): Promise { - await this.init(); - - if (!this.module || !this.module.WxIsaac64) { - if (this.module.asm && this.module.asm.WxIsaac64) { - this.module.WxIsaac64 = this.module.asm.WxIsaac64; - } - } - - if (!this.module.WxIsaac64) { - throw new Error('[WasmService] WxIsaac64 not found in WASM module'); - } - - try { - this.capturedKeystream = null; - const isaac = new this.module.WxIsaac64(key); - isaac.generate(size); - - if (isaac.delete) { - isaac.delete(); - } - - if (this.capturedKeystream) { - return Buffer.from(this.capturedKeystream); - } else { - throw new Error('[WasmService] Failed to capture keystream (callback not called)'); - } - } catch (error) { - console.error('[WasmService] Error generating raw keystream:', error); - throw error; - } - } -} diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts deleted file mode 100644 index 4e4e3af..0000000 --- a/electron/services/wcdbCore.ts +++ /dev/null @@ -1,4645 +0,0 @@ -import { join, dirname, basename } from 'path' -import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' -import { tmpdir } from 'os' -import * as fzstd from 'fzstd' -import { expandHomePath } from '../utils/pathUtils' - -//数据服务初始化错误信息,用于帮助用户诊断问题 -let lastDllInitError: string | null = null - -export function getLastDllInitError(): string | null { - return lastDllInitError -} - -function 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 -} - -export class WcdbCore { - 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 currentDbStoragePath: string | null = null - - // 函数引用 - private wcdbInitProtection: any = 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 wcdbUpdateMessage: any = null - private wcdbDeleteMessage: any = null - private wcdbGetSessions: any = null - private wcdbMarkAllSessionsRead: any = null - private wcdbGetMessages: any = null - private wcdbGetMessageCount: any = null - private wcdbGetMessageByServerId: any = null - private wcdbGetDisplayNames: any = null - private wcdbGetAvatarUrls: any = null - private wcdbGetGroupMemberCount: any = null - private wcdbGetGroupMemberCounts: any = null - private wcdbGetGroupMembers: any = null - private wcdbGetGroupNicknames: any = null - private wcdbGetMessageTables: any = null - private wcdbGetMessageMeta: any = null - private wcdbGetContact: any = null - private wcdbGetContactStatus: any = null - private wcdbGetContactTypeCounts: any = null - private wcdbGetContactsCompact: any = null - private wcdbGetContactAliasMap: any = null - private wcdbGetContactFriendFlags: any = null - private wcdbGetChatRoomExtBuffer: any = null - private wcdbGetMessageTableStats: any = null - private wcdbGetAggregateStats: any = null - private wcdbGetAvailableYears: any = null - private wcdbGetAnnualReportStats: any = null - private wcdbGetAnnualReportExtras: any = null - private wcdbGetDualReportStats: any = null - private wcdbGetGroupStats: any = null - private wcdbGetMyFootprintStats: any = null - private wcdbGetMessageDates: 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 wcdbGetEmoticonCaption: any = null - private wcdbGetEmoticonCaptionStrict: any = null - private wcdbGetDbStatus: any = null - private wcdbGetVoiceData: any = null - private wcdbGetVoiceDataBatch: any = null - private wcdbGetMediaSchemaSummary: any = null - private wcdbGetSessionMessageCounts: any = null - private wcdbGetSessionMessageTypeStats: any = null - private wcdbGetSessionMessageTypeStatsBatch: any = null - private wcdbGetSessionMessageDateCounts: any = null - private wcdbGetSessionMessageDateCountsBatch: any = null - private wcdbGetMessagesByType: any = null - private wcdbScanMediaStream: any = null - private wcdbGetHeadImageBuffers: any = null - private wcdbSearchMessages: any = null - private wcdbGetSnsTimeline: any = null - private wcdbGetSnsAnnualStats: any = null - private wcdbGetSnsUsernames: any = null - private wcdbGetSnsExportStats: any = null - private wcdbGetMessageTableColumns: any = null - private wcdbListTables: any = null - private wcdbGetTableSchema: any = null - private wcdbExportTableSnapshot: any = null - private wcdbImportTableSnapshot: any = null - private wcdbImportTableSnapshotWithSchema: any = null - private wcdbGetMessageTableTimeRange: any = null - private wcdbResolveImageHardlink: any = null - private wcdbResolveImageHardlinkBatch: any = null - private wcdbResolveVideoHardlinkMd5: any = null - private wcdbResolveVideoHardlinkMd5Batch: any = null - private wcdbInstallMessageAntiRevokeTrigger: any = null - private wcdbUninstallMessageAntiRevokeTrigger: any = null - private wcdbCheckMessageAntiRevokeTrigger: any = null - private wcdbInstallSnsBlockDeleteTrigger: any = null - private wcdbUninstallSnsBlockDeleteTrigger: any = null - private wcdbCheckSnsBlockDeleteTrigger: any = null - private wcdbDeleteSnsPost: any = null - private wcdbVerifyUser: any = null - private wcdbStartMonitorPipe: any = null - private wcdbStopMonitorPipe: any = null - private wcdbGetMonitorPipeName: any = null - private wcdbCloudInit: any = null - private wcdbCloudReport: any = null - private wcdbCloudStop: any = null - - private monitorPipeClient: any = null - private monitorCallback: ((type: string, json: string) => void) | null = null - private monitorReconnectTimer: any = null - private monitorPipePath: string = '' - - - private avatarUrlCache: Map = new Map() - private readonly avatarCacheTtlMs = 10 * 60 * 1000 - private imageHardlinkCache: Map = new Map() - private videoHardlinkCache: Map = new Map() - private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 - private readonly hardlinkCacheMaxEntries = 20000 - private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null - private mediaStreamSessionCacheAt = 0 - private readonly mediaStreamSessionCacheTtlMs = 12 * 1000 - private logTimer: NodeJS.Timeout | null = null - private lastLogTail: string | null = null - private lastResolvedLogPath: string | null = null - private lastCursorForceReopenAt = 0 - private readonly cursorForceReopenCooldownMs = 15000 - - setPaths(resourcesPath: string, userDataPath: string): void { - this.resourcesPath = resourcesPath - this.userDataPath = userDataPath - this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) - } - - getLastInitError(): string | null { - return lastDllInitError - } - - setLogEnabled(enabled: boolean): void { - this.logEnabled = enabled - this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) - if (this.isLogEnabled() && this.initialized) { - this.startLogPolling() - } else { - this.stopLogPolling() - } - } - - // 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket) - startMonitor(callback: (type: string, json: string) => void): boolean { - if (!this.wcdbStartMonitorPipe) { - return false - } - - this.monitorCallback = callback - - try { - const result = this.wcdbStartMonitorPipe() - if (result !== 0) { - return false - } - - // 从数据服务获取动态管道名(含 PID) - let pipePath = '\\\\.\\pipe\\weflow_monitor' - if (this.wcdbGetMonitorPipeName) { - try { - const namePtr = [null as any] - if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) { - pipePath = this.koffi.decode(namePtr[0], 'char', -1) - this.wcdbFreeString(namePtr[0]) - } - } catch { } - } - this.connectMonitorPipe(pipePath) - return true - } catch (e) { - console.error('[wcdbCore] startMonitor exception:', e) - return false - } - } - - // 连接命名管道,支持断开后自动重连 - private connectMonitorPipe(pipePath: string) { - this.monitorPipePath = pipePath - const net = require('net') - - setTimeout(() => { - if (!this.monitorCallback) return - - this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { }) - - let buffer = '' - this.monitorPipeClient.on('data', (data: Buffer) => { - const rawChunk = data.toString('utf8') - // macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包 - const normalizedChunk = rawChunk - .replace(/\u0000/g, '\n') - .replace(/}\s*{/g, '}\n{') - - buffer += normalizedChunk - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || '' - for (const line of lines) { - if (line.trim()) { - try { - const parsed = JSON.parse(line) - this.monitorCallback?.(parsed.action || 'update', line) - } catch { - this.monitorCallback?.('update', line) - } - } - } - - // 兜底:如果没有分隔符但已形成完整 JSON,则直接上报 - const tail = buffer.trim() - if (tail.startsWith('{') && tail.endsWith('}')) { - try { - const parsed = JSON.parse(tail) - this.monitorCallback?.(parsed.action || 'update', tail) - buffer = '' - } catch { - // 不可解析则继续等待下一块数据 - } - } - }) - - this.monitorPipeClient.on('error', () => { - // 保持静默,与现有错误处理策略一致 - }) - - this.monitorPipeClient.on('close', () => { - this.monitorPipeClient = null - this.scheduleReconnect() - }) - }, 100) - } - - // 定时重连 - private scheduleReconnect() { - if (this.monitorReconnectTimer || !this.monitorCallback) return - this.monitorReconnectTimer = setTimeout(() => { - this.monitorReconnectTimer = null - if (this.monitorCallback && !this.monitorPipeClient) { - this.connectMonitorPipe(this.monitorPipePath) - } - }, 3000) - } - - - - stopMonitor(): void { - this.monitorCallback = null - if (this.monitorReconnectTimer) { - clearTimeout(this.monitorReconnectTimer) - this.monitorReconnectTimer = null - } - if (this.monitorPipeClient) { - this.monitorPipeClient.destroy() - this.monitorPipeClient = null - } - if (this.wcdbStopMonitorPipe) { - this.wcdbStopMonitorPipe() - } - } - - // 保留旧方法签名以兼容 - setMonitor(callback: (type: string, json: string) => void): boolean { - return this.startMonitor(callback) - } - - - - /** - * 获取库文件路径(跨平台) - */ - private getDllPath(): string { - const isMac = process.platform === 'darwin' - const isLinux = process.platform === 'linux' - const isArm64 = process.arch === 'arm64' - const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' - const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') - const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32') - const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64') - - const envDllPath = process.env.WCDB_DLL_PATH - if (envDllPath && envDllPath.length > 0) { - return envDllPath - } - - // 基础路径探测 - const isPackaged = typeof process['resourcesPath'] !== 'undefined' - const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') - const roots = [ - process.env.WCDB_RESOURCES_PATH || null, - this.resourcesPath || null, - join(resourcesPath, 'resources'), - resourcesPath, - join(process.cwd(), 'resources') - ].filter(Boolean) as string[] - - const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64' - const relativeCandidates = [ - join('wcdb', platformDir, archDir, libName), - join('wcdb', platformDir, normalizedArch, libName), - join('wcdb', platformDir, 'x64', libName), - join('wcdb', platformDir, 'universal', libName), - join('wcdb', platformDir, libName) - ] - - const candidates: string[] = [] - for (const root of roots) { - for (const relativePath of relativeCandidates) { - candidates.push(join(root, relativePath)) - } - // 兼容旧目录:resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll - candidates.push(join(root, legacySubDir, libName)) - candidates.push(join(root, libName)) - } - - for (const path of candidates) { - if (existsSync(path)) return path - } - - return candidates[0] || libName - } - - private formatInitProtectionError(code: number): string { - const messages: Record = { - '-3001': '未找到数据库目录 (db_storage),请确认已选择正确的微信数据目录(应包含以 wxid_ 开头的子文件夹)', - '-3002': '未找到 session.db 文件,请确认微信已登录并且数据目录完整', - '-3003': '数据库句柄无效,请重试', - '-3004': '恢复数据库连接失败,请重试', - '-2301': '动态库加载失败,请检查安装是否完整', - '-2302': 'WCDB 初始化异常,请重试', - '-2303': 'WCDB 未能成功初始化', - } - const msg = messages[String(code) as unknown as keyof typeof messages] - return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}` - } - - private isLogEnabled(): boolean { - // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志 - if (process.env.WCDB_LOG_ENABLED === '1') return true - return this.logEnabled - } - - private writeLog(message: string, force = false): void { - if (!force && !this.isLogEnabled()) return - const line = `[${new Date().toISOString()}] ${message}` - - const candidates: string[] = [] - if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log')) - if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log')) - candidates.push(join(process.cwd(), 'logs', 'wcdb.log')) - candidates.push(join(tmpdir(), 'weflow-wcdb.log')) - - const uniq = Array.from(new Set(candidates)) - for (const filePath of uniq) { - try { - const dir = dirname(filePath) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - appendFileSync(filePath, line + '\n', { encoding: 'utf8' }) - this.lastResolvedLogPath = filePath - return - } catch (e) { - console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e) - } - } - - console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | ')) - } - - private formatSqlForLog(sql: string, maxLen = 240): string { - const compact = String(sql || '').replace(/\s+/g, ' ').trim() - if (compact.length <= maxLen) return compact - return compact.slice(0, maxLen) + '...' - } - - private async dumpDbStatus(tag: string): Promise { - try { - if (!this.ensureReady()) { - this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true) - return - } - if (!this.wcdbGetDbStatus) { - this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true) - return - } - const outPtr = [null as any] - const rc = this.wcdbGetDbStatus(this.handle, outPtr) - if (rc !== 0 || !outPtr[0]) { - this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) - return - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) { - this.writeLog(`[diag:${tag}] db_status decode failed`, true) - return - } - this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true) - } catch (e) { - this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true) - } - } - - private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise { - try { - this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true) - this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true) - this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true) - if (!dbStoragePath) return - try { - const entries = readdirSync(dbStoragePath) - const sample = entries.slice(0, 20).join(',') - this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true) - } catch (e) { - this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true) - } - - const contactProbe = await this.execQuery( - 'contact', - null, - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50" - ) - if (contactProbe.success) { - const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean) - this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true) - } else { - this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true) - } - - const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact') - if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) { - this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true) - } else { - this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true) - } - } catch (e) { - this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true) - } - } - - /** - * 递归查找 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 = expandHomePath(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 { } - } - // 兜底:向上查找 db_storage(最多 2 级),处理用户选择了子目录的情况 - try { - let parent = normalized - for (let i = 0; i < 2; i++) { - const up = join(parent, '..') - if (up === parent) break - parent = up - const candidateUp = join(parent, 'db_storage') - if (existsSync(candidateUp)) return candidateUp - if (wxid) { - const viaWxidUp = join(parent, wxid, 'db_storage') - if (existsSync(viaWxidUp)) return viaWxidUp - } - } - } catch { } - // 兜底:递归搜索 basePath 下的 db_storage 目录(最多 3 层深) - try { - const found = this.findDbStorageRecursive(normalized, 3) - if (found) return found - } catch { } - return null - } - - private findDbStorageRecursive(dir: string, maxDepth: number): string | null { - if (maxDepth <= 0) return null - try { - const entries = readdirSync(dir) - for (const entry of entries) { - if (entry.toLowerCase() === 'db_storage') { - const candidate = join(dir, entry) - try { if (statSync(candidate).isDirectory()) return candidate } catch { } - } - } - for (const entry of entries) { - const entryPath = join(dir, entry) - try { - if (statSync(entryPath).isDirectory()) { - const found = this.findDbStorageRecursive(entryPath, maxDepth - 1) - if (found) return found - } - } catch { } - } - } catch { } - return null - } - - private isRealDbFileName(name: string): boolean { - const lower = String(name || '').toLowerCase() - if (!lower.endsWith('.db')) return false - if (lower.endsWith('.db-shm')) return false - if (lower.endsWith('.db-wal')) return false - if (lower.endsWith('.db-journal')) return false - return true - } - - private resolveContactDbPath(): string | null { - const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '') - if (!dbStorage) return null - const contactDir = join(dbStorage, 'Contact') - if (!existsSync(contactDir)) return null - - const preferred = [ - join(contactDir, 'contact.db'), - join(contactDir, 'Contact.db') - ] - for (const p of preferred) { - if (existsSync(p)) return p - } - - try { - const entries = readdirSync(contactDir) - const cands = entries - .filter((name) => this.isRealDbFileName(name)) - .map((name) => join(contactDir, name)) - if (cands.length > 0) return cands[0] - } catch { } - return null - } - - private pickFirstStringField(row: Record, candidates: string[]): string { - for (const key of candidates) { - const v = row[key] - if (typeof v === 'string' && v.trim()) return v - if (v !== null && v !== undefined) { - const s = String(v).trim() - if (s) return s - } - } - return '' - } - - private escapeSqlString(value: string): string { - return String(value || '').replace(/'/g, "''") - } - - private buildContactSelectSql(usernames: string[] = []): string { - const uniq = Array.from(new Set((usernames || []).map((item) => String(item || '').trim()).filter(Boolean))) - if (uniq.length === 0) return 'SELECT * FROM contact' - const inList = uniq.map((username) => `'${this.escapeSqlString(username)}'`).join(',') - return `SELECT * FROM contact WHERE username IN (${inList})` - } - - private deriveContactTypeCounts(rows: Array>): { private: number; group: number; official: number; former_friend: number } { - const counts = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) - - for (const row of rows || []) { - const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) - if (!username) continue - - const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0 - const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0 - const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) - - if (username.endsWith('@chatroom')) { - counts.group += 1 - } else if (username.startsWith('gh_')) { - counts.official += 1 - } else if (localType === 1 && !excludeNames.has(username)) { - counts.private += 1 - } else if (localType === 0 && quanPin) { - counts.former_friend += 1 - } - } - - return counts - } - - /** - * 初始化 WCDB - */ - async initialize(): Promise { - if (this.initialized) return true - - try { - this.koffi = require('koffi') - const dllPath = this.getDllPath() - this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true) - - if (!existsSync(dllPath)) { - console.error('WCDB数据服务不存在:', dllPath) - this.writeLog(`[bootstrap] initialize failed:数据服务not found path=${dllPath}`, true) - return false - } - - const dllDir = dirname(dllPath) - const isMac = process.platform === 'darwin' - const isLinux = process.platform === 'linux' - - // 预加载依赖库 - if (isMac) { - const wcdbCorePath = join(dllDir, 'libWCDB.dylib') - if (existsSync(wcdbCorePath)) { - try { - this.koffi.load(wcdbCorePath) - this.writeLog('预加载 libWCDB.dylib 成功') - } catch (e) { - console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e) - this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`) - } - } - } else if (isLinux) { - // 如果有libWCDB.so的话, 没有就算了 - } else { - const wcdbCorePath = join(dllDir, 'WCDB.dll') - if (existsSync(wcdbCorePath)) { - try { - this.koffi.load(wcdbCorePath) - this.writeLog('预加载 WCDB.dll 成功') - } catch (e) { - console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) - } - } - const sdl2Path = join(dllDir, 'SDL2.dll') - if (existsSync(sdl2Path)) { - try { - this.koffi.load(sdl2Path) - this.writeLog('预加载 SDL2.dll 成功') - } catch (e) { - console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) - } - } - } - - this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true) - this.lib = this.koffi.load(dllPath) - this.writeLog('[bootstrap] koffi.load ok', true) - - // InitProtection (Added for security) - try { - this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') - - // 尝试多个可能的资源路径 - const resourcePaths = [ - dllDir, //数据服务所在目录 - dirname(dllDir), // 上级目录 - process.resourcesPath, // 打包后 Contents/Resources - process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources - this.resourcesPath, // 配置的资源路径 - join(process.cwd(), 'resources') // 开发环境 - ].filter(Boolean) - - let protectionOk = false - let protectionCode = -1 - let bestFailCode: number | null = null - const scoreFailCode = (code: number): number => { - if (code >= -2212 && code <= -2201) return 0 // manifest/signature/hash failures - if (code === -102 || code === -101 || code === -1006) return 1 - return 2 - } - for (const resPath of resourcePaths) { - try { - this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true) - protectionCode = Number(this.wcdbInitProtection(resPath)) - if (protectionCode === 0) { - protectionOk = true - break - } - if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) { - bestFailCode = protectionCode - } - this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true) - } catch (e) { - this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true) - } - } - - if (!protectionOk) { - const finalCode = bestFailCode ?? protectionCode - lastDllInitError = this.formatInitProtectionError(finalCode) - this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true) - return false - } - } catch (e) { - lastDllInitError = this.formatInitProtectionError(-2301) - this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true) - return false - } - - // 定义类型 - // 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 - } - - // wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, int32_t create_time, const char* new_content, char** out_error) - try { - this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)') - } catch { - this.wcdbUpdateMessage = null - } - - // wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error) - try { - this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)') - } catch { - this.wcdbDeleteMessage = 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_mark_all_sessions_read(wcdb_handle handle, char** out_error) - try { - this.wcdbMarkAllSessionsRead = this.lib.func('int32 wcdb_mark_all_sessions_read(int64 handle, _Out_ void** outError)') - } catch { - this.wcdbMarkAllSessionsRead = null - } - - // 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_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json) - this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)') - - // 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_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json) - try { - this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)') - } catch { - this.wcdbGetGroupNicknames = null - } - - // 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_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json) - try { - this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - } catch { - this.wcdbGetContactStatus = null - } - - try { - this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)') - } catch { - this.wcdbGetContactTypeCounts = null - } - try { - this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - } catch { - this.wcdbGetContactsCompact = null - } - try { - this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - } catch { - this.wcdbGetContactAliasMap = null - } - try { - this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - } catch { - this.wcdbGetContactFriendFlags = null - } - try { - this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)') - } catch { - this.wcdbGetChatRoomExtBuffer = null - } - - // 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_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - try { - this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)') - } catch { - this.wcdbGetDualReportStats = null - } - - // wcdb_status wcdb_get_logs(char** out_json) - try { - this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') - } catch { - this.wcdbGetLogs = 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_get_my_footprint_stats(wcdb_handle handle, const char* options_json, char** out_json) - try { - this.wcdbGetMyFootprintStats = this.lib.func('int32 wcdb_get_my_footprint_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetMyFootprintStats = null - } - - // wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json) - try { - this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)') - } catch { - this.wcdbGetMessageDates = 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_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption) - try { - this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)') - } catch (e) { - this.wcdbGetEmoticonCaption = null - this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true) - } - - // wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption) - try { - this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)') - } catch (e) { - this.wcdbGetEmoticonCaptionStrict = null - this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true) - } - - // 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)') - - // wcdb_status wcdb_get_db_status(wcdb_handle handle, char** out_json) - try { - this.wcdbGetDbStatus = this.lib.func('int32 wcdb_get_db_status(int64 handle, _Out_ void** outJson)') - } catch { - this.wcdbGetDbStatus = null - } - - // wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex) - try { - this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)') - } catch { - this.wcdbGetVoiceData = null - } - try { - this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetVoiceDataBatch = null - } - try { - this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)') - } catch { - this.wcdbGetMediaSchemaSummary = null - } - try { - this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetSessionMessageCounts = null - } - try { - this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') - } catch { - this.wcdbGetSessionMessageTypeStats = null - } - try { - this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetSessionMessageTypeStatsBatch = null - } - try { - this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)') - } catch { - this.wcdbGetSessionMessageDateCounts = null - } - try { - this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') - } catch { - this.wcdbGetSessionMessageDateCountsBatch = null - } - try { - this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)') - } catch { - this.wcdbGetMessagesByType = null - } - try { - this.wcdbScanMediaStream = this.lib.func('int32 wcdb_scan_media_stream(int64 handle, const char* sessionIdsJson, int32 mediaType, int32 beginTimestamp, int32 endTimestamp, int32 limit, int32 offset, _Out_ void** outJson, _Out_ int32* outHasMore)') - } catch { - this.wcdbScanMediaStream = null - } - try { - this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') - } catch { - this.wcdbGetHeadImageBuffers = null - } - - // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - try { - this.wcdbSearchMessages = this.lib.func('int32 wcdb_search_messages(int64 handle, const char* sessionId, const char* keyword, int32 limit, int32 offset, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') - } catch { - this.wcdbSearchMessages = null - } - - // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) - try { - this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)') - } catch { - this.wcdbGetSnsTimeline = null - } - - // wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) - try { - this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)') - } catch { - this.wcdbGetSnsAnnualStats = null - } - try { - this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)') - } catch { - this.wcdbGetSnsUsernames = null - } - try { - this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)') - } catch { - this.wcdbGetSnsExportStats = null - } - try { - this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') - } catch { - this.wcdbGetMessageTableColumns = null - } - try { - this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)') - } catch { - this.wcdbListTables = null - } - try { - this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)') - } catch { - this.wcdbGetTableSchema = null - } - try { - this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)') - } catch { - this.wcdbExportTableSnapshot = null - } - try { - this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)') - } catch { - this.wcdbImportTableSnapshot = null - } - try { - this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)') - } catch { - this.wcdbImportTableSnapshotWithSchema = null - } - try { - this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') - } catch { - this.wcdbGetMessageTableTimeRange = null - } - try { - this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)') - } catch { - this.wcdbResolveImageHardlink = null - } - try { - this.wcdbResolveImageHardlinkBatch = this.lib.func('int32 wcdb_resolve_image_hardlink_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') - } catch { - this.wcdbResolveImageHardlinkBatch = null - } - try { - this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)') - } catch { - this.wcdbResolveVideoHardlinkMd5 = null - } - try { - this.wcdbResolveVideoHardlinkMd5Batch = this.lib.func('int32 wcdb_resolve_video_hardlink_md5_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') - } catch { - this.wcdbResolveVideoHardlinkMd5Batch = null - } - - // wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) - try { - this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') - } catch { - this.wcdbInstallMessageAntiRevokeTrigger = null - } - - // wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) - try { - this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') - } catch { - this.wcdbUninstallMessageAntiRevokeTrigger = null - } - - // wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed) - try { - this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)') - } catch { - this.wcdbCheckMessageAntiRevokeTrigger = null - } - - // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) - try { - this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') - } catch { - this.wcdbInstallSnsBlockDeleteTrigger = null - } - - // wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error) - try { - this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') - } catch { - this.wcdbUninstallSnsBlockDeleteTrigger = null - } - - // wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed) - try { - this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)') - } catch { - this.wcdbCheckSnsBlockDeleteTrigger = null - } - - // wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error) - try { - this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)') - } catch { - this.wcdbDeleteSnsPost = null - } - - // Named pipe IPC for monitoring (replaces callback) - try { - this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()') - this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()') - this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)') - this.writeLog('Monitor pipe functions loaded') - } catch (e) { - console.warn('Failed to load monitor pipe functions:', e) - this.wcdbStartMonitorPipe = null - this.wcdbStopMonitorPipe = null - this.wcdbGetMonitorPipeName = null - } - - // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) - try { - this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)') - } catch { - this.wcdbVerifyUser = null - } - - // wcdb_status wcdb_cloud_init(int32_t interval_seconds) - try { - this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)') - } catch { - this.wcdbCloudInit = null - } - - // wcdb_status wcdb_cloud_report(const char* stats_json) - try { - this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)') - } catch { - this.wcdbCloudReport = null - } - - // void wcdb_cloud_stop() - try { - this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()') - } catch { - this.wcdbCloudStop = null - } - - - // 初始化 - const initResult = this.wcdbInit() - if (initResult !== 0) { - console.error('WCDB 初始化失败:', initResult) - lastDllInitError = this.formatInitProtectionError(initResult) - return false - } - - this.initialized = true - lastDllInitError = null - return true - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - console.error('WCDB 初始化异常:', errorMsg) - this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) - lastDllInitError = this.formatInitProtectionError(-2302) - return false - } - } - - /** - * 测试数据库连接 - */ - async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { - try { - // 如果当前已经有相同参数的活动连接,直接返回成功 - if (this.handle !== null && - this.currentPath === accountDir && - this.currentKey === hexKey) { - return { success: true, sessionCount: 0 } - } - - // 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接) - const hadActiveConnection = this.handle !== null - const prevPath = this.currentPath - const prevKey = this.currentKey - const prevWxid = this.currentWxid - - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) { - const detailedError = lastDllInitError || this.formatInitProtectionError(-2303) - return { success: false, error: detailedError } - } - } - - // 直接使用账号目录 - const dbStoragePath = join(accountDir, 'db_storage') - this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`) - - if (!dbStoragePath || !existsSync(dbStoragePath)) { - return { success: false, error: this.formatInitProtectionError(-3001) } - } - - // 递归查找 session.db - const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) - - if (!sessionDbPath) { - return { success: false, error: this.formatInitProtectionError(-3002) } - } - - // 分配输出参数内存 - const handleOut = [0] - const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) - - if (result !== 0) { - await this.printLogs() - this.writeLog(`testConnection openAccount failed code=${result}`) - return { success: false, error: this.formatInitProtectionError(result) } - } - - const tempHandle = handleOut[0] - if (tempHandle <= 0) { - return { success: false, error: this.formatInitProtectionError(-3003) } - } - - // 测试成功:使用 shutdown 清理资源(包括测试句柄) - // 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接 - try { - this.wcdbShutdown() - this.handle = null - this.currentPath = null - this.currentKey = null - this.currentWxid = null - this.initialized = false - } catch (closeErr) { - console.error('关闭测试数据库时出错:', closeErr) - } - - // 恢复测试前的连接(如果之前有活动连接) - if (hadActiveConnection && prevPath && prevKey) { - try { - await this.open(prevPath, prevKey) - } catch { - // 恢复失败则保持断开,由调用方处理 - } - } - - return { success: true, sessionCount: 0 } - } catch (e) { - console.error('测试连接异常:', e) - this.writeLog(`testConnection exception: ${String(e)}`) - return { success: false, error: this.formatInitProtectionError(-3004) } - } - } - - /** - * 打印数据服务内部日志(仅在出错时调用) - */ - 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 startLogPolling(): void { - if (this.logTimer || !this.isLogEnabled()) return - this.logTimer = setInterval(() => { - void this.pollLogs() - }, 2000) - } - - private stopLogPolling(): void { - if (this.logTimer) { - clearInterval(this.logTimer) - this.logTimer = null - } - this.lastLogTail = null - } - - private async pollLogs(): Promise { - try { - if (!this.wcdbGetLogs || !this.isLogEnabled()) return - const outPtr = [null as any] - const result = this.wcdbGetLogs(outPtr) - if (result !== 0 || !outPtr[0]) return - let jsonStr = '' - try { - jsonStr = this.koffi.decode(outPtr[0], 'char', -1) - } finally { - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - const logs = JSON.parse(jsonStr) as string[] - if (!Array.isArray(logs) || logs.length === 0) return - let startIdx = 0 - if (this.lastLogTail) { - const idx = logs.lastIndexOf(this.lastLogTail) - if (idx >= 0) startIdx = idx + 1 - } - for (let i = startIdx; i < logs.length; i += 1) { - this.writeLog(`wcdb: ${logs[i]}`) - } - this.lastLogTail = logs[logs.length - 1] - } catch (e) { - // ignore polling errors - } - } - - 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 parseMessageJson(jsonStr: string): any { - const raw = String(jsonStr || '') - if (!raw) return [] - // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 - const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw) - if (!needsInt64Normalize) { - return JSON.parse(raw) - } - const normalized = raw.replace( - /("server_id"\s*:\s*)(-?\d{16,})/g, - '$1"$2"' - ) - return JSON.parse(normalized) - } - - 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 } - } - - private makeHardlinkCacheKey(primary: string, secondary?: string | null): string { - const a = String(primary || '').trim().toLowerCase() - const b = String(secondary || '').trim().toLowerCase() - return `${a}\u001f${b}` - } - - private readHardlinkCache( - cache: Map, - key: string - ): { success: boolean; data?: any; error?: string } | null { - const entry = cache.get(key) - if (!entry) return null - if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) { - cache.delete(key) - return null - } - return this.cloneHardlinkResult(entry.result) - } - - private writeHardlinkCache( - cache: Map, - key: string, - result: { success: boolean; data?: any; error?: string } - ): void { - cache.set(key, { - result: this.cloneHardlinkResult(result), - updatedAt: Date.now() - }) - if (cache.size <= this.hardlinkCacheMaxEntries) return - - const now = Date.now() - for (const [cacheKey, entry] of cache) { - if (now - entry.updatedAt > this.hardlinkCacheTtlMs) { - cache.delete(cacheKey) - } - } - - while (cache.size > this.hardlinkCacheMaxEntries) { - const oldestKey = cache.keys().next().value as string | undefined - if (!oldestKey) break - cache.delete(oldestKey) - } - } - - private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } { - const data = result.data && typeof result.data === 'object' - ? { ...result.data } - : result.data - return { - success: result.success === true, - data, - error: result.error - } - } - - private clearHardlinkCaches(): void { - this.imageHardlinkCache.clear() - this.videoHardlinkCache.clear() - } - - private clearMediaStreamSessionCache(): void { - this.mediaStreamSessionCache = null - this.mediaStreamSessionCacheAt = 0 - } - - isReady(): boolean { - return this.ensureReady() - } - - /** - * 打开数据库 - */ - async open(accountDir: string, hexKey: string): Promise { - try { - lastDllInitError = null - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return false - } - - // 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接" - if (this.handle !== null && - this.currentPath === accountDir && - this.currentKey === hexKey) { - return true - } - - // 如果参数不同,则先关闭原来的连接 - if (this.handle !== null) { - this.close() - // 重新初始化,因为 close 呼叫了 shutdown - const initOk = await this.initialize() - if (!initOk) return false - } - - const dbStoragePath = join(accountDir, 'db_storage') - this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true) - - if (!dbStoragePath || !existsSync(dbStoragePath)) { - console.error('数据库目录不存在:', accountDir) - this.writeLog(`open failed: dbStorage not found for ${accountDir}`) - lastDllInitError = this.formatInitProtectionError(-3001) - return false - } - - const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true) - if (!sessionDbPath) { - console.error('未找到 session.db 文件') - this.writeLog('open failed: session.db not found') - lastDllInitError = this.formatInitProtectionError(-3002) - 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}`) - lastDllInitError = this.formatInitProtectionError(result) - return false - } - - const handle = handleOut[0] - if (handle <= 0) { - lastDllInitError = this.formatInitProtectionError(-3003) - return false - } - - // 从账号目录路径中提取 wxid(目录名) - const rawWxid = basename(accountDir) - const wxid = cleanAccountDirName(rawWxid) - - this.handle = handle - this.currentPath = accountDir - this.currentKey = hexKey - this.currentWxid = wxid - this.currentDbStoragePath = dbStoragePath - this.initialized = true - lastDllInitError = null - if (this.wcdbSetMyWxid && wxid) { - try { - this.wcdbSetMyWxid(this.handle, wxid) - } catch (e) { - // 静默失败 - } - } - if (this.isLogEnabled()) { - this.startLogPolling() - } - this.writeLog(`open ok handle=${handle}`, true) - await this.dumpDbStatus('open') - await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid) - return true - } catch (e) { - console.error('打开数据库异常:', e) - this.writeLog(`open exception: ${String(e)}`) - lastDllInitError = this.formatInitProtectionError(-3004) - return false - } - } - - /** - * 关闭数据库 - * 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替 - */ - close(): void { - if (this.handle !== null || this.initialized) { - // 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。 - try { this.stopMonitor() } catch {} - try { this.cloudStop() } catch {} - 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.currentDbStoragePath = null - this.initialized = false - this.clearHardlinkCaches() - this.clearMediaStreamSessionCache() - this.stopLogPolling() - } - } - - /** - * 关闭服务(与 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 { - // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbGetSessions(this.handle, outPtr) - - //数据服务调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - 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 markAllSessionsRead(): Promise<{ success: boolean; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbMarkAllSessionsRead) { - return { success: false, error: '当前数据服务版本不支持一键已读' } - } - try { - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbMarkAllSessionsRead(this.handle, outPtr) - let message = '' - if (outPtr[0]) { - try { message = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - - await new Promise(resolve => setImmediate(resolve)) - - if (result !== 0) { - this.writeLog(`markAllSessionsRead failed: code=${result} error=${message}`) - return { success: false, error: message || `一键已读失败: ${result}` } - } - this.clearMediaStreamSessionCache() - this.writeLog('markAllSessionsRead ok') - return { success: true } - } catch (e) { - this.writeLog(`markAllSessionsRead 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 = this.parseMessageJson(jsonStr) - return { success: true, messages } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 获取指定时间之后的新消息 - */ - async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - // 1. 打开游标 (使用 Ascending=1 从指定时间往后查) - const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0) - if (!openRes.success || !openRes.cursor) { - return { success: false, error: openRes.error } - } - - const cursor = openRes.cursor - try { - // 2. 获取批次 - const fetchRes = await this.fetchMessageBatch(cursor) - if (!fetchRes.success) { - return { success: false, error: fetchRes.error } - } - return { success: true, messages: fetchRes.rows } - } finally { - // 3. 关闭游标 - await this.closeMessageCursor(cursor) - } - } 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) { - if (result === -7) { - return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } - } - return { success: false, error: `获取消息总数失败: ${result}` } - } - return { success: true, count: outCount[0] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr) - if (result !== 0) { - return { success: false, error: `查询消息失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) { - return { success: true, row: null } - } - const parsed = JSON.parse(jsonStr) - if (!parsed || Object.keys(parsed).length === 0) { - return { success: true, row: null } - } - return { success: true, row: parsed } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - - const normalizedSessionIds = Array.from( - new Set( - (sessionIds || []) - .map((id) => String(id || '').trim()) - .filter(Boolean) - ) - ) - if (normalizedSessionIds.length === 0) { - return { success: true, counts: {} } - } - - try { - const counts: Record = {} - for (let i = 0; i < normalizedSessionIds.length; i += 1) { - const sessionId = normalizedSessionIds[i] - const outCount = [0] - const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) - if (result === -7) { - return { success: false, error: `message schema mismatch:会话 ${sessionId} 的消息表结构不匹配` } - } - counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 - - if (i > 0 && i % 160 === 0) { - await new Promise(resolve => setImmediate(resolve)) - } - } - return { success: true, counts } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds) - try { - const outPtr = [null as any] - const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) - if (result !== 0 || !outPtr[0]) { - if (result === -7) { - return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } - } - return { success: false, error: `获取会话消息总数失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析会话消息总数失败' } - const raw = JSON.parse(jsonStr) || {} - const counts: Record = {} - for (const sid of sessionIds || []) { - const value = Number(raw?.[sid] ?? 0) - counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 - } - return { success: true, counts } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSessionMessageTypeStats( - sessionId: string, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetSessionMessageTypeStats( - this.handle, - sessionId, - this.normalizeTimestamp(beginTimestamp), - this.normalizeTimestamp(endTimestamp), - outPtr - ) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取会话类型统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析会话类型统计失败' } - return { success: true, data: JSON.parse(jsonStr) || {} } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSessionMessageTypeStatsBatch( - sessionIds: string[], - options?: { - beginTimestamp?: number - endTimestamp?: number - quickMode?: boolean - includeGroupSenderCount?: boolean - } - ): Promise<{ success: boolean; data?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - if (normalizedSessionIds.length === 0) return { success: true, data: {} } - - if (!this.wcdbGetSessionMessageTypeStatsBatch) { - const data: Record = {} - for (const sessionId of normalizedSessionIds) { - const single = await this.getSessionMessageTypeStats( - sessionId, - options?.beginTimestamp || 0, - options?.endTimestamp || 0 - ) - if (single.success) { - data[sessionId] = single.data || {} - } - } - return { success: true, data } - } - - try { - const outPtr = [null as any] - const optionsJson = JSON.stringify({ - begin: this.normalizeTimestamp(options?.beginTimestamp || 0), - end: this.normalizeTimestamp(options?.endTimestamp || 0), - quick_mode: options?.quickMode === true, - include_group_sender_count: options?.includeGroupSenderCount !== false - }) - const result = this.wcdbGetSessionMessageTypeStatsBatch( - this.handle, - JSON.stringify(normalizedSessionIds), - optionsJson, - outPtr - ) - if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' } - return { success: true, data: JSON.parse(jsonStr) || {} } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetSessionMessageDateCounts(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 raw = JSON.parse(jsonStr) || {} - const counts: Record = {} - for (const [dateKey, value] of Object.entries(raw)) { - const count = Number(value) - if (!dateKey || !Number.isFinite(count) || count <= 0) continue - counts[String(dateKey)] = Math.floor(count) - } - return { success: true, counts } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) - if (normalizedSessionIds.length === 0) return { success: true, data: {} } - - if (!this.wcdbGetSessionMessageDateCountsBatch) { - const data: Record> = {} - for (const sessionId of normalizedSessionIds) { - const single = await this.getSessionMessageDateCounts(sessionId) - data[sessionId] = single.success && single.counts ? single.counts : {} - } - return { success: true, data } - } - - try { - const outPtr = [null as any] - const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' } - const raw = JSON.parse(jsonStr) || {} - const data: Record> = {} - for (const sessionId of normalizedSessionIds) { - const source = raw?.[sessionId] || {} - const next: Record = {} - for (const [dateKey, value] of Object.entries(source)) { - const count = Number(value) - if (!dateKey || !Number.isFinite(count) || count <= 0) continue - next[String(dateKey)] = Math.floor(count) - } - data[sessionId] = next - } - return { success: true, data } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMessagesByType( - sessionId: string, - localType: number, - ascending = false, - limit = 0, - offset = 0 - ): Promise<{ success: boolean; rows?: any[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessagesByType( - this.handle, - sessionId, - BigInt(localType), - ascending ? 1 : 0, - Math.max(0, Math.floor(limit || 0)), - Math.max(0, Math.floor(offset || 0)), - 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: Array.isArray(rows) ? rows : [] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMediaStream(options?: { - sessionId?: string - mediaType?: 'image' | 'video' | 'all' - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }): Promise<{ - success: boolean - items?: Array<{ - sessionId: string - sessionDisplayName?: string - mediaType: 'image' | 'video' - localId: number - serverId?: string - createTime: number - localType: number - senderUsername?: string - isSend?: number | null - imageMd5?: string - imageDatName?: string - videoMd5?: string - content?: string - }> - hasMore?: boolean - nextOffset?: number - error?: string - }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' } - try { - const toInt = (value: unknown): number => { - const n = Number(value || 0) - if (!Number.isFinite(n)) return 0 - return Math.floor(n) - } - const pickString = (row: Record, keys: string[]): string => { - for (const key of keys) { - const value = row[key] - if (value === null || value === undefined) continue - const text = String(value).trim() - if (text) return text - } - return '' - } - const pickRaw = (row: Record, keys: string[]): unknown => { - for (const key of keys) { - const value = row[key] - if (value === null || value === undefined) continue - return value - } - return undefined - } - const extractXmlValue = (xml: string, tag: string): string => { - if (!xml) return '' - const regex = new RegExp(`<${tag}>([\\s\\S]*?)`, 'i') - const match = regex.exec(xml) - if (!match) return '' - return String(match[1] || '').replace(//g, '').trim() - } - const looksLikeHex = (text: string): boolean => { - if (!text || text.length < 2 || text.length % 2 !== 0) return false - return /^[0-9a-fA-F]+$/.test(text) - } - const looksLikeBase64 = (text: string): boolean => { - if (!text || text.length < 16 || text.length % 4 !== 0) return false - return /^[A-Za-z0-9+/]+={0,2}$/.test(text) - } - const decodeBinaryContent = (data: Buffer, fallbackValue?: string): string => { - if (!data || data.length === 0) return '' - try { - if (data.length >= 4) { - const magicLE = data.readUInt32LE(0) - const magicBE = data.readUInt32BE(0) - if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) { - try { - const decompressed = fzstd.decompress(data) - return Buffer.from(decompressed).toString('utf-8') - } catch { - // ignore - } - } - } - const decoded = data.toString('utf-8') - const replacementCount = (decoded.match(/\uFFFD/g) || []).length - if (replacementCount < decoded.length * 0.2) { - return decoded.replace(/\uFFFD/g, '') - } - if (fallbackValue && replacementCount > 0) return fallbackValue - return data.toString('latin1') - } catch { - return fallbackValue || '' - } - } - const decodeMaybeCompressed = (raw: unknown): string => { - if (raw === null || raw === undefined) return '' - if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { - return decodeBinaryContent(Buffer.from(raw as any), String(raw)) - } - const text = String(raw).trim() - if (!text) return '' - - if (text.length > 16 && looksLikeHex(text)) { - try { - const bytes = Buffer.from(text, 'hex') - if (bytes.length > 0) return decodeBinaryContent(bytes, text) - } catch { - // ignore - } - } - if (text.length > 16 && looksLikeBase64(text)) { - try { - const bytes = Buffer.from(text, 'base64') - if (bytes.length > 0) return decodeBinaryContent(bytes, text) - } catch { - // ignore - } - } - return text - } - const decodeMessageContent = (messageContent: unknown, compressContent: unknown): string => { - const compressedDecoded = decodeMaybeCompressed(compressContent) - if (compressedDecoded) return compressedDecoded - return decodeMaybeCompressed(messageContent) - } - const extractImageMd5 = (xml: string): string => { - const byTag = extractXmlValue(xml, 'md5') || extractXmlValue(xml, 'imgmd5') - if (byTag) return byTag - const byAttr = /(?:md5|imgmd5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml) - return byAttr?.[1] || '' - } - const normalizeDatBase = (value: string): string => { - const input = String(value || '').trim() - if (!input) return '' - const fileBase = input.replace(/^.*[\\/]/, '').replace(/\.(?:t\.)?dat$/i, '') - const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase) - return String(md5Like?.[1] || fileBase || '').trim().toLowerCase() - } - const decodePackedInfoBuffer = (raw: unknown): 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 as any[]) - if (typeof raw === 'string') { - const text = raw.trim() - if (!text) return null - const compactHex = text.replace(/\s+/g, '') - if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { - try { - return Buffer.from(compactHex, 'hex') - } catch { - // ignore - } - } - try { - const base64 = Buffer.from(text, 'base64') - if (base64.length > 0) return base64 - } catch { - // ignore - } - return null - } - if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) { - return Buffer.from((raw as any).data) - } - return null - } - const decodePackedToPrintable = (raw: unknown): string => { - const buf = decodePackedInfoBuffer(raw) - if (!buf || buf.length === 0) return '' - const printable: number[] = [] - for (const byte of buf) { - if (byte >= 0x20 && byte <= 0x7e) printable.push(byte) - else printable.push(0x20) - } - return Buffer.from(printable).toString('utf-8') - } - const extractHexMd5 = (text: string): string => { - const input = String(text || '') - if (!input) return '' - const match = /([a-fA-F0-9]{32})/.exec(input) - return String(match?.[1] || '').toLowerCase() - } - const normalizeVideoFileToken = (value: unknown): string => { - let text = String(value || '').trim().toLowerCase() - if (!text) return '' - text = text.replace(/^.*[\\/]/, '') - text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') - text = text.replace(/_thumb$/, '') - const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) - if (direct) { - const suffix = /_raw$/i.test(text) ? '_raw' : '' - return `${direct[1].toLowerCase()}${suffix}` - } - const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) - if (preferred32?.[1]) return preferred32[1].toLowerCase() - const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) - return String(fallback?.[1] || '').toLowerCase() - } - const extractVideoFileNameFromPackedRaw = (raw: unknown): string => { - const buf = decodePackedInfoBuffer(raw) - if (!buf || buf.length === 0) return '' - const candidates: string[] = [] - let current = '' - for (const byte of buf) { - const isHex = - (byte >= 0x30 && byte <= 0x39) || - (byte >= 0x41 && byte <= 0x46) || - (byte >= 0x61 && byte <= 0x66) - if (isHex) { - current += String.fromCharCode(byte) - continue - } - if (current.length >= 16) candidates.push(current) - current = '' - } - if (current.length >= 16) candidates.push(current) - if (candidates.length === 0) return '' - const exact32 = candidates.find((item) => item.length === 32) - if (exact32) return exact32.toLowerCase() - const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) - return String(fallback || '').toLowerCase() - } - const extractImageDatName = (row: Record, content: string): string => { - const direct = pickString(row, [ - 'image_path', - 'imagePath', - 'image_dat_name', - 'imageDatName', - 'img_path', - 'imgPath', - 'img_name', - 'imgName' - ]) - const normalizedDirect = normalizeDatBase(direct) - if (normalizedDirect) return normalizedDirect - - const xmlCandidate = extractXmlValue(content, 'imgname') || extractXmlValue(content, 'cdnmidimgurl') - const normalizedXml = normalizeDatBase(xmlCandidate) - if (normalizedXml) return normalizedXml - - const packedRaw = pickRaw(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - const packedText = decodePackedToPrintable(packedRaw) - if (packedText) { - const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(packedText) - if (datLike?.[1]) return String(datLike[1]).toLowerCase() - const md5Like = /([0-9a-fA-F]{16,64})/.exec(packedText) - if (md5Like?.[1]) return String(md5Like[1]).toLowerCase() - } - - return '' - } - const extractPackedPayload = (row: Record): string => { - const packedRaw = pickRaw(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - return decodePackedToPrintable(packedRaw) - } - const extractVideoMd5 = (xml: string): string => { - const byTag = - extractXmlValue(xml, 'rawmd5') || - extractXmlValue(xml, 'videomd5') || - extractXmlValue(xml, 'newmd5') || - extractXmlValue(xml, 'md5') - if (byTag) return byTag - const byAttr = /(?:rawmd5|videomd5|newmd5|md5)\s*=\s*['"]?([a-fA-F0-9]{16,64})['"]?/i.exec(xml) - return byAttr?.[1] || '' - } - - const requestedSessionId = String(options?.sessionId || '').trim() - const mediaType = String(options?.mediaType || 'all').trim() as 'image' | 'video' | 'all' - const beginTimestamp = Math.max(0, toInt(options?.beginTimestamp)) - const endTimestamp = Math.max(0, toInt(options?.endTimestamp)) - const offset = Math.max(0, toInt(options?.offset)) - const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) - - const getSessionRows = async (): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> - error?: string - }> => { - const now = Date.now() - const cachedRows = this.mediaStreamSessionCache - if ( - cachedRows && - now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs - ) { - return { success: true, rows: cachedRows } - } - - const sessionsRes = await this.getSessions() - if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { - return { success: false, error: sessionsRes.error || '读取会话失败' } - } - - const rows = (sessionsRes.sessions || []) - .map((row: any) => ({ - sessionId: String( - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - '' - ).trim(), - displayName: String(row.displayName || row.display_name || row.remark || '').trim(), - sortTimestamp: toInt( - row.sort_timestamp || - row.sortTimestamp || - row.last_timestamp || - row.lastTimestamp || - 0 - ) - })) - .filter((row) => Boolean(row.sessionId)) - .sort((a, b) => b.sortTimestamp - a.sortTimestamp) - - this.mediaStreamSessionCache = rows - this.mediaStreamSessionCacheAt = now - return { success: true, rows } - } - - let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = [] - if (requestedSessionId) { - sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }] - } else { - const sessionsRowsRes = await getSessionRows() - if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) { - return { success: false, error: sessionsRowsRes.error || '读取会话失败' } - } - sessionRows = sessionsRowsRes.rows - } - - if (sessionRows.length === 0) { - return { success: true, items: [], hasMore: false, nextOffset: offset } - } - const sessionNameMap = new Map(sessionRows.map((row) => [row.sessionId, row.displayName || row.sessionId])) - - const outPtr = [null as any] - const outHasMore = [0] - const mediaTypeCode = mediaType === 'image' ? 1 : mediaType === 'video' ? 2 : 0 - const result = this.wcdbScanMediaStream( - this.handle, - JSON.stringify(sessionRows.map((row) => row.sessionId)), - mediaTypeCode, - beginTimestamp, - endTimestamp, - limit, - offset, - 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) - const list = Array.isArray(rows) ? rows as Array> : [] - - let items = list.map((row) => { - const sessionId = pickString(row, ['session_id', 'sessionId']) || requestedSessionId - const localType = toInt(row.local_type ?? row.localType) - const rawMessageContent = pickString(row, [ - 'message_content', - 'messageContent', - 'message_content_text', - 'messageText', - 'StrContent', - 'str_content', - 'msg_content', - 'msgContent', - 'strContent', - 'content', - 'rawContent', - 'WCDB_CT_message_content' - ]) - const rawCompressContent = pickString(row, [ - 'compress_content', - 'compressContent', - 'msg_compress_content', - 'msgCompressContent', - 'WCDB_CT_compress_content' - ]) - const useRawMessageContent = Boolean( - rawMessageContent && - (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) - ) - const decodeContentIfNeeded = (): string => { - if (useRawMessageContent) return rawMessageContent - if (!rawMessageContent && !rawCompressContent) return '' - return decodeMessageContent(rawMessageContent, rawCompressContent) - } - const packedPayload = extractPackedPayload(row) - const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) - const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) - const packedRaw = pickRaw(row, [ - 'packed_info_data', - 'packedInfoData', - 'packed_info_blob', - 'packedInfoBlob', - 'packed_info', - 'packedInfo', - 'BytesExtra', - 'bytes_extra', - 'WCDB_CT_packed_info', - 'reserved0', - 'Reserved0', - 'WCDB_CT_Reserved0' - ]) - - let content = '' - let imageMd5: string | undefined - let imageDatName: string | undefined - let videoMd5: string | undefined - - if (localType === 3) { - imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined - imageDatName = extractImageDatName(row, '') || undefined - if (!imageMd5 || !imageDatName) { - content = decodeContentIfNeeded() - if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined - if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined - } - } else if (localType === 43) { - videoMd5 = - extractVideoFileNameFromPackedRaw(packedRaw) || - normalizeVideoFileToken(videoMd5ByColumn) || - extractHexMd5(packedPayload) || - undefined - if (!videoMd5) { - content = decodeContentIfNeeded() - videoMd5 = - normalizeVideoFileToken(extractVideoMd5(content)) || - extractHexMd5(packedPayload) || - undefined - } else if (useRawMessageContent) { - // 占位态标题只依赖简单 XML,已带 md5 时不做额外解压 - content = rawMessageContent - } - } - - return { - sessionId, - sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, - mediaType: localType === 43 ? 'video' as const : 'image' as const, - localId: toInt(row.local_id ?? row.localId), - serverId: pickString(row, ['server_id', 'serverId']) || undefined, - createTime: toInt(row.create_time ?? row.createTime), - localType, - senderUsername: pickString(row, ['sender_username', 'senderUsername']) || undefined, - isSend: row.is_send === null || row.is_send === undefined ? null : toInt(row.is_send), - imageMd5, - imageDatName, - videoMd5, - content: localType === 43 ? (content || undefined) : undefined - } - }) - - const unresolvedSessionIds = Array.from( - new Set( - items - .map((item) => item.sessionId) - .filter((sessionId) => { - const name = String(sessionNameMap.get(sessionId) || '').trim() - return !name || name === sessionId - }) - ) - ) - if (unresolvedSessionIds.length > 0) { - const displayNameRes = await this.getDisplayNames(unresolvedSessionIds) - if (displayNameRes.success && displayNameRes.map) { - unresolvedSessionIds.forEach((sessionId) => { - const display = String(displayNameRes.map?.[sessionId] || '').trim() - if (display) sessionNameMap.set(sessionId, display) - }) - items = items.map((item) => ({ - ...item, - sessionDisplayName: sessionNameMap.get(item.sessionId) || item.sessionId - })) - } - } - - return { - success: true, - items, - hasMore: Number(outHasMore[0]) > 0, - nextOffset: offset + items.length - } - } 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 { - if (process.platform === 'darwin') { - const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean))) - if (uniq.length === 0) return { success: true, map: {} } - const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT * FROM contact WHERE username IN (${inList})` - const q = await this.execQuery('contact', null, sql) - if (!q.success) return { success: false, error: q.error || '获取昵称失败' } - const map: Record = {} - for (const row of (q.rows || []) as Array>) { - const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) - if (!username) continue - const display = this.pickFirstStringField(row, [ - 'remark', 'Remark', - 'nick_name', 'nickName', 'nickname', 'NickName', - 'alias', 'Alias' - ]) || username - map[username] = display - } - // 保证每个请求用户名至少有回退值 - for (const u of uniq) { - if (!map[u]) map[u] = u - } - return { success: true, map } - } - - // 让出控制权,避免阻塞事件循环 - await new Promise(resolve => setImmediate(resolve)) - - const outPtr = [null as any] - const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) - - //数据服务调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - 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) - // 只使用有效的缓存(URL不为空) - if (cached && cached.url && cached.url.trim() && now - cached.updatedAt < this.avatarCacheTtlMs) { - resultMap[username] = cached.url - continue - } - toFetch.push(username) - } - - if (toFetch.length === 0) { - return { success: true, map: resultMap } - } - - if (process.platform === 'darwin') { - const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT * FROM contact WHERE username IN (${inList})` - const q = await this.execQuery('contact', null, sql) - if (!q.success) { - if (Object.keys(resultMap).length > 0) { - return { success: true, map: resultMap, error: q.error || '获取头像失败' } - } - return { success: false, error: q.error || '获取头像失败' } - } - - for (const row of (q.rows || []) as Array>) { - const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) - if (!username) continue - const url = this.pickFirstStringField(row, [ - 'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url', - 'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url', - 'head_img_url', 'headImgUrl', - 'avatar_url', 'avatarUrl' - ]) - if (url) { - resultMap[username] = url - this.avatarUrlCache.set(username, { url, updatedAt: now }) - } - } - return { success: true, map: resultMap } - } - - // 让出控制权,避免阻塞事件循环 - const handle = this.handle - await new Promise(resolve => setImmediate(resolve)) - - // await 后 handle 可能已被关闭,需重新检查 - if (handle === null || this.handle !== handle) { - if (Object.keys(resultMap).length > 0) { - return { success: true, map: resultMap, error: '连接已断开' } - } - return { success: false, error: '连接已断开' } - } - - const outPtr = [null as any] - const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr) - - //数据服务调用后再次让出控制权 - await new Promise(resolve => setImmediate(resolve)) - - 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 && url.trim()) { - resultMap[username] = url - // 只缓存有效的URL - this.avatarUrlCache.set(username, { url, updatedAt: now }) - } - // 不缓存空URL,下次可以重新尝试 - } - return { success: true, map: resultMap } - } catch (e) { - console.error('[wcdbCore] getAvatarUrls 异常:', 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 getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetGroupNicknames) { - return { success: false, error: '当前数据服务版本不支持获取群昵称接口' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetGroupNicknames(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 nicknames = JSON.parse(jsonStr) - return { success: true, nicknames } - } 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 getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - if (!this.wcdbGetMessageDates) { - return { success: false, error: 'DLL 不支持 getMessageDates' } - } - const outPtr = [null as any] - const result = this.wcdbGetMessageDates(this.handle, sessionId, outPtr) - if (result !== 0 || !outPtr[0]) { - // 空结果也可能是正常的(无消息) - return { success: true, dates: [] } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析日期列表失败' } - const dates = JSON.parse(jsonStr) - return { success: true, dates } - } 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 { - if (process.platform === 'darwin') { - const safe = String(username || '').replace(/'/g, "''") - const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` - const q = await this.execQuery('contact', null, sql) - if (!q.success) { - return { success: false, error: q.error || '获取联系人失败' } - } - const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null - if (!row) { - return { success: false, error: `联系人不存在: ${username}` } - } - return { success: true, contact: row } - } - - const outPtr = [null as any] - const result = this.wcdbGetContact(this.handle, username, outPtr) - if (result !== 0 || !outPtr[0]) { - this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) - await this.dumpDbStatus('getContact-primary-fail') - await this.printLogs(true) - - // Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。 - const safe = String(username || '').replace(/'/g, "''") - const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` - const fallback = await this.execQuery('contact', null, fallbackSql) - if (fallback.success) { - const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null - if (row) { - this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true) - return { success: true, contact: row } - } - this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true) - return { success: false, error: `联系人不存在: ${username}` } - } - this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true) - return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` } - } - 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 getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetContactStatus) { - return { success: false, error: '接口未就绪' } - } - try { - const outPtr = [null as any] - const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr) - if (code !== 0 || !outPtr[0]) { - return { success: false, error: `获取会话状态失败: ${code}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析会话状态失败' } - - const rawMap = JSON.parse(jsonStr) || {} - const map: Record = {} - for (const username of usernames || []) { - const state = rawMap[username] || {} - map[username] = { - isFolded: Boolean(state.isFolded), - isMuted: Boolean(state.isMuted) - } - } - return { success: true, map } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析消息表列失败' } - const columns = JSON.parse(jsonStr) - return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbListTables) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbListTables(this.handle, kind, dbPath || '', 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: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr) - const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' - const data = jsonStr ? JSON.parse(jsonStr) : {} - if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` } - return { success: true, schema: String(data?.schema || '') } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr) - const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' - const data = jsonStr ? JSON.parse(jsonStr) : {} - if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` } - return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr) - const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' - const data = jsonStr ? JSON.parse(jsonStr) : {} - if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } - return { - success: true, - rows: Number(data?.rows || 0), - inserted: Number(data?.inserted || 0), - ignored: Number(data?.ignored || 0), - malformed: Number(data?.malformed || 0), - columns: Number(data?.columns || 0) - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr) - const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : '' - const data = jsonStr ? JSON.parse(jsonStr) : {} - if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` } - return { - success: true, - rows: Number(data?.rows || 0), - inserted: Number(data?.inserted || 0), - ignored: Number(data?.ignored || 0), - malformed: Number(data?.malformed || 0), - columns: Number(data?.columns || 0) - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, 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 getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - const runFallback = async (reason: string) => { - const contactsResult = await this.getContactsCompact() - if (!contactsResult.success || !Array.isArray(contactsResult.contacts)) { - return { success: false, error: `获取联系人分类统计失败: ${reason}; fallback=${contactsResult.error || 'unknown'}` } - } - const counts = this.deriveContactTypeCounts(contactsResult.contacts as Array>) - this.writeLog(`[diag:getContactTypeCounts] fallback reason=${reason} private=${counts.private} group=${counts.group} official=${counts.official} former_friend=${counts.former_friend}`, true) - return { success: true, counts } - } - - if (!this.wcdbGetContactTypeCounts) return await runFallback('api_missing') - try { - const outPtr = [null as any] - const code = this.wcdbGetContactTypeCounts(this.handle, outPtr) - if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return await runFallback('decode_empty') - const raw = JSON.parse(jsonStr) || {} - return { - success: true, - counts: { - private: Number(raw.private || 0), - group: Number(raw.group || 0), - official: Number(raw.official || 0), - former_friend: Number(raw.former_friend || 0) - } - } - } catch (e) { - return await runFallback(`exception=${String(e)}`) - } - } - - async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - const runFallback = async (reason: string) => { - const fallback = await this.execQuery('contact', null, this.buildContactSelectSql(usernames)) - if (!fallback.success) { - return { success: false, error: `获取联系人列表失败: ${reason}; fallback=${fallback.error || 'unknown'}` } - } - const rows = Array.isArray(fallback.rows) ? fallback.rows : [] - this.writeLog(`[diag:getContactsCompact] fallback reason=${reason} usernames=${Array.isArray(usernames) ? usernames.length : 0} rows=${rows.length}`, true) - return { success: true, contacts: rows } - } - - if (!this.wcdbGetContactsCompact) return await runFallback('api_missing') - try { - const outPtr = [null as any] - const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null - const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr) - if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return await runFallback('decode_empty') - const contacts = JSON.parse(jsonStr) - return { success: true, contacts: Array.isArray(contacts) ? contacts : [] } - } catch (e) { - return await runFallback(`exception=${String(e)}`) - } - } - - async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr) - if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' } - const map = JSON.parse(jsonStr) - return { success: true, map } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr) - if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` } - 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 getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr) - if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' } - const data = JSON.parse(jsonStr) || {} - const extBuffer = String(data.ext_buffer || '').trim() - return { success: true, extBuffer: extBuffer || undefined } - } 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 getMyFootprintStats(options: { - beginTimestamp?: number - endTimestamp?: number - myWxid?: string - privateSessionIds?: string[] - groupSessionIds?: string[] - mentionLimit?: number - privateLimit?: number - mentionMode?: 'text_at_me' | string - }): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetMyFootprintStats) { - return { success: false, error: '接口未就绪' } - } - - try { - const normalizedPrivateSessions = Array.from(new Set( - (options?.privateSessionIds || []) - .map((value) => String(value || '').trim()) - .filter(Boolean) - )) - const normalizedGroupSessions = Array.from(new Set( - (options?.groupSessionIds || []) - .map((value) => String(value || '').trim()) - .filter(Boolean) - )) - const mentionLimitRaw = Number(options?.mentionLimit ?? 0) - const privateLimitRaw = Number(options?.privateLimit ?? 0) - const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 ? Math.floor(mentionLimitRaw) : 0 - const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 ? Math.floor(privateLimitRaw) : 0 - - const payload = JSON.stringify({ - begin: this.normalizeTimestamp(options?.beginTimestamp || 0), - end: this.normalizeTimestamp(options?.endTimestamp || 0), - my_wxid: String(options?.myWxid || '').trim(), - private_session_ids: normalizedPrivateSessions, - group_session_ids: normalizedGroupSessions, - mention_limit: mentionLimit, - private_limit: privateLimit, - mention_mode: options?.mentionMode || 'text_at_me' - }) - - const outPtr = [null as any] - const result = this.wcdbGetMyFootprintStats(this.handle, payload, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取我的足迹统计失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) { - return { success: false, error: '解析我的足迹统计失败' } - } - return { success: true, data: JSON.parse(jsonStr) || {} } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。 - * 返回重新打开是否成功。 - */ - private async forceReopen(): Promise { - if (!this.currentPath || !this.currentKey || !this.currentWxid) return false - const path = this.currentPath - const key = this.currentKey - const wxid = this.currentWxid - this.writeLog('forceReopen: clearing cached handle and reopening...', true) - // 清空缓存状态,让 open() 真正重新打开 - try { this.wcdbShutdown() } catch { } - this.handle = null - this.currentPath = null - this.currentKey = null - this.currentWxid = null - this.currentDbStoragePath = null - this.initialized = false - return this.open(path, key, wxid) - } - - private shouldRetryCursorAfterNoDb(): boolean { - const now = Date.now() - if (now - this.lastCursorForceReopenAt < this.cursorForceReopenCooldownMs) { - return false - } - this.lastCursorForceReopenAt = now - return true - } - - 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] - let result = this.wcdbOpenMessageCursor( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - // result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空(常见于微信重装后) - // 自动强制重连并重试一次 - if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) { - this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true) - const reopened = await this.forceReopen() - if (reopened && this.handle !== null) { - outCursor[0] = 0 - result = this.wcdbOpenMessageCursor( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - this.writeLog(`openMessageCursor retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true) - } else { - this.writeLog('openMessageCursor forceReopen failed, giving up', true) - } - } - if (result !== 0 || outCursor[0] <= 0) { - if (result !== -3) { - 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 - ) - } - const hint = result === -3 - ? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试` - : result === -7 - ? 'message schema mismatch:当前账号消息表结构与程序要求不一致' - : `创建游标失败: ${result},请查看日志` - return { success: false, error: hint } - } - 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] - let result = this.wcdbOpenMessageCursorLite( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - - // result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空 - // 自动强制重连并重试一次 - if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) { - this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true) - const reopened = await this.forceReopen() - if (reopened && this.handle !== null) { - outCursor[0] = 0 - result = this.wcdbOpenMessageCursorLite( - this.handle, - sessionId, - batchSize, - ascending ? 1 : 0, - beginTimestamp, - endTimestamp, - outCursor - ) - this.writeLog(`openMessageCursorLite retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true) - } else { - this.writeLog('openMessageCursorLite forceReopen failed, giving up', true) - } - } - - if (result !== 0 || outCursor[0] <= 0) { - if (result !== -3) { - 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 - ) - } - if (result === -7) { - return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } - } - 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 = this.parseMessageJson(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 getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { - if (!this.lib) return { success: false, error: 'DLL 未加载' } - if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetLogs(outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取日志失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析日志失败' } - return { success: true, logs: JSON.parse(jsonStr) } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - const startedAt = Date.now() - try { - if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } - const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) - this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) - - // 如果提供了参数,使用参数化查询(需要 C++ 层支持) - // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 - // TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定 - if (params && params.length > 0) { - console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)') - } - - const normalizedKind = String(kind || '').toLowerCase() - const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) - let effectivePath = path || '' - if (normalizedKind === 'contact' && !effectivePath) { - const resolvedContactDb = this.resolveContactDbPath() - if (resolvedContactDb) { - effectivePath = resolvedContactDb - this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true) - } else { - this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true) - } - } - - const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr) - if (result !== 0 || !outPtr[0]) { - if (isContactQuery) { - this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) - await this.dumpDbStatus('execQuery-contact-fail') - await this.printLogs(true) - } - return { success: false, error: `执行查询失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析查询结果失败' } - const rows = JSON.parse(jsonStr) - this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`) - if (isContactQuery) { - const count = Array.isArray(rows) ? rows.length : -1 - this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) - } - return { success: true, rows } - } catch (e) { - this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`) - const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) - if (isContactQuery) { - this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) - await this.dumpDbStatus('execQuery-contact-exception') - } - 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 getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetEmoticonCaption) { - return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取表情释义失败: ${result}` } - } - const captionStr = this.decodeJsonPtr(outPtr[0]) - if (captionStr === null) return { success: false, error: '解析表情释义失败' } - return { success: true, caption: captionStr || undefined } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetEmoticonCaptionStrict) { - return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' } - } - try { - const outPtr = [null as any] - const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取表情释义失败(strict): ${result}` } - } - const captionStr = this.decodeJsonPtr(outPtr[0]) - if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' } - return { success: true, caption: captionStr || 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 = this.parseMessageJson(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) } - } - } - - async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetVoiceData) return { success: false, error: '当前数据服务版本不支持获取语音数据' } - try { - const outPtr = [null as any] - const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取语音数据失败: ${result}` } - } - const hex = this.decodeJsonPtr(outPtr[0]) - if (hex === null) return { success: false, error: '解析语音数据失败' } - return { success: true, hex: hex || undefined } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getVoiceDataBatch( - requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const payload = JSON.stringify(Array.isArray(requests) ? requests : []) - const result = this.wcdbGetVoiceDataBatch(this.handle, payload, 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) - const normalized = Array.isArray(rows) ? rows.map((row: any) => ({ - index: Number(row?.index ?? 0), - hex: row?.hex ? String(row.hex) : undefined - })) : [] - return { success: true, rows: normalized } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, 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 getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetHeadImageBuffers(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 resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' } - try { - const normalizedMd5 = String(md5 || '').trim().toLowerCase() - const normalizedAccountDir = String(accountDir || '').trim() - if (!normalizedMd5) return { success: false, error: 'md5 为空' } - const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir) - const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) - if (cached) return cached - - const outPtr = [null as any] - const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' } - const data = JSON.parse(jsonStr) || {} - const finalResult = { success: true, data } - this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult) - return finalResult - } catch (e) { - return { success: false, error: String(e) } - } - } - - async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' } - try { - const normalizedMd5 = String(md5 || '').trim().toLowerCase() - const normalizedDbPath = String(dbPath || '').trim() - if (!normalizedMd5) return { success: false, error: 'md5 为空' } - const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath) - const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) - if (cached) return cached - - const outPtr = [null as any] - const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' } - const data = JSON.parse(jsonStr) || {} - const finalResult = { success: true, data } - this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult) - return finalResult - } catch (e) { - return { success: false, error: String(e) } - } - } - - async resolveImageHardlinkBatch( - requests: Array<{ md5: string; accountDir?: string }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } - try { - const normalizedRequests = requests.map((req) => ({ - md5: String(req?.md5 || '').trim().toLowerCase(), - accountDir: String(req?.accountDir || '').trim() - })) - const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) - const unresolved: Array<{ index: number; md5: string; accountDir: string }> = [] - - for (let i = 0; i < normalizedRequests.length; i += 1) { - const req = normalizedRequests[i] - if (!req.md5) { - rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } - continue - } - const cacheKey = this.makeHardlinkCacheKey(req.md5, req.accountDir) - const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) - if (cached) { - rows[i] = { - index: i, - md5: req.md5, - success: cached.success === true, - data: cached.data, - error: cached.error - } - } else { - unresolved.push({ index: i, md5: req.md5, accountDir: req.accountDir }) - } - } - - if (unresolved.length === 0) { - return { success: true, rows } - } - - if (this.wcdbResolveImageHardlinkBatch) { - try { - const outPtr = [null as any] - const payload = JSON.stringify(unresolved.map((req) => ({ - md5: req.md5, - account_dir: req.accountDir || undefined - }))) - const result = this.wcdbResolveImageHardlinkBatch(this.handle, payload, outPtr) - if (result === 0 && outPtr[0]) { - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (jsonStr) { - const nativeRows = JSON.parse(jsonStr) - const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { - const rowIndexRaw = Number(row?.index) - const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index - const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', accountDir: '', index: -1 } - const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() - const success = row?.success === true || row?.success === 1 || row?.success === '1' - const data = row?.data && typeof row.data === 'object' ? row.data : {} - const error = row?.error ? String(row.error) : undefined - if (success && rowMd5) { - const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.accountDir) - this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, { success: true, data }) - } - return { - index: rowIndex, - md5: rowMd5, - success, - data, - error - } - }) : [] - for (const row of mappedRows) { - const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null - if (!fallbackReq) continue - rows[fallbackReq.index] = { - index: fallbackReq.index, - md5: row.md5 || fallbackReq.md5, - success: row.success, - data: row.data, - error: row.error - } - } - } - } - } catch { - // 回退到单条循环实现 - } - } - - for (const req of unresolved) { - if (rows[req.index]) continue - const result = await this.resolveImageHardlink(req.md5, req.accountDir) - rows[req.index] = { - index: req.index, - md5: req.md5, - success: result.success === true, - data: result.data, - error: result.error - } - } - return { success: true, rows } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async resolveVideoHardlinkMd5Batch( - requests: Array<{ md5: string; dbPath?: string }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } - try { - const normalizedRequests = requests.map((req) => ({ - md5: String(req?.md5 || '').trim().toLowerCase(), - dbPath: String(req?.dbPath || '').trim() - })) - const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) - const unresolved: Array<{ index: number; md5: string; dbPath: string }> = [] - - for (let i = 0; i < normalizedRequests.length; i += 1) { - const req = normalizedRequests[i] - if (!req.md5) { - rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } - continue - } - const cacheKey = this.makeHardlinkCacheKey(req.md5, req.dbPath) - const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) - if (cached) { - rows[i] = { - index: i, - md5: req.md5, - success: cached.success === true, - data: cached.data, - error: cached.error - } - } else { - unresolved.push({ index: i, md5: req.md5, dbPath: req.dbPath }) - } - } - - if (unresolved.length === 0) { - return { success: true, rows } - } - - if (this.wcdbResolveVideoHardlinkMd5Batch) { - try { - const outPtr = [null as any] - const payload = JSON.stringify(unresolved.map((req) => ({ - md5: req.md5, - db_path: req.dbPath || undefined - }))) - const result = this.wcdbResolveVideoHardlinkMd5Batch(this.handle, payload, outPtr) - if (result === 0 && outPtr[0]) { - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (jsonStr) { - const nativeRows = JSON.parse(jsonStr) - const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { - const rowIndexRaw = Number(row?.index) - const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index - const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', dbPath: '', index: -1 } - const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() - const success = row?.success === true || row?.success === 1 || row?.success === '1' - const data = row?.data && typeof row.data === 'object' ? row.data : {} - const error = row?.error ? String(row.error) : undefined - if (success && rowMd5) { - const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.dbPath) - this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, { success: true, data }) - } - return { - index: rowIndex, - md5: rowMd5, - success, - data, - error - } - }) : [] - for (const row of mappedRows) { - const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null - if (!fallbackReq) continue - rows[fallbackReq.index] = { - index: fallbackReq.index, - md5: row.md5 || fallbackReq.md5, - success: row.success, - data: row.data, - error: row.error - } - } - } - } - } catch { - // 回退到单条循环实现 - } - } - - for (const req of unresolved) { - if (rows[req.index]) continue - const result = await this.resolveVideoHardlinkMd5(req.md5, req.dbPath) - rows[req.index] = { - index: req.index, - md5: req.md5, - success: result.success === true, - data: result.data, - error: result.error - } - } - return { success: true, rows } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 数据收集初始化 - */ - async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> { - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return { success: false, error: 'WCDB init failed' } - } - if (!this.wcdbCloudInit) { - return { success: false, error: 'Cloud init API not supported by DLL' } - } - try { - const result = this.wcdbCloudInit(intervalSeconds) - if (result !== 0) { - return { success: false, error: `Cloud init failed: ${result}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return { success: false, error: 'WCDB init failed' } - } - if (!this.wcdbCloudReport) { - return { success: false, error: 'Cloud report API not supported by DLL' } - } - try { - const result = this.wcdbCloudReport(statsJson || '') - if (result !== 0) { - return { success: false, error: `Cloud report failed: ${result}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - - cloudStop(): { success: boolean; error?: string } { - if (!this.wcdbCloudStop) { - return { success: false, error: 'Cloud stop API not supported by DLL' } - } - try { - this.wcdbCloudStop() - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { - if (!this.initialized) { - const initOk = await this.initialize() - if (!initOk) return { success: false, error: 'WCDB 初始化失败' } - } - - if (!this.wcdbVerifyUser) { - return { success: false, error: 'Binding not found: VerifyUser' } - } - - return new Promise((resolve) => { - try { - // Allocate buffer for result JSON - const maxLen = 1024 - const outBuf = Buffer.alloc(maxLen) - - // Call native function - const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0) - this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen) - - // Parse result - const jsonStr = this.koffi.decode(outBuf, 'char', -1) - const result = JSON.parse(jsonStr) - resolve(result) - } catch (e) { - resolve({ success: false, error: String(e) }) - } - }) - } - - async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbSearchMessages) return { success: false, error: '当前数据服务版本不支持搜索消息' } - try { - const handle = this.handle - await new Promise(resolve => setImmediate(resolve)) - if (handle === null || this.handle !== handle) return { success: false, error: '连接已断开' } - const outPtr = [null as any] - const result = this.wcdbSearchMessages( - handle, - sessionId || '', - keyword, - limit || 50, - offset || 0, - beginTimestamp || 0, - endTimestamp || 0, - 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 = this.parseMessageJson(jsonStr) - return { success: true, messages } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' } - try { - const outPtr = [null as any] - const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : '' - const result = this.wcdbGetSnsTimeline( - this.handle, - limit, - offset, - usernamesJson, - keyword || '', - startTime || 0, - endTime || 0, - outPtr - ) - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取朋友圈失败: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' } - const timeline = JSON.parse(jsonStr) - return { success: true, timeline } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - try { - if (!this.wcdbGetSnsAnnualStats) { - return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' } - } - await new Promise(resolve => setImmediate(resolve)) - const outPtr = [null as any] - const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr) - await new Promise(resolve => setImmediate(resolve)) - - if (result !== 0 || !outPtr[0]) { - return { success: false, error: `getSnsAnnualStats failed: ${result}` } - } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: 'Failed to decode JSON' } - return { success: true, data: JSON.parse(jsonStr) } - } catch (e) { - console.error('getSnsAnnualStats 异常:', e) - return { success: false, error: String(e) } - } - } - - async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetSnsUsernames(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 usernames = JSON.parse(jsonStr) - return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' } - try { - const outPtr = [null as any] - const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr) - if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` } - const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' } - const raw = JSON.parse(jsonStr) || {} - return { - success: true, - data: { - totalPosts: Number(raw.total_posts || 0), - totalFriends: Number(raw.total_friends || 0), - myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0) - } - } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } - try { - const outPtr = [null] - const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) - let msg = '' - if (outPtr[0]) { - try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - if (status === 1) { - return { success: true, alreadyInstalled: true } - } - if (status !== 0) { - return { success: false, error: msg || `DLL error ${status}` } - } - return { success: true, alreadyInstalled: false } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } - try { - const outPtr = [null] - const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) - let msg = '' - if (outPtr[0]) { - try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - if (status !== 0) { - return { success: false, error: msg || `DLL error ${status}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } - try { - const outInstalled = [0] - const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled) - if (status !== 0) { - return { success: false, error: `DLL error ${status}` } - } - return { success: true, installed: outInstalled[0] === 1 } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> - error?: string - }> { - if (!Array.isArray(sessionIds) || sessionIds.length === 0) { - return { success: true, rows: [] } - } - const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) - const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = [] - for (const sessionId of uniqueIds) { - const result = await this.checkMessageAntiRevokeTrigger(sessionId) - rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error }) - } - return { success: true, rows } - } - - async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> - error?: string - }> { - if (!Array.isArray(sessionIds) || sessionIds.length === 0) { - return { success: true, rows: [] } - } - const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) - const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = [] - for (const sessionId of uniqueIds) { - const result = await this.installMessageAntiRevokeTrigger(sessionId) - rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error }) - } - return { success: true, rows } - } - - async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ - success: boolean - rows?: Array<{ sessionId: string; success: boolean; error?: string }> - error?: string - }> { - if (!Array.isArray(sessionIds) || sessionIds.length === 0) { - return { success: true, rows: [] } - } - const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) - const rows: Array<{ sessionId: string; success: boolean; error?: string }> = [] - for (const sessionId of uniqueIds) { - const result = await this.uninstallMessageAntiRevokeTrigger(sessionId) - rows.push({ sessionId, success: result.success, error: result.error }) - } - return { success: true, rows } - } - - /** - * 为朋友圈安装删除 - */ - async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - try { - const outPtr = [null] - const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr) - let msg = '' - if (outPtr[0]) { - try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - if (status === 1) { - //数据服务返回 1 表示已安装 - return { success: true, alreadyInstalled: true } - } - if (status !== 0) { - return { success: false, error: msg || `DLL error ${status}` } - } - return { success: true, alreadyInstalled: false } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 关闭朋友圈删除拦截 - */ - async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - try { - const outPtr = [null] - const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr) - let msg = '' - if (outPtr[0]) { - try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - if (status !== 0) { - return { success: false, error: msg || `DLL error ${status}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - - /** - * 查询朋友圈删除拦截是否已安装 - */ - async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } - try { - const outInstalled = [0] - const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled) - if (status !== 0) { - return { success: false, error: `DLL error ${status}` } - } - return { success: true, installed: outInstalled[0] === 1 } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前数据服务版本不支持此功能' } - try { - const outPtr = [null] - const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr) - let msg = '' - if (outPtr[0]) { - try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } - try { this.wcdbFreeString(outPtr[0]) } catch { } - } - if (status !== 0) { - return { success: false, error: msg || `DLL error ${status}` } - } - return { success: true } - } catch (e) { - return { success: false, error: String(e) } - } - } - - async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - if (!this.ensureReady()) { - return { success: false, error: 'WCDB 未连接' } - } - if (!this.wcdbGetDualReportStats) { - return { success: false, error: '未支持双人报告统计' } - } - try { - const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) - const outPtr = [null as any] - const result = this.wcdbGetDualReportStats(this.handle, sessionId, 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 updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> { - if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' } - if (!this.handle) return { success: false, error: 'Not Connected' } - - return new Promise((resolve) => { - try { - const outError = [null as any] - const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError) - - if (result !== 0) { - let errorMsg = 'Unknown Error' - if (outError[0]) { - errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)' - } - resolve({ success: false, error: errorMsg }) - return - } - - resolve({ success: true }) - } catch (e) { - resolve({ success: false, error: String(e) }) - } - }) - } - - /** - * 删除消息 - */ - async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { - if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' } - if (!this.handle) return { success: false, error: 'Not Connected' } - - return new Promise((resolve) => { - try { - const outError = [null as any] - const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError) - - if (result !== 0) { - let errorMsg = 'Unknown Error' - if (outError[0]) { - errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)' - } - console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`) - resolve({ success: false, error: errorMsg }) - return - } - - resolve({ success: true }) - } catch (e) { - console.error(`[WcdbCore] deleteMessage exception:`, e) - resolve({ success: false, error: String(e) }) - } - }) - } -} diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts deleted file mode 100644 index 280f455..0000000 --- a/electron/services/wcdbService.ts +++ /dev/null @@ -1,741 +0,0 @@ -import { Worker } from 'worker_threads' -import { join } from 'path' -import { existsSync } from 'fs' - -/** - * Worker 消息接口 - */ -interface WorkerMessage { - id: number - result?: any - error?: string -} - -/** - * WCDB 服务 (客户端代理) - * 负责与后台 Worker 线程通信,执行数据库操作 - * 避免主进程阻塞 - */ -export class WcdbService { - private worker: Worker | null = null - private messageId = 0 - private pending = new Map void; reject: (err: any) => void }>() - private resourcesPath: string | null = null - private userDataPath: string | null = null - private logEnabled = false - private monitorListener: ((type: string, json: string) => void) | null = null - - constructor() {} - - /** - * 初始化 Worker 线程 - */ - private initWorker() { - if (this.worker) return - - const isDev = process.env.NODE_ENV === 'development' - const workerPath = isDev - ? join(__dirname, '../dist-electron/wcdbWorker.js') - : join(__dirname, 'wcdbWorker.js') - - let finalPath = workerPath - if (isDev && !existsSync(finalPath)) { - finalPath = join(__dirname, 'wcdbWorker.js') - } - - try { - this.worker = new Worker(finalPath) - - this.worker.on('message', (msg: any) => { - const { id, result, error, type, payload } = msg - - if (type === 'monitor') { - if (this.monitorListener) { - this.monitorListener(payload.type, payload.json) - } - return - } - - const p = this.pending.get(id) - if (p) { - this.pending.delete(id) - if (error) p.reject(new Error(error)) - else p.resolve(result) - } - }) - - this.worker.on('error', (err) => { - // Worker 发生错误,需要 reject 所有 pending promises - console.error('WCDB Worker 错误:', err) - const errorMsg = err instanceof Error ? err.message : String(err) - for (const [id, p] of this.pending) { - p.reject(new Error(`Worker 错误: ${errorMsg}`)) - } - this.pending.clear() - }) - - this.worker.on('exit', (code) => { - // Worker 退出,需要 reject 所有 pending promises - if (code !== 0) { - console.error('WCDB Worker 异常退出,退出码:', code) - const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。` - for (const [id, p] of this.pending) { - p.reject(new Error(errorMsg)) - } - this.pending.clear() - } - this.worker = null - }) - - // 如果已有路径配置,重新发送给新的 worker - if (this.resourcesPath && this.userDataPath) { - this.setPaths(this.resourcesPath, this.userDataPath) - } - this.setLogEnabled(this.logEnabled) - if (this.monitorListener) { - this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }) - } - - } catch (e) { - // Failed to create worker - } - } - - /** - * 发送消息到 Worker 并等待响应 - */ - private callWorker(type: string, payload: any = {}): Promise { - if (!this.worker) this.initWorker() - if (!this.worker) return Promise.reject(new Error('WCDB Worker 不可用')) - - return new Promise((resolve, reject) => { - const id = ++this.messageId - this.pending.set(id, { resolve, reject }) - this.worker!.postMessage({ id, type, payload }) - }) - } - - /** - * 设置资源路径 - */ - setPaths(resourcesPath: string, userDataPath: string): void { - this.resourcesPath = resourcesPath - this.userDataPath = userDataPath - this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { }) - } - - /** - * 启用/禁用日志 - */ - setLogEnabled(enabled: boolean): void { - this.logEnabled = enabled - this.callWorker('setLogEnabled', { enabled }).catch(() => { }) - } - - /** - * 设置数据库监控回调 - */ - setMonitor(callback: (type: string, json: string) => void): void { - this.monitorListener = callback; - this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }); - } - - /** - * 检查服务是否就绪 - */ - isReady(): boolean { - return !!this.worker - } - - // ========================================== - // 代理方法 (Proxy Methods) - // ========================================== - - /** - * 测试数据库连接 - */ - async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { - return this.callWorker('testConnection', { accountDir, hexKey }) - } - - /** - * 打开数据库 - * @param accountDir 账号目录的完整路径 - * @param hexKey 解密密钥 - */ - async open(accountDir: string, hexKey: string): Promise { - return this.callWorker('open', { accountDir, hexKey }) - } - - async getLastInitError(): Promise { - return this.callWorker('getLastInitError') - } - - /** - * 关闭数据库连接 - */ - async close(): Promise { - return this.callWorker('close') - } - - /** - * 关闭服务 - */ - async shutdown(): Promise { - try { await this.close() } catch {} - if (this.worker) { - try { await this.worker.terminate() } catch {} - this.worker = null - } - } - - /** - * 获取数据库连接状态 - * 注意:此方法现在是异步的 - */ - async isConnected(): Promise { - return this.callWorker('isConnected') - } - - /** - * 获取会话列表 - */ - async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> { - return this.callWorker('getSessions') - } - - async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> { - return this.callWorker('markAllSessionsRead') - } - - /** - * 获取消息列表 - */ - async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { - return this.callWorker('getMessages', { sessionId, limit, offset }) - } - - /** - * 获取新消息(增量刷新) - */ - async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> { - return this.callWorker('getNewMessages', { sessionId, minTime, limit }) - } - - /** - * 获取消息总数 - */ - async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { - return this.callWorker('getMessageCount', { sessionId }) - } - - /** - * 根据 server_id 查询单条消息 - */ - async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> { - return this.callWorker('getMessageByServerId', { sessionId, svrid }) - } - - async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { - return this.callWorker('getMessageCounts', { sessionIds }) - } - - async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { - return this.callWorker('getSessionMessageCounts', { sessionIds }) - } - - async getSessionMessageTypeStats( - sessionId: string, - beginTimestamp: number = 0, - endTimestamp: number = 0 - ): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp }) - } - - async getSessionMessageTypeStatsBatch( - sessionIds: string[], - options?: { - beginTimestamp?: number - endTimestamp?: number - quickMode?: boolean - includeGroupSenderCount?: boolean - } - ): Promise<{ success: boolean; data?: Record; error?: string }> { - return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options }) - } - - async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { - return this.callWorker('getSessionMessageDateCounts', { sessionId }) - } - - async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { - return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds }) - } - - async getMessagesByType( - sessionId: string, - localType: number, - ascending = false, - limit = 0, - offset = 0 - ): Promise<{ success: boolean; rows?: any[]; error?: string }> { - return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) - } - - async getMediaStream(options?: { - sessionId?: string - mediaType?: 'image' | 'video' | 'all' - beginTimestamp?: number - endTimestamp?: number - limit?: number - offset?: number - }): Promise<{ - success: boolean - items?: Array<{ - sessionId: string - sessionDisplayName?: string - mediaType: 'image' | 'video' - localId: number - serverId?: string - createTime: number - localType: number - senderUsername?: string - isSend?: number | null - imageMd5?: string - imageDatName?: string - videoMd5?: string - content?: string - }> - hasMore?: boolean - nextOffset?: number - error?: string - }> { - return this.callWorker('getMediaStream', { options }) - } - - /** - * 获取联系人昵称 - */ - async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getDisplayNames', { usernames }) - } - - /** - * 获取头像 URL - */ - async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getAvatarUrls', { usernames }) - } - - /** - * 获取群成员数量 - */ - async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> { - return this.callWorker('getGroupMemberCount', { chatroomId }) - } - - /** - * 批量获取群成员数量 - */ - async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getGroupMemberCounts', { chatroomIds }) - } - - /** - * 获取群成员列表 - */ - async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> { - return this.callWorker('getGroupMembers', { chatroomId }) - } - - // 获取群成员群名片昵称 - async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { - return this.callWorker('getGroupNicknames', { chatroomId }) - } - - /** - * 获取消息表列表 - */ - async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { - return this.callWorker('getMessageTables', { sessionId }) - } - - /** - * 获取消息表统计 - */ - async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { - return this.callWorker('getMessageTableStats', { sessionId }) - } - - async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { - return this.callWorker('getMessageDates', { sessionId }) - } - - /** - * 获取消息元数据 - */ - async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> { - return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) - } - - async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { - return this.callWorker('getMessageTableColumns', { dbPath, tableName }) - } - - async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> { - return this.callWorker('listTables', { kind, dbPath }) - } - - async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> { - return this.callWorker('getTableSchema', { kind, dbPath, tableName }) - } - - async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> { - return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath }) - } - - async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { - return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath }) - } - - async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> { - return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql }) - } - - async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) - } - - /** - * 获取联系人详情 - */ - async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> { - return this.callWorker('getContact', { username }) - } - - /** - * 批量获取联系人 extra_buffer 状态(isFolded/isMuted) - */ - async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getContactStatus', { usernames }) - } - - async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { - return this.callWorker('getContactTypeCounts') - } - - async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { - return this.callWorker('getContactsCompact', { usernames }) - } - - async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getContactAliasMap', { usernames }) - } - - async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getContactFriendFlags', { usernames }) - } - - async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { - return this.callWorker('getChatRoomExtBuffer', { chatroomId }) - } - - /** - * 获取聚合统计数据 - */ - async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getAggregateStats', { sessionIds, beginTimestamp, endTimestamp }) - } - - /** - * 获取可用年份 - */ - async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> { - return this.callWorker('getAvailableYears', { sessionIds }) - } - - /** - * 获取年度报告统计 - */ - async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getAnnualReportStats', { sessionIds, beginTimestamp, endTimestamp }) - } - - /** - * 获取年度报告扩展数据 - */ - async getAnnualReportExtras(sessionIds: string[], beginTimestamp: number, endTimestamp: number, peakDayBegin: number, peakDayEnd: number): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd }) - } - - /** - * 获取双人报告统计数据 - */ - async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp }) - } - - /** - * 获取群聊统计 - */ - async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp }) - } - - async getMyFootprintStats(options: { - beginTimestamp?: number - endTimestamp?: number - myWxid?: string - privateSessionIds?: string[] - groupSessionIds?: string[] - mentionLimit?: number - privateLimit?: number - mentionMode?: 'text_at_me' | string - }): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getMyFootprintStats', { options }) - } - - /** - * 打开消息游标 - */ - async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { - return this.callWorker('openMessageCursor', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) - } - - /** - * 打开轻量级消息游标 - */ - async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { - return this.callWorker('openMessageCursorLite', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) - } - - /** - * 获取下一批消息 - */ - async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> { - return this.callWorker('fetchMessageBatch', { cursor }) - } - - /** - * 关闭消息游标 - */ - async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> { - return this.callWorker('closeMessageCursor', { cursor }) - } - - /** - * 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容) - */ - async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { - return this.callWorker('execQuery', { kind, path, sql, params }) - } - - /** - * 获取表情包 CDN URL - */ - async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> { - return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 }) - } - - /** - * 获取表情包释义 - */ - async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { - return this.callWorker('getEmoticonCaption', { dbPath, md5 }) - } - - /** - * 获取表情包释义(严格数据服务接口) - */ - async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { - return this.callWorker('getEmoticonCaptionStrict', { md5 }) - } - - /** - * 列出消息数据库 - */ - async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { - return this.callWorker('listMessageDbs') - } - - /** - * 列出媒体数据库 - */ - async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { - return this.callWorker('listMediaDbs') - } - - /** - * 根据 ID 获取消息 - */ - async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> { - return this.callWorker('getMessageById', { sessionId, localId }) - } - - async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { - return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) - } - - /** - * 获取语音数据 - */ - async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> { - return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) - } - - async getVoiceDataBatch( - requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { - return this.callWorker('getVoiceDataBatch', { requests }) - } - - async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getMediaSchemaSummary', { dbPath }) - } - - async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { - return this.callWorker('getHeadImageBuffers', { usernames }) - } - - async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('resolveImageHardlink', { md5, accountDir }) - } - - async resolveImageHardlinkBatch( - requests: Array<{ md5: string; accountDir?: string }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { - return this.callWorker('resolveImageHardlinkBatch', { requests }) - } - - async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath }) - } - - async resolveVideoHardlinkMd5Batch( - requests: Array<{ md5: string; dbPath?: string }> - ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { - return this.callWorker('resolveVideoHardlinkMd5Batch', { requests }) - } - - /** - * 获取朋友圈 - */ - async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { - return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) - } - - /** - * 获取朋友圈年度统计 - */ - async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { - return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) - } - - async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - return this.callWorker('getSnsUsernames') - } - - async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { - return this.callWorker('getSnsExportStats', { myWxid }) - } - - async checkMessageAntiRevokeTriggers( - sessionIds: string[] - ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> { - return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds }) - } - - async installMessageAntiRevokeTriggers( - sessionIds: string[] - ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> { - return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds }) - } - - async uninstallMessageAntiRevokeTriggers( - sessionIds: string[] - ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> { - return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds }) - } - - /** - * 安装朋友圈删除拦截 - */ - async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { - return this.callWorker('installSnsBlockDeleteTrigger') - } - - /** - * 卸载朋友圈删除拦截 - */ - async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { - return this.callWorker('uninstallSnsBlockDeleteTrigger') - } - - /** - * 查询朋友圈删除拦截是否已安装 - */ - async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { - return this.callWorker('checkSnsBlockDeleteTrigger') - } - - /** - * 从数据库直接删除朋友圈记录 - */ - async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - return this.callWorker('deleteSnsPost', { postId }) - } - - /** - * 获取数据服务内部日志 - */ - async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { - return this.callWorker('getLogs') - } - - /** - * 验证 Windows Hello - */ - async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { - return this.callWorker('verifyUser', { message, hwnd }) - } - - /** - * 修改消息内容 - */ - async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> { - return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent }) - } - - /** - * 删除消息 - */ - async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> { - return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint }) - } - - /** - * 数据收集:初始化 - */ - async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> { - return this.callWorker('cloudInit', { intervalSeconds }) - } - - /** - * 数据收集:上报数据 - */ - async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { - return this.callWorker('cloudReport', { statsJson }) - } - - /** - * 数据收集:停止 - */ - cloudStop(): Promise<{ success: boolean; error?: string }> { - return this.callWorker('cloudStop', {}) - } - - - -} - -export const wcdbService = new WcdbService() diff --git a/electron/services/windowsHelloService.ts b/electron/services/windowsHelloService.ts deleted file mode 100644 index 00d44e7..0000000 --- a/electron/services/windowsHelloService.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { wcdbService } from './wcdbService' -import { BrowserWindow } from 'electron' - -export class WindowsHelloService { - private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null - - /** - * 验证 Windows Hello - * @param message 提示信息 - */ - async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> { - // Prevent concurrent verification requests - if (this.verificationPromise) { - return this.verificationPromise - } - - // 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口 - const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] - const hwndBuffer = window?.getNativeWindowHandle() - // Convert buffer to int string for transport - const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined - - this.verificationPromise = wcdbService.verifyUser(message, hwndStr) - .finally(() => { - this.verificationPromise = null - }) - - return this.verificationPromise - } -} - -export const windowsHelloService = new WindowsHelloService() diff --git a/electron/transcribeWorker.ts b/electron/transcribeWorker.ts deleted file mode 100644 index 847ed06..0000000 --- a/electron/transcribeWorker.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { parentPort, workerData } from 'worker_threads' -import { existsSync } from 'fs' -import { join } from 'path' - -interface WorkerParams { - modelPath: string - tokensPath: string - wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] } - sampleRate: number - languages?: string[] -} - -function appendLibrarySearchPath(libDir: string): void { - if (!existsSync(libDir)) return - - if (process.platform === 'darwin') { - const current = process.env.DYLD_LIBRARY_PATH || '' - const paths = current.split(':').filter(Boolean) - if (!paths.includes(libDir)) { - process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':') - } - return - } - - if (process.platform === 'linux') { - const current = process.env.LD_LIBRARY_PATH || '' - const paths = current.split(':').filter(Boolean) - if (!paths.includes(libDir)) { - process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':') - } - } -} - -function prepareSherpaRuntimeEnv(): void { - const platform = process.platform === 'win32' ? 'win' : process.platform - const platformPkg = `sherpa-onnx-${platform}-${process.arch}` - const resourcesPath = (process as any).resourcesPath as string | undefined - - const candidates = [ - // Dev: /project/dist-electron -> /project/node_modules/... - join(__dirname, '..', 'node_modules', platformPkg), - // Fallback for alternate layouts - join(__dirname, 'node_modules', platformPkg), - join(process.cwd(), 'node_modules', platformPkg), - // Packaged app: Resources/app.asar.unpacked/node_modules/... - resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' - ].filter(Boolean) - - for (const dir of candidates) { - appendLibrarySearchPath(dir) - } -} - -// 语言标记映射 -const LANGUAGE_TAGS: Record = { - 'zh': '<|zh|>', - 'en': '<|en|>', - 'ja': '<|ja|>', - 'ko': '<|ko|>', - 'yue': '<|yue|>' // 粤语 -} - -// 技术标签(识别语言、语速、ITN等),需要从最终文本中移除 -const TECH_TAGS = [ - '<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>', - '<|nospeech|>', '<|speech|>', - '<|itn|>', '<|wo_itn|>', - '<|NORMAL|>' -] - -// 情感与事件标签映射,转换为直观的 Emoji -const RICH_TAG_MAP: Record = { - '<|HAPPY|>': '😊', - '<|SAD|>': '😔', - '<|ANGRY|>': '😠', - '<|NEUTRAL|>': '', // 中性情感不特别标记 - '<|FEARFUL|>': '😨', - '<|DISGUSTED|>': '🤢', - '<|SURPRISED|>': '😮', - '<|BGM|>': '🎵', - '<|Applause|>': '👏', - '<|Laughter|>': '😂', - '<|Cry|>': '😭', - '<|Cough|>': ' (咳嗽) ', - '<|Sneeze|>': ' (喷嚏) ', -} - -/** - * 富文本后处理:移除技术标签,转换识别出的情感和声音事件 - */ -function richTranscribePostProcess(text: string): string { - if (!text) return '' - - let processed = text - - // 1. 转换情感和事件标签 - for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) { - // 使用正则全局替换,不区分大小写以防不同版本差异 - const escapedTag = tag.replace(/[|<>]/g, '\\$&') - processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement) - } - - // 2. 移除所有剩余的技术标签 - for (const tag of TECH_TAGS) { - const escapedTag = tag.replace(/[|<>]/g, '\\$&') - processed = processed.replace(new RegExp(escapedTag, 'gi'), '') - } - - // 3. 清理多余空格并返回 - return processed.replace(/\s+/g, ' ').trim() -} - -// 检查识别结果是否在允许的语言列表中 -function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean { - if (!result || !result.lang) { - // 如果没有语言信息,默认允许(或从文本开头尝试提取) - return true - } - - // 如果没有指定语言或语言列表为空,默认允许中文和粤语 - if (!allowedLanguages || allowedLanguages.length === 0) { - allowedLanguages = ['zh', 'yue'] - } - - const langTag = result.lang - - - // 检查是否在允许的语言列表中 - for (const lang of allowedLanguages) { - if (LANGUAGE_TAGS[lang] === langTag) { - - return true - } - } - - - return false -} - -async function run() { - const isForkProcess = !parentPort - const emit = (msg: any) => { - if (parentPort) { - parentPort.postMessage(msg) - return - } - if (typeof process.send === 'function') { - process.send(msg) - } - } - - const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => { - if (Buffer.isBuffer(data)) return data - if (data instanceof Uint8Array) return Buffer.from(data) - if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) { - return Buffer.from((data as any).data) - } - return Buffer.alloc(0) - } - - const readParams = async (): Promise => { - if (parentPort) { - return workerData as WorkerParams - } - - return new Promise((resolve) => { - let settled = false - const finish = (value: WorkerParams | null) => { - if (settled) return - settled = true - resolve(value) - } - process.once('message', (msg) => finish(msg as WorkerParams)) - process.once('disconnect', () => finish(null)) - }) - } - - try { - prepareSherpaRuntimeEnv() - const params = await readParams() - if (!params) return - - // 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等) - let sherpa: any; - try { - sherpa = require('sherpa-onnx-node'); - } catch (requireError) { - emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); - if (isForkProcess) process.exit(1) - return; - } - - const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params - const wavData = normalizeBuffer(rawWavData); - // 确保有有效的语言列表,默认只允许中文 - let allowedLanguages = languages || ['zh'] - if (allowedLanguages.length === 0) { - allowedLanguages = ['zh'] - } - - - - // 1. 初始化识别器 (SenseVoiceSmall) - const recognizerConfig = { - modelConfig: { - senseVoice: { - model: modelPath, - useInverseTextNormalization: 1 - }, - tokens: tokensPath, - numThreads: 2, - debug: 0 - } - } - const recognizer = new sherpa.OfflineRecognizer(recognizerConfig) - - // 2. 处理音频数据 (全量识别) - const pcmData = wavData.slice(44) - const samples = new Float32Array(pcmData.length / 2) - for (let i = 0; i < samples.length; i++) { - samples[i] = pcmData.readInt16LE(i * 2) / 32768.0 - } - - const stream = recognizer.createStream() - stream.acceptWaveform({ sampleRate, samples }) - recognizer.decode(stream) - const result = recognizer.getResult(stream) - - - - // 3. 检查语言是否在白名单中 - if (isLanguageAllowed(result, allowedLanguages)) { - const processedText = richTranscribePostProcess(result.text) - - emit({ type: 'final', text: processedText }) - if (isForkProcess) process.exit(0) - } else { - - emit({ type: 'final', text: '' }) - if (isForkProcess) process.exit(0) - } - - } catch (error) { - emit({ type: 'error', error: String(error) }) - if (isForkProcess) process.exit(1) - } -} - -run(); diff --git a/electron/types/sherpa-onnx-node.d.ts b/electron/types/sherpa-onnx-node.d.ts deleted file mode 100644 index 13387f8..0000000 --- a/electron/types/sherpa-onnx-node.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'sherpa-onnx-node' { - const content: any; - export = content; -} diff --git a/electron/types/whisper-node.d.ts b/electron/types/whisper-node.d.ts deleted file mode 100644 index 70d7081..0000000 --- a/electron/types/whisper-node.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -declare module 'whisper-node' { - export type WhisperSegment = { - start: string - end: string - speech: string - } - - export type WhisperOptions = { - modelName?: string - modelPath?: string - whisperOptions?: { - language?: string - gen_file_txt?: boolean - gen_file_subtitle?: boolean - gen_file_vtt?: boolean - word_timestamps?: boolean - timestamp_size?: number - } - } - - export default function whisper(filePath: string, options?: WhisperOptions): Promise -} diff --git a/electron/utils/LRUCache.ts b/electron/utils/LRUCache.ts deleted file mode 100644 index fd9b1ac..0000000 --- a/electron/utils/LRUCache.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * LRU (Least Recently Used) Cache implementation for memory management - */ -export class LRUCache { - private cache: Map - private maxSize: number - - constructor(maxSize: number = 100) { - this.maxSize = maxSize - this.cache = new Map() - } - - /** - * Get value from cache - */ - get(key: K): V | undefined { - const value = this.cache.get(key) - if (value !== undefined) { - // Move to end (most recently used) - this.cache.delete(key) - this.cache.set(key, value) - } - return value - } - - /** - * Set value in cache - */ - set(key: K, value: V): void { - if (this.cache.has(key)) { - // Update existing - this.cache.delete(key) - } else if (this.cache.size >= this.maxSize) { - // Remove least recently used (first item) - const firstKey = this.cache.keys().next().value - if (firstKey !== undefined) { - this.cache.delete(firstKey) - } - } - this.cache.set(key, value) - } - - /** - * Check if key exists - */ - has(key: K): boolean { - return this.cache.has(key) - } - - /** - * Delete key from cache - */ - delete(key: K): boolean { - return this.cache.delete(key) - } - - /** - * Clear all cache entries - */ - clear(): void { - this.cache.clear() - } - - /** - * Get current cache size - */ - get size(): number { - return this.cache.size - } - - /** - * Get all keys (for debugging) - */ - keys(): IterableIterator { - return this.cache.keys() - } - - /** - * Get all values (for debugging) - */ - values(): IterableIterator { - return this.cache.values() - } - - /** - * Get all entries (for iteration support) - */ - entries(): IterableIterator<[K, V]> { - return this.cache.entries() - } - - /** - * Make LRUCache iterable (for...of support) - */ - [Symbol.iterator](): IterableIterator<[K, V]> { - return this.cache.entries() - } - - /** - * Force cleanup (optional method for explicit memory management) - */ - cleanup(): void { - // In JavaScript/TypeScript, this is mainly for consistency - // The garbage collector will handle actual memory cleanup - if (this.cache.size > this.maxSize * 1.5) { - // Emergency cleanup if cache somehow exceeds limit - const entries = Array.from(this.cache.entries()) - this.cache.clear() - // Keep only the most recent half - const keepEntries = entries.slice(-Math.floor(this.maxSize / 2)) - keepEntries.forEach(([key, value]) => this.cache.set(key, value)) - } - } -} \ No newline at end of file diff --git a/electron/utils/pathUtils.ts b/electron/utils/pathUtils.ts deleted file mode 100644 index c1fb638..0000000 --- a/electron/utils/pathUtils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { homedir } from 'os' - -/** - * Expand "~" prefix to current user's home directory. - * Examples: - * - "~" => "/Users/alex" - * - "~/Library/..." => "/Users/alex/Library/..." - */ -export function expandHomePath(inputPath: string): string { - const raw = String(inputPath || '').trim() - if (!raw) return raw - - if (raw === '~') return homedir() - if (/^~[\\/]/.test(raw)) { - return `${homedir()}${raw.slice(1)}` - } - - return raw -} - diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts deleted file mode 100644 index bad0bac..0000000 --- a/electron/wcdbWorker.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { parentPort, workerData } from 'worker_threads' -import { WcdbCore } from './services/wcdbCore' - -const core = new WcdbCore() - -if (parentPort) { - parentPort.on('message', async (msg) => { - const { id, type, payload } = msg - - try { - let result: any - - switch (type) { - case 'setPaths': - core.setPaths(payload.resourcesPath, payload.userDataPath) - result = { success: true } - break - case 'setLogEnabled': - core.setLogEnabled(payload.enabled) - result = { success: true } - break - case 'setMonitor': - { - const monitorOk = core.setMonitor((type, json) => { - parentPort!.postMessage({ - id: -1, - type: 'monitor', - payload: { type, json } - }) - }) - result = { success: monitorOk } - break - } - case 'testConnection': - result = await core.testConnection(payload.accountDir, payload.hexKey) - break - case 'open': - result = await core.open(payload.accountDir, payload.hexKey) - break - case 'getLastInitError': - result = core.getLastInitError() - break - case 'close': - core.close() - result = { success: true } - break - case 'isConnected': - result = core.isConnected() - break - case 'getSessions': - result = await core.getSessions() - break - case 'markAllSessionsRead': - result = await core.markAllSessionsRead() - break - case 'getMessages': - result = await core.getMessages(payload.sessionId, payload.limit, payload.offset) - break - case 'getNewMessages': - result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit) - break - case 'getMessageCount': - result = await core.getMessageCount(payload.sessionId) - break - case 'getMessageByServerId': - result = await core.getMessageByServerId(payload.sessionId, payload.svrid) - break - case 'getMessageCounts': - result = await core.getMessageCounts(payload.sessionIds) - break - case 'getSessionMessageCounts': - result = await core.getSessionMessageCounts(payload.sessionIds) - break - case 'getSessionMessageTypeStats': - result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) - break - case 'getSessionMessageTypeStatsBatch': - result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options) - break - case 'getSessionMessageDateCounts': - result = await core.getSessionMessageDateCounts(payload.sessionId) - break - case 'getSessionMessageDateCountsBatch': - result = await core.getSessionMessageDateCountsBatch(payload.sessionIds) - break - case 'getMessagesByType': - result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) - break - case 'getMediaStream': - result = await core.getMediaStream(payload.options) - break - case 'getDisplayNames': - result = await core.getDisplayNames(payload.usernames) - break - case 'getAvatarUrls': - result = await core.getAvatarUrls(payload.usernames) - break - case 'getGroupMemberCount': - result = await core.getGroupMemberCount(payload.chatroomId) - break - case 'getGroupMemberCounts': - result = await core.getGroupMemberCounts(payload.chatroomIds) - break - case 'getGroupMembers': - result = await core.getGroupMembers(payload.chatroomId) - break - case 'getGroupNicknames': - result = await core.getGroupNicknames(payload.chatroomId) - break - case 'getMessageTables': - result = await core.getMessageTables(payload.sessionId) - break - case 'getMessageTableStats': - result = await core.getMessageTableStats(payload.sessionId) - break - case 'getMessageDates': - result = await core.getMessageDates(payload.sessionId) - break - case 'getMessageMeta': - result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) - break - case 'getMessageTableColumns': - result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) - break - case 'listTables': - result = await core.listTables(payload.kind, payload.dbPath) - break - case 'getTableSchema': - result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName) - break - case 'exportTableSnapshot': - result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath) - break - case 'importTableSnapshot': - result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath) - break - case 'importTableSnapshotWithSchema': - result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql) - break - case 'getMessageTableTimeRange': - result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) - break - case 'getContact': - result = await core.getContact(payload.username) - break - case 'getContactStatus': - result = await core.getContactStatus(payload.usernames) - break - case 'getContactTypeCounts': - result = await core.getContactTypeCounts() - break - case 'getContactsCompact': - result = await core.getContactsCompact(payload.usernames) - break - case 'getContactAliasMap': - result = await core.getContactAliasMap(payload.usernames) - break - case 'getContactFriendFlags': - result = await core.getContactFriendFlags(payload.usernames) - break - case 'getChatRoomExtBuffer': - result = await core.getChatRoomExtBuffer(payload.chatroomId) - break - case 'getAggregateStats': - result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) - break - case 'getAvailableYears': - result = await core.getAvailableYears(payload.sessionIds) - break - case 'getAnnualReportStats': - result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) - break - case 'getAnnualReportExtras': - result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd) - break - case 'getDualReportStats': - result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) - break - case 'getGroupStats': - result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp) - break - case 'getMyFootprintStats': - result = await core.getMyFootprintStats(payload.options || {}) - break - case 'openMessageCursor': - result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) - break - case 'openMessageCursorLite': - result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) - break - case 'fetchMessageBatch': - result = await core.fetchMessageBatch(payload.cursor) - break - case 'closeMessageCursor': - result = await core.closeMessageCursor(payload.cursor) - break - case 'execQuery': - result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params) - break - case 'getEmoticonCdnUrl': - result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) - break - case 'getEmoticonCaption': - result = await core.getEmoticonCaption(payload.dbPath, payload.md5) - break - case 'getEmoticonCaptionStrict': - result = await core.getEmoticonCaptionStrict(payload.md5) - break - case 'listMessageDbs': - result = await core.listMessageDbs() - break - case 'listMediaDbs': - result = await core.listMediaDbs() - break - case 'getMessageById': - result = await core.getMessageById(payload.sessionId, payload.localId) - break - case 'searchMessages': - result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) - break - case 'getVoiceData': - result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) - if (!result.success) { - console.error('[wcdbWorker] getVoiceData failed:', result.error) - } - break - case 'getVoiceDataBatch': - result = await core.getVoiceDataBatch(payload.requests) - break - case 'getMediaSchemaSummary': - result = await core.getMediaSchemaSummary(payload.dbPath) - break - case 'getHeadImageBuffers': - result = await core.getHeadImageBuffers(payload.usernames) - break - case 'resolveImageHardlink': - result = await core.resolveImageHardlink(payload.md5, payload.accountDir) - break - case 'resolveImageHardlinkBatch': - result = await core.resolveImageHardlinkBatch(payload.requests) - break - case 'resolveVideoHardlinkMd5': - result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath) - break - case 'resolveVideoHardlinkMd5Batch': - result = await core.resolveVideoHardlinkMd5Batch(payload.requests) - break - case 'getSnsTimeline': - result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) - break - case 'getSnsAnnualStats': - result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) - break - case 'getSnsUsernames': - result = await core.getSnsUsernames() - break - case 'getSnsExportStats': - result = await core.getSnsExportStats(payload.myWxid) - break - case 'checkMessageAntiRevokeTriggers': - result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds) - break - case 'installMessageAntiRevokeTriggers': - result = await core.installMessageAntiRevokeTriggers(payload.sessionIds) - break - case 'uninstallMessageAntiRevokeTriggers': - result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds) - break - case 'installSnsBlockDeleteTrigger': - result = await core.installSnsBlockDeleteTrigger() - break - case 'uninstallSnsBlockDeleteTrigger': - result = await core.uninstallSnsBlockDeleteTrigger() - break - case 'checkSnsBlockDeleteTrigger': - result = await core.checkSnsBlockDeleteTrigger() - break - case 'deleteSnsPost': - result = await core.deleteSnsPost(payload.postId) - break - case 'getLogs': - result = await core.getLogs() - break - case 'verifyUser': - result = await core.verifyUser(payload.message, payload.hwnd) - break - case 'updateMessage': - result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent) - break - case 'deleteMessage': - result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint) - break - case 'cloudInit': - result = await core.cloudInit(payload.intervalSeconds) - break - case 'cloudReport': - result = await core.cloudReport(payload.statsJson) - break - case 'cloudStop': - result = core.cloudStop() - break - default: - result = { success: false, error: `Unknown method: ${type}` } - } - - parentPort!.postMessage({ id, result }) - } catch (e) { - parentPort!.postMessage({ id, error: String(e) }) - } - }) -} diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts deleted file mode 100644 index e4a5c57..0000000 --- a/electron/windows/notificationWindow.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { BrowserWindow, ipcMain, screen } from "electron"; -import { join } from "path"; -import { ConfigService } from "../services/config"; - -// Linux D-Bus通知服务 -const isLinux = process.platform === "linux"; -let linuxNotificationService: - | typeof import("../services/linuxNotificationService") - | null = null; - -// 用于处理通知点击的回调函数(在Linux上用于导航到会话) -let onNotificationNavigate: ((payload: unknown) => void) | null = null; - -export function setNotificationNavigateHandler( - callback: (payload: unknown) => void, -) { - onNotificationNavigate = callback; -} - -let notificationWindow: BrowserWindow | null = null; -let closeTimer: NodeJS.Timeout | null = null; - -export function destroyNotificationWindow() { - if (closeTimer) { - clearTimeout(closeTimer); - closeTimer = null; - } - lastNotificationData = null; - - // Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出) - if (isLinux && linuxNotificationService) { - linuxNotificationService.shutdownLinuxNotificationService().catch((error) => { - console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error); - }); - linuxNotificationService = null; - } - - if (!notificationWindow || notificationWindow.isDestroyed()) { - notificationWindow = null; - return; - } - - const win = notificationWindow; - notificationWindow = null; - - try { - win.destroy(); - } catch (error) { - console.warn("[NotificationWindow] Failed to destroy window:", error); - } -} - -export function createNotificationWindow() { - if (notificationWindow && !notificationWindow.isDestroyed()) { - return notificationWindow; - } - - const isDev = !!process.env.VITE_DEV_SERVER_URL; - const iconPath = isDev - ? join(__dirname, "../../public/icon.ico") - : join(process.resourcesPath, "icon.ico"); - - console.log("[NotificationWindow] Creating window..."); - const width = 344; - const height = 114; - - // Update default creation size - notificationWindow = new BrowserWindow({ - width: width, - height: height, - type: "toolbar", // 有助于在某些操作系统上保持置顶 - frame: false, - transparent: true, - resizable: false, - show: false, - alwaysOnTop: true, - skipTaskbar: true, - focusable: false, // 不抢占焦点 - icon: iconPath, - webPreferences: { - preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist) - contextIsolation: true, - nodeIntegration: false, - // devTools: true // Enable DevTools - }, - }); - - // notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools - notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透 - - // 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?) - // 实际上,我们希望窗口可点击。 - // 我们将在显示时将忽略鼠标事件设为 false。 - - const loadUrl = isDev - ? `${process.env.VITE_DEV_SERVER_URL}#/notification-window` - : `file://${join(__dirname, "../dist/index.html")}#/notification-window`; - - console.log("[NotificationWindow] Loading URL:", loadUrl); - notificationWindow.loadURL(loadUrl); - - notificationWindow.on("closed", () => { - notificationWindow = null; - }); - - return notificationWindow; -} - -export async function showNotification(data: any) { - // 先检查配置 - const config = ConfigService.getInstance(); - const sessionId = typeof data.sessionId === "string" ? data.sessionId : ""; - const channel = typeof data.channel === "string" ? data.channel : ""; - const isAiInsightNotification = channel === "ai-insight"; - - if (isAiInsightNotification) { - const enabled = await config.get("aiInsightNotificationEnabled"); - if (enabled === false) return; // 默认为 true - } else { - const enabled = await config.get("notificationEnabled"); - if (enabled === false) return; // 默认为 true - - // 检查会话过滤 - const filterMode = config.get("notificationFilterMode") || "all"; - const filterList = config.get("notificationFilterList") || []; - // 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响 - const isSystemNotification = sessionId.startsWith("weflow-"); - - if (!isSystemNotification && filterMode !== "all") { - const isInList = sessionId !== "" && filterList.includes(sessionId); - if (filterMode === "whitelist" && !isInList) { - // 白名单模式:不在列表中则不显示(空列表视为全部拦截) - return; - } - if (filterMode === "blacklist" && isInList) { - // 黑名单模式:在列表中则不显示 - return; - } - } - } - - // Linux 使用 D-Bus 通知 - if (isLinux) { - await showLinuxNotification(data); - return; - } - - let win = notificationWindow; - if (!win || win.isDestroyed()) { - win = createNotificationWindow(); - } - - if (!win) return; - - // 确保加载完成 - if (win.webContents.isLoading()) { - win.once("ready-to-show", () => { - showAndSend(win!, data); - }); - } else { - showAndSend(win, data); - } -} - -// 显示Linux通知 -async function showLinuxNotification(data: any) { - if (!linuxNotificationService) { - try { - linuxNotificationService = - await import("../services/linuxNotificationService"); - } catch (error) { - console.error( - "[NotificationWindow] Failed to load Linux notification service:", - error, - ); - return; - } - } - - const { showLinuxNotification: showNotification } = linuxNotificationService; - - const notificationData = { - title: data.title, - content: data.content, - avatarUrl: data.avatarUrl, - sessionId: data.sessionId, - channel: data.channel, - insightRecordId: data.insightRecordId, - targetRoute: data.targetRoute, - expireTimeout: 5000, - }; - - showNotification(notificationData); -} - -let lastNotificationData: any = null; - -async function showAndSend(win: BrowserWindow, data: any) { - lastNotificationData = data; - const config = ConfigService.getInstance(); - const position = (await config.get("notificationPosition")) || "top-right"; - - // 更新位置 - const { width: screenWidth, height: screenHeight } = - screen.getPrimaryDisplay().workAreaSize; - const winWidth = position === "top-center" ? 280 : 344; - const winHeight = 114; - const padding = 20; - - let x = 0; - let y = 0; - - switch (position) { - case "top-center": - x = (screenWidth - winWidth) / 2; - y = padding; - break; - case "top-right": - x = screenWidth - winWidth - padding; - y = padding; - break; - case "bottom-right": - x = screenWidth - winWidth - padding; - y = screenHeight - winHeight - padding; - break; - case "top-left": - x = padding; - y = padding; - break; - case "bottom-left": - x = padding; - y = screenHeight - winHeight - padding; - break; - } - - win.setPosition(Math.floor(x), Math.floor(y)); - win.setSize(winWidth, winHeight); // 确保尺寸 - - // 设为可交互 - win.setIgnoreMouseEvents(false); - win.showInactive(); // 显示但不聚焦 - win.setAlwaysOnTop(true, "screen-saver"); // 最高层级 - - win.webContents.send("notification:show", { ...data, position }); - - // 自动关闭计时器通常由渲染进程管理 - // 渲染进程发送 'notification:close' 来隐藏窗口 -} - -// 注册通知处理 -export async function registerNotificationHandlers() { - // Linux: 初始化D-Bus服务 - if (isLinux) { - try { - const linuxNotificationModule = - await import("../services/linuxNotificationService"); - linuxNotificationService = linuxNotificationModule; - - // 初始化服务 - await linuxNotificationModule.initLinuxNotificationService(); - - // 在Linux上注册通知点击回调 - linuxNotificationModule.onNotificationAction((payload: unknown) => { - console.log( - "[NotificationWindow] Linux notification clicked, sessionId:", - payload, - ); - // 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。 - if (onNotificationNavigate) { - onNotificationNavigate(payload); - } else { - // 如果尚未设置处理程序,则通过ipcMain发出事件 - // 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。 - console.warn( - "[NotificationWindow] onNotificationNavigate not set yet", - ); - } - }); - - console.log( - "[NotificationWindow] Linux notification service initialized", - ); - } catch (error) { - console.error( - "[NotificationWindow] Failed to initialize Linux notification service:", - error, - ); - } - } - - ipcMain.handle("notification:show", (_, data) => { - showNotification(data); - }); - - ipcMain.handle("notification:close", () => { - if (isLinux && linuxNotificationService) { - // 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪 - return; - } - if (notificationWindow && !notificationWindow.isDestroyed()) { - notificationWindow.hide(); - notificationWindow.setIgnoreMouseEvents(true, { forward: true }); - } - }); - - // Handle renderer ready event (fix race condition) - ipcMain.on("notification:ready", (event) => { - if (isLinux) { - // Linux不需要通知窗口,拦截通知窗口渲染 - return; - } - console.log("[NotificationWindow] Renderer ready, checking cached data"); - if ( - lastNotificationData && - notificationWindow && - !notificationWindow.isDestroyed() - ) { - console.log("[NotificationWindow] Re-sending cached data"); - notificationWindow.webContents.send( - "notification:show", - lastNotificationData, - ); - } - }); - - // Handle resize request from renderer - ipcMain.on("notification:resize", (event, { width, height }) => { - if (isLinux) { - // Linux 通知通过D-Bus自动调整大小 - return; - } - if (notificationWindow && !notificationWindow.isDestroyed()) { - // Enforce max-height if needed, or trust renderer - // Ensure it doesn't go off screen bottom? - // Logic in showAndSend handles position, but we need to keep anchor point (top-right usually). - // If we resize, we should re-calculate position to keep it anchored? - // Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right. - // If bottom-right, growing down pushes it off screen. - - // Simple version: just setSize. For V1 we assume Top-Right. - // But wait, the config supports bottom-right. - // We can re-call setPosition or just let it be. - // If bottom-right, y needs to prevent overflow. - - // Ideally we get current config position - const bounds = notificationWindow.getBounds(); - // Check if we need to adjust Y? - // For now, let's just set the size as requested. - notificationWindow.setSize(Math.round(width), Math.round(height)); - } - }); - - // 'notification-clicked' 在 main.ts 中处理 (导航) -} diff --git a/index.html b/index.html deleted file mode 100644 index c9c6afb..0000000 --- a/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - WeFlow - - -
- - - diff --git a/installer.nsh b/installer.nsh deleted file mode 100644 index b7c8f63..0000000 --- a/installer.nsh +++ /dev/null @@ -1,65 +0,0 @@ -; 高 DPI 支持 -ManifestDPIAware true - -!include "WordFunc.nsh" -!include "nsDialogs.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 - -; 安装完成后检测并安装 VC++ Redistributable -!macro customInstall - ; 检查 VC++ 2015-2022 x64 是否已安装 - ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" - ${If} $0 != "1" - ; 未安装,显示提示并下载 - MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装?(约 24MB)" IDYES downloadVC IDNO skipVC - - downloadVC: - DetailPrint "正在下载 Visual C++ Redistributable..." - SetOutPath "$TEMP" - - ; 从微软官方下载 VC++ Redistributable x64 - inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \ - "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END - Pop $0 - - ${If} $0 == "OK" - DetailPrint "下载完成,正在安装..." - ; 使用 ShellExecute 以管理员权限运行 - ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE - ; 等待安装完成 - Sleep 5000 - ; 检查是否安装成功 - ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" - ${If} $1 == "1" - DetailPrint "Visual C++ Redistributable 安装成功" - MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!" - ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,你可能需要手动安装。" - ${EndIf} - Delete "$TEMP\vc_redist.x64.exe" - ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n你可以稍后手动下载安装 Visual C++ Redistributable。" - ${EndIf} - Goto doneVC - - skipVC: - DetailPrint "用户跳过 Visual C++ Redistributable 安装" - - doneVC: - ${Else} - DetailPrint "Visual C++ Redistributable 已安装" - ${EndIf} -!macroend diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d69ba36..0000000 --- a/package-lock.json +++ /dev/null @@ -1,10104 +0,0 @@ -{ - "name": "weflow", - "version": "4.3.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "weflow", - "version": "4.3.0", - "hasInstallScript": true, - "dependencies": { - "@vscode/sudo-prompt": "^9.3.2", - "echarts": "^6.0.0", - "echarts-for-react": "^3.0.2", - "electron-store": "^11.0.2", - "electron-updater": "^6.3.9", - "exceljs": "^4.4.0", - "ffmpeg-static": "^5.3.0", - "fzstd": "^0.1.1", - "html2canvas": "^1.4.1", - "jieba-wasm": "^2.2.0", - "jszip": "^3.10.1", - "koffi": "^2.9.0", - "lucide-react": "^1.8.0", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-markdown": "^10.1.0", - "react-router-dom": "^7.14.0", - "react-virtuoso": "^4.18.5", - "remark-gfm": "^4.0.1", - "sherpa-onnx-node": "^1.10.38", - "silk-wasm": "^3.7.1", - "wechat-emojis": "^1.0.2", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.0", - "@vitejs/plugin-react": "^6.0.1", - "electron": "^41.1.1", - "electron-builder": "^26.8.1", - "esbuild": "^0.28.0", - "sass": "^1.98.0", - "sharp": "^0.34.5", - "typescript": "^6.0.3", - "vite": "^8.0.10", - "vite-plugin-electron": "^0.28.8", - "vite-plugin-electron-renderer": "^0.14.6" - } - }, - "node_modules/@derhuerst/http-basic": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", - "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", - "license": "MIT", - "dependencies": { - "caseless": "^0.12.0", - "concat-stream": "^2.0.0", - "http-response-object": "^3.0.1", - "parse-cache-control": "^1.0.1" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" - } - }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/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/fuses/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/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/fuses/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/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.npmjs.org/@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.npmjs.org/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.npmjs.org/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.npmjs.org/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.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", - "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": "^7.5.6", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@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/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "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.npmjs.org/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.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "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.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "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.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/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.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fast-csv/format": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", - "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", - "license": "MIT", - "dependencies": { - "@types/node": "^14.0.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isboolean": "^3.0.3", - "lodash.isequal": "^4.5.0", - "lodash.isfunction": "^3.0.9", - "lodash.isnil": "^4.0.0" - } - }, - "node_modules/@fast-csv/format/node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "license": "MIT" - }, - "node_modules/@fast-csv/parse": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", - "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", - "license": "MIT", - "dependencies": { - "@types/node": "^14.0.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.groupby": "^4.6.0", - "lodash.isfunction": "^3.0.9", - "lodash.isnil": "^4.0.0", - "lodash.isundefined": "^3.0.1", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/@fast-csv/parse/node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "license": "MIT" - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "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.npmjs.org/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.npmjs.org/@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/@malept/cross-spawn-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/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.npmjs.org/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.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/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.npmjs.org/@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/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "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.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "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/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/@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/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tybys/wasm-util/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@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.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@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/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@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.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@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.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/@vscode/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", - "license": "MIT" - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "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.npmjs.org/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.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/app-builder-lib": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", - "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@types/fs-extra": "9.0.13", - "async-exit-hook": "^2.0.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.8.1", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.8.1", - "electron-builder-squirrel-windows": "26.8.1" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "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": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/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/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/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-extra/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/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/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/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/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "license": "MIT", - "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.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "license": "MIT", - "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.npmjs.org/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/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "license": "Unlicense", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "license": "MIT", - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/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.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "license": "MIT" - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/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": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/buffer-indexof-polyfill": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", - "engines": { - "node": ">=0.2.0" - } - }, - "node_modules/builder-util": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", - "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/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/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "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.npmjs.org/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.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "license": "Apache-2.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "license": "MIT/X11", - "dependencies": { - "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/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/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/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": "3.0.0", - "resolved": "https://registry.npmjs.org/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/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/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/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "license": "MIT", - "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.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/conf": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", - "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "atomically": "^2.0.3", - "debounce-fn": "^6.0.0", - "dot-prop": "^10.0.0", - "env-paths": "^3.0.0", - "json-schema-typed": "^8.0.1", - "semver": "^7.7.2", - "uint8array-extras": "^1.5.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/conf/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "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.npmjs.org/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.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" - }, - "node_modules/debounce-fn": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "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.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dir-compare": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/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/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/dmg-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", - "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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": "10.1.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", - "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", - "license": "MIT", - "dependencies": { - "type-fest": "^5.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "license": "BSD-3-Clause", - "dependencies": { - "readable-stream": "^2.0.2" - } - }, - "node_modules/duplexer2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/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/duplexer2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/duplexer2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/echarts": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", - "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "2.3.0", - "zrender": "6.0.0" - } - }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "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.npmjs.org/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": "41.1.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", - "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, - "node_modules/electron-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", - "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.8.1", - "fs-extra": "^10.1.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": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", - "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "electron-winstaller": "5.4.0" - } - }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", - "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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": "11.0.2", - "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", - "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", - "license": "MIT", - "dependencies": { - "conf": "^15.0.2", - "type-fest": "^5.0.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/electron-updater": { - "version": "6.8.3", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", - "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", - "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/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" - } - }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/exceljs": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", - "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", - "license": "MIT", - "dependencies": { - "archiver": "^5.0.0", - "dayjs": "^1.8.34", - "fast-csv": "^4.3.1", - "jszip": "^3.10.1", - "readable-stream": "^3.6.0", - "saxes": "^5.0.1", - "tmp": "^0.2.0", - "unzipper": "^0.10.11", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=8.3.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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-csv": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", - "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", - "license": "MIT", - "dependencies": { - "@fast-csv/format": "4.3.5", - "@fast-csv/parse": "4.3.6" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/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/ffmpeg-static": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", - "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", - "hasInstallScript": true, - "license": "GPL-3.0-or-later", - "dependencies": { - "@derhuerst/http-basic": "^8.2.0", - "env-paths": "^2.2.0", - "https-proxy-agent": "^5.0.0", - "progress": "^2.0.3" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/ffmpeg-static/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ffmpeg-static/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/filelist": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", - "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/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/fstream": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" - }, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", - "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", - "license": "MIT" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "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/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/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/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/http-response-object": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", - "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", - "license": "MIT", - "dependencies": { - "@types/node": "^10.0.3" - } - }, - "node_modules/http-response-object/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "license": "MIT" - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/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.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/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-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/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.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/jieba-wasm/-/jieba-wasm-2.4.0.tgz", - "integrity": "sha512-ZvQdS+FGifrFXZIXSgOyOgEz+1wdy1P4vSvwe37FVtku9ycSdHTZbHqF5i9tMN1JucoAmeiLBeI6/YaqcGD+KA==", - "license": "MIT" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/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/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.15.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", - "integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/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.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "license": "MIT", - "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.npmjs.org/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/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", - "license": "ISC" - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "license": "MIT" - }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "license": "MIT" - }, - "node_modules/lodash.groupby": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", - "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/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.isfunction": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", - "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", - "license": "MIT" - }, - "node_modules/lodash.isnil": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", - "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isundefined": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", - "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", - "license": "MIT" - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "license": "MIT" - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/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/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lucide-react": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", - "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", - "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.npmjs.org/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/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/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.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/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.28.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", - "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/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/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/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/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse-cache-control": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", - "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/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.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "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/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/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/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-markdown": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", - "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "node_modules/react-router": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", - "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", - "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.14.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", - "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", - "license": "MIT", - "dependencies": { - "react-router": "7.14.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/react-virtuoso": { - "version": "4.18.5", - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.5.tgz", - "integrity": "sha512-QDyNjyNEuurZG67SOmzYyxEkQYSyGmAMixOI6M15L/Q4CF39EgG+88y6DgZRo0q7rmy0HPx3Fj90I8/tPdnRCQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=16 || >=17 || >= 18 || >= 19", - "react-dom": ">=16 || >=17 || >= 18 || >=19" - } - }, - "node_modules/read-binary-file-arch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/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/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/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/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sanitize-filename": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", - "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", - "dev": true, - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sass": { - "version": "1.99.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", - "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.1.5", - "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.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sherpa-onnx-darwin-arm64": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.12.35.tgz", - "integrity": "sha512-WGIABo3ruBXE/7FhAdaVNuM+ZKx0B7jkA+jT22k5TxUcw58nWzgkY6k+CPdM14lfaaXR+jPWdDrM4gXl/bP4RQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sherpa-onnx-darwin-x64": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.12.35.tgz", - "integrity": "sha512-hzWQm4CJhGyf3N9Sd1Oobcdz49FauuSCmhrm5vRqydyNsANjs89wATHAuatPAtinpBkgEqacDPrGz+1A/BWpNA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/sherpa-onnx-linux-arm64": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.12.35.tgz", - "integrity": "sha512-9glJ+dRv/rFWz/61tiKfaR9Gj+8B6sXi7NBgwBAnO/+ygu/WAjBfQRz2+S0YIy1dxqu7ng246TBNnx1M2XaNXA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/sherpa-onnx-linux-x64": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.12.35.tgz", - "integrity": "sha512-h+v4Yed8T+k1qLlKX2LTGoXP/11ycz7jbqC2f80kDWgz9J8m46mOBa/H20wVkLyQPy1vG1O5iH5Fe5Wh4QlLhw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/sherpa-onnx-node": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.12.35.tgz", - "integrity": "sha512-RHCgV+9fos/ZxP0MsIL7JPU9K3YHnIDmwtX674ChQZY6DLVaIQaju+J3hDqzRu1R3agnDg9WDf01zsT46NC7SQ==", - "license": "Apache-2.0", - "optionalDependencies": { - "sherpa-onnx-darwin-arm64": "^1.12.35", - "sherpa-onnx-darwin-x64": "^1.12.35", - "sherpa-onnx-linux-arm64": "^1.12.35", - "sherpa-onnx-linux-x64": "^1.12.35", - "sherpa-onnx-win-ia32": "^1.12.35", - "sherpa-onnx-win-x64": "^1.12.35" - } - }, - "node_modules/sherpa-onnx-win-ia32": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.35.tgz", - "integrity": "sha512-6H6BSdXXWtz92AuvOmr4w/QvCofxXbfbNKT7jCxdE7Nd4AvinLJxT02vbnL6T54vuXd9chu0QvQrDl1tuRphAA==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/sherpa-onnx-win-x64": { - "version": "1.12.35", - "resolved": "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.12.35.tgz", - "integrity": "sha512-+GLrxwaEvpJAO0KZgKulfd4qUR089MD+TjE5jVSugMTq4Eh/R/TpPPqYQGibRZVPHW7Se1ABfHGapZQoFMHH5Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/silk-wasm": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/silk-wasm/-/silk-wasm-3.7.1.tgz", - "integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==", - "license": "MIT", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/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.3", - "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", - "license": "MIT" - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", - "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/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/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/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/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/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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-async-pool": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", - "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^5.5.0" - } - }, - "node_modules/tiny-async-pool/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/tiny-typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", - "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "license": "MIT/X11", - "engines": { - "node": "*" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "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.npmjs.org/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.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unzipper": { - "version": "0.10.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", - "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", - "license": "MIT", - "dependencies": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", - "duplexer2": "~0.1.4", - "fstream": "^1.0.12", - "graceful-fs": "^4.2.2", - "listenercount": "~1.0.1", - "readable-stream": "~2.3.6", - "setimmediate": "~1.0.4" - } - }, - "node_modules/unzipper/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/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/unzipper/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/unzipper/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/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/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/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/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "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.npmjs.org/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.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", - "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", - "dev": true, - "license": "MIT" - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "license": "MIT" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "license": "MIT", - "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.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "license": "MIT", - "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": "6.0.0", - "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", - "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", - "license": "BSD-3-Clause", - "dependencies": { - "tslib": "2.3.0" - } - }, - "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", - "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 - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 1534c31..0000000 --- a/package.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "name": "weflow", - "version": "4.3.0", - "description": "WeFlow", - "main": "dist-electron/main.js", - "author": { - "name": "cc", - "email": "yccccccy@proton.me" - }, - "repository": { - "type": "git", - "url": "https://github.com/hicccc77/WeFlow" - }, - "//": "二改不应改变此处的作者与应用信息", - "scripts": { - "postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs", - "rebuild": "electron-rebuild", - "dev": "node scripts/prepare-electron-runtime.cjs && vite", - "typecheck": "tsc --noEmit", - "build": "tsc && vite build && electron-builder", - "preview": "vite preview", - "electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron", - "electron:build": "npm run build" - }, - "dependencies": { - "@vscode/sudo-prompt": "^9.3.2", - "echarts": "^6.0.0", - "echarts-for-react": "^3.0.2", - "electron-store": "^11.0.2", - "electron-updater": "^6.3.9", - "exceljs": "^4.4.0", - "ffmpeg-static": "^5.3.0", - "fzstd": "^0.1.1", - "html2canvas": "^1.4.1", - "jieba-wasm": "^2.2.0", - "jszip": "^3.10.1", - "koffi": "^2.9.0", - "lucide-react": "^1.8.0", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-markdown": "^10.1.0", - "react-router-dom": "^7.14.0", - "react-virtuoso": "^4.18.5", - "remark-gfm": "^4.0.1", - "sherpa-onnx-node": "^1.10.38", - "silk-wasm": "^3.7.1", - "wechat-emojis": "^1.0.2", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@types/react": "^19.1.0", - "@types/react-dom": "^19.1.0", - "@vitejs/plugin-react": "^6.0.1", - "electron": "^41.1.1", - "electron-builder": "^26.8.1", - "esbuild": "^0.28.0", - "sass": "^1.98.0", - "sharp": "^0.34.5", - "typescript": "^6.0.3", - "vite": "^8.0.10", - "vite-plugin-electron": "^0.28.8", - "vite-plugin-electron-renderer": "^0.14.6" - }, - "pnpm": { - "overrides": { - "tar": ">=6.2.1", - "minimatch": ">=3.1.2", - "rollup": ">=4.0.0", - "immutable": ">=4.0.0", - "lodash": ">=4.17.21", - "brace-expansion": ">=1.1.11", - "picomatch": ">=2.3.1", - "ajv": ">=8.18.0" - } - }, - "build": { - "appId": "com.WeFlow.app", - "publish": { - "provider": "github", - "owner": "hicccc77", - "repo": "WeFlow", - "releaseType": "release" - }, - "productName": "WeFlow", - "artifactName": "${productName}-${version}-Setup.${ext}", - "directories": { - "output": "release" - }, - "mac": { - "target": [ - "dmg", - "zip" - ], - "category": "public.app-category.utilities", - "hardenedRuntime": false, - "gatekeeperAssess": false, - "entitlements": "electron/entitlements.mac.plist", - "entitlementsInherit": "electron/entitlements.mac.plist", - "icon": "resources/icons/macos/icon.icns" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "public/icon.ico", - "extraFiles": [ - { - "from": "resources/runtime/win32/msvcp140.dll", - "to": "." - }, - { - "from": "resources/runtime/win32/msvcp140_1.dll", - "to": "." - }, - { - "from": "resources/runtime/win32/vcruntime140.dll", - "to": "." - }, - { - "from": "resources/runtime/win32/vcruntime140_1.dll", - "to": "." - } - ] - }, - "linux": { - "icon": "public/icon.png", - "target": [ - "appimage", - "tar.gz" - ], - "category": "Utility", - "executableName": "weflow", - "synopsis": "WeFlow for Linux", - "extraFiles": [ - { - "from": "resources/installer/linux/install.sh", - "to": "install.sh" - } - ] - }, - "nsis": { - "oneClick": false, - "differentialPackage": 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" - }, - { - "from": "public/icon.png", - "to": "icon.png" - }, - { - "from": "electron/assets/wasm/", - "to": "assets/wasm/" - } - ], - "files": [ - "dist/**/*", - "dist-electron/**/*" - ], - "asarUnpack": [ - "node_modules/silk-wasm/**/*", - "node_modules/sherpa-onnx-node/**/*", - "node_modules/sherpa-onnx-*/*", - "node_modules/sherpa-onnx-*/**/*", - "node_modules/ffmpeg-static/**/*", - "resources/wedecrypt/**/*.node" - ], - "icon": "resources/icons/macos/icon.icns" - }, - "overrides": { - "picomatch": "^4.0.4", - "tar": "^7.5.13", - "immutable": "^5.1.5" - } -} diff --git a/public/assets/animal/发抖.png b/public/assets/animal/发抖.png deleted file mode 100644 index 2bc6cf2..0000000 Binary files a/public/assets/animal/发抖.png and /dev/null differ diff --git a/public/assets/animal/猪头.png b/public/assets/animal/猪头.png deleted file mode 100644 index c14749e..0000000 Binary files a/public/assets/animal/猪头.png and /dev/null differ diff --git a/public/assets/animal/跳跳.png b/public/assets/animal/跳跳.png deleted file mode 100644 index ae1307a..0000000 Binary files a/public/assets/animal/跳跳.png and /dev/null differ diff --git a/public/assets/animal/转圈.png b/public/assets/animal/转圈.png deleted file mode 100644 index 7aa3b5a..0000000 Binary files a/public/assets/animal/转圈.png and /dev/null differ diff --git a/public/assets/blessing/庆祝.png b/public/assets/blessing/庆祝.png deleted file mode 100644 index 6b3c639..0000000 Binary files a/public/assets/blessing/庆祝.png and /dev/null differ diff --git a/public/assets/blessing/烟花.png b/public/assets/blessing/烟花.png deleted file mode 100644 index 58d2848..0000000 Binary files a/public/assets/blessing/烟花.png and /dev/null differ diff --git a/public/assets/blessing/爆竹.png b/public/assets/blessing/爆竹.png deleted file mode 100644 index 52ac8fb..0000000 Binary files a/public/assets/blessing/爆竹.png and /dev/null differ diff --git a/public/assets/blessing/發.png b/public/assets/blessing/發.png deleted file mode 100644 index 2af454c..0000000 Binary files a/public/assets/blessing/發.png and /dev/null differ diff --git a/public/assets/blessing/礼物.png b/public/assets/blessing/礼物.png deleted file mode 100644 index 6082fba..0000000 Binary files a/public/assets/blessing/礼物.png and /dev/null differ diff --git a/public/assets/blessing/福.png b/public/assets/blessing/福.png deleted file mode 100644 index d4a5b17..0000000 Binary files a/public/assets/blessing/福.png and /dev/null differ diff --git a/public/assets/blessing/红包.png b/public/assets/blessing/红包.png deleted file mode 100644 index 28b9698..0000000 Binary files a/public/assets/blessing/红包.png and /dev/null differ diff --git a/public/assets/face/666.png b/public/assets/face/666.png deleted file mode 100644 index f947dc6..0000000 Binary files a/public/assets/face/666.png and /dev/null differ diff --git a/public/assets/face/Emm.png b/public/assets/face/Emm.png deleted file mode 100644 index 8fb9370..0000000 Binary files a/public/assets/face/Emm.png and /dev/null differ diff --git a/public/assets/face/亲亲.png b/public/assets/face/亲亲.png deleted file mode 100644 index 3190fa3..0000000 Binary files a/public/assets/face/亲亲.png and /dev/null differ diff --git a/public/assets/face/偷笑.png b/public/assets/face/偷笑.png deleted file mode 100644 index 51836d1..0000000 Binary files a/public/assets/face/偷笑.png and /dev/null differ diff --git a/public/assets/face/傲慢.png b/public/assets/face/傲慢.png deleted file mode 100644 index cfd4cf5..0000000 Binary files a/public/assets/face/傲慢.png and /dev/null differ diff --git a/public/assets/face/再见.png b/public/assets/face/再见.png deleted file mode 100644 index 8ef994b..0000000 Binary files a/public/assets/face/再见.png and /dev/null differ diff --git a/public/assets/face/加油.png b/public/assets/face/加油.png deleted file mode 100644 index 911ca55..0000000 Binary files a/public/assets/face/加油.png and /dev/null differ diff --git a/public/assets/face/发呆.png b/public/assets/face/发呆.png deleted file mode 100644 index e6e388f..0000000 Binary files a/public/assets/face/发呆.png and /dev/null differ diff --git a/public/assets/face/发怒.png b/public/assets/face/发怒.png deleted file mode 100644 index c8cba00..0000000 Binary files a/public/assets/face/发怒.png and /dev/null differ diff --git a/public/assets/face/可怜.png b/public/assets/face/可怜.png deleted file mode 100644 index 1e75cbb..0000000 Binary files a/public/assets/face/可怜.png and /dev/null differ diff --git a/public/assets/face/右哼哼.png b/public/assets/face/右哼哼.png deleted file mode 100644 index e466e2a..0000000 Binary files a/public/assets/face/右哼哼.png and /dev/null differ diff --git a/public/assets/face/叹气.png b/public/assets/face/叹气.png deleted file mode 100644 index 2840584..0000000 Binary files a/public/assets/face/叹气.png and /dev/null differ diff --git a/public/assets/face/吃瓜.png b/public/assets/face/吃瓜.png deleted file mode 100644 index 64f72ad..0000000 Binary files a/public/assets/face/吃瓜.png and /dev/null differ diff --git a/public/assets/face/吐.png b/public/assets/face/吐.png deleted file mode 100644 index b3e072e..0000000 Binary files a/public/assets/face/吐.png and /dev/null differ diff --git a/public/assets/face/呲牙.png b/public/assets/face/呲牙.png deleted file mode 100644 index abce5e0..0000000 Binary files a/public/assets/face/呲牙.png and /dev/null differ diff --git a/public/assets/face/咒骂.png b/public/assets/face/咒骂.png deleted file mode 100644 index 0a8c0bf..0000000 Binary files a/public/assets/face/咒骂.png and /dev/null differ diff --git a/public/assets/face/哇.png b/public/assets/face/哇.png deleted file mode 100644 index 5d1c179..0000000 Binary files a/public/assets/face/哇.png and /dev/null differ diff --git a/public/assets/face/嘘.png b/public/assets/face/嘘.png deleted file mode 100644 index a670056..0000000 Binary files a/public/assets/face/嘘.png and /dev/null differ diff --git a/public/assets/face/嘿哈.png b/public/assets/face/嘿哈.png deleted file mode 100644 index d424a3f..0000000 Binary files a/public/assets/face/嘿哈.png and /dev/null differ diff --git a/public/assets/face/囧.png b/public/assets/face/囧.png deleted file mode 100644 index fc7fbfc..0000000 Binary files a/public/assets/face/囧.png and /dev/null differ diff --git a/public/assets/face/困.png b/public/assets/face/困.png deleted file mode 100644 index 148c66f..0000000 Binary files a/public/assets/face/困.png and /dev/null differ diff --git a/public/assets/face/坏笑.png b/public/assets/face/坏笑.png deleted file mode 100644 index 8585d82..0000000 Binary files a/public/assets/face/坏笑.png and /dev/null differ diff --git a/public/assets/face/大哭.png b/public/assets/face/大哭.png deleted file mode 100644 index 3c64886..0000000 Binary files a/public/assets/face/大哭.png and /dev/null differ diff --git a/public/assets/face/天啊.png b/public/assets/face/天啊.png deleted file mode 100644 index 67d1b97..0000000 Binary files a/public/assets/face/天啊.png and /dev/null differ diff --git a/public/assets/face/失望.png b/public/assets/face/失望.png deleted file mode 100644 index d38c888..0000000 Binary files a/public/assets/face/失望.png and /dev/null differ diff --git a/public/assets/face/奸笑.png b/public/assets/face/奸笑.png deleted file mode 100644 index 895c591..0000000 Binary files a/public/assets/face/奸笑.png and /dev/null differ diff --git a/public/assets/face/好的.png b/public/assets/face/好的.png deleted file mode 100644 index 7005ce9..0000000 Binary files a/public/assets/face/好的.png and /dev/null differ diff --git a/public/assets/face/委屈.png b/public/assets/face/委屈.png deleted file mode 100644 index b6491fd..0000000 Binary files a/public/assets/face/委屈.png and /dev/null differ diff --git a/public/assets/face/害羞.png b/public/assets/face/害羞.png deleted file mode 100644 index 50ec786..0000000 Binary files a/public/assets/face/害羞.png and /dev/null differ diff --git a/public/assets/face/尴尬.png b/public/assets/face/尴尬.png deleted file mode 100644 index 6d4900e..0000000 Binary files a/public/assets/face/尴尬.png and /dev/null differ diff --git a/public/assets/face/得意.png b/public/assets/face/得意.png deleted file mode 100644 index 0bac94b..0000000 Binary files a/public/assets/face/得意.png and /dev/null differ diff --git a/public/assets/face/微笑.png b/public/assets/face/微笑.png deleted file mode 100644 index a3195d6..0000000 Binary files a/public/assets/face/微笑.png and /dev/null differ diff --git a/public/assets/face/快哭了.png b/public/assets/face/快哭了.png deleted file mode 100644 index f0558ba..0000000 Binary files a/public/assets/face/快哭了.png and /dev/null differ diff --git a/public/assets/face/恐惧.png b/public/assets/face/恐惧.png deleted file mode 100644 index 6e935b2..0000000 Binary files a/public/assets/face/恐惧.png and /dev/null differ diff --git a/public/assets/face/悠闲.png b/public/assets/face/悠闲.png deleted file mode 100644 index dd19c44..0000000 Binary files a/public/assets/face/悠闲.png and /dev/null differ diff --git a/public/assets/face/惊恐.png b/public/assets/face/惊恐.png deleted file mode 100644 index 8bfda02..0000000 Binary files a/public/assets/face/惊恐.png and /dev/null differ diff --git a/public/assets/face/惊讶.png b/public/assets/face/惊讶.png deleted file mode 100644 index 33feac5..0000000 Binary files a/public/assets/face/惊讶.png and /dev/null differ diff --git a/public/assets/face/愉快.png b/public/assets/face/愉快.png deleted file mode 100644 index a78c8e6..0000000 Binary files a/public/assets/face/愉快.png and /dev/null differ diff --git a/public/assets/face/憨笑.png b/public/assets/face/憨笑.png deleted file mode 100644 index b554894..0000000 Binary files a/public/assets/face/憨笑.png and /dev/null differ diff --git a/public/assets/face/打脸.png b/public/assets/face/打脸.png deleted file mode 100644 index d12031a..0000000 Binary files a/public/assets/face/打脸.png and /dev/null differ diff --git a/public/assets/face/抓狂.png b/public/assets/face/抓狂.png deleted file mode 100644 index b440837..0000000 Binary files a/public/assets/face/抓狂.png and /dev/null differ diff --git a/public/assets/face/抠鼻.png b/public/assets/face/抠鼻.png deleted file mode 100644 index e44adf6..0000000 Binary files a/public/assets/face/抠鼻.png and /dev/null differ diff --git a/public/assets/face/捂脸.png b/public/assets/face/捂脸.png deleted file mode 100644 index ea8a13c..0000000 Binary files a/public/assets/face/捂脸.png and /dev/null differ diff --git a/public/assets/face/撇嘴.png b/public/assets/face/撇嘴.png deleted file mode 100644 index 937ae74..0000000 Binary files a/public/assets/face/撇嘴.png and /dev/null differ diff --git a/public/assets/face/擦汗.png b/public/assets/face/擦汗.png deleted file mode 100644 index b9256ad..0000000 Binary files a/public/assets/face/擦汗.png and /dev/null differ diff --git a/public/assets/face/敲打.png b/public/assets/face/敲打.png deleted file mode 100644 index 5eb4480..0000000 Binary files a/public/assets/face/敲打.png and /dev/null differ diff --git a/public/assets/face/无语.png b/public/assets/face/无语.png deleted file mode 100644 index 443f9d6..0000000 Binary files a/public/assets/face/无语.png and /dev/null differ diff --git a/public/assets/face/旺柴.png b/public/assets/face/旺柴.png deleted file mode 100644 index 02000fb..0000000 Binary files a/public/assets/face/旺柴.png and /dev/null differ diff --git a/public/assets/face/晕.png b/public/assets/face/晕.png deleted file mode 100644 index 8b0a5a2..0000000 Binary files a/public/assets/face/晕.png and /dev/null differ diff --git a/public/assets/face/机智.png b/public/assets/face/机智.png deleted file mode 100644 index 999d4b5..0000000 Binary files a/public/assets/face/机智.png and /dev/null differ diff --git a/public/assets/face/汗.png b/public/assets/face/汗.png deleted file mode 100644 index 3b940c5..0000000 Binary files a/public/assets/face/汗.png and /dev/null differ diff --git a/public/assets/face/流泪.png b/public/assets/face/流泪.png deleted file mode 100644 index bdfe6fc..0000000 Binary files a/public/assets/face/流泪.png and /dev/null differ diff --git a/public/assets/face/生病.png b/public/assets/face/生病.png deleted file mode 100644 index 39fdd91..0000000 Binary files a/public/assets/face/生病.png and /dev/null differ diff --git a/public/assets/face/疑问.png b/public/assets/face/疑问.png deleted file mode 100644 index c2bb9c9..0000000 Binary files a/public/assets/face/疑问.png and /dev/null differ diff --git a/public/assets/face/白眼.png b/public/assets/face/白眼.png deleted file mode 100644 index fa261a4..0000000 Binary files a/public/assets/face/白眼.png and /dev/null differ diff --git a/public/assets/face/皱眉.png b/public/assets/face/皱眉.png deleted file mode 100644 index 123bf08..0000000 Binary files a/public/assets/face/皱眉.png and /dev/null differ diff --git a/public/assets/face/睡.png b/public/assets/face/睡.png deleted file mode 100644 index 8b37848..0000000 Binary files a/public/assets/face/睡.png and /dev/null differ diff --git a/public/assets/face/破涕为笑.png b/public/assets/face/破涕为笑.png deleted file mode 100644 index 447dcb6..0000000 Binary files a/public/assets/face/破涕为笑.png and /dev/null differ diff --git a/public/assets/face/社会社会.png b/public/assets/face/社会社会.png deleted file mode 100644 index e4a311b..0000000 Binary files a/public/assets/face/社会社会.png and /dev/null differ diff --git a/public/assets/face/笑脸.png b/public/assets/face/笑脸.png deleted file mode 100644 index e3ac78d..0000000 Binary files a/public/assets/face/笑脸.png and /dev/null differ diff --git a/public/assets/face/翻白眼.png b/public/assets/face/翻白眼.png deleted file mode 100644 index 4ebf05d..0000000 Binary files a/public/assets/face/翻白眼.png and /dev/null differ diff --git a/public/assets/face/耶.png b/public/assets/face/耶.png deleted file mode 100644 index 969fd94..0000000 Binary files a/public/assets/face/耶.png and /dev/null differ diff --git a/public/assets/face/脸红.png b/public/assets/face/脸红.png deleted file mode 100644 index 92ba41d..0000000 Binary files a/public/assets/face/脸红.png and /dev/null differ diff --git a/public/assets/face/色.png b/public/assets/face/色.png deleted file mode 100644 index 630ae0e..0000000 Binary files a/public/assets/face/色.png and /dev/null differ diff --git a/public/assets/face/苦涩.png b/public/assets/face/苦涩.png deleted file mode 100644 index bf0dd64..0000000 Binary files a/public/assets/face/苦涩.png and /dev/null differ diff --git a/public/assets/face/衰.png b/public/assets/face/衰.png deleted file mode 100644 index 47ed471..0000000 Binary files a/public/assets/face/衰.png and /dev/null differ diff --git a/public/assets/face/裂开.png b/public/assets/face/裂开.png deleted file mode 100644 index 626d479..0000000 Binary files a/public/assets/face/裂开.png and /dev/null differ diff --git a/public/assets/face/让我看看.png b/public/assets/face/让我看看.png deleted file mode 100644 index c3fafee..0000000 Binary files a/public/assets/face/让我看看.png and /dev/null differ diff --git a/public/assets/face/调皮.png b/public/assets/face/调皮.png deleted file mode 100644 index 5aba419..0000000 Binary files a/public/assets/face/调皮.png and /dev/null differ diff --git a/public/assets/face/鄙视.png b/public/assets/face/鄙视.png deleted file mode 100644 index 3d32430..0000000 Binary files a/public/assets/face/鄙视.png and /dev/null differ diff --git a/public/assets/face/闭嘴.png b/public/assets/face/闭嘴.png deleted file mode 100644 index d89f350..0000000 Binary files a/public/assets/face/闭嘴.png and /dev/null differ diff --git a/public/assets/face/阴险.png b/public/assets/face/阴险.png deleted file mode 100644 index d37f39c..0000000 Binary files a/public/assets/face/阴险.png and /dev/null differ diff --git a/public/assets/face/难过.png b/public/assets/face/难过.png deleted file mode 100644 index 636ae7b..0000000 Binary files a/public/assets/face/难过.png and /dev/null differ diff --git a/public/assets/face/骷髅.png b/public/assets/face/骷髅.png deleted file mode 100644 index f883579..0000000 Binary files a/public/assets/face/骷髅.png and /dev/null differ diff --git a/public/assets/face/鼓掌.png b/public/assets/face/鼓掌.png deleted file mode 100644 index c6963bc..0000000 Binary files a/public/assets/face/鼓掌.png and /dev/null differ diff --git a/public/assets/gesture/OK.png b/public/assets/gesture/OK.png deleted file mode 100644 index 2d0f6e5..0000000 Binary files a/public/assets/gesture/OK.png and /dev/null differ diff --git a/public/assets/gesture/勾引.png b/public/assets/gesture/勾引.png deleted file mode 100644 index 28e3733..0000000 Binary files a/public/assets/gesture/勾引.png and /dev/null differ diff --git a/public/assets/gesture/合十.png b/public/assets/gesture/合十.png deleted file mode 100644 index eca2b73..0000000 Binary files a/public/assets/gesture/合十.png and /dev/null differ diff --git a/public/assets/gesture/弱.png b/public/assets/gesture/弱.png deleted file mode 100644 index be8b1a8..0000000 Binary files a/public/assets/gesture/弱.png and /dev/null differ diff --git a/public/assets/gesture/强.png b/public/assets/gesture/强.png deleted file mode 100644 index f81c624..0000000 Binary files a/public/assets/gesture/强.png and /dev/null differ diff --git a/public/assets/gesture/抱拳.png b/public/assets/gesture/抱拳.png deleted file mode 100644 index 51d17db..0000000 Binary files a/public/assets/gesture/抱拳.png and /dev/null differ diff --git a/public/assets/gesture/拥抱.png b/public/assets/gesture/拥抱.png deleted file mode 100644 index 0bbcdb9..0000000 Binary files a/public/assets/gesture/拥抱.png and /dev/null differ diff --git a/public/assets/gesture/拳头.png b/public/assets/gesture/拳头.png deleted file mode 100644 index 91c10e0..0000000 Binary files a/public/assets/gesture/拳头.png and /dev/null differ diff --git a/public/assets/gesture/握手.png b/public/assets/gesture/握手.png deleted file mode 100644 index 9e6be93..0000000 Binary files a/public/assets/gesture/握手.png and /dev/null differ diff --git a/public/assets/gesture/胜利.png b/public/assets/gesture/胜利.png deleted file mode 100644 index 2a54535..0000000 Binary files a/public/assets/gesture/胜利.png and /dev/null differ diff --git a/public/assets/insight/AI_Insight.png b/public/assets/insight/AI_Insight.png deleted file mode 100644 index 35afe67..0000000 Binary files a/public/assets/insight/AI_Insight.png and /dev/null differ diff --git a/public/assets/other/便便.png b/public/assets/other/便便.png deleted file mode 100644 index 9ee508f..0000000 Binary files a/public/assets/other/便便.png and /dev/null differ diff --git a/public/assets/other/凋谢.png b/public/assets/other/凋谢.png deleted file mode 100644 index b189bb9..0000000 Binary files a/public/assets/other/凋谢.png and /dev/null differ diff --git a/public/assets/other/咖啡.png b/public/assets/other/咖啡.png deleted file mode 100644 index 91b7c79..0000000 Binary files a/public/assets/other/咖啡.png and /dev/null differ diff --git a/public/assets/other/啤酒.png b/public/assets/other/啤酒.png deleted file mode 100644 index 81d40ba..0000000 Binary files a/public/assets/other/啤酒.png and /dev/null differ diff --git a/public/assets/other/嘴唇.png b/public/assets/other/嘴唇.png deleted file mode 100644 index 858e854..0000000 Binary files a/public/assets/other/嘴唇.png and /dev/null differ diff --git a/public/assets/other/太阳.png b/public/assets/other/太阳.png deleted file mode 100644 index 04578c4..0000000 Binary files a/public/assets/other/太阳.png and /dev/null differ diff --git a/public/assets/other/心碎.png b/public/assets/other/心碎.png deleted file mode 100644 index dc23ec8..0000000 Binary files a/public/assets/other/心碎.png and /dev/null differ diff --git a/public/assets/other/月亮.png b/public/assets/other/月亮.png deleted file mode 100644 index 20ed34e..0000000 Binary files a/public/assets/other/月亮.png and /dev/null differ diff --git a/public/assets/other/炸弹.png b/public/assets/other/炸弹.png deleted file mode 100644 index 9ece24c..0000000 Binary files a/public/assets/other/炸弹.png and /dev/null differ diff --git a/public/assets/other/爱心.png b/public/assets/other/爱心.png deleted file mode 100644 index aa9b744..0000000 Binary files a/public/assets/other/爱心.png and /dev/null differ diff --git a/public/assets/other/玫瑰.png b/public/assets/other/玫瑰.png deleted file mode 100644 index 83cc3b2..0000000 Binary files a/public/assets/other/玫瑰.png and /dev/null differ diff --git a/public/assets/other/菜刀.png b/public/assets/other/菜刀.png deleted file mode 100644 index 77c5a13..0000000 Binary files a/public/assets/other/菜刀.png and /dev/null differ diff --git a/public/assets/other/蛋糕.png b/public/assets/other/蛋糕.png deleted file mode 100644 index 9a62501..0000000 Binary files a/public/assets/other/蛋糕.png and /dev/null differ diff --git a/public/icon.ico b/public/icon.ico deleted file mode 100644 index 35229bb..0000000 Binary files a/public/icon.ico and /dev/null differ diff --git a/public/icon.png b/public/icon.png deleted file mode 100644 index 1216756..0000000 Binary files a/public/icon.png and /dev/null differ diff --git a/public/logo.png b/public/logo.png deleted file mode 100644 index 1216756..0000000 Binary files a/public/logo.png and /dev/null differ diff --git a/public/splash.html b/public/splash.html deleted file mode 100644 index 0c50382..0000000 --- a/public/splash.html +++ /dev/null @@ -1,481 +0,0 @@ - - - - - - WeFlow - - - - -
-
- - -

WeFlow

-

微信聊天记录管理工具

-
- -
-
- -
正在预加载会话逻辑...
-
-
-
- - -
- - - - diff --git a/resources/fonts/annual-report/CormorantGaramond-Var.ttf b/resources/fonts/annual-report/CormorantGaramond-Var.ttf deleted file mode 100644 index d992a83..0000000 Binary files a/resources/fonts/annual-report/CormorantGaramond-Var.ttf and /dev/null differ diff --git a/resources/fonts/annual-report/Inter-Var.ttf b/resources/fonts/annual-report/Inter-Var.ttf deleted file mode 100644 index 047c92f..0000000 Binary files a/resources/fonts/annual-report/Inter-Var.ttf and /dev/null differ diff --git a/resources/fonts/annual-report/NotoSerifSC-Var.ttf b/resources/fonts/annual-report/NotoSerifSC-Var.ttf deleted file mode 100644 index eab063f..0000000 Binary files a/resources/fonts/annual-report/NotoSerifSC-Var.ttf and /dev/null differ diff --git a/resources/fonts/annual-report/PlayfairDisplay-Var.ttf b/resources/fonts/annual-report/PlayfairDisplay-Var.ttf deleted file mode 100644 index 7a09eb7..0000000 Binary files a/resources/fonts/annual-report/PlayfairDisplay-Var.ttf and /dev/null differ diff --git a/resources/fonts/annual-report/SpaceMono-Bold.ttf b/resources/fonts/annual-report/SpaceMono-Bold.ttf deleted file mode 100644 index 2c4f268..0000000 Binary files a/resources/fonts/annual-report/SpaceMono-Bold.ttf and /dev/null differ diff --git a/resources/fonts/annual-report/SpaceMono-Regular.ttf b/resources/fonts/annual-report/SpaceMono-Regular.ttf deleted file mode 100644 index 1cfa365..0000000 Binary files a/resources/fonts/annual-report/SpaceMono-Regular.ttf and /dev/null differ diff --git a/resources/icons/macos/icon.icns b/resources/icons/macos/icon.icns deleted file mode 100644 index 70df606..0000000 Binary files a/resources/icons/macos/icon.icns and /dev/null differ diff --git a/resources/image/README.md b/resources/image/README.md deleted file mode 100644 index a964638..0000000 --- a/resources/image/README.md +++ /dev/null @@ -1 +0,0 @@ -> 目前只适配了x64 win32平台,其它平台同样原理,但是代码还没写( \ No newline at end of file diff --git a/resources/image/win32/x64/img_helper.dll b/resources/image/win32/x64/img_helper.dll deleted file mode 100644 index bfc0859..0000000 Binary files a/resources/image/win32/x64/img_helper.dll and /dev/null differ diff --git a/resources/installer/linux/.gitignore b/resources/installer/linux/.gitignore deleted file mode 100644 index 32accc2..0000000 --- a/resources/installer/linux/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -*.tar.gz -*.tar.xz -*.zip -src/ -pkg/ -weflow-*/ diff --git a/resources/installer/linux/PKGBUILD b/resources/installer/linux/PKGBUILD deleted file mode 100644 index 573a5e9..0000000 --- a/resources/installer/linux/PKGBUILD +++ /dev/null @@ -1,30 +0,0 @@ -# Maintainer: H3CoF6 -pkgname=weflow -pkgver=4.3.0 -pkgrel=1 -pkgdesc="A local WeChat database decryption and analysis tool" -arch=('x86_64') -url="https://github.com/hicccc77/weflow" -license=('CC-BY-NC-SA-4.0') -depends=('alsa-lib' 'gtk3' 'nss' 'glibc') -options=('!strip' '!debug') - -source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz" - "weflow.desktop" - "icon.png") - -sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340' - '2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7' - 'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33') - -package() { - install -dm755 "${pkgdir}/opt/${pkgname}" - - cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/" - - install -dm755 "${pkgdir}/usr/bin" - ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}" - - install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/" - install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png" -} diff --git a/resources/installer/linux/icon.png b/resources/installer/linux/icon.png deleted file mode 100644 index 7372ec7..0000000 Binary files a/resources/installer/linux/icon.png and /dev/null differ diff --git a/resources/installer/linux/install.sh b/resources/installer/linux/install.sh deleted file mode 100644 index eacc714..0000000 --- a/resources/installer/linux/install.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -set -e - -APP_NAME="weflow" -APP_EXEC="weflow" -OPT_DIR="/opt/$APP_NAME" -BIN_LINK="/usr/bin/$APP_NAME" -DESKTOP_DIR="/usr/share/applications" -ICON_DIR="/usr/share/pixmaps" - -if [ "$EUID" -ne 0 ]; then - echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)" - exit 1 -fi - -echo "🚀 开始安装 $APP_NAME..." - -echo "📦 正在复制文件到 $OPT_DIR..." -rm -rf "$OPT_DIR" -mkdir -p "$OPT_DIR" -cp -r ./* "$OPT_DIR/" -chmod -R 755 "$OPT_DIR" -chmod +x "$OPT_DIR/$APP_EXEC" - -echo "🔗 正在创建软链接 $BIN_LINK..." -ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK" - -echo "📝 正在创建桌面快捷方式..." -cat <"$DESKTOP_DIR/${APP_NAME}.desktop" -[Desktop Entry] -Name=WeFlow -Exec=$OPT_DIR/$APP_EXEC %U -Terminal=false -Type=Application -Icon=$APP_NAME -StartupWMClass=WeFlow -Comment=A local WeChat database decryption and analysis tool -Categories=Utility; -EOF -chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop" - -echo "🖼️ 正在安装图标..." -if [ -f "$OPT_DIR/resources/icon.png" ]; then - cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png" - chmod 644 "$ICON_DIR/${APP_NAME}.png" -elif [ -f "$OPT_DIR/icon.png" ]; then - cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png" - chmod 644 "$ICON_DIR/${APP_NAME}.png" -else - echo "⚠️ 警告: 未找到图标文件,跳过图标安装。" -fi - -if command -v update-desktop-database >/dev/null 2>&1; then - echo "🔄 更新桌面数据库..." - update-desktop-database "$DESKTOP_DIR" -fi - -echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。" diff --git a/resources/installer/linux/weflow.desktop b/resources/installer/linux/weflow.desktop deleted file mode 100644 index f2b1276..0000000 --- a/resources/installer/linux/weflow.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=WeFlow -Comment=一个本地的微信聊天记录导出和年度报告应用 -Exec=/usr/bin/weflow %U -Terminal=false -Type=Application -Icon=weflow -StartupWMClass=WeFlow -Categories=Utility; diff --git a/resources/key/linux/x64/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux deleted file mode 100755 index 8b3cd18..0000000 Binary files a/resources/key/linux/x64/xkey_helper_linux and /dev/null differ diff --git a/resources/key/macos/source/image_scan_entitlements.plist b/resources/key/macos/source/image_scan_entitlements.plist deleted file mode 100644 index 023065e..0000000 --- a/resources/key/macos/source/image_scan_entitlements.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.cs.debugger - - com.apple.security.cs.allow-unsigned-executable-memory - - - diff --git a/resources/key/macos/source/image_scan_helper.c b/resources/key/macos/source/image_scan_helper.c deleted file mode 100644 index 39bcf27..0000000 --- a/resources/key/macos/source/image_scan_helper.c +++ /dev/null @@ -1,77 +0,0 @@ -/* - * image_scan_helper - 轻量包装程序 - * 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey - * 用法: image_scan_helper - * 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."} - */ -#include -#include -#include -#include -#include -#include - -typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext); -typedef void (*FreeStringFn)(const char* str); - -int main(int argc, char* argv[]) { - if (argc != 3) { - fprintf(stderr, "Usage: %s \n", argv[0]); - printf("{\"success\":false,\"error\":\"invalid arguments\"}\n"); - return 1; - } - - int pid = atoi(argv[1]); - const char* ciphertext_hex = argv[2]; - - if (pid <= 0) { - printf("{\"success\":false,\"error\":\"invalid pid\"}\n"); - return 1; - } - - /* 定位 dylib: 与自身同目录下的 libwx_key.dylib */ - char exe_path[4096]; - uint32_t size = sizeof(exe_path); - if (_NSGetExecutablePath(exe_path, &size) != 0) { - printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n"); - return 1; - } - - char* dir = dirname(exe_path); - char dylib_path[4096]; - snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir); - - void* handle = dlopen(dylib_path, RTLD_LAZY); - if (!handle) { - printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror()); - return 1; - } - - ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey"); - if (!scan_fn) { - printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n"); - dlclose(handle); - return 1; - } - - FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString"); - - fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex); - - const char* result = scan_fn(pid, ciphertext_hex); - - if (result && strlen(result) > 0) { - /* 检查是否是错误 */ - if (strncmp(result, "ERROR", 5) == 0) { - printf("{\"success\":false,\"error\":\"%s\"}\n", result); - } else { - printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result); - } - if (free_fn) free_fn(result); - } else { - printf("{\"success\":false,\"error\":\"no key found\"}\n"); - } - - dlclose(handle); - return 0; -} diff --git a/resources/key/macos/universal/image_scan_helper b/resources/key/macos/universal/image_scan_helper deleted file mode 100644 index b10856d..0000000 Binary files a/resources/key/macos/universal/image_scan_helper and /dev/null differ diff --git a/resources/key/macos/universal/libwx_key.dylib b/resources/key/macos/universal/libwx_key.dylib deleted file mode 100644 index 59c673a..0000000 Binary files a/resources/key/macos/universal/libwx_key.dylib and /dev/null differ diff --git a/resources/key/macos/universal/xkey_helper b/resources/key/macos/universal/xkey_helper deleted file mode 100644 index d0c53a8..0000000 Binary files a/resources/key/macos/universal/xkey_helper and /dev/null differ diff --git a/resources/key/macos/universal/xkey_helper_macos b/resources/key/macos/universal/xkey_helper_macos deleted file mode 100644 index c44dae8..0000000 Binary files a/resources/key/macos/universal/xkey_helper_macos and /dev/null differ diff --git a/resources/key/win32/x64/wx_key.dll b/resources/key/win32/x64/wx_key.dll deleted file mode 100644 index 9ab5f7b..0000000 Binary files a/resources/key/win32/x64/wx_key.dll and /dev/null differ diff --git a/resources/runtime/win32/msvcp140.dll b/resources/runtime/win32/msvcp140.dll deleted file mode 100644 index 554d2ff..0000000 Binary files a/resources/runtime/win32/msvcp140.dll and /dev/null differ diff --git a/resources/runtime/win32/msvcp140_1.dll b/resources/runtime/win32/msvcp140_1.dll deleted file mode 100644 index 184514f..0000000 Binary files a/resources/runtime/win32/msvcp140_1.dll and /dev/null differ diff --git a/resources/runtime/win32/vcruntime140.dll b/resources/runtime/win32/vcruntime140.dll deleted file mode 100644 index 950b587..0000000 Binary files a/resources/runtime/win32/vcruntime140.dll and /dev/null differ diff --git a/resources/runtime/win32/vcruntime140_1.dll b/resources/runtime/win32/vcruntime140_1.dll deleted file mode 100644 index a481970..0000000 Binary files a/resources/runtime/win32/vcruntime140_1.dll and /dev/null differ diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so deleted file mode 100644 index 609ed8d..0000000 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and /dev/null differ diff --git a/resources/wcdb/macos/universal/libWCDB.dylib b/resources/wcdb/macos/universal/libWCDB.dylib deleted file mode 100644 index 75eb279..0000000 Binary files a/resources/wcdb/macos/universal/libWCDB.dylib and /dev/null differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib deleted file mode 100644 index f7ff65d..0000000 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and /dev/null differ diff --git a/resources/wcdb/win32/arm64/WCDB.dll b/resources/wcdb/win32/arm64/WCDB.dll deleted file mode 100644 index 60c3508..0000000 Binary files a/resources/wcdb/win32/arm64/WCDB.dll and /dev/null differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll deleted file mode 100644 index 7bde0dc..0000000 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and /dev/null differ diff --git a/resources/wcdb/win32/x64/SDL2.dll b/resources/wcdb/win32/x64/SDL2.dll deleted file mode 100644 index e26bcb1..0000000 Binary files a/resources/wcdb/win32/x64/SDL2.dll and /dev/null differ diff --git a/resources/wcdb/win32/x64/WCDB.dll b/resources/wcdb/win32/x64/WCDB.dll deleted file mode 100644 index 8127079..0000000 Binary files a/resources/wcdb/win32/x64/WCDB.dll and /dev/null differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll deleted file mode 100644 index f61e40f..0000000 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and /dev/null differ diff --git a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node deleted file mode 100644 index f61ba21..0000000 Binary files a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node and /dev/null differ diff --git a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node deleted file mode 100644 index 7a9a1a0..0000000 Binary files a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node and /dev/null differ diff --git a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node deleted file mode 100644 index e3bdaa6..0000000 Binary files a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node and /dev/null differ diff --git a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node deleted file mode 100644 index 7b370ad..0000000 Binary files a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node and /dev/null differ diff --git a/scripts/prepare-electron-runtime.cjs b/scripts/prepare-electron-runtime.cjs deleted file mode 100644 index 1230732..0000000 --- a/scripts/prepare-electron-runtime.cjs +++ /dev/null @@ -1,57 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const runtimeNames = [ - 'msvcp140.dll', - 'msvcp140_1.dll', - 'vcruntime140.dll', - 'vcruntime140_1.dll', -]; - -function copyIfDifferent(sourcePath, targetPath) { - const source = fs.statSync(sourcePath); - const targetExists = fs.existsSync(targetPath); - - if (targetExists) { - const target = fs.statSync(targetPath); - if (target.size === source.size && target.mtimeMs >= source.mtimeMs) { - return false; - } - } - - fs.copyFileSync(sourcePath, targetPath); - return true; -} - -function main() { - if (process.platform !== 'win32') { - return; - } - - const projectRoot = path.resolve(__dirname, '..'); - const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32'); - const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist'); - - if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) { - return; - } - - let copiedCount = 0; - - for (const name of runtimeNames) { - const sourcePath = path.join(sourceDir, name); - const targetPath = path.join(targetDir, name); - if (!fs.existsSync(sourcePath)) { - continue; - } - if (copyIfDifferent(sourcePath, targetPath)) { - copiedCount += 1; - } - } - - if (copiedCount > 0) { - console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`); - } -} - -main(); diff --git a/shared/groupSummaryPrompt.json b/shared/groupSummaryPrompt.json deleted file mode 100644 index 9267994..0000000 --- a/shared/groupSummaryPrompt.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "defaultSystemPrompt": "你是一个群聊会议纪要式总结助手。你只根据用户提供的群聊记录总结,不编造记录中没有的信息。\n\n严格要求:\n1. 必须且只能输出合法纯 JSON,禁止 Markdown 和解释说明。\n2. 按话题分类总结,每个话题包含参与者、关键/矛盾点、结论。\n3. 参与者写群成员显示名;不确定参与者时写已有发言人。\n4. 关键/矛盾点必须来自聊天记录,避免泛泛而谈。\n5. 结论要短、具体;没有结论时写“暂无明确结论”。\n\nJSON 输出格式:\n{\n \"topics\": [\n {\n \"title\": \"话题名称\",\n \"participants\": [\"参与者A\", \"参与者B\"],\n \"key_points\": [\"关键点或矛盾点\"],\n \"conclusion\": \"结论\"\n }\n ]\n}" -} diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 4c73248..0000000 --- a/src/App.scss +++ /dev/null @@ -1,313 +0,0 @@ -.app-container { - height: 100vh; - display: flex; - flex-direction: column; - background: var(--bg-primary); - position: relative; - overflow: hidden; -} - -.window-drag-region { - position: fixed; - top: 0; - left: 0; - right: 150px; - height: 40px; - -webkit-app-region: drag; - pointer-events: auto; - z-index: 2000; -} - -.main-layout { - flex: 1; - display: flex; - overflow: hidden; -} - -.content { - flex: 1; - overflow: auto; - padding: 24px 32px; - position: relative; - background: var(--bg-primary); -} - -.export-keepalive-page { - height: 100%; - - &.hidden { - display: none; - } -} - -.export-route-anchor { - display: none; -} - -// ---- Update banner ---- -.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.15s; - - &: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.15s; - - &: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 modal ---- -.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; - backdrop-filter: blur(4px); -} - -.agreement-modal { - width: 520px; - max-height: 80vh; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 16px; - overflow: hidden; - display: flex; - flex-direction: column; - box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); -} - -.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(245, 158, 11, 0.3); - background: rgba(245, 158, 11, 0.08); - 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; - } -} - -.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.15s; - - &:hover { - background: var(--bg-hover); - } - } - - .btn-primary { - background: var(--primary); - color: var(--on-primary); - border: none; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - transition: opacity 0.15s; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &:hover:not(:disabled) { - opacity: 0.9; - } - } -} diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 78cdd84..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,742 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { Routes, Route, Navigate, useNavigate, useLocation, type Location } 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 AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' -import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage' -import AnnualReportPage from './pages/AnnualReportPage' -import AnnualReportWindow from './pages/AnnualReportWindow' -import DualReportPage from './pages/DualReportPage' -import DualReportWindow from './pages/DualReportWindow' -import AgreementPage from './pages/AgreementPage' -import GroupAnalyticsPage from './pages/GroupAnalyticsPage' -import SettingsPage from './pages/SettingsPage' -import ExportPage from './pages/ExportPage' -import MyFootprintPage from './pages/MyFootprintPage' -import VideoWindow from './pages/VideoWindow' -import ImageWindow from './pages/ImageWindow' -import SnsPage from './pages/SnsPage' -import BizPage from './pages/BizPage' -import ContactsPage from './pages/ContactsPage' -import ResourcesPage from './pages/ResourcesPage' -import ChatHistoryPage from './pages/ChatHistoryPage' -import NotificationWindow from './pages/NotificationWindow' -import AccountManagementPage from './pages/AccountManagementPage' -import BackupPage from './pages/BackupPage' -import InsightInboxPage from './pages/InsightInboxPage' - -import { useAppStore } from './stores/appStore' -import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' -import * as configService from './services/config' -import * as cloudControl from './services/cloudControl' -import { Download, X, Shield } from 'lucide-react' -import './App.scss' - -import UpdateDialog from './components/UpdateDialog' -import UpdateProgressCapsule from './components/UpdateProgressCapsule' -import LockScreen from './components/LockScreen' -import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' -import WindowCloseDialog from './components/WindowCloseDialog' - -function RouteStateRedirect({ to }: { to: string }) { - const location = useLocation() - - return -} - -function App() { - const navigate = useNavigate() - const location = useLocation() - const settingsBackgroundRef = useRef({ - pathname: '/home', - search: '', - hash: '', - state: null, - key: 'settings-fallback' - } as Location) - - const { - setDbConnected, - updateInfo, - setUpdateInfo, - isDownloading, - setIsDownloading, - downloadProgress, - setDownloadProgress, - showUpdateDialog, - setShowUpdateDialog, - setUpdateError, - isLocked, - setLocked - } = useAppStore() - - const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() - const isAgreementWindow = location.pathname === '/agreement-window' - const isOnboardingWindow = location.pathname === '/onboarding-window' - const isVideoPlayerWindow = location.pathname === '/video-player-window' - const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') - const isStandaloneChatWindow = location.pathname === '/chat-window' - const isNotificationWindow = location.pathname === '/notification-window' - const isAnnualReportWindow = location.pathname === '/annual-report/view' - const isDualReportWindow = location.pathname === '/dual-report/view' - const isSettingsRoute = location.pathname === '/settings' - const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null - const routeLocation = isSettingsRoute - ? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current - : location - const isExportRoute = routeLocation.pathname === '/export' - const [themeHydrated, setThemeHydrated] = useState(false) - const [sidebarCollapsed, setSidebarCollapsed] = useState(false) - const [showCloseDialog, setShowCloseDialog] = useState(false) - const [canMinimizeToTray, setCanMinimizeToTray] = useState(false) - const [closeRestoreMethod, setCloseRestoreMethod] = useState<'tray' | 'dock'>('tray') - - // 锁定状态 - // const [isLocked, setIsLocked] = useState(false) // Moved to store - const [lockAvatar, setLockAvatar] = useState( - localStorage.getItem('app_lock_avatar') || undefined - ) - const [lockUseHello, setLockUseHello] = useState(false) - - // 协议同意状态 - const [showAgreement, setShowAgreement] = useState(false) - const [agreementChecked, setAgreementChecked] = useState(false) - const [agreementLoading, setAgreementLoading] = useState(true) - - // 数据收集同意状态 - const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) - const [analyticsConsent, setAnalyticsConsent] = useState(null) - - useEffect(() => { - if (location.pathname !== '/settings') { - settingsBackgroundRef.current = location - } - }, [location]) - - useEffect(() => { - const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => { - setCanMinimizeToTray(Boolean(payload.canMinimizeToTray)) - setCloseRestoreMethod(payload.restoreMethod === 'dock' ? 'dock' : 'tray') - setShowCloseDialog(true) - }) - - return () => removeCloseConfirmListener() - }, []) - - useEffect(() => { - const root = document.documentElement - const body = document.body - const appRoot = document.getElementById('app') - - if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) { - 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, isNotificationWindow, isAnnualReportWindow, isDualReportWindow]) - - // 应用主题 (accent color + light/dark mode) - useEffect(() => { - const mq = window.matchMedia('(prefers-color-scheme: dark)') - const applyMode = (mode: ThemeMode, systemDark?: boolean) => { - const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode - document.documentElement.setAttribute('data-theme', currentTheme) - document.documentElement.setAttribute('data-mode', effectiveMode) - } - - applyMode(themeMode) - - // 监听系统主题变化 - const handler = (e: MediaQueryListEvent) => { - if (useThemeStore.getState().themeMode === 'system') { - applyMode('system', e.matches) - } - } - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) - }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow]) - - // 读取已保存的主题设置 - 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' || savedThemeMode === 'system') { - 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) - } else { - // 协议已同意,检查数据收集同意状态 - const consent = await configService.getAnalyticsConsent() - const denyCount = await configService.getAnalyticsDenyCount() - setAnalyticsConsent(consent) - // 如果未设置同意状态且拒绝次数小于2次,显示弹窗 - if (consent === null && denyCount < 2) { - setShowAnalyticsConsent(true) - } - } - } catch (e) { - console.error('检查协议状态失败:', e) - } finally { - setAgreementLoading(false) - } - } - checkAgreement() - }, []) - - // 初始化数据收集(仅在用户同意后) - useEffect(() => { - if (analyticsConsent === true) { - cloudControl.initCloudControl() - } - }, [analyticsConsent]) - - // 记录页面访问(仅在用户同意后) - useEffect(() => { - if (analyticsConsent !== true) return - const path = location.pathname - if (path && path !== '/') { - cloudControl.recordPage(path) - } - }, [location.pathname, analyticsConsent]) - - const handleAgree = async () => { - if (!agreementChecked) return - await configService.setAgreementAccepted(true) - setShowAgreement(false) - // 协议同意后,检查数据收集同意 - const consent = await configService.getAnalyticsConsent() - if (consent === null) { - setShowAnalyticsConsent(true) - } - } - - const handleDisagree = () => { - window.electronAPI.window.close() - } - - const handleAnalyticsAllow = async () => { - await configService.setAnalyticsConsent(true) - setAnalyticsConsent(true) - setShowAnalyticsConsent(false) - } - - const handleAnalyticsDeny = async () => { - const denyCount = await configService.getAnalyticsDenyCount() - await configService.setAnalyticsDenyCount(denyCount + 1) - setShowAnalyticsConsent(false) - } - - // 监听启动时的更新通知 - useEffect(() => { - if (isNotificationWindow) return // Skip updates in notification window - - const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => { - // 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示 - if (info) { - window.electronAPI.app.getVersion().then((currentVersion: string) => { - const isMandatory = !!(info.minimumVersion && currentVersion && - currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0) - setUpdateInfo({ ...info, hasUpdate: true, isMandatory }) - if (!useAppStore.getState().isLocked) { - setShowUpdateDialog(true) - } - }) - } - }) - const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => { - setDownloadProgress(progress) - }) - return () => { - removeUpdateListener?.() - removeProgressListener?.() - } - }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) - - // 监听通知点击导航事件 - useEffect(() => { - if (isNotificationWindow) return - - const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => { - if (!sessionId) return - // 导航到聊天页面,通过URL参数让ChatPage接收sessionId - navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true }) - }) - - return () => { - removeListener?.() - } - }, [navigate, isNotificationWindow]) - - useEffect(() => { - if (isNotificationWindow) return - - const removeListener = window.electronAPI?.notification?.onNavigateToRoute?.((route: string) => { - if (!route || !route.startsWith('/')) return - navigate(route, { replace: true }) - }) - - return () => { - removeListener?.() - } - }, [navigate, isNotificationWindow]) - - // 解锁后显示暂存的更新弹窗 - useEffect(() => { - if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { - setShowUpdateDialog(true) - } - }, [isLocked]) - - const handleUpdateNow = async () => { - setShowUpdateDialog(false) - setIsDownloading(true) - setDownloadProgress({ percent: 0 }) - try { - await window.electronAPI.app.downloadAndInstall() - } catch (e: any) { - console.error('更新失败:', e) - setIsDownloading(false) - // Extract clean error message if possible - const errorMsg = e.message || String(e) - setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg) - } - } - - const handleIgnoreUpdate = async () => { - if (!updateInfo || !updateInfo.version) return - - try { - await window.electronAPI.app.ignoreUpdate(updateInfo.version) - setShowUpdateDialog(false) - setUpdateInfo(null) - } catch (e: any) { - console.error('忽略更新失败:', e) - } - } - - const dismissUpdate = () => { - setUpdateInfo(null) - } - - const handleWindowCloseAction = async ( - action: 'tray' | 'quit' | 'cancel', - rememberChoice = false - ) => { - setShowCloseDialog(false) - if (rememberChoice && action !== 'cancel') { - try { - await configService.setWindowCloseBehavior(action) - } catch (error) { - console.error('保存关闭偏好失败:', error) - } - } - - try { - await window.electronAPI.window.respondCloseConfirm(action) - } catch (error) { - console.error('处理关闭确认失败:', error) - } - } - - // 启动时自动检查配置并连接数据库 - 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() - const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null - const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey - - if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) { - await configService.setDecryptKey(wxidConfig.decryptKey) - } - - // 如果配置完整,自动测试连接 - if (dbPath && effectiveDecryptKey && wxid) { - if (!onboardingDone) { - await configService.setOnboardingDone(true) - } - - const result = await window.electronAPI.chat.connect() - - if (result.success) { - - setDbConnected(true, dbPath) - // 如果当前在欢迎页,跳转到首页 - if (window.location.hash === '#/' || window.location.hash === '') { - navigate('/home') - } - } else { - - // 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户 - // 其他错误可能需要重新配置 - const errorMsg = result.error || '' - if (errorMsg.includes('Visual C++') || - errorMsg.includes('DLL') || - errorMsg.includes('Worker') || - errorMsg.includes('126') || - errorMsg.includes('模块')) { - console.warn('检测到可能的运行时依赖问题:', errorMsg) - // 不清除配置,让用户安装 VC++ 后重试 - } - } - } - } catch (e) { - console.error('自动连接出错:', e) - // 捕获异常但不清除配置,防止循环重新引导 - } - } - - autoConnect() - }, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected]) - - // 检查应用锁 - useEffect(() => { - if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return - - const checkLock = async () => { - // 并行获取配置,减少等待 - const [enabled, useHello] = await Promise.all([ - window.electronAPI.auth.verifyEnabled(), - configService.getAuthUseHello() - ]) - - if (enabled) { - setLockUseHello(useHello) - setLocked(true) - // 尝试获取头像 - try { - const result = await window.electronAPI.chat.getMyAvatarUrl() - if (result && result.success && result.avatarUrl) { - setLockAvatar(result.avatarUrl) - localStorage.setItem('app_lock_avatar', result.avatarUrl) - } - } catch (e) { - console.error('获取锁屏头像失败', e) - } - } - } - checkLock() - }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) - - - - // 独立协议窗口 - if (isAgreementWindow) { - return - } - - if (isOnboardingWindow) { - return - } - - // 独立视频播放窗口 - if (isVideoPlayerWindow) { - return - } - - // 独立图片查看窗口 - const isImageViewerWindow = location.pathname === '/image-viewer-window' - if (isImageViewerWindow) { - return - } - - // 独立聊天记录窗口 - if (isChatHistoryWindow) { - return - } - - // 独立会话聊天窗口(仅显示聊天内容区域) - if (isStandaloneChatWindow) { - const params = new URLSearchParams(location.search) - const sessionId = params.get('sessionId') || '' - const standaloneSource = params.get('source') - const standaloneInitialDisplayName = params.get('initialDisplayName') - const standaloneInitialAvatarUrl = params.get('initialAvatarUrl') - const standaloneInitialContactType = params.get('initialContactType') - return ( - - ) - } - - // 独立通知窗口 - if (isNotificationWindow) { - return - } - - // 独立年度报告全屏窗口 - if (isAnnualReportWindow) { - return - } - - // 独立双人报告全屏窗口 - if (isDualReportWindow) { - return - } - - // 主窗口 - 完整布局 - const handleCloseSettings = () => { - const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current - if (backgroundLocation.pathname === '/settings') { - navigate('/home', { replace: true }) - return - } - navigate( - { - pathname: backgroundLocation.pathname, - search: backgroundLocation.search, - hash: backgroundLocation.hash - }, - { - replace: true, - state: backgroundLocation.state - } - ) - } - - return ( -
- - ) -} - -export default App diff --git a/src/components/AnimatedStreamingText.tsx b/src/components/AnimatedStreamingText.tsx deleted file mode 100644 index 2b20d9a..0000000 --- a/src/components/AnimatedStreamingText.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { memo, useEffect, useState, useRef } from 'react' - -interface AnimatedStreamingTextProps { - text: string - className?: string - loading?: boolean -} - -export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => { - const [displayedSegments, setDisplayedSegments] = useState([]) - const prevTextRef = useRef('') - - useEffect(() => { - const currentText = (text || '').trim() - const prevText = prevTextRef.current - - if (currentText === prevText) return - if (!currentText.startsWith(prevText) && prevText !== '') { - // 如果不是追加而是全新的文本(比如重新识别),则重置 - setDisplayedSegments([currentText]) - prevTextRef.current = currentText - return - } - - const newPart = currentText.slice(prevText.length) - if (newPart) { - // 将新部分作为单独的段加入,以触发动画 - setDisplayedSegments(prev => [...prev, newPart]) - } - prevTextRef.current = currentText - }, [text]) - - // 处理 loading 状态的显示 - if (loading && !text) { - return 转写中... - } - - return ( - - {displayedSegments.map((segment, index) => ( - - {segment} - - ))} - - - ) -}) - -AnimatedStreamingText.displayName = 'AnimatedStreamingText' diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss deleted file mode 100644 index 6a15310..0000000 --- a/src/components/Avatar.scss +++ /dev/null @@ -1,104 +0,0 @@ -.avatar-component { - position: relative; - display: inline-block; - overflow: hidden; - background-color: var(--bg-tertiary, #f5f5f5); - flex-shrink: 0; - border-radius: 4px; - /* Default radius */ - - &.circle { - border-radius: 50%; - } - - &.rounded { - border-radius: 6px; - } - - /* Image styling */ - img.avatar-image { - width: 100%; - height: 100%; - object-fit: cover; - opacity: 0; - transition: opacity 0.3s ease-in-out; - border-radius: inherit; - - &.loaded { - opacity: 1; - } - - &.instant { - transition: none !important; - opacity: 1 !important; - } - } - - /* Placeholder/Letter styling */ - .avatar-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - color: var(--text-secondary, #666); - background-color: var(--bg-tertiary, #e0e0e0); - font-size: 1.2em; - text-transform: uppercase; - user-select: none; - border-radius: inherit; - } - - .avatar-loading { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-tertiary, #999); - background-color: var(--bg-tertiary, #e0e0e0); - border-radius: inherit; - - .avatar-loading-icon { - animation: avatar-spin 0.9s linear infinite; - } - } - - /* Loading Skeleton */ - .avatar-skeleton { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(90deg, - var(--bg-tertiary, #f0f0f0) 25%, - var(--bg-secondary, #e0e0e0) 50%, - var(--bg-tertiary, #f0f0f0) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - z-index: 1; - border-radius: inherit; - } - - @keyframes shimmer { - 0% { - background-position: 200% 0; - } - - 100% { - background-position: -200% 0; - } - } - - @keyframes avatar-spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } - } -} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx deleted file mode 100644 index 304a50a..0000000 --- a/src/components/Avatar.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react' -import { Loader2, User } from 'lucide-react' -import { avatarLoadQueue } from '../utils/AvatarLoadQueue' -import './Avatar.scss' - -// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画 -const loadedAvatarCache = new Set() -const MAX_LOADED_AVATAR_CACHE_SIZE = 3000 - -const rememberLoadedAvatar = (src: string): void => { - if (!src) return - if (loadedAvatarCache.has(src)) { - loadedAvatarCache.delete(src) - } - loadedAvatarCache.add(src) - - while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) { - const oldest = loadedAvatarCache.values().next().value as string | undefined - if (!oldest) break - loadedAvatarCache.delete(oldest) - } -} - -interface AvatarProps { - src?: string - name?: string - size?: number | string - shape?: 'circle' | 'square' | 'rounded' - className?: string - lazy?: boolean - loading?: boolean - onClick?: () => void -} - -export const Avatar = React.memo(function Avatar({ - src, - name, - size = 48, - shape = 'rounded', - className = '', - lazy = true, - loading = false, - onClick -}: AvatarProps) { - // 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画 - const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src]) - const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src]) - const [imageLoaded, setImageLoaded] = useState(isCached) - const [imageError, setImageError] = useState(isFailed) - const [shouldLoad, setShouldLoad] = useState(!lazy || isCached) - const [isInQueue, setIsInQueue] = useState(false) - const imgRef = useRef(null) - const containerRef = useRef(null) - - const getAvatarLetter = (): string => { - if (!name) return '?' - const chars = [...name] - return chars[0] || '?' - } - - // Intersection Observer for lazy loading - useEffect(() => { - if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !isInQueue) { - setIsInQueue(true) - avatarLoadQueue.enqueue(src).then(() => { - setImageError(false) - setShouldLoad(true) - }).catch(() => { - setImageError(true) - setShouldLoad(false) - }).finally(() => { - setIsInQueue(false) - }) - observer.disconnect() - } - }) - }, - { rootMargin: '100px' } - ) - - observer.observe(containerRef.current) - - return () => observer.disconnect() - }, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed]) - - // Reset state when src changes - useEffect(() => { - const cached = src ? loadedAvatarCache.has(src) : false - const failed = src ? avatarLoadQueue.hasFailed(src) : false - setImageLoaded(cached) - setImageError(failed) - if (failed) { - setShouldLoad(false) - setIsInQueue(false) - } else if (lazy && !cached) { - setShouldLoad(false) - setIsInQueue(false) - } else { - setShouldLoad(true) - } - }, [src, lazy]) - - // Check if image is already cached/loaded - useEffect(() => { - if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { - setImageLoaded(true) - } - }, [src, shouldLoad]) - - const style = { - width: typeof size === 'number' ? `${size}px` : size, - height: typeof size === 'number' ? `${size}px` : size, - } - - const hasValidUrl = !!src && !imageError && shouldLoad - const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError - - return ( -
- {hasValidUrl ? ( - <> - {!imageLoaded &&
} - {name { - if (src) { - avatarLoadQueue.clearFailed(src) - rememberLoadedAvatar(src) - } - setImageLoaded(true) - setImageError(false) - }} - onError={() => { - if (src) { - avatarLoadQueue.markFailed(src) - loadedAvatarCache.delete(src) - } - setImageLoaded(false) - setImageError(true) - setShouldLoad(false) - }} - loading={lazy ? "lazy" : "eager"} - referrerPolicy="no-referrer" - /> - - ) : shouldShowLoadingPlaceholder ? ( -
- -
- ) : ( -
- {name ? {getAvatarLetter()} : } -
- )} -
- ) -}) diff --git a/src/components/BatchImageDecryptGlobal.tsx b/src/components/BatchImageDecryptGlobal.tsx deleted file mode 100644 index e819d14..0000000 --- a/src/components/BatchImageDecryptGlobal.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { createPortal } from 'react-dom' -import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react' -import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' -import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' -import '../styles/batchTranscribe.scss' - -export const BatchImageDecryptGlobal: React.FC = () => { - const { - isBatchDecrypting, - progress, - showToast, - showResultToast, - result, - sessionName, - startTime, - setShowToast, - setShowResultToast - } = useBatchImageDecryptStore() - - const voiceToastOccupied = useBatchTranscribeStore( - state => state.isBatchTranscribing && state.showToast - ) - - const [eta, setEta] = useState('') - - useEffect(() => { - if (!isBatchDecrypting || !startTime || progress.current === 0) { - setEta('') - return - } - - const timer = setInterval(() => { - const elapsed = Date.now() - startTime - if (elapsed <= 0) return - const rate = progress.current / elapsed - const remain = progress.total - progress.current - if (remain <= 0 || rate <= 0) { - setEta('') - return - } - const seconds = Math.ceil((remain / rate) / 1000) - if (seconds < 60) { - setEta(`${seconds}秒`) - } else { - const m = Math.floor(seconds / 60) - const s = seconds % 60 - setEta(`${m}分${s}秒`) - } - }, 1000) - - return () => clearInterval(timer) - }, [isBatchDecrypting, progress.current, progress.total, startTime]) - - useEffect(() => { - if (!showResultToast) return - const timer = window.setTimeout(() => setShowResultToast(false), 6000) - return () => window.clearTimeout(timer) - }, [showResultToast, setShowResultToast]) - - const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied]) - - return ( - <> - {showToast && isBatchDecrypting && createPortal( -
-
-
- - 批量解密图片{sessionName ? `(${sessionName})` : ''} -
- -
-
-
-
- {progress.current} / {progress.total} - - {progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}% - -
- {eta && ( -
- - 剩余 {eta} -
- )} -
-
-
0 ? (progress.current / progress.total) * 100 : 0}%` - }} - /> -
-
-
, - document.body - )} - - {showResultToast && createPortal( -
-
-
- - 图片批量解密完成 -
- -
-
-
-
- - 成功 {result.success} -
-
0 ? 'fail' : 'muted'}`}> - - 失败 {result.fail} -
-
-
-
, - document.body - )} - - ) -} - diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx deleted file mode 100644 index 0c6d825..0000000 --- a/src/components/BatchTranscribeGlobal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { createPortal } from 'react-dom' -import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react' -import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' -import '../styles/batchTranscribe.scss' - -/** - * 全局批量转写进度浮窗 + 结果弹窗 - * 挂载在 App 层,切换页面时不会消失 - */ -export const BatchTranscribeGlobal: React.FC = () => { - const { - isBatchTranscribing, - progress, - showToast, - showResult, - result, - sessionName, - startTime, - taskType, - setShowToast, - setShowResult - } = useBatchTranscribeStore() - - const [eta, setEta] = useState('') - - // 计算剩余时间 - useEffect(() => { - if (!isBatchTranscribing || !startTime || progress.current === 0) { - setEta('') - return - } - - const timer = setInterval(() => { - const now = Date.now() - const elapsed = now - startTime - const rate = progress.current / elapsed // ms per item - const remainingItems = progress.total - progress.current - - if (remainingItems <= 0) { - setEta('') - return - } - - const remainingTimeMs = remainingItems / rate - const remainingSeconds = Math.ceil(remainingTimeMs / 1000) - - if (remainingSeconds < 60) { - setEta(`${remainingSeconds}秒`) - } else { - const minutes = Math.floor(remainingSeconds / 60) - const seconds = remainingSeconds % 60 - setEta(`${minutes}分${seconds}秒`) - } - }, 1000) - - return () => clearInterval(timer) - }, [isBatchTranscribing, startTime, progress.current, progress.total]) - - return ( - <> - {/* 批量转写进度浮窗(非阻塞) */} - {showToast && isBatchTranscribing && createPortal( -
-
-
- - {taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''} -
- -
-
-
-
- {progress.current} / {progress.total} - - {progress.total > 0 - ? Math.round((progress.current / progress.total) * 100) - : 0}% - -
- {eta && ( -
- - 剩余 {eta} -
- )} -
- -
-
0 - ? (progress.current / progress.total) * 100 - : 0}%` - }} - /> -
-
-
, - document.body - )} - - {/* 批量转写结果对话框 */} - {showResult && createPortal( -
setShowResult(false)}> -
e.stopPropagation()}> -
- {taskType === 'decrypt' ? : } -

{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}

-
-
-
-
- - 成功: - {result.success} 条 -
- {result.fail > 0 && ( -
- - 失败: - {result.fail} 条 -
- )} -
- {result.fail > 0 && ( -
- - {taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'} -
- )} -
-
- -
-
-
, - document.body - )} - - ) -} diff --git a/src/components/ChatAnalysisHeader.scss b/src/components/ChatAnalysisHeader.scss deleted file mode 100644 index f920d6d..0000000 --- a/src/components/ChatAnalysisHeader.scss +++ /dev/null @@ -1,139 +0,0 @@ -.chat-analysis-header { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 32px; - padding: 4px 0; - background: transparent; - border: none; - flex-shrink: 0; -} - -.chat-analysis-back { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 8px 4px 4px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; - font-size: 13px; - font-weight: 500; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } -} - -.chat-analysis-breadcrumb { - display: flex; - align-items: center; - gap: 4px; - font-size: 13px; - color: var(--text-tertiary); - - .chat-analysis-breadcrumb-separator { - opacity: 0.5; - font-size: 12px; - } -} - -.chat-analysis-dropdown { - position: relative; -} - -.chat-analysis-current-trigger { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: background 0.15s ease, color 0.15s ease; - - .current { - color: var(--text-primary); - } - - svg { - transition: transform 0.15s ease; - } - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.open svg { - transform: rotate(180deg); - } -} - -.chat-analysis-menu { - position: absolute; - top: calc(100% + 6px); - right: 0; - min-width: 120px; - padding: 4px; - background: var(--bg-secondary-solid, var(--bg-secondary)); - border: 1px solid var(--border-color); - border-radius: 10px; - box-shadow: var(--shadow-md); - z-index: 20; -} - -.chat-analysis-menu-item { - width: 100%; - display: block; - padding: 8px 12px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--text-primary); - text-align: left; - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: background 0.15s ease; - - &:hover { - background: var(--bg-hover); - } -} - -.chat-analysis-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - margin-left: auto; - flex-wrap: wrap; -} - -@media (max-width: 768px) { - .chat-analysis-header { - align-items: flex-start; - flex-wrap: wrap; - } - - .chat-analysis-breadcrumb { - flex-wrap: wrap; - row-gap: 4px; - } - - .chat-analysis-actions { - width: 100%; - justify-content: flex-start; - } -} diff --git a/src/components/ChatAnalysisHeader.tsx b/src/components/ChatAnalysisHeader.tsx deleted file mode 100644 index 112a15d..0000000 --- a/src/components/ChatAnalysisHeader.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { ChevronDown, ChevronLeft } from 'lucide-react' -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' -import './ChatAnalysisHeader.scss' - -export type ChatAnalysisMode = 'private' | 'group' - -interface ChatAnalysisHeaderProps { - currentMode: ChatAnalysisMode - actions?: ReactNode -} - -const MODE_CONFIG: Record = { - private: { - label: '私聊分析', - path: '/analytics/private' - }, - group: { - label: '群聊分析', - path: '/analytics/group' - } -} - -function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) { - const navigate = useNavigate() - const currentLabel = MODE_CONFIG[currentMode].label - const [menuOpen, setMenuOpen] = useState(false) - const dropdownRef = useRef(null) - const alternateMode = useMemo( - () => (currentMode === 'private' ? 'group' : 'private'), - [currentMode] - ) - - useEffect(() => { - if (!menuOpen) return - - const handleClickOutside = (event: MouseEvent) => { - if (!dropdownRef.current?.contains(event.target as Node)) { - setMenuOpen(false) - } - } - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setMenuOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleEscape) - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleEscape) - } - }, [menuOpen]) - - return ( -
-
- - / -
- - - {menuOpen && ( -
- -
- )} -
-
- - {actions ?
{actions}
: null} -
- ) -} - -export default ChatAnalysisHeader diff --git a/src/components/ConfirmDialog.scss b/src/components/ConfirmDialog.scss deleted file mode 100644 index 5acd32d..0000000 --- a/src/components/ConfirmDialog.scss +++ /dev/null @@ -1,123 +0,0 @@ -.confirm-dialog-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - animation: fadeIn 0.2s ease-out; - - .confirm-dialog { - width: 480px; - background: var(--bg-primary); - border-radius: 20px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - position: relative; - animation: slideUp 0.2s ease-out; - overflow: hidden; - - .close-btn { - position: absolute; - top: 16px; - right: 16px; - background: rgba(0, 0, 0, 0.05); - border: none; - color: var(--text-secondary); - cursor: pointer; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - - &:hover { - background: rgba(0, 0, 0, 0.1); - color: var(--text-primary); - } - } - - .dialog-title { - padding: 40px 40px 16px; - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - } - - .dialog-content { - padding: 0 40px 24px; - - p { - font-size: 15px; - color: var(--text-primary); - line-height: 1.6; - margin: 0 0 16px 0; - - &:last-child { - margin-bottom: 0; - } - } - } - - .dialog-actions { - padding: 0 40px 40px; - display: flex; - justify-content: flex-end; - gap: 12px; - - button { - padding: 12px 24px; - border-radius: 12px; - font-size: 15px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - border: none; - - &.btn-cancel { - background: var(--bg-tertiary); - color: var(--text-secondary); - - &:hover { - background: var(--bg-hover); - } - } - - &.btn-confirm { - background: var(--primary); - color: var(--on-primary); - - &:hover { - background: var(--primary-hover); - } - - &:active { - transform: scale(0.98); - } - } - } - } - } -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideUp { - from { - transform: translateY(20px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx deleted file mode 100644 index 7126edf..0000000 --- a/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { X } from 'lucide-react' -import './ConfirmDialog.scss' - -interface ConfirmDialogProps { - open: boolean - title?: string - message: string - onConfirm: () => void - onCancel: () => void -} - -export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) { - if (!open) return null - - return ( -
-
e.stopPropagation()}> - - {title &&
{title}
} -
-

{message}

-
-
- - -
-
-
- ) -} diff --git a/src/components/DateRangePicker.scss b/src/components/DateRangePicker.scss deleted file mode 100644 index fafebc2..0000000 --- a/src/components/DateRangePicker.scss +++ /dev/null @@ -1,292 +0,0 @@ -.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(--bg-secondary-solid, var(--bg-primary, var(--card-bg))); - border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); - backdrop-filter: none; - -webkit-backdrop-filter: none; - 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); - - &.clickable { - cursor: pointer; - border-radius: 6px; - padding: 2px 8px; - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - } - } - } - - .calendar-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: auto repeat(6, 32px); - gap: 2px; - } - - .weekday-header { - text-align: center; - font-size: 12px; - color: var(--text-tertiary); - padding: 8px 0; - font-weight: 500; - } - - .calendar-day { - 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); - } - - .year-month-picker { - padding: 4px 0; - - .year-selector { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - - .year-label { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .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-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .month-btn { - padding: 8px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - } -} diff --git a/src/components/DateRangePicker.tsx b/src/components/DateRangePicker.tsx deleted file mode 100644 index 7c2fdbe..0000000 --- a/src/components/DateRangePicker.tsx +++ /dev/null @@ -1,255 +0,0 @@ -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 [showYearMonthPicker, setShowYearMonthPicker] = useState(false) - const containerRef = useRef(null) - - const [internalStart, setInternalStart] = useState(startDate) - const [internalEnd, setInternalEnd] = useState(endDate) - - useEffect(() => { - setInternalStart(startDate) - setInternalEnd(endDate) - }, [startDate, endDate]) - - useEffect(() => { - if (isOpen) { - setSelectingStart(true) - } - }, [isOpen]) - - // 点击外部关闭 - 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) - const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}` - const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}` - onStartDateChange(startStr) - onEndDateChange(endStr) - } - 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) { - setInternalStart(dateStr) - if (internalEnd && dateStr > internalEnd) { - setInternalEnd('') - } - setSelectingStart(false) - } else { - let finalStart = internalStart - let finalEnd = dateStr - - if (dateStr < internalStart) { - finalStart = dateStr - finalEnd = internalStart - } - - setInternalStart(finalStart) - setInternalEnd(finalEnd) - - setSelectingStart(true) - setIsOpen(false) - - onStartDateChange(finalStart) - onEndDateChange(finalEnd) - setTimeout(() => onRangeComplete?.(), 0) - } - } - - const isInRange = (day: number) => { - if (!internalStart || !internalEnd) return false - const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` - return dateStr >= internalStart && dateStr <= internalEnd - } - - const isStartDate = (day: number) => { - const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` - return dateStr === internalStart - } - - const isEndDate = (day: number) => { - const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` - return dateStr === internalEnd - } - - 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 => ( - - ))} -
-
-
- - setShowYearMonthPicker(!showYearMonthPicker)}> - {currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]} - - -
- {showYearMonthPicker ? ( -
-
- - {currentMonth.getFullYear()}年 - -
-
- {MONTH_NAMES.map((name, i) => ( - - ))} -
-
- ) : renderCalendar()} -
- {selectingStart ? '请选择开始日期' : '请选择结束日期'} -
-
-
- )} -
- ) -} - -export default DateRangePicker diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx deleted file mode 100644 index 6d02c86..0000000 --- a/src/components/ErrorBoundary.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Component, ReactNode } from 'react' - -interface Props { - children: ReactNode - fallback?: ReactNode -} - -interface State { - hasError: boolean - error?: Error -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props) - this.state = { hasError: false } - } - - static getDerivedStateFromError(error: Error): State { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: any) { - console.error('ErrorBoundary caught:', error, errorInfo) - } - - render() { - if (this.state.hasError) { - return this.props.fallback || ( -
-

消息渲染出错

-

- {this.state.error?.message || '未知错误'} -

-
- ) - } - - return this.props.children - } -} diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss deleted file mode 100644 index fab4e11..0000000 --- a/src/components/Export/ExportDateRangeDialog.scss +++ /dev/null @@ -1,502 +0,0 @@ -.export-date-range-dialog-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.35); - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - z-index: 9200; -} - -.export-date-range-dialog { - width: min(480px, calc(100vw - 32px)); - max-height: calc(100vh - 80px); - border-radius: 16px; - border: 1px solid var(--border-color); - background: var(--bg-secondary-solid, var(--bg-primary)); - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); - overflow: hidden; -} - -.export-date-range-dialog-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-shrink: 0; - - h4 { - margin: 0; - font-size: 14px; - color: var(--text-primary); - } -} - -.export-date-range-dialog-content { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - display: flex; - flex-direction: column; - gap: 10px; - padding-right: 2px; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 3px; - } -} - -.export-date-range-dialog-close-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 8px; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-secondary); -} - -.export-date-range-preset-list { - display: flex; - flex-wrap: nowrap; - gap: 4px; - overflow-x: auto; - padding-bottom: 2px; - - &::-webkit-scrollbar { - height: 4px; - } -} - -.export-date-range-preset-item { - flex: 0 0 auto; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - color: var(--text-primary); - min-height: 30px; - padding: 0 8px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 4px; - font-size: 11px; - cursor: pointer; - white-space: nowrap; - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - color: var(--primary); - } -} - -.export-date-range-mode-banner { - border-radius: 10px; - padding: 7px 10px; - font-size: 11px; - line-height: 1.4; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - color: var(--text-secondary); - - &.range { - border-color: rgba(var(--primary-rgb), 0.4); - background: rgba(var(--primary-rgb), 0.1); - color: var(--primary); - } -} - -.export-date-range-boundary-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.export-date-range-boundary-card { - border: 1px solid var(--border-color); - border-radius: 10px; - background: var(--bg-secondary); - padding: 8px; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); - } - - .boundary-label { - font-size: 11px; - color: var(--text-secondary); - } -} - -.export-date-range-selection-hint { - font-size: 11px; - color: var(--text-secondary); - padding: 0 2px; -} - -.export-date-range-calendar-panel { - border: 1px solid var(--border-color); - border-radius: 12px; - background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary); - padding: 10px; - - &.single { - width: 100%; - } -} - -.export-date-range-calendar-panel-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; -} - -.export-date-range-calendar-date-label { - display: flex; - flex-direction: column; - gap: 3px; - - span { - font-size: 11px; - color: var(--text-secondary); - } - - strong { - font-size: 13px; - color: var(--text-primary); - } -} - -.export-date-range-date-input { - width: 100%; - min-width: 0; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - height: 30px; - padding: 0 9px; - font-size: 12px; - - &:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); - } - - &.invalid { - border-color: #e84d4d; - box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2); - } -} - -.export-date-range-time-select { - position: relative; - width: 100%; - - &.open .export-date-range-time-trigger { - border-color: var(--primary); - box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); - color: var(--primary); - } -} - -.export-date-range-time-trigger { - width: 100%; - min-width: 0; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - height: 30px; - padding: 0 9px; - font-size: 12px; - font-family: inherit; - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 8px; - cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; - - &:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); - } -} - -.export-date-range-time-trigger-value { - flex: 1; - min-width: 0; - text-align: left; -} - -.export-date-range-time-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 24; - border: 1px solid var(--border-color); - border-radius: 12px; - background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); - box-shadow: var(--shadow-md); - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); -} - -.export-date-range-time-dropdown-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - - span { - font-size: 11px; - color: var(--text-secondary); - } - - strong { - font-size: 13px; - color: var(--text-primary); - } -} - -.export-date-range-time-quick-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.export-date-range-time-quick-item, -.export-date-range-time-option { - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: var(--text-primary); - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; - - &:hover { - background: var(--bg-tertiary); - } - - &.active { - border-color: rgba(var(--primary-rgb), 0.28); - background: rgba(var(--primary-rgb), 0.12); - color: var(--primary); - } -} - -.export-date-range-time-quick-item { - min-width: 52px; - height: 28px; - padding: 0 10px; - font-size: 11px; -} - -.export-date-range-time-columns { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.export-date-range-time-column { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.export-date-range-time-column-label { - font-size: 11px; - color: var(--text-secondary); -} - -.export-date-range-time-column-list { - max-height: 168px; - overflow-y: auto; - padding-right: 2px; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px; -} - -.export-date-range-time-option { - min-height: 28px; - padding: 0 8px; - font-size: 11px; -} - -.export-date-range-calendar-nav { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--text-primary); - - button { - width: 28px; - height: 28px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - cursor: pointer; - padding: 0; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - - &:disabled { - cursor: not-allowed; - opacity: 0.45; - } - } -} - -.export-date-range-calendar-weekdays { - margin-top: 10px; - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - - span { - text-align: center; - font-size: 10px; - color: var(--text-tertiary); - } -} - -.export-date-range-calendar-days { - margin-top: 6px; - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; -} - -.export-date-range-calendar-day { - border: 1px solid transparent; - border-radius: 10px; - min-height: 34px; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 12px; - cursor: pointer; - padding: 0; - transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease; - - &:hover { - border-color: rgba(var(--primary-rgb), 0.28); - transform: translateY(-1px); - } - - &:disabled:hover { - border-color: transparent; - transform: none; - } - - &.outside { - color: var(--text-quaternary); - opacity: 0.72; - } - - &.disabled { - cursor: not-allowed; - opacity: 0.35; - transform: none; - border-color: transparent; - } - - &.in-range { - background: rgba(var(--primary-rgb), 0.1); - color: var(--primary); - } - - &.range-start, - &.range-end { - border-color: var(--primary); - background: var(--primary); - color: #fff; - font-weight: 600; - opacity: 1; - } - - &.active-boundary { - box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22); - } -} - -.export-date-range-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - flex-shrink: 0; -} - -.export-date-range-dialog-btn { - border-radius: 8px; - padding: 7px 12px; - font-size: 12px; - font-weight: 600; - border: 1px solid var(--border-color); - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; - - &.primary { - border-color: var(--primary); - background: var(--primary); - color: #fff; - - &:hover { - background: var(--primary-hover); - } - } - - &.secondary { - background: var(--bg-secondary); - color: var(--text-primary); - - &:hover { - border-color: var(--primary); - color: var(--primary); - } - } -} - -@media (max-width: 640px) { - .export-date-range-boundary-row { - grid-template-columns: 1fr; - } -} diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx deleted file mode 100644 index 0bde562..0000000 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ /dev/null @@ -1,762 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react' -import { - EXPORT_DATE_RANGE_PRESETS, - WEEKDAY_SHORT_LABELS, - addMonths, - buildCalendarCells, - cloneExportDateRangeSelection, - createDateRangeByPreset, - createDefaultDateRange, - formatCalendarMonthTitle, - isSameDay, - parseDateInputValue, - startOfDay, - endOfDay, - toMonthStart, - type ExportDateRangePreset, - type ExportDateRangeSelection -} from '../../utils/exportDateRange' -import './ExportDateRangeDialog.scss' - -interface ExportDateRangeDialogProps { - open: boolean - value: ExportDateRangeSelection - title?: string - minDate?: Date | null - maxDate?: Date | null - onClose: () => void - onConfirm: (value: ExportDateRangeSelection) => void -} - -type ActiveBoundary = 'start' | 'end' - -interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { - panelMonth: Date -} - -const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0')) -const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0')) -const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59'] - -const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { - if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null - if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null - const normalizedMin = startOfDay(minDate) - const normalizedMax = endOfDay(maxDate) - if (normalizedMin.getTime() > normalizedMax.getTime()) return null - return { - minDate: normalizedMin, - maxDate: normalizedMax - } -} - -const clampSelectionToBounds = ( - value: ExportDateRangeSelection, - minDate?: Date | null, - maxDate?: Date | null -): ExportDateRangeSelection => { - const bounds = resolveBounds(minDate, maxDate) - if (!bounds) return cloneExportDateRangeSelection(value) - - // For custom selections, only ensure end >= start, preserve time precision - if (value.preset === 'custom' && !value.useAllTime) { - const { start, end } = value.dateRange - if (end.getTime() < start.getTime()) { - return { - ...value, - dateRange: { start, end: start } - } - } - return cloneExportDateRangeSelection(value) - } - - // For useAllTime, use bounds directly - if (value.useAllTime) { - return { - preset: value.preset, - useAllTime: true, - dateRange: { - start: bounds.minDate, - end: bounds.maxDate - } - } - } - - // For preset selections (not custom), clamp dates to bounds and use default times - const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate - - // Set default times: start at 00:00:00, end at 23:59:59 - nextStart.setHours(0, 0, 0, 0) - nextEnd.setHours(23, 59, 59, 999) - - return { - preset: value.preset, - useAllTime: false, - dateRange: { - start: nextStart, - end: nextEnd - } - } -} - -const buildDialogDraft = ( - value: ExportDateRangeSelection, - minDate?: Date | null, - maxDate?: Date | null -): ExportDateRangeDialogDraft => { - const nextValue = clampSelectionToBounds(value, minDate, maxDate) - return { - ...nextValue, - panelMonth: toMonthStart(nextValue.dateRange.start) - } -} - -export function ExportDateRangeDialog({ - open, - value, - title = '时间范围设置', - minDate, - maxDate, - onClose, - onConfirm -}: ExportDateRangeDialogProps) { - // Helper: Format date only (YYYY-MM-DD) for the date input field - const formatDateOnly = (date: Date): string => { - const y = date.getFullYear() - const m = `${date.getMonth() + 1}`.padStart(2, '0') - const d = `${date.getDate()}`.padStart(2, '0') - return `${y}-${m}-${d}` - } - - // Helper: Format time only (HH:mm) for the time input field - const formatTimeOnly = (date: Date): string => { - const h = `${date.getHours()}`.padStart(2, '0') - const m = `${date.getMinutes()}`.padStart(2, '0') - return `${h}:${m}` - } - - const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) - const [activeBoundary, setActiveBoundary] = useState('start') - const [dateInput, setDateInput] = useState({ - start: formatDateOnly(value.dateRange.start), - end: formatDateOnly(value.dateRange.end) - }) - const [dateInputError, setDateInputError] = useState({ start: false, end: false }) - - // Default times: start at 00:00, end at 23:59 - const [timeInput, setTimeInput] = useState({ - start: '00:00', - end: '23:59' - }) - const [openTimeDropdown, setOpenTimeDropdown] = useState(null) - const startTimeSelectRef = useRef(null) - const endTimeSelectRef = useRef(null) - - useEffect(() => { - if (!open) return - const nextDraft = buildDialogDraft(value, minDate, maxDate) - setDraft(nextDraft) - setActiveBoundary('start') - setDateInput({ - start: formatDateOnly(nextDraft.dateRange.start), - end: formatDateOnly(nextDraft.dateRange.end) - }) - // For preset-based selections (not custom), use default times 00:00 and 23:59 - // For custom selections, preserve the time from value.dateRange - if (nextDraft.useAllTime || nextDraft.preset !== 'custom') { - setTimeInput({ - start: '00:00', - end: '23:59' - }) - } else { - setTimeInput({ - start: formatTimeOnly(nextDraft.dateRange.start), - end: formatTimeOnly(nextDraft.dateRange.end) - }) - } - setOpenTimeDropdown(null) - setDateInputError({ start: false, end: false }) - }, [maxDate, minDate, open, value]) - - useEffect(() => { - if (!open) return - setDateInput({ - start: formatDateOnly(draft.dateRange.start), - end: formatDateOnly(draft.dateRange.end) - }) - // Don't sync timeInput here - it's controlled by the time picker - setDateInputError({ start: false, end: false }) - }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) - - useEffect(() => { - if (!openTimeDropdown) return - - const handlePointerDown = (event: MouseEvent) => { - const target = event.target as Node - const activeContainer = openTimeDropdown === 'start' - ? startTimeSelectRef.current - : endTimeSelectRef.current - if (!activeContainer?.contains(target)) { - setOpenTimeDropdown(null) - } - } - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpenTimeDropdown(null) - } - } - - document.addEventListener('mousedown', handlePointerDown) - document.addEventListener('keydown', handleEscape) - return () => { - document.removeEventListener('mousedown', handlePointerDown) - document.removeEventListener('keydown', handleEscape) - } - }, [openTimeDropdown]) - - const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) - const clampStartDate = useCallback((targetDate: Date) => { - if (!bounds) return targetDate - const min = bounds.minDate - const max = bounds.maxDate - if (targetDate.getTime() < min.getTime()) return min - if (targetDate.getTime() > max.getTime()) return max - return targetDate - }, [bounds]) - const clampEndDate = useCallback((targetDate: Date) => { - if (!bounds) return targetDate - const min = bounds.minDate - const max = bounds.maxDate - if (targetDate.getTime() < min.getTime()) return min - if (targetDate.getTime() > max.getTime()) return max - return targetDate - }, [bounds]) - - const setRangeStart = useCallback((targetDate: Date) => { - const start = clampStartDate(targetDate) - setDraft(prev => { - return { - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - start, - end: prev.dateRange.end - }, - panelMonth: toMonthStart(start) - } - }) - }, [clampStartDate]) - - const setRangeEnd = useCallback((targetDate: Date) => { - const end = clampEndDate(targetDate) - setDraft(prev => { - const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start - return { - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: end - }, - panelMonth: toMonthStart(targetDate) - } - }) - }, [clampEndDate, clampStartDate]) - - const applyPreset = useCallback((preset: Exclude) => { - if (preset === 'all') { - const previewRange = bounds - ? { start: bounds.minDate, end: bounds.maxDate } - : createDefaultDateRange() - setTimeInput({ - start: '00:00', - end: '23:59' - }) - setOpenTimeDropdown(null) - setDraft(prev => ({ - ...prev, - preset, - useAllTime: true, - dateRange: previewRange, - panelMonth: toMonthStart(previewRange.start) - })) - setActiveBoundary('start') - return - } - - const range = clampSelectionToBounds({ - preset, - useAllTime: false, - dateRange: createDateRangeByPreset(preset) - }, minDate, maxDate).dateRange - setTimeInput({ - start: '00:00', - end: '23:59' - }) - setOpenTimeDropdown(null) - setDraft(prev => ({ - ...prev, - preset, - useAllTime: false, - dateRange: range, - panelMonth: toMonthStart(range.start) - })) - setActiveBoundary('start') - }, [bounds, maxDate, minDate]) - - const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => { - const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) - if (!matched) return null - const hours = Number(matched[1]) - const minutes = Number(matched[2]) - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null - return { hours, minutes } - } - - const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => { - setTimeInput(prev => ({ ...prev, [boundary]: timeStr })) - - const parsedTime = parseTimeValue(timeStr) - if (!parsedTime) return - - setDraft(prev => { - const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end - const newDate = new Date(dateObj) - newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) - return { - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - ...prev.dateRange, - [boundary]: newDate - } - } - }) - }, []) - - const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => { - setActiveBoundary(boundary) - setOpenTimeDropdown(prev => (prev === boundary ? null : boundary)) - }, []) - - const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => { - const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? { - hours: boundary === 'start' ? 0 : 23, - minutes: boundary === 'start' ? 0 : 59 - } - const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours - const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes - updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`) - }, [timeInput, updateBoundaryTime]) - - const renderTimeDropdown = (boundary: ActiveBoundary) => { - const currentTime = timeInput[boundary] - const parsedCurrent = parseTimeValue(currentTime) ?? { - hours: boundary === 'start' ? 0 : 23, - minutes: boundary === 'start' ? 0 : 59 - } - - return ( -
event.stopPropagation()}> -
- {boundary === 'start' ? '开始时间' : '结束时间'} - {currentTime} -
-
- {QUICK_TIME_OPTIONS.map(option => ( - - ))} -
-
-
- 小时 -
- {HOUR_OPTIONS.map(option => ( - - ))} -
-
-
- 分钟 -
- {MINUTE_OPTIONS.map(option => ( - - ))} -
-
-
-
- ) - } - - // Check if date input string contains time (YYYY-MM-DD HH:mm format) - const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim()) - - const commitStartFromInput = useCallback(() => { - const parsedDate = parseDateInputValue(dateInput.start) - if (!parsedDate) { - setDateInputError(prev => ({ ...prev, start: true })) - return - } - // Only apply time picker value if date input doesn't contain time - if (!dateInputHasTime(dateInput.start)) { - const parsedTime = parseTimeValue(timeInput.start) - if (parsedTime) { - parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) - } - } - setDateInputError(prev => ({ ...prev, start: false })) - setRangeStart(parsedDate) - }, [dateInput.start, timeInput.start, setRangeStart]) - - const commitEndFromInput = useCallback(() => { - const parsedDate = parseDateInputValue(dateInput.end) - if (!parsedDate) { - setDateInputError(prev => ({ ...prev, end: true })) - return - } - // Only apply time picker value if date input doesn't contain time - if (!dateInputHasTime(dateInput.end)) { - const parsedTime = parseTimeValue(timeInput.end) - if (parsedTime) { - parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) - } - } - setDateInputError(prev => ({ ...prev, end: false })) - setRangeEnd(parsedDate) - }, [dateInput.end, timeInput.end, setRangeEnd]) - - const shiftPanelMonth = useCallback((delta: number) => { - setDraft(prev => ({ - ...prev, - panelMonth: addMonths(prev.panelMonth, delta) - })) - }, []) - - const handleCalendarSelect = useCallback((targetDate: Date) => { - // Use time from timeInput state (which is updated by the time picker) - const parseTime = (timeStr: string): { hours: number; minutes: number } => { - const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) - if (!matched) return { hours: 0, minutes: 0 } - return { hours: Number(matched[1]), minutes: Number(matched[2]) } - } - - if (activeBoundary === 'start') { - const newStart = new Date(targetDate) - const time = parseTime(timeInput.start) - newStart.setHours(time.hours, time.minutes, 0, 0) - setRangeStart(newStart) - setActiveBoundary('end') - setOpenTimeDropdown(null) - return - } - - const pickedStart = startOfDay(targetDate) - const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start - const nextStart = pickedStart <= start ? pickedStart : start - - const newEnd = new Date(targetDate) - const time = parseTime(timeInput.end) - // If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput - if (pickedStart <= start) { - newEnd.setHours(23, 59, 59, 999) - setTimeInput(prev => ({ ...prev, end: '23:59' })) - } else { - newEnd.setHours(time.hours, time.minutes, 59, 999) - } - - setDraft(prev => ({ - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: newEnd - }, - panelMonth: toMonthStart(targetDate) - })) - setActiveBoundary('start') - setOpenTimeDropdown(null) - }, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart]) - - const isRangeModeActive = !draft.useAllTime - const modeText = isRangeModeActive - ? '当前导出模式:按时间范围导出' - : '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围' - - const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { - if (preset === 'all') return draft.useAllTime - return !draft.useAllTime && draft.preset === preset - }, [draft]) - - const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) - const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null - const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null - const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime() - const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime() - - const isStartSelected = useCallback((date: Date) => ( - !draft.useAllTime && isSameDay(date, draft.dateRange.start) - ), [draft]) - - const isEndSelected = useCallback((date: Date) => ( - !draft.useAllTime && isSameDay(date, draft.dateRange.end) - ), [draft]) - - const isDateInRange = useCallback((date: Date) => ( - !draft.useAllTime && - startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() && - startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() - ), [draft]) - - const isDateSelectable = useCallback((date: Date) => { - if (!bounds) return true - const target = startOfDay(date).getTime() - return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime() - }, [bounds]) - - const hintText = draft.useAllTime - ? '选择开始或结束日期后,会自动切换为自定义时间范围' - : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') - - if (!open) return null - - return createPortal( -
{ - event.stopPropagation() - onClose() - }} - >
event.stopPropagation()}> -
-

{title}

- -
- -
-
- {EXPORT_DATE_RANGE_PRESETS.map((preset) => { - const active = isPresetActive(preset.value) - return ( - - ) - })} -
- -
- {modeText} -
- -
-
setActiveBoundary('start')} - > - 开始 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, start: nextValue })) - if (dateInputError.start) { - setDateInputError(prev => ({ ...prev, start: false })) - } - }} - onFocus={() => setActiveBoundary('start')} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitStartFromInput() - }} - onBlur={commitStartFromInput} - /> -
event.stopPropagation()} - > - - {openTimeDropdown === 'start' && renderTimeDropdown('start')} -
-
-
setActiveBoundary('end')} - > - 结束 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, end: nextValue })) - if (dateInputError.end) { - setDateInputError(prev => ({ ...prev, end: false })) - } - }} - onFocus={() => setActiveBoundary('end')} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitEndFromInput() - }} - onBlur={commitEndFromInput} - /> -
event.stopPropagation()} - > - - {openTimeDropdown === 'end' && renderTimeDropdown('end')} -
-
-
- -
{hintText}
- -
-
-
- 选择日期范围 - {formatCalendarMonthTitle(draft.panelMonth)} -
-
- - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {calendarCells.map((cell) => { - const startSelected = isStartSelected(cell.date) - const endSelected = isEndSelected(cell.date) - const inRange = isDateInRange(cell.date) - const selectable = isDateSelectable(cell.date) - return ( - - ) - })} -
-
-
- -
- - -
-
-
, - document.body - ) -} diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss deleted file mode 100644 index ec8f7f3..0000000 --- a/src/components/Export/ExportDefaultsSettingsForm.scss +++ /dev/null @@ -1,685 +0,0 @@ -.export-defaults-settings-form { - .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; - } - - .form-hint { - display: block; - font-size: 12px; - color: var(--text-tertiary); - margin-bottom: 8px; - } - - .select-field { - position: relative; - margin-bottom: 10px; - } - - .select-trigger { - 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); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--text-tertiary); - } - - &.open { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); - } - } - - .select-value { - flex: 1; - min-width: 0; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .select-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 6px; - box-shadow: var(--shadow-md); - z-index: 120; - max-height: 320px; - overflow-y: auto; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - } - - .select-option { - width: 100%; - text-align: left; - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: none; - border-radius: 10px; - background: transparent; - cursor: pointer; - transition: all 0.15s; - color: var(--text-primary); - font-size: 14px; - - &:hover { - background: var(--bg-tertiary); - } - - &.active { - background: color-mix(in srgb, var(--primary) 12%, transparent); - color: var(--primary); - } - } - - .option-label { - font-weight: 500; - } - - .option-desc { - font-size: 12px; - color: var(--text-tertiary); - } - - .format-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); - gap: 6px; - width: 100%; - margin-bottom: 10px; - } - - .format-card { - width: 100%; - min-height: 0; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 10px; - text-align: left; - background: var(--bg-primary); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - transition: border-color 0.2s ease, background 0.2s ease; - - &:hover { - border-color: var(--text-tertiary); - } - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - } - } - - .format-label { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - line-height: 1.35; - } - - .format-desc { - margin-top: 1px; - font-size: 11px; - color: var(--text-tertiary); - line-height: 1.35; - } - - .select-option.active .option-desc { - color: var(--primary); - } - - .settings-time-range-field { - margin-bottom: 10px; - } - - .settings-time-range-trigger { - 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); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: rgba(var(--primary-rgb), 0.45); - color: var(--primary); - } - - &.open { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); - } - } - - .settings-time-range-value { - flex: 1; - min-width: 0; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .settings-time-range-arrow { - color: var(--text-tertiary); - font-weight: 700; - line-height: 1; - } - - .log-toggle-line { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; - padding: 10px 14px; - border: 1px solid var(--border-color); - border-radius: 14px; - background: var(--bg-primary); - } - - .media-default-grid { - width: 100%; - display: flex; - align-items: center; - flex-wrap: nowrap; - gap: 12px; - margin-bottom: 10px; - - label { - display: inline-flex; - align-items: center; - gap: 6px; - position: relative; - margin-bottom: 0; - font-size: 13px; - line-height: 1; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - white-space: nowrap; - transition: border-color 0.16s ease, background 0.16s ease; - } - - input[type='checkbox'] { - margin: 0; - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; - - &:checked + .media-default-check { - border-color: var(--primary); - background: color-mix(in srgb, var(--primary) 88%, #fff); - } - - &:checked + .media-default-check::after { - opacity: 1; - transform: rotate(-45deg) scale(1); - } - - &:focus-visible + .media-default-check { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); - } - } - - .media-default-check { - width: 14px; - height: 14px; - flex: 0 0 14px; - border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-tertiary)); - border-radius: 4px; - background: var(--bg-primary); - display: inline-flex; - align-items: center; - justify-content: center; - transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; - - &::after { - content: ''; - width: 7px; - height: 4px; - border-left: 2px solid #fff; - border-bottom: 2px solid #fff; - opacity: 0; - transform: rotate(-45deg) scale(0.72); - transform-origin: center; - transition: opacity 0.16s ease, transform 0.16s ease; - } - } - } - - .log-status { - font-size: 13px; - color: var(--text-secondary); - } - - .concurrency-inline-options { - width: 100%; - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 6px; - margin-bottom: 10px; - } - - .concurrency-option { - border: 1px solid var(--border-color); - border-radius: 10px; - min-height: 38px; - padding: 0; - background: var(--bg-primary); - color: var(--text-primary); - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; - - &:hover { - border-color: var(--text-tertiary); - } - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - color: var(--primary); - } - } - - .switch { - position: relative; - display: inline-flex; - width: 48px; - height: 28px; - cursor: pointer; - flex-shrink: 0; - } - - .switch-input { - opacity: 0; - width: 0; - height: 0; - position: absolute; - - &:checked + .switch-slider { - background: var(--primary); - } - - &:checked + .switch-slider::before { - transform: translateX(20px); - } - - &:focus + .switch-slider { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); - } - } - - .switch-slider { - position: absolute; - inset: 0; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 999px; - transition: all 0.2s ease; - - &::before { - content: ''; - position: absolute; - width: 20px; - height: 20px; - left: 3px; - top: 3px; - border-radius: 50%; - background: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); - transition: transform 0.2s ease; - } - } - - &.layout-split { - .form-group { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); - gap: 18px; - align-items: center; - padding: 14px 0; - margin-bottom: 0; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); - } - - .form-group:last-child { - border-bottom: none; - padding-bottom: 0; - } - - .form-group:first-child { - padding-top: 0; - } - - .form-copy { - min-width: 0; - } - - .form-control { - min-width: 0; - display: flex; - justify-content: flex-end; - } - - .form-hint { - margin-bottom: 0; - line-height: 1.5; - } - - .select-field, - .settings-time-range-field { - width: 100%; - max-width: 360px; - margin-bottom: 0; - } - - .log-toggle-line { - width: 100%; - max-width: 360px; - margin-bottom: 0; - } - - .media-default-grid { - max-width: 360px; - margin-bottom: 0; - } - - .concurrency-inline-options { - max-width: 360px; - margin-bottom: 0; - } - - .format-setting-group { - grid-template-columns: 1fr; - gap: 10px; - align-items: stretch; - } - - .format-setting-group .form-control { - justify-content: flex-start; - } - - .format-grid { - max-width: none; - grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-bottom: 0; - } - } -} - -@media (max-width: 1024px) { - .export-defaults-settings-form.layout-split { - .media-setting-group { - grid-template-columns: 1fr; - gap: 10px; - align-items: stretch; - } - - .media-setting-group .form-control { - justify-content: flex-start; - } - - .media-default-grid { - max-width: none; - flex-wrap: wrap; - } - } -} - -@media (max-width: 760px) { - .export-defaults-settings-form.layout-split { - .form-group { - grid-template-columns: 1fr; - gap: 10px; - } - - .form-control { - justify-content: flex-start; - } - - .select-field, - .settings-time-range-field, - .log-toggle-line, - .media-default-grid, - .concurrency-inline-options, - .format-grid { - max-width: none; - } - - .media-default-grid { - flex-wrap: wrap; - } - - .format-grid { - grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); - } - } -} - -// UI rebuild polish for the modal variant used by ExportPage. -.export-defaults-settings-form.layout-split { - display: grid; - gap: 10px; - - .form-group { - grid-template-columns: minmax(176px, 0.82fr) minmax(0, 1.18fr); - gap: 12px; - align-items: start; - padding: 12px; - border: none; - border-radius: 12px; - background: color-mix(in srgb, var(--bg-secondary) 82%, var(--bg-primary)); - } - - .form-group:first-child, - .form-group:last-child { - padding: 12px; - } - - .form-copy { - padding-top: 2px; - } - - label { - margin-bottom: 3px; - line-height: 1.35; - } - - .form-hint { - line-height: 1.45; - } - - .form-control { - width: 100%; - min-width: 0; - justify-content: stretch; - } - - .select-field, - .settings-time-range-field, - .log-toggle-line, - .media-default-grid, - .concurrency-inline-options { - max-width: none; - width: 100%; - } - - .select-trigger, - .settings-time-range-trigger { - border-radius: 12px; - background: var(--bg-primary); - min-height: 42px; - padding: 9px 12px; - } - - .log-toggle-line { - border-radius: 12px; - background: var(--bg-primary); - min-height: 42px; - padding: 8px 12px; - } - - .concurrency-inline-options { - grid-template-columns: repeat(6, minmax(38px, 1fr)); - gap: 6px; - } - - .concurrency-option { - min-width: 0; - min-height: 36px; - border-radius: 8px; - } - - .format-setting-group { - grid-template-columns: 1fr; - gap: 10px; - } - - .format-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - width: 100%; - } - - .format-card { - min-height: 68px; - padding: 10px 12px; - border-radius: 8px; - background: var(--bg-primary); - } - - .format-label, - .format-desc { - max-width: 100%; - overflow-wrap: anywhere; - } - - .media-default-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); - gap: 8px; - - label { - min-height: 34px; - padding: 7px 10px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-primary); - - &:hover { - border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); - background: color-mix(in srgb, var(--text-tertiary) 4%, var(--bg-primary)); - } - } - } -} - -.select-dropdown-floating { - background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 6px; - box-shadow: var(--shadow-md); - overflow-y: auto; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - - .select-option { - width: 100%; - text-align: left; - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: none; - border-radius: 10px; - background: transparent; - cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; - color: var(--text-primary); - font-size: 14px; - - &:hover { - background: var(--bg-tertiary); - } - - &.active { - background: color-mix(in srgb, var(--primary) 12%, transparent); - color: var(--primary); - } - } - - .option-label { - font-weight: 500; - } - - .option-desc { - font-size: 12px; - color: var(--text-tertiary); - } - - .select-option.active .option-desc { - color: var(--primary); - } -} - -@media (max-width: 980px) { - .export-defaults-settings-form.layout-split { - .form-group { - grid-template-columns: 1fr; - gap: 10px; - } - - .format-grid { - grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); - } - } -} diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx deleted file mode 100644 index 6b527d8..0000000 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ /dev/null @@ -1,583 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { CSSProperties } from 'react' -import { createPortal } from 'react-dom' -import { ChevronDown } from 'lucide-react' -import * as configService from '../../services/config' -import { ExportDateRangeDialog } from './ExportDateRangeDialog' -import { - createDefaultExportDateRangeSelection, - getExportDateRangeLabel, - resolveExportDateRangeConfig, - serializeExportDateRangeConfig, - type ExportDateRangeSelection -} from '../../utils/exportDateRange' -import './ExportDefaultsSettingsForm.scss' - -export interface ExportDefaultsSettingsPatch { - format?: string - avatars?: boolean - dateRange?: ExportDateRangeSelection - fileNamingMode?: configService.ExportFileNamingMode - media?: configService.ExportDefaultMediaConfig - voiceAsText?: boolean - excelCompactColumns?: boolean - concurrency?: number -} - -interface ExportDefaultsSettingsFormProps { - onNotify?: (text: string, success: boolean) => void - onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void - layout?: 'stacked' | 'split' -} - -const exportFormatOptions = [ - { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, - { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, - { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, - { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, - { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, - { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, - { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, - { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } -] as const - -const exportExcelColumnOptions = [ - { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, - { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } -] as const - -const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [ - { value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' }, - { value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' } -] - -const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const - -const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { - return options.find((option) => option.value === value)?.label ?? value -} - -interface SelectDropdownPlacement { - left: number - width: number - maxHeight: number - top?: number - bottom?: number -} - -const resolveSelectDropdownPlacement = (anchor: HTMLElement | null): SelectDropdownPlacement | null => { - if (!anchor || typeof window === 'undefined') return null - - const rect = anchor.getBoundingClientRect() - const viewportWidth = window.innerWidth || document.documentElement.clientWidth - const viewportHeight = window.innerHeight || document.documentElement.clientHeight - const viewportMargin = 12 - const dropdownGap = 6 - const minDropdownHeight = 128 - const availableWidth = Math.max(160, viewportWidth - viewportMargin * 2) - const width = Math.min(Math.max(rect.width, 220), availableWidth) - const left = Math.max(viewportMargin, Math.min(rect.left, viewportWidth - width - viewportMargin)) - const spaceBelow = Math.max(0, viewportHeight - rect.bottom - viewportMargin - dropdownGap) - const spaceAbove = Math.max(0, rect.top - viewportMargin - dropdownGap) - const shouldOpenAbove = spaceBelow < minDropdownHeight && spaceAbove > spaceBelow - const availableHeight = shouldOpenAbove ? spaceAbove : spaceBelow - const maxHeight = Math.max(96, Math.min(320, availableHeight)) - - return shouldOpenAbove - ? { left, width, maxHeight, bottom: viewportHeight - rect.top + dropdownGap } - : { left, width, maxHeight, top: rect.bottom + dropdownGap } -} - -const getSelectDropdownStyle = (placement: SelectDropdownPlacement): CSSProperties => ({ - position: 'fixed', - top: placement.top, - bottom: placement.bottom, - left: placement.left, - right: 'auto', - width: placement.width, - maxHeight: placement.maxHeight, - zIndex: 9300 -}) - -export function ExportDefaultsSettingsForm({ - onNotify, - onDefaultsChanged, - layout = 'stacked' -}: ExportDefaultsSettingsFormProps) { - const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) - const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false) - const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) - const exportExcelColumnsDropdownRef = useRef(null) - const exportFileNamingModeDropdownRef = useRef(null) - const exportExcelColumnsMenuRef = useRef(null) - const exportFileNamingModeMenuRef = useRef(null) - const [exportExcelColumnsPlacement, setExportExcelColumnsPlacement] = useState(null) - const [exportFileNamingModePlacement, setExportFileNamingModePlacement] = useState(null) - - const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') - const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) - const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) - const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState('classic') - const [exportDefaultMedia, setExportDefaultMedia] = useState({ - images: true, - videos: true, - voices: true, - emojis: true, - files: true - }) - const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) - const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) - const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) - - useEffect(() => { - let cancelled = false - void (async () => { - const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ - configService.getExportDefaultFormat(), - configService.getExportDefaultAvatars(), - configService.getExportDefaultDateRange(), - configService.getExportDefaultFileNamingMode(), - configService.getExportDefaultMedia(), - configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns(), - configService.getExportDefaultConcurrency() - ]) - - if (cancelled) return - - setExportDefaultFormat(savedFormat || 'excel') - setExportDefaultAvatars(savedAvatars ?? true) - setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) - setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') - setExportDefaultMedia(savedMedia ?? { - images: true, - videos: true, - voices: true, - emojis: true, - files: true - }) - setExportDefaultVoiceAsText(savedVoiceAsText ?? false) - setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) - setExportDefaultConcurrency(savedConcurrency ?? 2) - })() - - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node - if ( - showExportExcelColumnsSelect && - exportExcelColumnsDropdownRef.current && - !exportExcelColumnsDropdownRef.current.contains(target) && - !exportExcelColumnsMenuRef.current?.contains(target) - ) { - setShowExportExcelColumnsSelect(false) - } - if ( - showExportFileNamingModeSelect && - exportFileNamingModeDropdownRef.current && - !exportFileNamingModeDropdownRef.current.contains(target) && - !exportFileNamingModeMenuRef.current?.contains(target) - ) { - setShowExportFileNamingModeSelect(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect]) - - const updateSelectDropdownPlacements = useCallback(() => { - if (showExportExcelColumnsSelect) { - setExportExcelColumnsPlacement(resolveSelectDropdownPlacement(exportExcelColumnsDropdownRef.current)) - } - if (showExportFileNamingModeSelect) { - setExportFileNamingModePlacement(resolveSelectDropdownPlacement(exportFileNamingModeDropdownRef.current)) - } - }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect]) - - useEffect(() => { - if (!showExportExcelColumnsSelect) setExportExcelColumnsPlacement(null) - if (!showExportFileNamingModeSelect) setExportFileNamingModePlacement(null) - if (!showExportExcelColumnsSelect && !showExportFileNamingModeSelect) return - - updateSelectDropdownPlacements() - window.addEventListener('resize', updateSelectDropdownPlacements) - document.addEventListener('scroll', updateSelectDropdownPlacements, true) - - return () => { - window.removeEventListener('resize', updateSelectDropdownPlacements) - document.removeEventListener('scroll', updateSelectDropdownPlacements, true) - } - }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect, updateSelectDropdownPlacements]) - - const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' - const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) - const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) - const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode]) - - const notify = (text: string, success = true) => { - onNotify?.(text, success) - } - - const fileNamingModeDropdown = showExportFileNamingModeSelect && exportFileNamingModePlacement - ? createPortal( -
event.stopPropagation()} - > - {exportFileNamingModeOptions.map((option) => ( - - ))} -
, - document.body - ) - : null - - const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement - ? createPortal( -
event.stopPropagation()} - > - {exportExcelColumnOptions.map((option) => ( - - ))} -
, - document.body - ) - : null - - return ( -
-
-
- - 导出多个会话时的最大并发(1~6) -
-
-
- {exportConcurrencyOptions.map((option) => ( - - ))} -
-
-
- -
-
- - 导出页面默认选中的格式 -
-
-
- {exportFormatOptions.map((option) => ( - - ))} -
-
-
- -
-
- - 开启后导出的聊天消息对应的文件中会带头像信息。 -
-
-
- {exportDefaultAvatars ? '已开启' : '已关闭'} - -
-
-
- -
-
- - 控制导出页面的默认时间选择 -
-
-
- -
-
-
- - setIsExportDateRangeDialogOpen(false)} - onConfirm={async (nextSelection) => { - setExportDefaultDateRange(nextSelection) - await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) - onDefaultsChanged?.({ dateRange: nextSelection }) - notify('已更新默认导出时间范围', true) - setIsExportDateRangeDialogOpen(false) - }} - /> - -
-
- - 控制导出文件名是否包含时间范围 -
-
-
- - {fileNamingModeDropdown} -
-
-
- -
-
- - 控制 Excel 导出的列字段 -
-
-
- - {excelColumnsDropdown} -
-
-
- -
-
- - 控制图片、视频、语音、表情包、文件的默认导出开关 -
-
-
- - - - - -
-
-
- -
-
- - 导出时默认将语音转写为文字 -
-
-
- {exportDefaultVoiceAsText ? '已开启' : '已关闭'} - -
-
-
- -
- ) -} diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx deleted file mode 100644 index 42a8880..0000000 --- a/src/components/GlobalSessionMonitor.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { useEffect, useRef } from 'react' -import { useChatStore } from '../stores/chatStore' -import type { ChatSession, Message } from '../types/models' -import { useNavigate } from 'react-router-dom' - -export function GlobalSessionMonitor() { - const navigate = useNavigate() - const { - sessions, - setSessions, - currentSessionId, - appendMessages, - messages - } = useChatStore() - - const sessionsRef = useRef(sessions) - // 保持 ref 同步 - useEffect(() => { - sessionsRef.current = sessions - }, [sessions]) - - // 去重辅助函数:获取消息 key - const getMessageKey = (msg: Message) => { - if (msg.messageKey) return msg.messageKey - return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` - } - - // 处理数据库变更 - useEffect(() => { - const handleDbChange = (_event: any, data: { type: string; json: string }) => { - try { - const payload = JSON.parse(data.json) - const tableName = payload.table - - // 只关注 Session 表 - if (tableName === 'Session' || tableName === 'session') { - refreshSessions() - } - } catch (e) { - console.error('解析数据库变更失败:', e) - } - } - - if (window.electronAPI.chat.onWcdbChange) { - const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange) - return () => { - removeListener() - } - } - return () => { } - }, []) - - const refreshSessions = async () => { - try { - const result = await window.electronAPI.chat.getSessions() - if (result.success && result.sessions && Array.isArray(result.sessions)) { - const newSessions = result.sessions as ChatSession[] - const oldSessions = sessionsRef.current - - // 1. 检测变更并通知 - checkForNewMessages(oldSessions, newSessions) - - // 2. 更新 store - setSessions(newSessions) - - // 3. 如果在活跃会话中,增量刷新消息 - const currentId = useChatStore.getState().currentSessionId - if (currentId) { - const currentSessionNew = newSessions.find(s => s.username === currentId) - const currentSessionOld = oldSessions.find(s => s.username === currentId) - - if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) { - void handleActiveSessionRefresh(currentId) - } - } - } - } catch (e) { - console.error('全局会话刷新失败:', e) - } - } - - const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => { - if (!oldSessions || oldSessions.length === 0) { - console.log('[NotificationFilter] Skipping check on initial load (empty baseline)') - return - } - - const oldMap = new Map(oldSessions.map(s => [s.username, s])) - - for (const newSession of newSessions) { - const oldSession = oldMap.get(newSession.username) - - // 条件: 新会话或时间戳更新 - const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId - - if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { - // 这是新消息事件 - - // 免打扰、折叠群、折叠入口不弹通知 - if (newSession.isMuted || newSession.isFolded) continue - if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue - - // 1. 群聊过滤自己发送的消息 - if (newSession.username.includes('@chatroom')) { - // 如果是自己发的消息,不弹通知 - // 注意:lastMsgSender 需要后端支持返回 - // 使用宽松比较以处理 wxid_ 前缀差异 - if (newSession.lastMsgSender && newSession.selfWxid) { - const sender = newSession.lastMsgSender.replace(/^wxid_/, ''); - const self = newSession.selfWxid.replace(/^wxid_/, ''); - - // 使用主进程日志打印,方便用户查看 - const debugInfo = { - type: 'NotificationFilter', - username: newSession.username, - lastMsgSender: newSession.lastMsgSender, - selfWxid: newSession.selfWxid, - senderClean: sender, - selfClean: self, - match: sender === self - }; - - if (window.electronAPI.log?.debug) { - window.electronAPI.log.debug(debugInfo); - } else { - console.log('[NotificationFilter]', debugInfo); - } - - if (sender === self) { - if (window.electronAPI.log?.debug) { - window.electronAPI.log.debug('[NotificationFilter] Filtered own message'); - } else { - console.log('[NotificationFilter] Filtered own message'); - } - continue; - } - } else { - const missingInfo = { - type: 'NotificationFilter Missing info', - lastMsgSender: newSession.lastMsgSender, - selfWxid: newSession.selfWxid - }; - if (window.electronAPI.log?.debug) { - window.electronAPI.log.debug(missingInfo); - } else { - console.log('[NotificationFilter] Missing info:', missingInfo); - } - } - } - - // 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知 - const oldUnread = oldSession ? oldSession.unreadCount : 0 - const newUnread = newSession.unreadCount - if (newUnread <= oldUnread) { - // 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知 - continue - } - - let title = newSession.displayName || newSession.username - let avatarUrl = newSession.avatarUrl - let content = newSession.summary || '[新消息]' - - if (newSession.username.includes('@chatroom')) { - // 1. 群聊过滤自己发送的消息 - // 辅助函数:清理 wxid 后缀 (如 _8602) - const cleanWxid = (id: string) => { - if (!id) return ''; - const trimmed = id.trim(); - // 仅移除末尾的 _xxxx (4位字母数字) - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/); - return suffixMatch ? suffixMatch[1] : trimmed; - } - - if (newSession.lastMsgSender && newSession.selfWxid) { - const senderClean = cleanWxid(newSession.lastMsgSender); - const selfClean = cleanWxid(newSession.selfWxid); - const match = senderClean === selfClean; - - if (match) { - continue; - } - } - - // 2. 群聊显示发送者名字 (放在内容中: "Name: Message") - // 标题保持为群聊名称 (title 变量) - if (newSession.lastSenderDisplayName) { - content = `${newSession.lastSenderDisplayName}: ${content}` - } - } - - // 修复 "Random User" 的逻辑 (缺少具体信息) - // 如果标题看起来像 wxid 或没有头像,尝试获取信息 - const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username - - if (needsEnrichment && newSession.username) { - try { - // 尝试丰富或获取联系人详情 - const contact = await window.electronAPI.chat.getContact(newSession.username) - if (contact) { - if (contact.remark || contact.nickName) { - title = contact.remark || contact.nickName - } - const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username) - if (avatarResult?.avatarUrl) { - avatarUrl = avatarResult.avatarUrl - } - } else { - // 如果不在缓存/数据库中 - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username]) - if (enrichResult.success && enrichResult.contacts) { - const enrichedContact = enrichResult.contacts[newSession.username] - if (enrichedContact) { - if (enrichedContact.displayName) { - title = enrichedContact.displayName - } - if (enrichedContact.avatarUrl) { - avatarUrl = enrichedContact.avatarUrl - } - } - } - // 如果仍然没有有效名称,再尝试一次获取 - if (title === newSession.username || title.startsWith('wxid_')) { - const retried = await window.electronAPI.chat.getContact(newSession.username) - if (retried) { - title = retried.remark || retried.nickName || title - const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username) - if (retriedAvatar?.avatarUrl) { - avatarUrl = retriedAvatar.avatarUrl - } - } - } - } - } catch (e) { - console.warn('获取通知的联系人信息失败', e) - } - } - - // 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户) - // 群聊例外,因为群聊 username 包含 @chatroom - const isGroupChat = newSession.username.includes('@chatroom') - const isWxidTitle = title.startsWith('wxid_') && title === newSession.username - if (isWxidTitle && !isGroupChat) { - console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username) - continue - } - - // 调用 IPC 以显示独立窗口通知 - window.electronAPI.notification?.show({ - title: title, - content: content, - avatarUrl: avatarUrl, - sessionId: newSession.username - }) - - // 我们不再为 Toast 设置本地状态 - } - } - } - - const handleActiveSessionRefresh = async (sessionId: string) => { - // 从 ChatPage 复制/调整的逻辑,以保持集中 - const state = useChatStore.getState() - const msgs = state.messages || [] - const lastMsg = msgs[msgs.length - 1] - const minTime = lastMsg?.createTime || 0 - - try { - const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime) - if (result.success && result.messages && result.messages.length > 0) { - const latestMessages = useChatStore.getState().messages || [] - const existingKeys = new Set(latestMessages.map(getMessageKey)) - const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg))) - if (newMessages.length > 0) { - appendMessages(newMessages, false) - } - } - } catch (e) { - console.warn('后台活跃会话刷新失败:', e) - } - } - - // 此组件不再渲染 UI - return null -} diff --git a/src/components/ImagePreview.scss b/src/components/ImagePreview.scss deleted file mode 100644 index d065ebd..0000000 --- a/src/components/ImagePreview.scss +++ /dev/null @@ -1,90 +0,0 @@ -.image-preview-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 9999; - user-select: none; -} - -.preview-image { - max-width: 90vw; - max-height: 90vh; - object-fit: contain; - transition: transform 0.15s ease-out; - - &.dragging { - transition: none; - } -} - -.preview-content { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: fit-content; - height: fit-content; -} - -.image-preview-close { - position: absolute; - bottom: 40px; - left: 50%; - transform: translateX(-50%); - width: 48px; - height: 48px; - border-radius: 50%; - border: 2px solid rgba(255, 255, 255, 0.3); - background: rgba(0, 0, 0, 0.7); - color: #fff; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - backdrop-filter: blur(10px); - - &:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.5); - transform: translateX(-50%) scale(1.1); - } -} - -.live-photo-btn { - position: absolute; - top: 15px; - right: 15px; - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 16px; - background: rgba(0, 0, 0, 0.5); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - cursor: pointer; - backdrop-filter: blur(10px); - transition: all 0.2s; - z-index: 10000; - - &:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.4); - transform: translateY(-2px); - } - - &.active { - background: var(--accent-color, #007aff); - border-color: transparent; - box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3); - } - - span { - font-size: 14px; - font-weight: 500; - } -} \ No newline at end of file diff --git a/src/components/ImagePreview.tsx b/src/components/ImagePreview.tsx deleted file mode 100644 index 9ad03ea..0000000 --- a/src/components/ImagePreview.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react' -import { X } from 'lucide-react' -import { LivePhotoIcon } from './LivePhotoIcon' -import { createPortal } from 'react-dom' -import './ImagePreview.scss' - -interface ImagePreviewProps { - src: string - isVideo?: boolean - liveVideoPath?: string - onClose: () => void -} - -export const ImagePreview: React.FC = ({ src, isVideo, liveVideoPath, onClose }) => { - const [scale, setScale] = useState(1) - const [position, setPosition] = useState({ x: 0, y: 0 }) - const [isDragging, setIsDragging] = useState(false) - const [showLive, setShowLive] = useState(false) - const dragStart = useRef({ x: 0, y: 0 }) - const positionStart = useRef({ x: 0, y: 0 }) - const containerRef = useRef(null) - - // 滚轮缩放 - const handleWheel = useCallback((e: React.WheelEvent) => { - if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化 - e.preventDefault() - const delta = e.deltaY > 0 ? 0.9 : 1.1 - setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5)) - }, [showLive]) - - // 开始拖动 - const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (showLive || scale <= 1) return - e.preventDefault() - setIsDragging(true) - dragStart.current = { x: e.clientX, y: e.clientY } - positionStart.current = { ...position } - }, [scale, position, showLive]) - - // 拖动中 - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!isDragging) return - const dx = e.clientX - dragStart.current.x - const dy = e.clientY - dragStart.current.y - setPosition({ - x: positionStart.current.x + dx, - y: positionStart.current.y + dy - }) - }, [isDragging]) - - // 结束拖动 - const handleMouseUp = useCallback(() => { - setIsDragging(false) - }, []) - - // 双击重置 - const handleDoubleClick = useCallback(() => { - setScale(1) - setPosition({ x: 0, y: 0 }) - }, []) - - // 点击背景关闭 - const handleOverlayClick = useCallback((e: React.MouseEvent) => { - if (e.target === containerRef.current) { - onClose() - } - }, [onClose]) - - // ESC 关闭 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [onClose]) - - return createPortal( -
-
e.stopPropagation()} - > - {(isVideo || showLive) ? ( -
- - -
, - document.body - ) -} diff --git a/src/components/JumpToDateDialog.scss b/src/components/JumpToDateDialog.scss deleted file mode 100644 index 0cdcadb..0000000 --- a/src/components/JumpToDateDialog.scss +++ /dev/null @@ -1,404 +0,0 @@ -.jump-date-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - animation: fadeIn 0.2s ease-out; -} - -.jump-date-modal { - background: var(--card-bg); - width: 340px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - display: flex; - flex-direction: column; - overflow: hidden; - animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} - -.jump-date-header { - padding: 18px 20px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--border-color); - - .title-area { - display: flex; - align-items: center; - gap: 10px; - color: var(--text-primary); - - svg { - color: var(--primary); - } - - h3 { - font-size: 16px; - font-weight: 600; - margin: 0; - } - } - - .close-btn { - background: none; - border: none; - color: var(--text-tertiary); - cursor: pointer; - padding: 4px; - border-radius: 6px; - display: flex; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - } -} - -.calendar-view { - padding: 20px; - - .calendar-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; - - .current-month { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - border: none; - background: transparent; - - &.clickable { - cursor: pointer; - border-radius: 6px; - padding: 2px 8px; - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - } - } - - .nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - color: var(--text-secondary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - } - } - - .year-month-picker { - padding: 4px 0; - - .year-selector { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - - .year-label { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - color: var(--text-secondary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - } - } - - .month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .month-btn { - padding: 10px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - - .year-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .year-btn { - padding: 10px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - } -} - -.calendar-grid { - position: relative; - - &.loading { - - .weekdays, - .days { - pointer-events: none; - } - } - - .calendar-loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - color: var(--text-secondary); - font-size: 13px; - - .spin { - color: var(--primary); - animation: spin 1s linear infinite; - } - } - - .weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - margin-bottom: 8px; - - .weekday { - text-align: center; - font-size: 12px; - font-weight: 500; - color: var(--text-tertiary); - padding: 4px 0; - } - } - - .days { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: repeat(6, 36px); - gap: 4px; - - .day-cell { - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - color: var(--text-primary); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - position: relative; - - &.empty { - cursor: default; - } - - &:not(.empty):not(.no-message):hover { - background: var(--bg-hover); - } - - &.selected { - background: var(--primary); - color: #fff; - font-weight: 600; - } - - &.today:not(.selected) { - color: var(--primary); - font-weight: 600; - background: var(--primary-light); - } - - // 无消息的日期 - 灰显且不可点击 - &.no-message { - opacity: 0.3; - cursor: default; - pointer-events: none; - } - - // 有消息的日期指示器小圆点 - .message-dot { - position: absolute; - bottom: 3px; - left: 50%; - transform: translateX(-50%); - width: 4px; - height: 4px; - border-radius: 50%; - background: var(--primary); - } - - &.selected .message-dot { - background: rgba(255, 255, 255, 0.7); - } - } - } -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.quick-options { - display: flex; - gap: 8px; - padding: 0 20px 16px; - - button { - flex: 1; - padding: 8px; - font-size: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 6px; - color: var(--text-secondary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - border-color: var(--primary); - } - } -} - -.dialog-footer { - padding: 16px 20px; - display: flex; - gap: 12px; - background: var(--bg-secondary); - - button { - flex: 1; - padding: 10px; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - } - - .cancel-btn { - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-secondary); - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - } - - .confirm-btn { - background: var(--primary); - border: none; - color: #fff; - - &:hover { - background: var(--primary-hover); - } - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes modalSlideUp { - from { - opacity: 0; - transform: translateY(20px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/src/components/JumpToDateDialog.tsx b/src/components/JumpToDateDialog.tsx deleted file mode 100644 index 47bfb80..0000000 --- a/src/components/JumpToDateDialog.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React, { useState } from 'react' -import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react' -import './JumpToDateDialog.scss' - -interface JumpToDateDialogProps { - isOpen: boolean - onClose: () => void - onSelect: (date: Date) => void - currentDate?: Date - /** 有消息的日期集合,格式为 YYYY-MM-DD */ - messageDates?: Set - /** 是否正在加载消息日期 */ - loadingDates?: boolean -} - -const JumpToDateDialog: React.FC = ({ - isOpen, - onClose, - onSelect, - currentDate = new Date(), - messageDates, - loadingDates = false -}) => { - type CalendarViewMode = 'day' | 'month' | 'year' - const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12 - const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime()) - const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date()) - const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) - const [viewMode, setViewMode] = useState('day') - const [yearPageStart, setYearPageStart] = useState( - getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear()) - ) - - if (!isOpen) return null - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month + 1, 0).getDate() - } - - const getFirstDayOfMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month, 1).getDay() - } - - const generateCalendar = () => { - const daysInMonth = getDaysInMonth(calendarDate) - const firstDay = getFirstDayOfMonth(calendarDate) - 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 days - } - - /** - * 判断某天是否有消息 - */ - const hasMessage = (day: number): boolean => { - if (!messageDates || messageDates.size === 0) return true // 未加载时默认全部可点击 - const year = calendarDate.getFullYear() - const month = calendarDate.getMonth() + 1 - const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` - return messageDates.has(dateStr) - } - - const handleDateClick = (day: number) => { - // 如果已加载日期数据且该日期无消息,则不可点击 - if (messageDates && messageDates.size > 0 && !hasMessage(day)) return - const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) - setSelectedDate(newDate) - } - - const handleConfirm = () => { - onSelect(selectedDate) - onClose() - } - - const isToday = (day: number) => { - const today = new Date() - return day === today.getDate() && - calendarDate.getMonth() === today.getMonth() && - calendarDate.getFullYear() === today.getFullYear() - } - - const isSelected = (day: number) => { - return day === selectedDate.getDate() && - calendarDate.getMonth() === selectedDate.getMonth() && - calendarDate.getFullYear() === selectedDate.getFullYear() - } - - /** - * 获取某天的 CSS 类名 - */ - const getDayClassName = (day: number | null): string => { - if (day === null) return 'day-cell empty' - - const classes = ['day-cell'] - if (isSelected(day)) classes.push('selected') - if (isToday(day)) classes.push('today') - - // 仅在已加载消息日期数据时区分有/无消息 - if (messageDates && messageDates.size > 0) { - if (hasMessage(day)) { - classes.push('has-message') - } else { - classes.push('no-message') - } - } - - return classes.join(' ') - } - - const weekdays = ['日', '一', '二', '三', '四', '五', '六'] - const days = generateCalendar() - const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] - - const updateCalendarDate = (nextDate: Date) => { - setCalendarDate(nextDate) - } - - const openMonthView = () => setViewMode('month') - const openYearView = () => { - setYearPageStart(getYearPageStart(calendarDate.getFullYear())) - setViewMode('year') - } - - const handleTitleClick = () => { - if (viewMode === 'day') { - openMonthView() - return - } - if (viewMode === 'month') { - openYearView() - } - } - - const handlePrev = () => { - if (viewMode === 'day') { - updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1)) - return - } - if (viewMode === 'month') { - updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1)) - return - } - setYearPageStart((prev) => prev - 12) - } - - const handleNext = () => { - if (viewMode === 'day') { - updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1)) - return - } - if (viewMode === 'month') { - updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1)) - return - } - setYearPageStart((prev) => prev + 12) - } - - const navTitle = viewMode === 'day' - ? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月` - : viewMode === 'month' - ? `${calendarDate.getFullYear()}年` - : `${yearPageStart}年 - ${yearPageStart + 11}年` - - return ( -
-
e.stopPropagation()}> -
-
- -

跳转到日期

-
- -
- -
-
- - - -
- - {viewMode === 'month' ? ( -
-
- {monthNames.map((name, i) => ( - - ))} -
-
- ) : viewMode === 'year' ? ( -
-
- {Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => ( - - ))} -
-
- ) : ( -
- {loadingDates && ( -
- - 正在加载... -
- )} -
- {weekdays.map(d =>
{d}
)} -
-
- {days.map((day, i) => ( -
day !== null && handleDateClick(day)} - > - {day} - {day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && ( - - )} -
- ))} -
-
- )} -
- -
- - - -
- -
- - -
-
-
- ) -} - -export default JumpToDateDialog diff --git a/src/components/JumpToDatePopover.scss b/src/components/JumpToDatePopover.scss deleted file mode 100644 index eb7d447..0000000 --- a/src/components/JumpToDatePopover.scss +++ /dev/null @@ -1,215 +0,0 @@ -.jump-date-popover { - position: absolute; - top: calc(100% + 10px); - right: 0; - width: 312px; - border-radius: 14px; - border: 1px solid var(--border-color); - background: none; - background-color: var(--bg-secondary-solid, #ffffff) !important; - opacity: 1; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - mix-blend-mode: normal; - isolation: isolate; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); - padding: 12px; - z-index: 1600; -} - -.jump-date-popover .calendar-nav { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; -} - -.jump-date-popover .current-month { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - border: none; - background: transparent; - border-radius: 8px; - padding: 4px 8px; -} - -.jump-date-popover .current-month.clickable { - cursor: pointer; - transition: all 0.18s ease; -} - -.jump-date-popover .current-month.clickable:hover { - color: var(--primary); - background: var(--bg-hover); -} - -.jump-date-popover .nav-btn { - width: 28px; - height: 28px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: none; - background-color: var(--bg-secondary-solid, #ffffff) !important; - color: var(--text-secondary); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.18s ease; -} - -.jump-date-popover .nav-btn:hover { - border-color: var(--primary); - color: var(--primary); - background: var(--bg-hover); -} - -.jump-date-popover .status-line { - min-height: 16px; - margin-bottom: 6px; -} - -.jump-date-popover .status-item { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--text-tertiary); - font-size: 11px; -} - -.jump-date-popover .calendar-grid .weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - margin-bottom: 6px; -} - -.jump-date-popover .calendar-grid .weekday { - text-align: center; - font-size: 11px; - color: var(--text-tertiary); -} - -.jump-date-popover .calendar-grid .days { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: repeat(6, 36px); - gap: 4px; -} - -.jump-date-popover .month-grid, -.jump-date-popover .year-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - min-height: 256px; -} - -.jump-date-popover .month-cell, -.jump-date-popover .year-cell { - border: none; - border-radius: 8px; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - transition: all 0.18s ease; -} - -.jump-date-popover .month-cell:hover, -.jump-date-popover .year-cell:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.jump-date-popover .month-cell.active, -.jump-date-popover .year-cell.active { - background: var(--primary); - color: #fff; -} - -.jump-date-popover .day-cell { - position: relative; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: var(--text-primary); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1px; - padding: 0; - font-size: 13px; - transition: all 0.18s ease; -} - -.jump-date-popover .day-cell .day-number { - position: relative; - z-index: 1; - font-size: 12px; - line-height: 1; - font-weight: 500; -} - -.jump-date-popover .day-cell.empty { - cursor: default; - background: transparent; -} - -.jump-date-popover .day-cell:not(.empty):not(.no-message):hover { - background: var(--bg-hover); -} - -.jump-date-popover .day-cell.today { - border-color: var(--primary-light); - color: var(--primary); -} - -.jump-date-popover .day-cell.selected { - background: var(--primary); - color: #fff; -} - -.jump-date-popover .day-cell.no-message { - opacity: 0.5; - cursor: default; -} - -.jump-date-popover .day-count { - position: static; - margin-top: 1px; - font-size: 13px; - line-height: 1; - color: var(--primary, #07c160); - font-weight: 700; -} - -.jump-date-popover .day-cell.selected .day-count { - color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); -} - -.jump-date-popover .day-count-loading { - position: static; - margin-top: 1px; - color: var(--primary, #07c160); -} - -.jump-date-popover .day-cell.selected .day-count-loading { - color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); -} - -.jump-date-popover .spin { - animation: jump-date-spin 1s linear infinite; -} - -@keyframes jump-date-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx deleted file mode 100644 index e377798..0000000 --- a/src/components/JumpToDatePopover.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' -import './JumpToDatePopover.scss' - -interface JumpToDatePopoverProps { - isOpen: boolean - onClose: () => void - onSelect: (date: Date) => void - onMonthChange?: (date: Date) => void - className?: string - style?: React.CSSProperties - currentDate?: Date - messageDates?: Set - hasLoadedMessageDates?: boolean - messageDateCounts?: Record - loadingDates?: boolean - loadingDateCounts?: boolean - maxDate?: Date -} - -const JumpToDatePopover: React.FC = ({ - isOpen, - onClose, - onSelect, - onMonthChange, - className, - style, - currentDate = new Date(), - messageDates, - hasLoadedMessageDates = false, - messageDateCounts, - loadingDates = false, - loadingDateCounts = false, - maxDate -}) => { - type CalendarViewMode = 'day' | 'month' | 'year' - const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12 - const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) - const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) - const [viewMode, setViewMode] = useState('day') - const [yearPageStart, setYearPageStart] = useState(getYearPageStart(new Date(currentDate).getFullYear())) - - useEffect(() => { - if (!isOpen) return - const normalized = new Date(currentDate) - setCalendarDate(normalized) - setSelectedDate(normalized) - setViewMode('day') - setYearPageStart(getYearPageStart(normalized.getFullYear())) - }, [isOpen, currentDate]) - - if (!isOpen) return null - - const getDaysInMonth = (date: Date): number => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month + 1, 0).getDate() - } - - const getFirstDayOfMonth = (date: Date): number => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month, 1).getDay() - } - - const toDateKey = (day: number): string => { - const year = calendarDate.getFullYear() - const month = calendarDate.getMonth() + 1 - return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` - } - - const hasMessage = (day: number): boolean => { - if (!hasLoadedMessageDates) return true - if (!messageDates || messageDates.size === 0) return false - return messageDates.has(toDateKey(day)) - } - - const isAfterMaxDate = (day: number): boolean => { - if (!maxDate) return false - const max = new Date(maxDate) - max.setHours(23, 59, 59, 999) - const candidate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day, 0, 0, 0, 0) - return candidate.getTime() > max.getTime() - } - - const isToday = (day: number): boolean => { - const today = new Date() - return day === today.getDate() - && calendarDate.getMonth() === today.getMonth() - && calendarDate.getFullYear() === today.getFullYear() - } - - const isSelected = (day: number): boolean => { - return day === selectedDate.getDate() - && calendarDate.getMonth() === selectedDate.getMonth() - && calendarDate.getFullYear() === selectedDate.getFullYear() - } - - const generateCalendar = (): Array => { - const daysInMonth = getDaysInMonth(calendarDate) - const firstDay = getFirstDayOfMonth(calendarDate) - const days: Array = [] - - for (let i = 0; i < firstDay; i++) { - days.push(null) - } - for (let i = 1; i <= daysInMonth; i++) { - days.push(i) - } - return days - } - - const handleDateClick = (day: number) => { - if (hasLoadedMessageDates && !hasMessage(day)) return - if (isAfterMaxDate(day)) return - const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) - setSelectedDate(targetDate) - onSelect(targetDate) - onClose() - } - - const getDayClassName = (day: number | null): string => { - if (day === null) return 'day-cell empty' - const classes = ['day-cell'] - if (isToday(day)) classes.push('today') - if (isSelected(day)) classes.push('selected') - if ((hasLoadedMessageDates && !hasMessage(day)) || isAfterMaxDate(day)) classes.push('no-message') - return classes.join(' ') - } - - const weekdays = ['日', '一', '二', '三', '四', '五', '六'] - const days = generateCalendar() - const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim() - - const updateCalendarDate = (nextDate: Date) => { - setCalendarDate(nextDate) - onMonthChange?.(nextDate) - } - - const openMonthView = () => setViewMode('month') - const openYearView = () => { - setYearPageStart(getYearPageStart(calendarDate.getFullYear())) - setViewMode('year') - } - - const handleTitleClick = () => { - if (viewMode === 'day') { - openMonthView() - return - } - if (viewMode === 'month') { - openYearView() - } - } - - const handlePrev = () => { - if (viewMode === 'day') { - updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1)) - return - } - if (viewMode === 'month') { - updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1)) - return - } - setYearPageStart((prev) => prev - 12) - } - - const handleNext = () => { - if (viewMode === 'day') { - updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1)) - return - } - if (viewMode === 'month') { - updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1)) - return - } - setYearPageStart((prev) => prev + 12) - } - - const navTitle = viewMode === 'day' - ? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月` - : viewMode === 'month' - ? `${calendarDate.getFullYear()}年` - : `${yearPageStart}年 - ${yearPageStart + 11}年` - - return ( -
-
- - - -
- -
- {loadingDates && ( - - - 日期加载中 - - )} - {!loadingDates && loadingDateCounts && ( - - - 条数加载中 - - )} -
- - {viewMode === 'day' && ( -
-
- {weekdays.map(day => ( -
{day}
- ))} -
-
- {days.map((day, index) => { - if (day === null) return
- const dateKey = toDateKey(day) - const hasMessageOnDay = hasMessage(day) - const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day) - const count = Number(messageDateCounts?.[dateKey] || 0) - const showCount = count > 0 - const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount - return ( - - ) - })} -
-
- )} - - {viewMode === 'month' && ( -
- {['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => ( - - ))} -
- )} - - {viewMode === 'year' && ( -
- {Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => ( - - ))} -
- )} -
- ) -} - -export default JumpToDatePopover diff --git a/src/components/LivePhotoIcon.tsx b/src/components/LivePhotoIcon.tsx deleted file mode 100644 index ce904b9..0000000 --- a/src/components/LivePhotoIcon.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -interface LivePhotoIconProps { - size?: number | string; - className?: string; - style?: React.CSSProperties; -} - -export const LivePhotoIcon: React.FC = ({ size = 24, className = '', style = {} }) => { - return ( - - - - - - - - - - ); -}; diff --git a/src/components/LockScreen.scss b/src/components/LockScreen.scss deleted file mode 100644 index a2546a9..0000000 --- a/src/components/LockScreen.scss +++ /dev/null @@ -1,185 +0,0 @@ -.lock-screen { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: var(--bg-primary); - z-index: 9999; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - user-select: none; - -webkit-app-region: drag; - transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1); - backdrop-filter: blur(25px) saturate(180%); - background-color: var(--bg-primary); - // 让背景带一点透明度以增强毛玻璃效果 - opacity: 1; - - &.unlocked { - opacity: 0; - pointer-events: none; - backdrop-filter: blur(0) saturate(100%); - transform: scale(1.02); - - .lock-content { - transform: translateY(-20px) scale(0.95); - filter: blur(10px); - opacity: 0; - } - } - - .lock-content { - display: flex; - flex-direction: column; - align-items: center; - width: 320px; - -webkit-app-region: no-drag; - animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - - .lock-avatar { - width: 100px; - height: 100px; - border-radius: 50%; - margin-bottom: 24px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - border: 4px solid var(--bg-total); - background-color: var(--bg-secondary); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - } - - .lock-title { - font-size: 24px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 32px; - } - - .lock-form { - width: 100%; - display: flex; - flex-direction: column; - gap: 16px; - - .input-group { - position: relative; - width: 100%; - - input { - width: 100%; - height: 48px; - padding: 0 16px; - padding-right: 48px; - border-radius: 12px; - border: 1px solid var(--border-color); - background-color: var(--bg-input); - color: var(--text-primary); - font-size: 16px; - outline: none; - transition: all 0.2s; - - &:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 2px var(--primary-color-alpha); - } - } - - .submit-btn { - position: absolute; - right: 8px; - top: 8px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - border: none; - background: var(--primary-color); - color: white; - cursor: pointer; - transition: opacity 0.2s; - - &:hover { - opacity: 0.9; - } - } - } - - .hello-btn { - width: 100%; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - border-radius: 12px; - border: 1px solid var(--border-color); - background-color: var(--bg-secondary); - color: var(--text-primary); - font-size: 15px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background-color: var(--bg-hover); - transform: translateY(-1px); - } - - &.loading { - opacity: 0.7; - pointer-events: none; - } - } - } - - .lock-error { - margin-top: 16px; - color: #ff4d4f; - font-size: 14px; - animation: shake 0.5s ease-in-out; - } - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(20px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes shake { - - 0%, - 100% { - transform: translateX(0); - } - - 10%, - 30%, - 50%, - 70%, - 90% { - transform: translateX(-4px); - } - - 20%, - 40%, - 60%, - 80% { - transform: translateX(4px); - } -} \ No newline at end of file diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx deleted file mode 100644 index 5f9945f..0000000 --- a/src/components/LockScreen.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react' -import './LockScreen.scss' - -interface LockScreenProps { - onUnlock: () => void - avatar?: string - useHello?: boolean -} - -export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) { - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [isVerifying, setIsVerifying] = useState(false) - const [isUnlocked, setIsUnlocked] = useState(false) - const [showHello, setShowHello] = useState(false) - const [helloAvailable, setHelloAvailable] = useState(false) - - // 用于取消 WebAuthn 请求 - const abortControllerRef = useRef(null) - const inputRef = useRef(null) - - useEffect(() => { - // 快速检查配置并启动 - quickStartHello() - inputRef.current?.focus() - - return () => { - // 组件卸载时取消请求 - abortControllerRef.current?.abort() - } - }, []) - - const handleUnlock = () => { - setIsUnlocked(true) - setTimeout(() => { - onUnlock() - }, 1500) - } - - const quickStartHello = async () => { - try { - if (useHello) { - setHelloAvailable(true) - setShowHello(true) - verifyHello() - } - } catch (e) { - console.error('Quick start hello failed', e) - } - } - - const verifyHello = async () => { - if (isVerifying || isUnlocked) return - - setIsVerifying(true) - setError('') - - try { - const result = await window.electronAPI.auth.hello() - - if (result.success) { - handleUnlock() - } else { - console.error('Hello verification failed:', result.error) - setError(result.error || '验证失败') - } - } catch (e: any) { - console.error('Hello verification error:', e) - setError(`验证失败: ${e.message || String(e)}`) - } finally { - setIsVerifying(false) - } - } - - const handlePasswordSubmit = async (e?: React.FormEvent) => { - e?.preventDefault() - if (!password || isUnlocked) return - - setIsVerifying(true) - setError('') - - try { - // 发送原始密码到主进程,由主进程验证并解密密钥 - const result = await window.electronAPI.auth.unlock(password) - - if (result.success) { - handleUnlock() - } else { - setError(result.error || '密码错误') - setPassword('') - setIsVerifying(false) - } - } catch (e) { - setError('验证失败') - setIsVerifying(false) - } - } - - return ( -
-
-
- {avatar ? ( - User - ) : ( - - )} -
- -

WeFlow 已锁定

- -
-
- setPassword(e.target.value)} - // 移除 disabled,允许用户随时输入 - /> - -
- - {showHello && ( - - )} -
- - {error &&
{error}
} -
-
- ) -} diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss deleted file mode 100644 index 57dc558..0000000 --- a/src/components/NotificationToast.scss +++ /dev/null @@ -1,242 +0,0 @@ -.notification-toast-container { - position: fixed; - z-index: 9999; - width: 320px; - background: var(--bg-secondary); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid var(--border-light); - - // 浅色模式下使用完全不透明背景,并禁用毛玻璃效果 - [data-mode="light"] &, - :not([data-mode]) & { - background: rgba(255, 255, 255, 1); - backdrop-filter: none; - -webkit-backdrop-filter: none; - } - - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); - padding: 12px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0; - transform: scale(0.95); - pointer-events: none; // Allow clicking through when hidden - - &.visible { - opacity: 1; - transform: scale(1); - pointer-events: auto; - } - - &.static { - position: relative !important; - width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey - height: auto !important; // Fits content - min-height: 0; - top: 0 !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - transform: none !important; - margin: 2px !important; // 2px centered margin - border-radius: 12px !important; // Rounded corners - - - // Disable backdrop filter - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - - // 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 上的主题属性 - background: #ffffff; - color: #3d3d3d; - --text-primary: #3d3d3d; - --text-secondary: #666666; - --text-tertiary: #999999; - --border-light: rgba(0, 0, 0, 0.08); - - // 深色模式覆盖 - [data-mode="dark"] & { - background: var(--bg-secondary-solid, #282420); - color: var(--text-primary, #F0EEE9); - --text-primary: #F0EEE9; - --text-secondary: #b3b0aa; - --text-tertiary: #807d78; - --border-light: rgba(255, 255, 255, 0.1); - } - - box-shadow: none !important; // NO SHADOW - border: 1px solid var(--border-light); - - display: flex; - padding: 16px; - padding-right: 32px; // Make space for close button - box-sizing: border-box; - - // Force close button to be visible but transparent background - .notification-close { - opacity: 1 !important; - top: 12px; - right: 12px; - background: transparent !important; // Transparent per user request - - &:hover { - color: var(--text-primary); - background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect - } - } - - .notification-time { - top: 24px; // Match padding - right: 40px; // Left of close button (12px + 20px + 8px) - } - } - - // Position variants - &.bottom-right { - bottom: 24px; - right: 24px; - transform: translate(0, 20px) scale(0.95); - - &.visible { - transform: translate(0, 0) scale(1); - } - } - - &.top-right { - top: 24px; - right: 24px; - transform: translate(0, -20px) scale(0.95); - - &.visible { - transform: translate(0, 0) scale(1); - } - } - - &.bottom-left { - bottom: 24px; - left: 24px; - transform: translate(0, 20px) scale(0.95); - - &.visible { - transform: translate(0, 0) scale(1); - } - } - - &.top-left { - top: 24px; - left: 24px; - transform: translate(0, -20px) scale(0.95); - - &.visible { - transform: translate(0, 0) scale(1); - } - } - - &.top-center { - top: 24px; - left: 50%; - transform: translate(-50%, -20px) scale(0.95); - - &.visible { - transform: translate(-50%, 0) scale(1); - } - - // 灵动岛样式 - border-radius: 40px !important; - padding: 12px 16px; - box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2); - - &.static { - border-radius: 40px !important; - } - } - - &:hover { - box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important; - } - - .notification-content { - display: flex; - align-items: flex-start; - gap: 12px; - } - - .notification-avatar { - flex-shrink: 0; - } - - .notification-text { - flex: 1; - min-width: 0; - - .notification-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; - - .notification-title { - font-weight: 600; - font-size: 14px; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; // 允许缩放 - flex: 1; // 占据剩余空间 - min-width: 0; // 关键:允许 flex 子项收缩到内容以下 - margin-right: 60px; // Make space for absolute time + close button - } - - .notification-time { - font-size: 12px; - color: var(--text-tertiary); - position: absolute; - top: 16px; - right: 36px; // Left of close button (8px + 20px + 8px) - font-variant-numeric: tabular-nums; - } - } - - .notification-body { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-all; - } - } - - .notification-close { - position: absolute; - top: 8px; - right: 8px; - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - opacity: 0; - transition: all 0.2s; - - &:hover { - background: var(--bg-tertiary); - color: var(--text-primary); - } - } - - &:hover .notification-close { - opacity: 1; - } -} \ No newline at end of file diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx deleted file mode 100644 index 2a586ce..0000000 --- a/src/components/NotificationToast.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { createPortal } from 'react-dom' -import { X } from 'lucide-react' -import { Avatar } from './Avatar' -import './NotificationToast.scss' - -export interface NotificationData { - id: string - sessionId: string - channel?: string - insightRecordId?: string - targetRoute?: string - avatarUrl?: string - title: string - content: string - timestamp: number -} - -interface NotificationToastProps { - data: NotificationData | null - onClose: () => void - onClick: (data: NotificationData) => void - duration?: number - position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' - isStatic?: boolean - initialVisible?: boolean -} - -export function NotificationToast({ - data, - onClose, - onClick, - duration = 5000, - position = 'top-right', - isStatic = false, - initialVisible = false -}: NotificationToastProps) { - const [isVisible, setIsVisible] = useState(initialVisible) - const [currentData, setCurrentData] = useState(null) - - useEffect(() => { - if (data) { - setCurrentData(data) - setIsVisible(true) - - const timer = setTimeout(() => { - setIsVisible(false) - // clean up data after animation - setTimeout(onClose, 300) - }, duration) - - return () => clearTimeout(timer) - } else { - setIsVisible(false) - } - }, [data, duration, onClose]) - - if (!currentData) return null - - const handleClose = (e: React.MouseEvent) => { - e.stopPropagation() - setIsVisible(false) - setTimeout(onClose, 300) - } - - const handleClick = () => { - setIsVisible(false) - setTimeout(() => { - onClose() - onClick(currentData) - }, 300) - } - - const content = ( -
-
-
- -
-
-
- {currentData.title} - - {new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - -
-
- {currentData.content} -
-
- -
-
- ) - - if (isStatic) { - return content - } - - // Portal to document.body to ensure it's on top - return createPortal(content, document.body) -} diff --git a/src/components/ReportComponents.scss b/src/components/ReportComponents.scss deleted file mode 100644 index 3793cd0..0000000 --- a/src/components/ReportComponents.scss +++ /dev/null @@ -1,142 +0,0 @@ -// Shared styles for Report components (Heatmap, WordCloud) - -// --- Heatmap --- -.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); // Assumes --ar-text-sub is defined in parent context or globally - 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; - } -} - - -// --- Word Cloud --- -.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(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%), - radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%), - radial-gradient(circle at 50% 65%, var(--bg-tertiary, 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; -} \ No newline at end of file diff --git a/src/components/ReportHeatmap.tsx b/src/components/ReportHeatmap.tsx deleted file mode 100644 index ef87390..0000000 --- a/src/components/ReportHeatmap.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import './ReportComponents.scss' - -interface ReportHeatmapProps { - data: number[][] -} - -const ReportHeatmap: React.FC = ({ data }) => { - if (!data || data.length === 0) return null - - 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 ( -
- ) - }) - )} -
-
-
- ) -} - -export default ReportHeatmap diff --git a/src/components/ReportWordCloud.tsx b/src/components/ReportWordCloud.tsx deleted file mode 100644 index 031b913..0000000 --- a/src/components/ReportWordCloud.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react' -import './ReportComponents.scss' - -interface ReportWordCloudProps { - words: { phrase: string; count: number }[] -} - -const ReportWordCloud: React.FC = ({ words }) => { - if (!words || words.length === 0) return null - - 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} -
-
- ) -} - -export default ReportWordCloud diff --git a/src/components/RouteGuard.tsx b/src/components/RouteGuard.tsx deleted file mode 100644 index 07ca585..0000000 --- a/src/components/RouteGuard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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', '/account-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 deleted file mode 100644 index 0c1d31e..0000000 --- a/src/components/Sidebar.scss +++ /dev/null @@ -1,324 +0,0 @@ -// Redesigned sidebar — premium feel with left accent bar, refined spacing -.sidebar { - width: var(--sidebar-width, 260px); - background: var(--bg-sidebar, var(--bg-secondary)); - display: flex; - flex-direction: column; - padding: 0; - transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1); - flex-shrink: 0; - overflow: hidden; - border-right: 1px solid var(--border-color); - - &.collapsed { - width: 68px; - - .sidebar-user-card-wrap { - margin: 0 8px 8px; - } - - .sidebar-user-card { - padding: 8px 0; - justify-content: center; - - .user-meta { - display: none; - } - - .user-menu-caret { - display: none; - } - } - - .nav-menu { - padding: 0 8px; - } - - .sidebar-footer { - padding: 0 8px; - padding-top: 8px; - } - - .nav-label { - display: none; - } - - .nav-badge:not(.icon-badge) { - display: none; - } - - .nav-item { - justify-content: center; - padding: 10px; - gap: 0; - - &::before { - display: none; - } - } - } -} - -// ---- Navigation ---- -.nav-menu { - flex: 1; - display: flex; - flex-direction: column; - gap: 1px; - padding: 12px 10px; - overflow-y: auto; - overflow-x: hidden; -} - -.nav-item { - position: relative; - display: flex; - align-items: center; - gap: 12px; - padding: 9px 14px; - border-radius: 10px; - color: var(--text-secondary); - text-decoration: none; - transition: background 0.15s ease, color 0.15s ease; - white-space: nowrap; - border: none; - background: transparent; - cursor: pointer; - font-family: inherit; - font-size: 14px; - margin: 1px 0; - - // Left accent bar for active state - &::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%) scaleY(0); - width: 3px; - height: 16px; - border-radius: 0 2px 2px 0; - background: var(--primary); - transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); - } - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--bg-hover); - color: var(--text-primary); - font-weight: 600; - - &::before { - transform: translateY(-50%) scaleY(1); - } - - .nav-icon { - color: var(--primary); - } - } -} - -.nav-icon { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - flex-shrink: 0; - transition: color 0.15s ease; -} - -.nav-icon-with-badge { - position: relative; -} - -.nav-label { - font-size: 14px; - font-weight: 500; -} - -.nav-badge { - margin-left: auto; - min-width: 20px; - height: 20px; - border-radius: 999px; - padding: 0 6px; - background: #ef4444; - color: #ffffff; - font-size: 11px; - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.nav-badge.icon-badge { - position: absolute; - top: -7px; - right: -10px; - margin-left: 0; - min-width: 16px; - height: 16px; - padding: 0 4px; - font-size: 10px; - box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary)); -} - -// ---- Footer ---- -.sidebar-footer { - padding: 4px 10px; - border-top: 1px solid var(--border-color); - padding-top: 8px; - margin-top: 4px; - display: flex; - flex-direction: column; - gap: 1px; -} - -// ---- User card ---- -.sidebar-user-card-wrap { - position: relative; - margin: 0 10px 10px; - --sidebar-user-menu-width: 172px; -} - -.sidebar-user-menu { - position: absolute; - left: 0; - right: auto; - bottom: calc(100% + 8px); - width: max(100%, var(--sidebar-user-menu-width)); - z-index: 12; - border: 1px solid var(--border-color); - border-radius: 12px; - background: var(--bg-secondary-solid, var(--bg-secondary)); - display: flex; - flex-direction: column; - gap: 2px; - padding: 4px; - box-shadow: var(--shadow-md); - opacity: 0; - transform: translateY(6px) scale(0.97); - pointer-events: none; - transition: opacity 0.15s ease, transform 0.15s ease; - - &.open { - opacity: 1; - transform: translateY(0) scale(1); - pointer-events: auto; - } -} - -.sidebar-user-menu-item { - width: 100%; - border: none; - border-radius: 8px; - background: transparent; - color: var(--text-primary); - padding: 8px 10px; - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - text-align: left; - transition: background 0.15s ease; - - &:hover { - background: var(--bg-hover); - } - - &.danger { - color: #ef4444; - - &:hover { - background: rgba(239, 68, 68, 0.08); - } - } -} - -.sidebar-user-card { - width: 100%; - padding: 10px 12px; - border-radius: 10px; - background: transparent; - display: flex; - align-items: center; - gap: 10px; - min-height: 52px; - cursor: pointer; - border: none; - transition: background 0.15s ease; - - &:hover { - background: var(--bg-hover); - } - - &.menu-open { - background: var(--bg-hover); - } - - .user-avatar { - width: 34px; - height: 34px; - border-radius: 50%; - overflow: hidden; - background: var(--primary); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary)); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - span { - color: var(--on-primary); - font-size: 13px; - font-weight: 600; - } - } - - .user-meta { - min-width: 0; - flex: 1; - } - - .user-name { - font-size: 13px; - color: var(--text-primary); - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .user-wxid { - margin-top: 1px; - font-size: 11px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .user-menu-caret { - color: var(--text-tertiary); - display: inline-flex; - transition: transform 0.15s ease; - - &.open { - transform: rotate(180deg); - } - } -} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx deleted file mode 100644 index 6928299..0000000 --- a/src/components/Sidebar.tsx +++ /dev/null @@ -1,511 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react' -import { useAppStore } from '../stores/appStore' -import * as configService from '../services/config' -import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' - -import './Sidebar.scss' - -interface SidebarUserProfile { - wxid: string - displayName: string - alias?: string - avatarUrl?: string -} - -const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' -const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' -const DEFAULT_DISPLAY_NAME = '微信用户' -const DEFAULT_SUBTITLE = '微信账号' - -interface SidebarUserProfileCache extends SidebarUserProfile { - updatedAt: number -} - -interface AccountProfilesCache { - [wxid: string]: { - displayName: string - avatarUrl?: string - alias?: string - updatedAt: number - } -} - -const readSidebarUserProfileCache = (): SidebarUserProfile | null => { - try { - const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as SidebarUserProfileCache - if (!parsed || typeof parsed !== 'object') return null - if (!parsed.wxid) return null - return { - wxid: parsed.wxid, - displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '', - alias: parsed.alias, - avatarUrl: parsed.avatarUrl - } - } catch { - return null - } -} - -const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { - if (!profile.wxid) return - try { - const payload: SidebarUserProfileCache = { - ...profile, - updatedAt: Date.now() - } - window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) - - // 同时写入账号缓存池 - const accountsCache = readAccountProfilesCache() - accountsCache[profile.wxid] = { - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - alias: profile.alias, - updatedAt: Date.now() - } - window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache)) - } catch { - // 忽略本地缓存失败,不影响主流程 - } -} - -const readAccountProfilesCache = (): AccountProfilesCache => { - try { - const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY) - if (!raw) return {} - const parsed = JSON.parse(raw) - return typeof parsed === 'object' && parsed ? parsed : {} - } catch { - return {} - } -} - -const normalizeAccountId = (value?: string | null): string => { - const trimmed = String(value || '').trim() - if (!trimmed) return '' - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - return match?.[1] || trimmed - } - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - return suffixMatch ? suffixMatch[1] : trimmed -} - -interface SidebarProps { - collapsed: boolean -} - -function Sidebar({ collapsed }: SidebarProps) { - const location = useLocation() - const navigate = useNavigate() - const [authEnabled, setAuthEnabled] = useState(false) - const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) - const [userProfile, setUserProfile] = useState({ - wxid: '', - displayName: DEFAULT_DISPLAY_NAME - }) - const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) - const accountCardWrapRef = useRef(null) - const setLocked = useAppStore(state => state.setLocked) - - useEffect(() => { - window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) - }, []) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (!isAccountMenuOpen) return - const target = event.target as Node | null - if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) { - setIsAccountMenuOpen(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isAccountMenuOpen]) - - useEffect(() => { - const unsubscribe = onExportSessionStatus((payload) => { - const countFromPayload = typeof payload?.activeTaskCount === 'number' - ? payload.activeTaskCount - : Array.isArray(payload?.inProgressSessionIds) - ? payload.inProgressSessionIds.length - : 0 - const normalized = Math.max(0, Math.floor(countFromPayload)) - setActiveExportTaskCount(normalized) - }) - - requestExportSessionStatus() - const timer = window.setTimeout(() => requestExportSessionStatus(), 120) - - return () => { - unsubscribe() - window.clearTimeout(timer) - } - }, []) - - useEffect(() => { - let disposed = false - let loadSeq = 0 - - const loadCurrentUser = async () => { - const seq = ++loadSeq - const patchUserProfile = (patch: Partial) => { - if (disposed || seq !== loadSeq) return - setUserProfile(prev => { - const next: SidebarUserProfile = { - ...prev, - ...patch - } - if (typeof next.displayName !== 'string' || next.displayName.length === 0) { - next.displayName = DEFAULT_DISPLAY_NAME - } - writeSidebarUserProfileCache(next) - return next - }) - } - - try { - const wxid = await configService.getMyWxid() - if (disposed || seq !== loadSeq) return - const resolvedWxidRaw = String(wxid || '').trim() - const cleanedWxid = normalizeAccountId(resolvedWxidRaw) - const resolvedWxid = cleanedWxid || resolvedWxidRaw - - if (!resolvedWxidRaw && !resolvedWxid) { - window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) - patchUserProfile({ - wxid: '', - displayName: DEFAULT_DISPLAY_NAME, - alias: undefined, - avatarUrl: undefined - }) - return - } - - setUserProfile((prev) => { - if (prev.wxid === resolvedWxid) return prev - const seeded: SidebarUserProfile = { - wxid: resolvedWxid, - displayName: DEFAULT_DISPLAY_NAME, - alias: undefined, - avatarUrl: undefined - } - writeSidebarUserProfileCache(seeded) - return seeded - }) - - const wxidCandidates = new Set([ - resolvedWxidRaw.toLowerCase(), - resolvedWxid.trim().toLowerCase(), - cleanedWxid.trim().toLowerCase() - ].filter(Boolean)) - - const normalizeName = (value?: string | null): string | undefined => { - if (typeof value !== 'string') return undefined - if (value.length === 0) return undefined - const lowered = value.trim().toLowerCase() - if (lowered === 'self') return undefined - if (lowered.startsWith('wxid_')) return undefined - if (wxidCandidates.has(lowered)) return undefined - return value - } - - const pickFirstValidName = (...candidates: Array): string | undefined => { - for (const candidate of candidates) { - const normalized = normalizeName(candidate) - if (normalized) return normalized - } - return undefined - } - - // 并行获取名称和头像 - const [contactResult, avatarResult] = await Promise.allSettled([ - (async () => { - const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean))) - for (const candidate of candidates) { - const contact = await window.electronAPI.chat.getContact(candidate) - if (contact?.remark || contact?.nickName || contact?.alias) { - return contact - } - } - return null - })(), - window.electronAPI.chat.getMyAvatarUrl() - ]) - if (disposed || seq !== loadSeq) return - - const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null - const displayName = pickFirstValidName( - myContact?.remark, - myContact?.nickName, - myContact?.alias - ) || DEFAULT_DISPLAY_NAME - const alias = normalizeName(myContact?.alias) - - patchUserProfile({ - wxid: resolvedWxid, - displayName, - alias, - avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success - ? avatarResult.value.avatarUrl - : undefined - }) - } catch (error) { - console.error('加载侧边栏用户信息失败:', error) - } - } - - const cachedProfile = readSidebarUserProfileCache() - if (cachedProfile) { - setUserProfile(cachedProfile) - } - - void loadCurrentUser() - const onWxidChanged = () => { void loadCurrentUser() } - const onWindowFocus = () => { void loadCurrentUser() } - const onVisibilityChange = () => { - if (document.visibilityState === 'visible') { - void loadCurrentUser() - } - } - window.addEventListener('wxid-changed', onWxidChanged as EventListener) - window.addEventListener('focus', onWindowFocus) - document.addEventListener('visibilitychange', onVisibilityChange) - return () => { - disposed = true - loadSeq += 1 - window.removeEventListener('wxid-changed', onWxidChanged as EventListener) - window.removeEventListener('focus', onWindowFocus) - document.removeEventListener('visibilitychange', onVisibilityChange) - } - }, []) - - const getAvatarLetter = (name: string): string => { - if (!name) return '微' - const visible = name.trim() - return (visible && [...visible][0]) || '微' - } - - const openSettingsFromAccountMenu = () => { - setIsAccountMenuOpen(false) - navigate('/settings', { - state: { - backgroundLocation: location - } - }) - } - - const openAccountManagement = () => { - setIsAccountMenuOpen(false) - navigate('/account-management') - } - - const isActive = (path: string) => { - return location.pathname === path || location.pathname.startsWith(`${path}/`) - } - const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}` - - return ( - <> - - - ) -} - -export default Sidebar diff --git a/src/components/Sns/ContactSnsTimelineDialog.scss b/src/components/Sns/ContactSnsTimelineDialog.scss deleted file mode 100644 index b8e1c20..0000000 --- a/src/components/Sns/ContactSnsTimelineDialog.scss +++ /dev/null @@ -1,318 +0,0 @@ -.contact-sns-dialog-overlay { - position: fixed; - inset: 0; - z-index: 1200; - display: flex; - align-items: center; - justify-content: center; - padding: 24px 16px; - background: rgba(15, 23, 42, 0.28); -} - -.contact-sns-dialog { - width: min(720px, 100%); - max-height: min(84vh, 820px); - border-radius: 10px; - border: 1px solid var(--border-color); - background: var(--bg-secondary-solid, #ffffff); - box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18); - display: flex; - flex-direction: column; - overflow: hidden; - - .spin { - animation: contactSnsDialogSpin 1s linear infinite; - } - - .contact-sns-dialog-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 12px 14px; - border-bottom: 1px solid var(--border-color); - } - - .contact-sns-dialog-header-main { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - } - - .contact-sns-dialog-avatar { - width: 36px; - height: 36px; - border-radius: 8px; - background: linear-gradient(135deg, var(--primary), var(--primary-hover)); - overflow: hidden; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - span { - color: #fff; - font-size: 14px; - font-weight: 600; - } - } - - .contact-sns-dialog-meta { - min-width: 0; - - h4 { - margin: 0; - font-size: 14px; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - .contact-sns-dialog-username { - margin-top: 2px; - font-size: 11px; - color: var(--text-tertiary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .contact-sns-dialog-stats { - margin-top: 4px; - font-size: 11px; - color: var(--text-secondary); - } - - .contact-sns-dialog-header-actions { - display: flex; - align-items: flex-start; - gap: 8px; - flex-shrink: 0; - } - - .contact-sns-dialog-rank-switch { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - } - - .contact-sns-dialog-rank-btn { - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-primary); - color: var(--text-secondary); - height: 30px; - padding: 0 9px; - font-size: 11px; - line-height: 1; - cursor: pointer; - white-space: nowrap; - - &:hover { - color: var(--text-primary); - border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); - } - - &.active { - color: var(--primary); - border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); - background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); - } - } - - .contact-sns-dialog-rank-panel { - position: absolute; - top: calc(100% + 8px); - right: 0; - width: 228px; - max-height: calc((26px * 15) + 16px); - overflow-y: auto; - border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); - border-radius: 10px; - background: var(--bg-primary); - box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); - padding: 8px; - z-index: 12; - } - - .contact-sns-dialog-rank-empty { - font-size: 12px; - color: var(--text-tertiary); - line-height: 1.5; - text-align: center; - padding: 6px 0; - } - - .contact-sns-dialog-rank-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 28px; - padding: 4px 0 8px; - font-size: 12px; - color: var(--text-secondary); - line-height: 1.5; - } - - .contact-sns-dialog-rank-row { - display: grid; - grid-template-columns: 20px minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - min-height: 28px; - padding: 0 4px; - border-radius: 7px; - - &:hover { - background: var(--bg-hover); - } - } - - .contact-sns-dialog-rank-index { - font-size: 12px; - color: var(--text-tertiary); - text-align: right; - font-variant-numeric: tabular-nums; - } - - .contact-sns-dialog-rank-name { - font-size: 12px; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .contact-sns-dialog-rank-count { - font-size: 12px; - color: var(--text-secondary); - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - - .contact-sns-dialog-close-btn { - border: none; - background: transparent; - color: var(--text-secondary); - width: 28px; - height: 28px; - border-radius: 7px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - } - - .contact-sns-dialog-tip { - display: none; - } - - .contact-sns-dialog-body { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 10px 12px 12px; - } - - .contact-sns-dialog-posts-list { - display: flex; - flex-direction: column; - gap: 10px; - } - - .contact-sns-dialog-posts-list .post-header-actions { - display: none; - } - - .contact-sns-dialog-status { - padding: 16px 10px; - text-align: center; - font-size: 12px; - color: var(--text-secondary); - - &.empty { - color: var(--text-tertiary); - } - } - - .contact-sns-dialog-load-more { - display: block; - margin: 12px auto 0; - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - border-radius: 10px; - padding: 8px 14px; - font-size: 12px; - cursor: pointer; - - &:hover:not(:disabled) { - border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); - color: var(--primary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.72; - } - } -} - -@media (max-width: 768px) { - .contact-sns-dialog-overlay { - padding: 10px 8px; - } - - .contact-sns-dialog { - width: min(100vw - 16px, 720px); - max-height: calc(100vh - 24px); - - .contact-sns-dialog-header { - padding: 10px 12px; - } - - .contact-sns-dialog-header-actions { - gap: 6px; - } - - .contact-sns-dialog-rank-btn { - height: 26px; - padding: 0 8px; - font-size: 10px; - } - - .contact-sns-dialog-rank-panel { - width: min(78vw, 232px); - } - - .contact-sns-dialog-body { - padding: 10px 10px 12px; - } - } -} - -@keyframes contactSnsDialogSpin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx deleted file mode 100644 index 3ab1280..0000000 --- a/src/components/Sns/ContactSnsTimelineDialog.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import { createPortal } from 'react-dom' -import { Loader2, X } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { SnsPostItem } from './SnsPostItem' -import type { SnsPost } from '../../types/sns' -import { - type ContactSnsRankItem, - type ContactSnsRankMode, - type ContactSnsTimelineTarget, - getAvatarLetter -} from './contactSnsTimeline' -import './ContactSnsTimelineDialog.scss' - -const TIMELINE_PAGE_SIZE = 20 -const SNS_RANK_PAGE_SIZE = 50 -const SNS_RANK_DISPLAY_LIMIT = 15 - -interface ContactSnsRankCacheEntry { - likes: ContactSnsRankItem[] - comments: ContactSnsRankItem[] - totalPosts: number -} - -interface ContactSnsTimelineDialogProps { - target: ContactSnsTimelineTarget | null - onClose: () => void - initialTotalPosts?: number | null - initialTotalPostsLoading?: boolean - isProtected?: boolean - onDeletePost?: (postId: string, username: string) => void -} - -const normalizeTotalPosts = (value?: number | null): number | null => { - if (!Number.isFinite(value)) return null - return Math.max(0, Math.floor(Number(value))) -} - -const formatYmdDateFromSeconds = (timestamp?: number): string => { - if (!timestamp || !Number.isFinite(timestamp)) return '—' - const date = new Date(timestamp * 1000) - const year = date.getFullYear() - const month = `${date.getMonth() + 1}`.padStart(2, '0') - const day = `${date.getDate()}`.padStart(2, '0') - return `${year}-${month}-${day}` -} - -const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => { - const likeMap = new Map() - const commentMap = new Map() - - for (const post of posts) { - const createTime = Number(post?.createTime) || 0 - const likes = Array.isArray(post?.likes) ? post.likes : [] - const comments = Array.isArray(post?.comments) ? post.comments : [] - - for (const likeNameRaw of likes) { - const name = String(likeNameRaw || '').trim() || '未知用户' - const current = likeMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - likeMap.set(name, { name, count: 1, latestTime: createTime }) - } - - for (const comment of comments) { - const name = String(comment?.nickname || '').trim() || '未知用户' - const current = commentMap.get(name) - if (current) { - current.count += 1 - if (createTime > current.latestTime) current.latestTime = createTime - continue - } - commentMap.set(name, { name, count: 1, latestTime: createTime }) - } - } - - const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => { - if (right.count !== left.count) return right.count - left.count - if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime - return left.name.localeCompare(right.name, 'zh-CN') - } - - return { - likes: [...likeMap.values()].sort(sorter), - comments: [...commentMap.values()].sort(sorter) - } -} - -export function ContactSnsTimelineDialog({ - target, - onClose, - initialTotalPosts = null, - initialTotalPostsLoading = false, - isProtected = false, - onDeletePost -}: ContactSnsTimelineDialogProps) { - const [timelinePosts, setTimelinePosts] = useState([]) - const [timelineLoading, setTimelineLoading] = useState(false) - const [timelineLoadingMore, setTimelineLoadingMore] = useState(false) - const [timelineHasMore, setTimelineHasMore] = useState(false) - const [timelineTotalPosts, setTimelineTotalPosts] = useState(null) - const [timelineStatsLoading, setTimelineStatsLoading] = useState(false) - const [rankMode, setRankMode] = useState(null) - const [likeRankings, setLikeRankings] = useState([]) - const [commentRankings, setCommentRankings] = useState([]) - const [rankLoading, setRankLoading] = useState(false) - const [rankError, setRankError] = useState(null) - const [rankLoadedPosts, setRankLoadedPosts] = useState(0) - const [rankTotalPosts, setRankTotalPosts] = useState(null) - - const timelinePostsRef = useRef([]) - const timelineLoadingRef = useRef(false) - const timelineRequestTokenRef = useRef(0) - const totalPostsRequestTokenRef = useRef(0) - const rankRequestTokenRef = useRef(0) - const rankLoadingRef = useRef(false) - const rankCacheRef = useRef>({}) - - const targetUsername = String(target?.username || '').trim() - const targetDisplayName = target?.displayName || targetUsername - const targetAvatarUrl = target?.avatarUrl - - useEffect(() => { - timelinePostsRef.current = timelinePosts - }, [timelinePosts]) - - const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => { - const reset = Boolean(options?.reset) - if (timelineLoadingRef.current) return - - timelineLoadingRef.current = true - if (reset) { - setTimelineLoading(true) - setTimelineLoadingMore(false) - setTimelineHasMore(false) - } else { - setTimelineLoadingMore(true) - } - - const requestToken = ++timelineRequestTokenRef.current - - try { - let endTime: number | undefined - if (!reset && timelinePostsRef.current.length > 0) { - endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1 - } - - const result = await window.electronAPI.sns.getTimeline( - TIMELINE_PAGE_SIZE, - 0, - [nextTarget.username], - '', - undefined, - endTime - ) - if (requestToken !== timelineRequestTokenRef.current) return - - if (!result.success || !Array.isArray(result.timeline)) { - if (reset) { - setTimelinePosts([]) - setTimelineHasMore(false) - } - return - } - - const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) - if (reset) { - setTimelinePosts(timeline) - setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE) - return - } - - const existingIds = new Set(timelinePostsRef.current.map((post) => post.id)) - const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) - if (uniqueOlder.length > 0) { - const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime) - setTimelinePosts(merged) - } - if (timeline.length < TIMELINE_PAGE_SIZE) { - setTimelineHasMore(false) - } - } catch (error) { - console.error('加载联系人朋友圈失败:', error) - if (requestToken === timelineRequestTokenRef.current && reset) { - setTimelinePosts([]) - setTimelineHasMore(false) - } - } finally { - if (requestToken === timelineRequestTokenRef.current) { - timelineLoadingRef.current = false - setTimelineLoading(false) - setTimelineLoadingMore(false) - } - } - }, []) - - const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { - const requestToken = ++totalPostsRequestTokenRef.current - setTimelineStatsLoading(true) - - try { - const result = await window.electronAPI.sns.getUserPostCounts() - if (requestToken !== totalPostsRequestTokenRef.current) return - - if (!result.success || !result.counts) { - setTimelineTotalPosts(null) - setRankTotalPosts(null) - return - } - - const rawCount = Number(result.counts[nextTarget.username] || 0) - const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 - setTimelineTotalPosts(normalized) - setRankTotalPosts(normalized) - } catch (error) { - console.error('加载联系人朋友圈条数失败:', error) - if (requestToken !== totalPostsRequestTokenRef.current) return - setTimelineTotalPosts(null) - setRankTotalPosts(null) - } finally { - if (requestToken === totalPostsRequestTokenRef.current) { - setTimelineStatsLoading(false) - } - } - }, []) - - const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { - const normalizedUsername = String(nextTarget?.username || '').trim() - if (!normalizedUsername || rankLoadingRef.current) return - - const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts) - const cached = rankCacheRef.current[normalizedUsername] - - if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { - setLikeRankings(cached.likes) - setCommentRankings(cached.comments) - setRankLoadedPosts(cached.totalPosts) - setRankTotalPosts(cached.totalPosts) - setRankError(null) - setRankLoading(false) - return - } - - rankLoadingRef.current = true - const requestToken = ++rankRequestTokenRef.current - setRankLoading(true) - setRankError(null) - setRankLoadedPosts(0) - setRankTotalPosts(normalizedKnownTotal) - - try { - const allPosts: SnsPost[] = [] - let endTime: number | undefined - let hasMore = true - - while (hasMore) { - const result = await window.electronAPI.sns.getTimeline( - SNS_RANK_PAGE_SIZE, - 0, - [normalizedUsername], - '', - undefined, - endTime - ) - if (requestToken !== rankRequestTokenRef.current) return - - if (!result.success) { - throw new Error(result.error || '加载朋友圈排行失败') - } - - const pagePosts = Array.isArray(result.timeline) - ? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) - : [] - if (pagePosts.length === 0) { - hasMore = false - break - } - - allPosts.push(...pagePosts) - setRankLoadedPosts(allPosts.length) - if (normalizedKnownTotal === null) { - setRankTotalPosts(allPosts.length) - } - - endTime = pagePosts[pagePosts.length - 1].createTime - 1 - hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE - } - - if (requestToken !== rankRequestTokenRef.current) return - - const rankings = buildContactSnsRankings(allPosts) - const totalPosts = allPosts.length - rankCacheRef.current[normalizedUsername] = { - likes: rankings.likes, - comments: rankings.comments, - totalPosts - } - setLikeRankings(rankings.likes) - setCommentRankings(rankings.comments) - setRankLoadedPosts(totalPosts) - setRankTotalPosts(totalPosts) - setRankError(null) - } catch (error) { - if (requestToken !== rankRequestTokenRef.current) return - const message = error instanceof Error ? error.message : String(error) - setLikeRankings([]) - setCommentRankings([]) - setRankError(message || '加载朋友圈排行失败') - } finally { - if (requestToken === rankRequestTokenRef.current) { - rankLoadingRef.current = false - setRankLoading(false) - } - } - }, [timelineTotalPosts]) - - useEffect(() => { - if (!targetUsername) return - - totalPostsRequestTokenRef.current += 1 - rankRequestTokenRef.current += 1 - rankLoadingRef.current = false - setRankMode(null) - setLikeRankings([]) - setCommentRankings([]) - setRankLoading(false) - setRankError(null) - setRankLoadedPosts(0) - setRankTotalPosts(null) - setTimelinePosts([]) - setTimelineTotalPosts(null) - setTimelineStatsLoading(false) - setTimelineHasMore(false) - setTimelineLoadingMore(false) - setTimelineLoading(false) - - void loadTimelinePosts({ - username: targetUsername, - displayName: targetDisplayName, - avatarUrl: targetAvatarUrl - }, { reset: true }) - }, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername]) - - useEffect(() => { - if (!targetUsername) return - - const normalizedTotal = normalizeTotalPosts(initialTotalPosts) - if (normalizedTotal !== null) { - setTimelineTotalPosts(normalizedTotal) - setRankTotalPosts(normalizedTotal) - setTimelineStatsLoading(false) - return - } - - if (initialTotalPostsLoading) { - setTimelineTotalPosts(null) - setRankTotalPosts(null) - setTimelineStatsLoading(true) - return - } - - void loadTimelineTotalPosts({ - username: targetUsername, - displayName: targetDisplayName, - avatarUrl: targetAvatarUrl - }) - }, [ - initialTotalPosts, - initialTotalPostsLoading, - loadTimelineTotalPosts, - targetAvatarUrl, - targetDisplayName, - targetUsername - ]) - - useEffect(() => { - if (timelineTotalPosts === null) return - if (timelinePosts.length >= timelineTotalPosts) { - setTimelineHasMore(false) - } - }, [timelinePosts.length, timelineTotalPosts]) - - useEffect(() => { - if (!rankMode || !targetUsername) return - void loadRankings({ - username: targetUsername, - displayName: targetDisplayName, - avatarUrl: targetAvatarUrl - }) - }, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername]) - - useEffect(() => { - if (!targetUsername) return - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose() - } - } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [onClose, targetUsername]) - - const timelineStatsText = useMemo(() => { - const loadedCount = timelinePosts.length - const loadPart = timelineStatsLoading - ? `已加载 ${loadedCount} / 总数统计中...` - : timelineTotalPosts === null - ? `已加载 ${loadedCount} 条` - : `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条` - - if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` - if (loadedCount === 0) return loadPart - - const latest = timelinePosts[0]?.createTime - const earliest = timelinePosts[timelinePosts.length - 1]?.createTime - return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` - }, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts]) - - const activeRankings = useMemo(() => { - if (rankMode === 'likes') return likeRankings - if (rankMode === 'comments') return commentRankings - return [] - }, [commentRankings, likeRankings, rankMode]) - - const loadMore = useCallback(() => { - if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return - void loadTimelinePosts({ - username: targetUsername, - displayName: targetDisplayName, - avatarUrl: targetAvatarUrl - }, { reset: false }) - }, [ - loadTimelinePosts, - targetAvatarUrl, - targetDisplayName, - targetUsername, - timelineHasMore, - timelineLoading, - timelineLoadingMore - ]) - - const handleBodyScroll = useCallback((event: React.UIEvent) => { - const element = event.currentTarget - const remaining = element.scrollHeight - element.scrollTop - element.clientHeight - if (remaining <= 160) { - loadMore() - } - }, [loadMore]) - - const toggleRankMode = useCallback((mode: ContactSnsRankMode) => { - setRankMode((previous) => (previous === mode ? null : mode)) - }, []) - - if (!target) return null - - return createPortal( -
-
event.stopPropagation()} - > -
-
-
- {targetAvatarUrl ? ( - - ) : ( - {getAvatarLetter(targetDisplayName)} - )} -
-
-

{targetDisplayName}

-
@{targetUsername}
-
{timelineStatsText}
-
-
-
-
- - - {rankMode && ( -
- {rankLoading && ( -
- - - {rankTotalPosts !== null && rankTotalPosts > 0 - ? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条` - : `统计中,已加载 ${rankLoadedPosts} 条`} - -
- )} - {!rankLoading && rankError ? ( -
{rankError}
- ) : !rankLoading && activeRankings.length === 0 ? ( -
- {rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} -
- ) : ( - activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => ( -
- {index + 1} - {item.name} - - {item.count.toLocaleString('zh-CN')} - {rankMode === 'likes' ? '次' : '条'} - -
- )) - )} -
- )} -
- -
-
- -
- {timelinePosts.length > 0 && ( -
- {timelinePosts.map((post) => ( - { - if (isVideo) { - void window.electronAPI.window.openVideoPlayerWindow(src) - } else { - void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) - } - }} - onDebug={() => {}} - onDelete={onDeletePost} - hideAuthorMeta - /> - ))} -
- )} - - {timelineLoading && ( -
正在加载该联系人的朋友圈...
- )} - - {!timelineLoading && timelinePosts.length === 0 && ( -
该联系人暂无朋友圈
- )} - - {!timelineLoading && timelineHasMore && ( - - )} -
-
-
, - document.body - ) -} diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx deleted file mode 100644 index 16c860c..0000000 --- a/src/components/Sns/SnsFilterPanel.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React from 'react' -import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react' -import { Virtuoso } from 'react-virtuoso' -import { Avatar } from '../Avatar' - -interface Contact { - username: string - displayName: string - avatarUrl?: string - postCount?: number - postCountStatus?: 'idle' | 'loading' | 'ready' -} - -interface ContactsCountProgress { - resolved: number - total: number - running: boolean -} - -interface SnsFilterPanelProps { - searchKeyword: string - setSearchKeyword: (val: string) => void - totalFriendsLabel?: string - contacts: Contact[] - contactSearch: string - setContactSearch: (val: string) => void - loading?: boolean - contactsCountProgress?: ContactsCountProgress - selectedContactUsernames: string[] - activeContactUsername?: string - onOpenContactTimeline: (contact: Contact) => void - onToggleContactSelected: (contact: Contact) => void - onToggleFilteredContacts: (usernames: string[], shouldSelect: boolean) => void - onClearSelectedContacts: () => void - onExportSelectedContacts: () => void -} - -export const SnsFilterPanel: React.FC = ({ - searchKeyword, - setSearchKeyword, - totalFriendsLabel, - contacts, - contactSearch, - setContactSearch, - loading, - contactsCountProgress, - selectedContactUsernames, - activeContactUsername, - onOpenContactTimeline, - onToggleContactSelected, - onToggleFilteredContacts, - onClearSelectedContacts, - onExportSelectedContacts -}) => { - const filteredContacts = React.useMemo(() => { - const keyword = contactSearch.trim().toLowerCase() - if (!keyword) return contacts - return contacts.filter(c => - (c.displayName || '').toLowerCase().includes(keyword) || - c.username.toLowerCase().includes(keyword) - ) - }, [contacts, contactSearch]) - const selectedContactLookup = React.useMemo( - () => new Set(selectedContactUsernames), - [selectedContactUsernames] - ) - const filteredContactUsernames = React.useMemo( - () => filteredContacts.map((contact) => contact.username), - [filteredContacts] - ) - const selectedFilteredCount = React.useMemo( - () => filteredContactUsernames.filter((username) => selectedContactLookup.has(username)).length, - [filteredContactUsernames, selectedContactLookup] - ) - const hasFilteredContacts = filteredContactUsernames.length > 0 - const allFilteredSelected = hasFilteredContacts && selectedFilteredCount === filteredContactUsernames.length - - const clearFilters = () => { - setSearchKeyword('') - setContactSearch('') - } - - const getEmptyStateText = () => { - if (loading && contacts.length === 0) { - return '正在加载联系人...' - } - if (contacts.length === 0) { - return '暂无好友或曾经的好友' - } - return '没有找到联系人' - } - - const renderContactRow = React.useCallback((_: number, contact: Contact) => { - const isPostCountReady = contact.postCountStatus === 'ready' - const isSelected = selectedContactLookup.has(contact.username) - const isActive = activeContactUsername === contact.username - - return ( -
- - -
- ) - }, [activeContactUsername, onOpenContactTimeline, onToggleContactSelected, selectedContactLookup]) - - return ( - - ) -} - -function RefreshCw({ size, className }: { size?: number, className?: string }) { - return ( - - - - - - ) -} diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx deleted file mode 100644 index 6200223..0000000 --- a/src/components/Sns/SnsMediaGrid.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import React, { useState, useRef } from 'react' -import { Play, Lock, Download, ImageOff } from 'lucide-react' -import { LivePhotoIcon } from '../../components/LivePhotoIcon' -import { RefreshCw } from 'lucide-react' - -interface SnsMedia { - url: string - thumb: string - md5?: string - token?: string - key?: string - encIdx?: string - livePhoto?: { - url: string - thumb: string - token?: string - key?: string - encIdx?: string - } -} - -interface SnsMediaGridProps { - mediaList: SnsMedia[] - postType?: number - onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void - onMediaDeleted?: () => void -} - -const isSnsVideoUrl = (url?: string): boolean => { - if (!url) return false - const lower = url.toLowerCase() - return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') -} - -const extractVideoFrame = async (videoPath: string): Promise => { - return new Promise((resolve, reject) => { - const video = document.createElement('video') - video.preload = 'auto' - video.src = videoPath - video.muted = true - video.currentTime = 0 // Initial reset - // video.crossOrigin = 'anonymous' // Not needed for file:// usually - - const onSeeked = () => { - try { - const canvas = document.createElement('canvas') - canvas.width = video.videoWidth - canvas.height = video.videoHeight - const ctx = canvas.getContext('2d') - if (ctx) { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - const dataUrl = canvas.toDataURL('image/jpeg', 0.8) - resolve(dataUrl) - } else { - reject(new Error('Canvas context failed')) - } - } catch (e) { - reject(e) - } finally { - // Cleanup - video.removeEventListener('seeked', onSeeked) - video.src = '' - video.load() - } - } - - video.onloadedmetadata = () => { - if (video.duration === Infinity || isNaN(video.duration)) { - // Determine duration failed, try a fixed small offset - video.currentTime = 1 - } else { - video.currentTime = Math.max(0.1, video.duration / 2) - } - } - - video.onseeked = onSeeked - - video.onerror = (e) => { - reject(new Error('Video load failed')) - } - }) -} - -const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { - const [error, setError] = useState(false) - const [deleted, setDeleted] = useState(false) - const [loading, setLoading] = useState(true) - const markDeleted = () => { setDeleted(true); onMediaDeleted?.() } - const retryCount = useRef(0) - const [retryKey, setRetryKey] = useState(0) - const [thumbSrc, setThumbSrc] = useState('') - const [videoPath, setVideoPath] = useState('') - const [liveVideoPath, setLiveVideoPath] = useState('') - const [isDecrypting, setIsDecrypting] = useState(false) - const [isGeneratingCover, setIsGeneratingCover] = useState(false) - - const isVideo = isSnsVideoUrl(media.url) - const isLive = !!media.livePhoto - const targetUrl = media.thumb || media.url - // type 7 的朋友圈媒体不需要解密,直接使用原始 URL - const skipDecrypt = postType === 7 - - // 视频重试:失败时重试最多2次,耗尽才标记删除 - const videoRetryOrDelete = () => { - if (retryCount.current < 2) { - retryCount.current++ - setRetryKey(k => k + 1) - } else { - markDeleted() - } - } - - // Simple effect to load image/decrypt - // Simple effect to load image/decrypt - React.useEffect(() => { - let cancelled = false - setLoading(true) - - const load = async () => { - try { - if (!isVideo) { - // For images, we proxy to get the local path/base64 - const result = await window.electronAPI.sns.proxyImage({ - url: targetUrl, - key: skipDecrypt ? undefined : media.key - }) - if (cancelled) return - - if (result.success) { - if (result.dataUrl) setThumbSrc(result.dataUrl) - else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`) - } else { - markDeleted() - } - - // Pre-load live photo video if needed - if (isLive && media.livePhoto?.url) { - window.electronAPI.sns.proxyImage({ - url: media.livePhoto.url, - key: skipDecrypt ? undefined : (media.livePhoto.key || media.key) - }).then((res: any) => { - if (!cancelled && res.success && res.videoPath) { - setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) - } - }).catch(() => { }) - } - setLoading(false) - } else { - // Video logic: Decrypt -> Extract Frame - setIsGeneratingCover(true) - - // First check if we already have it decryptable? - // Usually we need to call proxyImage with the video URL to decrypt it to cache - const result = await window.electronAPI.sns.proxyImage({ - url: media.url, - key: skipDecrypt ? undefined : media.key - }) - - if (cancelled) return - - if (result.success && result.videoPath) { - const localPath = `file://${result.videoPath.replace(/\\/g, '/')}` - setVideoPath(localPath) - - try { - const coverDataUrl = await extractVideoFrame(localPath) - if (!cancelled) setThumbSrc(coverDataUrl) - } catch (err) { - console.error('Frame extraction failed', err) - // 封面提取失败,用视频路径作为 fallback,让