From 10775a2888e41078d62a176d29394ed7cfea3b30 Mon Sep 17 00:00:00 2001 From: wanguliu <756933962@qq.com> Date: Sat, 18 Jul 2026 00:16:37 +0800 Subject: [PATCH] chore: initialize repository with plugin source and muscle-analysis tooling --- .gitignore | 16 + LICENSE | 21 + README.md | 271 +++ THIRD-PARTY-NOTICES | 55 + esbuild.config.mjs | 47 + manifest.json | 10 + muscle_analysis/analyze.py | 215 ++ muscle_analysis/generate_catalog.py | 265 ++ muscle_analysis/muscle_layer_back.svg | 200 ++ muscle_analysis/muscle_layer_front.svg | 292 +++ muscle_analysis/肌肉统计与分组报告.md | 238 ++ package-lock.json | 3062 ++++++++++++++++++++++++ package.json | 32 + src/assets/muscle_layer_back.svg | 200 ++ src/assets/muscle_layer_front.svg | 292 +++ src/codeBlockDefs.ts | 92 + src/codeblock/registry.ts | 185 ++ src/codeblock/workoutDay.ts | 244 ++ src/codeblock/workoutHeatmap.ts | 437 ++++ src/codeblock/workoutLog.ts | 346 +++ src/codeblock/workoutPlan.ts | 231 ++ src/data/CSVStore.ts | 305 +++ src/data/ConfigStore.ts | 160 ++ src/data/DataManager.ts | 703 ++++++ src/data/display.test.ts | 60 + src/data/display.ts | 173 ++ src/data/muscleMapping.ts | 98 + src/data/planScanner.ts | 74 + src/data/seed.ts | 468 ++++ src/data/statExpr.test.ts | 156 ++ src/data/statExpr.ts | 344 +++ src/data/svgMuscleCatalog.test.ts | 73 + src/data/svgMuscleCatalog.ts | 202 ++ src/data/types.ts | 186 ++ src/i18n/en.ts | 483 ++++ src/i18n/index.ts | 85 + src/i18n/zh.ts | 484 ++++ src/main.ts | 333 +++ src/test/obsidian-shim.ts | 270 +++ src/types/obsidian-augment.d.ts | 20 + src/types/svg.d.ts | 7 + src/ui/Confirm.ts | 66 + src/ui/ExerciseManagerModal.ts | 188 ++ src/ui/ExerciseModal.ts | 276 +++ src/ui/InsertCodeBlockModal.ts | 110 + src/ui/InsertCodeBlockParamModal.ts | 148 ++ src/ui/MuscleEditModal.ts | 526 ++++ src/ui/MuscleManagerModal.ts | 218 ++ src/ui/NewPlanModal.ts | 486 ++++ src/ui/PlanSetEditModal.ts | 169 ++ src/ui/RecordModal.ts | 595 +++++ src/ui/SettingsTab.ts | 401 ++++ src/ui/StatManagerModal.ts | 180 ++ src/ui/StatModal.ts | 412 ++++ src/ui/TrainingPlanManagerModal.ts | 152 ++ src/ui/TypeManagerModal.ts | 176 ++ src/ui/TypeModal.ts | 389 +++ src/ui/VaultFolderSuggestModal.ts | 37 + src/ui/VaultPathSuggest.ts | 72 + src/ui/idValidation.test.ts | 22 + src/ui/idValidation.ts | 16 + src/ui/management-ui.test.ts | 353 +++ src/util/duration.ts | 59 + src/util/id.ts | 21 + src/util/units.ts | 36 + styles.css | 1267 ++++++++++ tsconfig.json | 25 + vitest.config.ts | 22 + 上架注意事项.md | 79 + 69 files changed, 17936 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD-PARTY-NOTICES create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 muscle_analysis/analyze.py create mode 100644 muscle_analysis/generate_catalog.py create mode 100644 muscle_analysis/muscle_layer_back.svg create mode 100644 muscle_analysis/muscle_layer_front.svg create mode 100644 muscle_analysis/肌肉统计与分组报告.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/assets/muscle_layer_back.svg create mode 100644 src/assets/muscle_layer_front.svg create mode 100644 src/codeBlockDefs.ts create mode 100644 src/codeblock/registry.ts create mode 100644 src/codeblock/workoutDay.ts create mode 100644 src/codeblock/workoutHeatmap.ts create mode 100644 src/codeblock/workoutLog.ts create mode 100644 src/codeblock/workoutPlan.ts create mode 100644 src/data/CSVStore.ts create mode 100644 src/data/ConfigStore.ts create mode 100644 src/data/DataManager.ts create mode 100644 src/data/display.test.ts create mode 100644 src/data/display.ts create mode 100644 src/data/muscleMapping.ts create mode 100644 src/data/planScanner.ts create mode 100644 src/data/seed.ts create mode 100644 src/data/statExpr.test.ts create mode 100644 src/data/statExpr.ts create mode 100644 src/data/svgMuscleCatalog.test.ts create mode 100644 src/data/svgMuscleCatalog.ts create mode 100644 src/data/types.ts create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/zh.ts create mode 100644 src/main.ts create mode 100644 src/test/obsidian-shim.ts create mode 100644 src/types/obsidian-augment.d.ts create mode 100644 src/types/svg.d.ts create mode 100644 src/ui/Confirm.ts create mode 100644 src/ui/ExerciseManagerModal.ts create mode 100644 src/ui/ExerciseModal.ts create mode 100644 src/ui/InsertCodeBlockModal.ts create mode 100644 src/ui/InsertCodeBlockParamModal.ts create mode 100644 src/ui/MuscleEditModal.ts create mode 100644 src/ui/MuscleManagerModal.ts create mode 100644 src/ui/NewPlanModal.ts create mode 100644 src/ui/PlanSetEditModal.ts create mode 100644 src/ui/RecordModal.ts create mode 100644 src/ui/SettingsTab.ts create mode 100644 src/ui/StatManagerModal.ts create mode 100644 src/ui/StatModal.ts create mode 100644 src/ui/TrainingPlanManagerModal.ts create mode 100644 src/ui/TypeManagerModal.ts create mode 100644 src/ui/TypeModal.ts create mode 100644 src/ui/VaultFolderSuggestModal.ts create mode 100644 src/ui/VaultPathSuggest.ts create mode 100644 src/ui/idValidation.test.ts create mode 100644 src/ui/idValidation.ts create mode 100644 src/ui/management-ui.test.ts create mode 100644 src/util/duration.ts create mode 100644 src/util/id.ts create mode 100644 src/util/units.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 上架注意事项.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01cb546 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 依赖(由 npm install 生成,不入库) +node_modules/ + +# 构建产物(由 esbuild 生成,不入库;发布时再构建 / 用 GitHub Release) +main.js +*.js.map +dist/ + +# 本地发布归档(与根目录 main.js/manifest.json 完全重复;用 git tag / GitHub Release 管理历史版本) +版本/ + +# 系统 / 编辑器临时文件 +.DS_Store +*.log +Thumbs.db +.obsidian/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f098151 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wanguliu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a427f7a --- /dev/null +++ b/README.md @@ -0,0 +1,271 @@ +# 训练块 Workout Block + +一款以**「极致的自由度」**为核心的 Obsidian 训练记录插件。它不预设任何训练体系——你**自己定义训练类型**、**记录任何你想记录的数据**、**对记录字段做任意衍生计算**;训练计划、肌肉管理、肌肉热力图同样全部由你按自己的方式配置。所有内容以**代码块**形式呈现,渲染与数据层高度解耦。数据以纯文本(CSV / JSON)存放在你的 vault 中,可被 Dataview 等工具直接查询。 + +> **核心主张**:没有「固定字段」、没有「标准动作库」、没有「官方模板」。插件只提供一套可无限自定义的骨架,怎么用完全由你决定。 + +> 插件 ID:`workout-block` | 最低 Obsidian 版本:`1.5.0` | 许可证:MIT | 语言:中文 / English + +--- + +## 🧭 设计哲学:一切皆可自定义 + +多数训练 App 把字段、动作、计划写死,你只能被动适配。本插件反其道而行——它把「定义权」还给你: + +| 你决定什么 | 怎么决定 | 文档/代码 | +|------------|----------|-----------| +| 有哪些**训练类型** | 完全自建,如「力量 / 有氧 / 自重 / 敏捷 / 攀岩」任意增删 | 记录录入 §5 | +| 每种类型**记录哪些字段** | 任意数量、任意组合(数字 / 时长 / 文本 / 下拉),无系统约束 | 同上 | +| 字段**单位** | 重量自动 kg/lb 换算,或自由文本单位(次 / 公里 / 层 / 圈…) | 同上 | +| 记录字段的**衍生数据** | 像配训练类型一样配「数据统计」,引导式或写表达式 `sum(reps*weight)` | 数据统计设计 | +| **训练计划**怎么排 | 每个训练项、每一组的目标量自定义;按具体日期或每周周几执行 | 训练计划设计 | +| **肌肉怎么管** | 任意颗粒度:一块肌肉可映射到 1 条或 N 条解剖路径 | 肌肉管理设计 | +| **热力图**画什么 | 逐肌选指标 / 时间窗 / 颜色分档,医学解剖级人体图 | 同上 | + +下面分模块展开。 + +--- + +## ✨ 功能特性 + +### 1. 完全自定义的训练类型 —— 你练什么,由你定义 + +训练类型(如「力量」「有氧」「自重」)只是种子默认值,**你可以新建任意类型**,并为每个类型配置它包含的字段。系统**不预设**「某类型必须有哪些字段」,字段的「数量、控件、单位」完全由你决定。 + +- 支持 **4 种输入控件**自由组合:`number`(数字)/ `duration`(时·分·秒,存储为秒)/ `text`(文本)/ `select`(下拉,可自定义选项)。 +- 每个类型至少 1 个字段,可任意增删。 +- 改一个类型,所有「新建训练项 / 记录」的录入界面随之改变(字段由类型的 `fields` 动态渲染)。 + +**自定义示例**:新建「敏捷训练」类型,字段设为 `组数`(数字) + `距离`(数字,单位「公里」) + `组间休息`(下拉:30s / 60s / 90s)。—— 换作力量训练,也可以只有 `重量` + `次数`。没有限制。 + +### 2. 记录任何你想训练的数据 —— 自由度落到「每一组」 + +记录的最小颗粒度是**「一组」**(如「卧推 60kg × 8 次」= CSV 一行)。录入时填写的字段**完全由所选训练类型决定**,所以你定义了多少种类型、每个类型有多少字段,就能记录多少种数据。 + +- **上次值记忆**:录入下一组时自动带出该训练项上一次的数值,省去重复输入(可在设置关闭)。 +- **模糊匹配**:训练项搜索支持子串匹配,随手输入即可命中。 +- 所有数据落盘为纯文本,字段以 JSON 存于 `fields` 列,表结构与类型定义解耦——新增类型 / 字段不会破坏历史记录。 + +### 3. 对记录字段做任意衍生计算 —— 你想要的指标,自己算 + +内置的「总组数」只是系统预置的**一条普通统计**,你完全可以删掉它,定义自己关心的派生指标。 + +- **双模式公式**: + - 引导式构建器:求和 `sum` / 乘积求和 `Σ(a×b)` / 平均·最大·最小 / 计数 `count`,点选即可。 + - 自由表达式(高级):直接写 `sum(reps * weight)`、`avg(weight)`、`max(weight)` 等。 +- **关联训练类型**:一条统计可关联一个或多个类型,只在对应类型的代码块里出现(如「总训练量」同时关联力量与自重)。 +- **安全**:内置受限求值器,禁用 `eval` / `Function`,函数白名单 + 字段引用 + 四则运算,保存前做语法与合法性校验,非法公式拦截保存。 +- 统计结果**渲染时实时计算**,不写回 CSV、不污染原始数据。 + +### 4. 极高自由度的训练计划 —— 排你自己的练法 + +训练计划不是被模板框死的,而是完全可配的训练方案实例: + +- **逐组目标量**:每个训练项、每一组都可以预设不同的目标字段值(因为字段是你自定义的,每组可能不同)。 +- **灵活的计划时间**:具体到某一天,或「每周一、三、五」这类 ISO 周几循环。 +- **基于方案快速建计划**:扫描含 `workout-plan` 代码块的笔记作为来源,一键合并训练项;也支持手动添加方案外的项目、单独增删任意训练组。 +- **笔记即方案**:含 `workout-plan` 代码块的笔记天然就是训练方案,无需额外实体。 +- **完成态独立持久化**:在代码块里逐组点「完成」即写入记录;完成状态存于配置、独立于训练记录,**完成即完成,不做按日 / 周重置**,删除记录不影响完成态。 + +### 5. 任意颗粒度的肌肉管理 —— 从「一大块肩膀」到「前/中/后束」 + +肌肉与人体示意图 SVG 的关系是 **1 块肌肉 → N 条 SVG 路径**的可配置映射,精细度由你定: + +- 新手只想看「肩膀」一大块;教练要区分「三角肌前 / 中 / 后束」分别着色——同一个插件都支持。 +- **首次引导三档导入**(只是初份配置,不是锁死的「模式」): + - **默认**:13 块基础肌肉,按健身群映射到全部解剖路径(最完整,推荐)。 + - **精简**:每块只映射代表性主路径,图表更干净。 + - **手动**:映射留空,由你逐个勾选。 +- 导入后随时在编辑弹窗增删映射,「想改就改,不被预设绑架」。 +- 双语肌肉目录(中文 / 英文解剖名)共 **143 条**可映射路径,配搜索框应对规模。 + +### 6. 医学解剖级的肌肉热力图 —— 看见身体的强弱 + +基于**完整正 / 背面人体解剖 SVG**(医学解剖命名,源自 flutter-body-atlas,CC BY 4.0)渲染全身肌肉负荷: + +- 正 / 背面一键切换,两份 SVG 均内联,切换只改显示不重算。 +- 按训练量(默认「次数」,可逐肌改)给各肌肉着色,颜色分级逐肌可配(级数 ≤ 99,每级颜色与阈值自定义十六进制)。 +- **三级回退**:肌肉级 → 代码块级(`metric` / `range` 参数)→ 全局默认,每块肌肉都能单独定「用什么指标、看多久」。 +- 加权着色:主练权重 1.0、辅练 0.5 累加;绝对阈值着色(非全图归一化),多肌命中同路径取贡献最大者分档。 +- 进入视口才计算上色(懒渲染),主线程负担低。 + +### 7. 代码块呈现、高度解耦 —— 内容长在笔记里 + +插件接管四类围栏代码块的渲染,直接在笔记里写即可,渲染与数据层完全分离: + +| 代码块 | 视角 | +|--------|------| +| `workout-log` | 单动作历史表 + 分组聚合统计 | +| `workout-day` | 当日训练总览(项目 / 统计值 / 主辅肌群 / 方案) | +| `workout-plan` | 训练计划完成面板(逐组点完成) | +| `workout-heatmap` | 全身肌肉负荷热力图(正/背切换) | + +- **可扩展注册表**:内部维护代码块类型注册表,新增一种代码块 = 写一个 handler 并 `registerCodeBlock`,**无需改动既有逻辑**。 +- **精准重渲染**:数据变更时只重画含该训练项的代码块(`rerenderBlocksForExercise`),避免全量卡顿;语言切换 / 外部改文件才全局刷新。 +- 预览模式与阅读模式均生效,样式作用域隔离,不污染 vault 全局。 + +### 8. 中英双语 & 纯文本数据 + +- 界面随 Obsidian 语言切换,切换即重渲染,代码块表格 / 热力图文字 / 时长 token 均干净无残留。 +- 所有数据存于 vault 内的文本文件(CSV + JSON),不使用任何私有二进制格式,可进 Git、随手备份、被 Dataview 等工具直接查询。 + +--- + +## 📦 安装 + +### 方式一:BRAT(推荐,支持自动更新) + +1. 在 Obsidian 社区插件市场安装 **BRAT**。 +2. 打开 BRAT 设置 → `Add a beta plugin`,填入本仓库地址。 +3. 在「社区插件」中启用 **训练块 Workout Block**。 + +### 方式二:手动安装 + +1. 从 Releases 或仓库根目录下载 `main.js` 与 `manifest.json`。 +2. 把它们放进你的 vault:`/.obsidian/plugins/workout-block/`。 +3. 在「社区插件」中启用本插件。 + +> 首次启动会自动写入默认训练类型、肌肉与统计配置,热力图开箱即用,无需手动初始化。 + +--- + +## 🚀 快速上手 + +启用插件后: + +- 点击左侧栏的 **哑铃图标**,或命令面板(`Ctrl/Cmd + P`)搜索「记录一组」,即可录入一条训练。 +- 想长期跟踪某训练项,在笔记里写一个 `workout-log` 代码块(见下)。 +- 想自定义体系:打开设置 → 训练块 → 对应的「管理」弹窗,新建训练类型 / 训练项 / 训练计划 / 统计 / 肌肉映射。 + +--- + +## 📝 代码块(Code Blocks) + +插件接管了以下四种围栏代码块的渲染,直接在笔记里写即可。 + +### `workout-log` —— 训练记录表(按动作看历史) + +展示训练记录,可点「记录 / 编辑 / 删除」交互操作;分组上方显示该组聚合统计。 + +| 参数 | 说明 | 默认 | +|------|------|------| +| `exercise` | 仅显示指定训练项(按名称) | 显示全部 | +| `limit` / `number` | 最多显示条数 | 50 | +| `day` | 仅显示最近 N 天的记录 | — | +| `group_by` | `date`(按天)/ `week`(年-周) | `date` | +| `sort` | `desc` / `asc` | `desc` | +| `show_add` | 是否显示顶部「添加记录」按钮 | `true` | + +````markdown +```workout-log +exercise: 深蹲 +limit: 20 +``` +```` + +### `workout-day` —— 当日训练总览 + +按天汇总「练了哪些项目」,表格列为:项目 / 数据统计值 / 主肌群 / 辅助肌群 / 训练方案。 + +| 参数 | 说明 | +|------|------| +| `day: 2026-07-12` | 查询指定日期 | +| `day: today` | 显示当日(活数据,随日期滚动) | +| 不写 `day` | 同样显示当日,并提供「固定为当日」按钮写回日期 | + +````markdown +```workout-day +day: 2026-07-12 +``` +```` + +### `workout-heatmap` —— 肌肉热力图 + +渲染完整人体肌肉图,按训练量着色。代码块上方有 **正面 / 背面** 切换按钮;颜色分级(默认 4 级:蓝 / 绿 / 橙 / 红)的级数、每级颜色与阈值,均可在「肌肉管理」里逐肌肉自定义。 + +| 参数 | 说明 | 默认 | +|------|------|------| +| `metric` | 引用一条数据统计配置(如「次数」) | 全局默认(次数) | +| `range` | `7d` / `30d` / `90d` / `all` / 日期区间 | 全局默认(7d) | + +````markdown +```workout-heatmap +metric: 次数 +range: 7d +``` +```` + +### `workout-plan` —— 训练计划完成面板 + +跟踪某个训练方案的完成进度,逐组标记完成。 + +| 参数 | 说明 | +|------|------| +| `plan: 计划名` | 指定要展示的训练方案;不写则显示「选择计划」下拉,选中后自动把计划名写回代码块 | + +````markdown +```workout-plan +plan: 推日 A +``` +```` + +--- + +## ⚙️ 设置与数据管理 + +在设置页(`Ctrl/Cmd + ,` → 训练块)可配置: + +- **训练类型 / 训练项 / 肌肉 / 统计值 / 训练计划**:均在对应的「管理」弹窗中增删改——这是插件「自由度」的总开关。 +- **语言**:中文 / English。 +- **重量单位**:kg / lb(影响重量类字段的展示与换算)。 +- **上次值记忆**:开关。 +- **数据目录**:训练记录 CSV 的存放位置(默认 vault 根目录)。 +- **压缩清理 CSV**:删除训练记录采用「软删除」(界面即时移除,磁盘空间延迟回收);点此按钮真正压缩文件、回收空间。 + +### 数据存储位置 + +| 文件 | 内容 | +|------|------| +| `workout_logs.csv` | 所有训练记录(纯 CSV,便于 Dataview 查询) | +| `workout-config.json` | 配置:训练类型、训练项、肌肉及其 SVG 映射、统计值、训练计划 | + +两套数据都位于 vault 内(数据目录可在设置中更改),均为纯文本,可放进 Git 或随手备份。 + +--- + +## 🔧 开发 + +```bash +# 安装依赖 +npm install + +# 开发模式(监听改动,不压缩) +npm run dev + +# 生产构建(输出根目录 main.js) +npm run build + +# 运行测试 +npm test + +# 测试覆盖率 +npm run test:coverage +``` + +构建产物为根目录的 `main.js`,配合 `manifest.json` 即可作为插件运行。 + +### 技术栈 + +- TypeScript + [esbuild](https://esbuild.github.io/)(打包) +- [Vitest](https://vitest.dev/) + jsdom(单元测试) +- [papaparse](https://www.papaparse.com/)(CSV 读写) +- Obsidian API + +--- + +## 📄 许可证 + +MIT —— 可自由使用、修改与分发。仓库根目录已附带 `LICENSE` 文件(MIT 全文),以便 GitHub 自动识别许可证。 + +插件打包的肌肉 SVG 插图来自第三方,遵循 **CC BY 4.0**(作者 Ryan Graves)与 **BSD-3-Clause**(flutter-body-atlas, Kit G.)。署名与许可详情见 [THIRD-PARTY-NOTICES](./THIRD-PARTY-NOTICES)。 + diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES new file mode 100644 index 0000000..e16052b --- /dev/null +++ b/THIRD-PARTY-NOTICES @@ -0,0 +1,55 @@ +Third-Party Notices +==================== + +This project bundles third-party assets. Their licenses are reproduced below. + + +Muscle illustrations +-------------------- +Files: + src/assets/muscle_layer_front.svg + src/assets/muscle_layer_back.svg + src/assets/muscle_layer_front_nohead.svg + +Original artwork : Figma Muscle Atlas by Ryan Graves +Bundled via : flutter-body-atlas (https://github.com/kit-g/flutter-body-atlas) +License : Creative Commons Attribution 4.0 International (CC BY 4.0) + https://creativecommons.org/licenses/by/4.0/ + +CC BY 4.0 attribution requirements satisfied here: + - Author credited : Ryan Graves + - License linked : CC BY 4.0 (see URL above) + - Modifications : The head region was removed via SVG path id filtering. + - No endorsement by the original author is implied. + + +flutter-body-atlas code wrapper +------------------------------- +The repository above is distributed under the BSD 3-Clause License. + +Copyright (c) 2026 Kit G. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..a8080e1 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,47 @@ +/* + * esbuild.config.mjs(打包脚本) + * 文件作用:用 esbuild 把 TypeScript 源码打包成 Obsidian 能加载的单个 main.js。 + * 概念解释:Obsidian 插件最终运行的是 main.js,本脚本负责把 src/main.ts 及其依赖"打包/压缩"成它。 + * 用法:npm run build(带 --production 压缩)或 npm run dev(watch 监听改动热重载)。 + */ + +import esbuild from 'esbuild'; +import process from 'process'; +import fs from 'fs'; + +// banner:生成文件顶部的注释块,声明这是由 esbuild 自动生成的文件(提醒不要直接手改 main.js) +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +// 是否生产模式:命令行带 --production 时走压缩,否则进入监听模式 +const prod = process.argv.includes('--production'); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ['src/main.ts'], // 打包入口:从 main.ts 开始把依赖都收进来 + bundle: true, // bundle=true:把所有 import 合并成一个文件 + external: ['obsidian'], // external:obsidian 由 Obsidian 运行时提供,不要打进包里(减小体积、避免重复) + format: 'cjs', // 输出 CommonJS 格式(Obsidian 加载插件用的模块格式) + target: 'ES2018', // 目标语法级别,兼容主流环境 + logLevel: 'info', + sourcemap: prod ? false : 'inline', // 非生产模式生成内联 sourcemap,便于调试时定位源码 + treeShaking: true, // 摇树优化:删除未被引用的无用代码 + outfile: 'main.js', // 输出文件:Obsidian 加载的就是它 + loader: { + '.svg': 'text', // SVG 作为字符串内联,便于热力图直接插入 DOM + }, +}); + +if (prod) { + // 生产模式:一次性重新构建后退出 + await context.rebuild(); + process.exit(0); +} else { + // 开发模式:持续监听文件改动并自动重新打包(热重载) + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..31e8521 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "workout-block", + "name": "训练块 Workout Block", + "version": "1.0.0", + "minAppVersion": "1.5.0", + "description": "Obsidian 训练记录插件:记录力量/有氧/自重训练,支持完全自定义训练类型、肌肉映射、热力图与训练计划;内置移动端适配与代码块快捷插入。", + "author": "wanguliux", + "authorUrl": "https://github.com/wanguliux", + "isDesktopOnly": false +} diff --git a/muscle_analysis/analyze.py b/muscle_analysis/analyze.py new file mode 100644 index 0000000..b3d4f1c --- /dev/null +++ b/muscle_analysis/analyze.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +import re, os + +ROOT = os.path.dirname(os.path.abspath(__file__)) + +# Non-muscle base layers / body-part ids to exclude +EXCLUDE = { + 'underlayer', 'non_muscle', + 'foot_l', 'foot_r', 'ankle_l', 'ankle_r', + 'hand_l', 'hand_r', 'hands', 'wrist_l', 'wrist_r', 'palm_l', 'palm_r', + 'head', 'face', +} +# Top-level containers (grouping wrappers, not individual muscles) +CONTAINERS = { + 'legs', 'adductors', 'glutes', 'arms', 'core', 'back', + 'shoulders', 'chest', 'neck', 'hamstrings', +} + +# Chinese anatomical names for the muscle base (after stripping side/segment suffixes) +BASE_CN = { + 'tibialis_anterior': '胫骨前肌', + 'extensor_hallucis_longus': '拇长伸肌', + 'fibularis_longus': '腓骨长肌', + 'extensor_digitorum_longus': '趾长伸肌', + 'gastrocnemius': '腓肠肌', + 'semitendinosus': '半腱肌', + 'vastus_lateralis': '股外侧肌', + 'vastus_medialis': '股内侧肌', + 'sartoris': '缝匠肌', # SVG typo for sartorius + 'sartorius': '缝匠肌', + 'gracilis': '股薄肌', + 'rectus_femoris': '股直肌', + 'iliotibial_tract': '髂胫束(筋膜)', + 'adductor_longus': '长收肌', + 'pectineus': '耻骨肌', + 'adductor_magnus': '大收肌', + 'gluteus_medius': '臀中肌', + 'gluteus_maximus': '臀大肌', + 'flexor_digitorum_superficialis': '指浅屈肌', + 'pronator_quadratus': '旋前方肌', + 'extensor_carpi_radialis_longus': '桡侧腕长伸肌', + 'palmaris_longus': '掌长肌', + 'flexor_carpi_radialis': '桡侧腕屈肌', + 'pronator_teres': '旋前圆肌', + 'triceps_brachii': '肱三头肌', + 'brachioradialis': '肱桡肌', + 'biceps_brachii': '肱二头肌', + 'external_oblique': '腹外斜肌', + 'rectus_abdominis': '腹直肌', + 'latissimus_dorsi': '背阔肌', + 'lateral_deltoid': '三角肌(中束)', + 'trapezius_upper': '斜方肌(上束)', + 'trapezius_middle': '斜方肌(中束)', + 'trapezius_lower': '斜方肌(下束)', + 'anterior_deltoid': '三角肌(前束)', + 'posterior_deltoid': '三角肌(后束)', + 'pectoralis_major': '胸大肌', + 'platysma': '颈阔肌', + 'sternohyoid': '胸骨舌骨肌', + 'sternocleidomastoid': '胸锁乳突肌', + 'infraspinatus': '冈下肌', + 'semimembranosus': '半膜肌', + 'biceps_femoris': '股二头肌', + 'extensor_digitorum': '指总伸肌', + 'extensor_carpi_ulnaris': '尺侧腕伸肌', + 'anconeus': '肘肌', + 'flexor_carpi_ulnaris': '尺侧腕屈肌', +} + +def base_name(mid): + """Strip side (_l/_r) and keep head/segment info minimal -> base muscle key.""" + s = mid.lower() + # triceps/biceps heads: keep just the muscle name (merge heads for grouping) + if s.startswith('triceps_brachii'): + return 'triceps_brachii' + if s.startswith('biceps_brachii'): + return 'biceps_brachii' + # strip trailing _l / _r + s = re.sub(r'_[lr]$', '', s) + # strip numeric segment suffixes like _1 _2 and _r/_l already removed + s = re.sub(r'_\d+$', '', s) + return s + +def fitness_group(mid): + s = mid.lower() + if 'pectoralis_major' in s: return '胸部' + if s.startswith('trapezius'): return '斜方肌' + if 'latissimus_dorsi' in s or 'infraspinatus' in s: return '背部' + if 'deltoid' in s: return '肩部(三角肌)' + if 'biceps_brachii' in s: return '肱二头肌' + if 'triceps_brachii' in s: return '肱三头肌' + if any(k in s for k in ['gastrocnemius','tibialis','fibularis','extensor_hallucis','extensor_digitorum_longus']): + return '小腿' + if any(k in s for k in ['brachioradialis','flexor_digitorum','pronator','extensor_carpi', + 'palmaris','flexor_carpi','extensor_digitorum','anconeus']): + return '前臂' + if 'rectus_abdominis' in s or 'external_oblique' in s: return '腹肌/核心' + if 'gluteus' in s: return '臀部' + if any(k in s for k in ['rectus_femoris','vastus','sartoris','sartorius','iliotibial']): + return '股四头肌' + if any(k in s for k in ['semitendinosus','semimembranosus','biceps_femoris']): return '腘绳肌' + if any(k in s for k in ['adductor','pectineus','gracilis']): return '内收肌' + if any(k in s for k in ['sternocleidomastoid','platysma','sternohyoid']): return '颈部' + return '其他' + +def parse_ids(path): + with open(path, encoding='utf-8') as f: + txt = f.read() + return re.findall(r'\bid="([^"]+)"', txt) + +files = { + '正面': os.path.join(ROOT, 'muscle_layer_front.svg'), + '背面': os.path.join(ROOT, 'muscle_layer_back.svg'), +} + +muscles = [] # (id, view, base, group) +for view, fp in files.items(): + for mid in parse_ids(fp): + if mid in EXCLUDE or mid in CONTAINERS: + continue + b = base_name(mid) + g = fitness_group(mid) + muscles.append((mid, view, b, g)) + +# ---- Counts ---- +from collections import defaultdict, OrderedDict +per_view = defaultdict(int) +for mid, view, b, g in muscles: + per_view[view] += 1 +total = len(muscles) + +# distinct anatomical muscle types (merge L/R + segments + across views) +distinct_types = set(b for mid, view, b, g in muscles) + +# per fitness group aggregation +group_info = OrderedDict() +for mid, view, b, g in muscles: + if g not in group_info: + group_info[g] = {'paths': 0, 'bases': set(), 'views': set(), 'ids_by_view': defaultdict(list)} + group_info[g]['paths'] += 1 + group_info[g]['bases'].add(b) + group_info[g]['views'].add(view) + if mid not in group_info[g]['ids_by_view'][view]: # dedupe within a view + group_info[g]['ids_by_view'][view].append(mid) + +# ---- Write report ---- +lines = [] +lines.append('# 肌肉热力图 SVG 肌肉统计与健身肌肉群分组') +lines.append('') +lines.append('> 数据源:`kit-g/flutter-body-atlas` 的 `muscle_layer_front.svg`(正面)与 `muscle_layer_back.svg`(背面)') +lines.append('> 已下载至 `muscle_analysis/` 目录。') +lines.append('') +lines.append('## 一、肌肉数量统计(不含面部)') +lines.append('') +lines.append('| 视图 | 肌肉路径块数 |') +lines.append('| --- | ---: |') +lines.append(f'| 正面 (front) | {per_view["正面"]} |') +lines.append(f'| 背面 (back) | {per_view["背面"]} |') +lines.append(f'| **合计** | **{total}** |') +lines.append('') +lines.append(f'- 上述为**单独绘制的肌肉路径块数**(左右侧、分节段各自计为 1 块;已剔除 `face` 面部、`foot`/`ankle`/`hand`/`wrist`/`palm` 等非肌肉部位,以及 `underlayer`/`non_muscle` 底层)。') +lines.append(f'- 若将左右侧合并、分节段合并、正反视图去重,**不同的解剖肌肉类型共 {len(distinct_types)} 种**。') +lines.append('') +lines.append('## 二、健身常用肌肉群分组表') +lines.append('') +lines.append('下表把 143 块细碎肌肉路径汇总为健身人群常用的 14 个肌肉群。') +lines.append('') +lines.append('| 健身肌肉群 | 路径块数 | 出现视图 | 包含的具体肌肉(SVG 基础名 → 中文) |') +lines.append('| --- | ---: | --- | --- |') +for g in ['胸部','背部','斜方肌','肩部(三角肌)','肱二头肌','肱三头肌','前臂','腹肌/核心', + '臀部','股四头肌','腘绳肌','内收肌','小腿','颈部']: + if g not in group_info: + continue + info = group_info[g] + views = '/'.join(sorted(info['views'])) + parts = [] + for b in sorted(info['bases']): + parts.append(f'{b} → {BASE_CN.get(b, b)}') + detail = ';'.join(parts) + lines.append(f'| {g} | {info["paths"]} | {views} | {detail} |') +lines.append('') +lines.append(f'**合计路径块数:** {sum(v["paths"] for v in group_info.values())}(= 143,校验一致)') +lines.append('') +lines.append('## 三、附录:每个健身肌肉群对应的完整 SVG 元素 id(可直接用于热力图填色)') +lines.append('') +for g in ['胸部','背部','斜方肌','肩部(三角肌)','肱二头肌','肱三头肌','前臂','腹肌/核心', + '臀部','股四头肌','腘绳肌','内收肌','小腿','颈部']: + if g not in group_info: + continue + info = group_info[g] + lines.append(f'### {g}(共 {info["paths"]} 块)') + lines.append('') + for view in ['正面','背面']: + ids = sorted(info['ids_by_view'].get(view, [])) + if not ids: + continue + lines.append(f'**{view}视图({len(ids)} 块):**') + lines.append('') + lines.append('```') + lines.append(', '.join(ids)) + lines.append('```') + lines.append('') + lines.append('') + +out = os.path.join(ROOT, '肌肉统计与分组报告.md') +with open(out, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + +print('Total muscle paths (excl. face):', total) +print('Front:', per_view['正面'], 'Back:', per_view['背面']) +print('Distinct anatomical types:', len(distinct_types)) +print('Fitness groups:', len(group_info)) +for g, info in group_info.items(): + print(f' {g}: {info["paths"]} paths, {len(info["bases"])} types, views={sorted(info["views"])}') +print('Report written to', out) diff --git a/muscle_analysis/generate_catalog.py b/muscle_analysis/generate_catalog.py new file mode 100644 index 0000000..c1f3ef2 --- /dev/null +++ b/muscle_analysis/generate_catalog.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +import re +import os + +ROOT = os.path.dirname(os.path.abspath(__file__)) + +EXCLUDE = { + 'underlayer', 'non_muscle', + 'foot_l', 'foot_r', 'ankle_l', 'ankle_r', + 'hand_l', 'hand_r', 'hands', 'wrist_l', 'wrist_r', 'palm_l', 'palm_r', + 'head', 'face', +} +CONTAINERS = { + 'legs', 'adductors', 'glutes', 'arms', 'core', 'back', + 'shoulders', 'chest', 'neck', 'hamstrings', +} + +BASE_CN = { + 'tibialis_anterior': '胫骨前肌', + 'extensor_hallucis_longus': '拇长伸肌', + 'fibularis_longus': '腓骨长肌', + 'extensor_digitorum_longus': '趾长伸肌', + 'gastrocnemius': '腓肠肌', + 'semitendinosus': '半腱肌', + 'vastus_lateralis': '股外侧肌', + 'vastus_medialis': '股内侧肌', + 'sartoris': '缝匠肌', + 'sartorius': '缝匠肌', + 'gracilis': '股薄肌', + 'rectus_femoris': '股直肌', + 'iliotibial_tract': '髂胫束(筋膜)', + 'adductor_longus': '长收肌', + 'pectineus': '耻骨肌', + 'adductor_magnus': '大收肌', + 'gluteus_medius': '臀中肌', + 'gluteus_maximus': '臀大肌', + 'flexor_digitorum_superficialis': '指浅屈肌', + 'pronator_quadratus': '旋前方肌', + 'extensor_carpi_radialis_longus': '桡侧腕长伸肌', + 'palmaris_longus': '掌长肌', + 'flexor_carpi_radialis': '桡侧腕屈肌', + 'pronator_teres': '旋前圆肌', + 'triceps_brachii': '肱三头肌', + 'brachioradialis': '肱桡肌', + 'biceps_brachii': '肱二头肌', + 'external_oblique': '腹外斜肌', + 'rectus_abdominis': '腹直肌', + 'latissimus_dorsi': '背阔肌', + 'lateral_deltoid': '三角肌(中束)', + 'trapezius_upper': '斜方肌(上束)', + 'trapezius_middle': '斜方肌(中束)', + 'trapezius_lower': '斜方肌(下束)', + 'anterior_deltoid': '三角肌(前束)', + 'posterior_deltoid': '三角肌(后束)', + 'pectoralis_major': '胸大肌', + 'platysma': '颈阔肌', + 'sternohyoid': '胸骨舌骨肌', + 'sternocleidomastoid': '胸锁乳突肌', + 'infraspinatus': '冈下肌', + 'semimembranosus': '半膜肌', + 'biceps_femoris': '股二头肌', + 'extensor_digitorum': '指总伸肌', + 'extensor_carpi_ulnaris': '尺侧腕伸肌', + 'anconeus': '肘肌', + 'flexor_carpi_ulnaris': '尺侧腕屈肌', +} + +BASE_EN = { + 'tibialis_anterior': 'Tibialis Anterior', + 'extensor_hallucis_longus': 'Extensor Hallucis Longus', + 'fibularis_longus': 'Fibularis Longus', + 'extensor_digitorum_longus': 'Extensor Digitorum Longus', + 'gastrocnemius': 'Gastrocnemius', + 'semitendinosus': 'Semitendinosus', + 'vastus_lateralis': 'Vastus Lateralis', + 'vastus_medialis': 'Vastus Medialis', + 'sartoris': 'Sartorius', + 'sartorius': 'Sartorius', + 'gracilis': 'Gracilis', + 'rectus_femoris': 'Rectus Femoris', + 'iliotibial_tract': 'Iliotibial Tract', + 'adductor_longus': 'Adductor Longus', + 'pectineus': 'Pectineus', + 'adductor_magnus': 'Adductor Magnus', + 'gluteus_medius': 'Gluteus Medius', + 'gluteus_maximus': 'Gluteus Maximus', + 'flexor_digitorum_superficialis': 'Flexor Digitorum Superficialis', + 'pronator_quadratus': 'Pronator Quadratus', + 'extensor_carpi_radialis_longus': 'Extensor Carpi Radialis Longus', + 'palmaris_longus': 'Palmaris Longus', + 'flexor_carpi_radialis': 'Flexor Carpi Radialis', + 'pronator_teres': 'Pronator Teres', + 'triceps_brachii': 'Triceps Brachii', + 'brachioradialis': 'Brachioradialis', + 'biceps_brachii': 'Biceps Brachii', + 'external_oblique': 'External Oblique', + 'rectus_abdominis': 'Rectus Abdominis', + 'latissimus_dorsi': 'Latissimus Dorsi', + 'lateral_deltoid': 'Deltoid (Lateral)', + 'trapezius_upper': 'Trapezius (Upper)', + 'trapezius_middle': 'Trapezius (Middle)', + 'trapezius_lower': 'Trapezius (Lower)', + 'anterior_deltoid': 'Deltoid (Anterior)', + 'posterior_deltoid': 'Deltoid (Posterior)', + 'pectoralis_major': 'Pectoralis Major', + 'platysma': 'Platysma', + 'sternohyoid': 'Sternohyoid', + 'sternocleidomastoid': 'Sternocleidomastoid', + 'infraspinatus': 'Infraspinatus', + 'semimembranosus': 'Semimembranosus', + 'biceps_femoris': 'Biceps Femoris', + 'extensor_digitorum': 'Extensor Digitorum', + 'extensor_carpi_ulnaris': 'Extensor Carpi Ulnaris', + 'anconeus': 'Anconeus', + 'flexor_carpi_ulnaris': 'Flexor Carpi Ulnaris', +} + +GROUP_ZH_TO_KEY = { + '胸部': 'chest', + '背部': 'back', + '斜方肌': 'traps', + '肩部(三角肌)': 'shoulders', + '肱二头肌': 'biceps', + '肱三头肌': 'triceps', + '前臂': 'forearms', + '腹肌/核心': 'abs', + '臀部': 'glutes', + '股四头肌': 'quads', + '腘绳肌': 'hamstrings', + '内收肌': 'adductors', + '小腿': 'calves', + '颈部': 'neck', +} + +GROUP_KEY_TO_EN = { + 'chest': 'Chest', + 'back': 'Back', + 'traps': 'Traps', + 'shoulders': 'Shoulders (Delts)', + 'biceps': 'Biceps', + 'triceps': 'Triceps', + 'forearms': 'Forearms', + 'abs': 'Abs / Core', + 'glutes': 'Glutes', + 'quads': 'Quads', + 'hamstrings': 'Hamstrings', + 'adductors': 'Adductors', + 'calves': 'Calves', + 'neck': 'Neck', +} + + +def base_name(mid): + s = mid.lower() + if s.startswith('triceps_brachii'): + return 'triceps_brachii' + if s.startswith('biceps_brachii'): + return 'biceps_brachii' + s = re.sub(r'_[lr]$', '', s) + s = re.sub(r'_\d+$', '', s) + return s + + +def fitness_group(mid): + s = mid.lower() + if 'pectoralis_major' in s: + return '胸部' + if s.startswith('trapezius'): + return '斜方肌' + if 'latissimus_dorsi' in s or 'infraspinatus' in s: + return '背部' + if 'deltoid' in s: + return '肩部(三角肌)' + if 'biceps_brachii' in s: + return '肱二头肌' + if 'triceps_brachii' in s: + return '肱三头肌' + if any(k in s for k in ['gastrocnemius', 'tibialis', 'fibularis', 'extensor_hallucis', 'extensor_digitorum_longus']): + return '小腿' + if any(k in s for k in ['brachioradialis', 'flexor_digitorum', 'pronator', 'extensor_carpi', + 'palmaris', 'flexor_carpi', 'extensor_digitorum', 'anconeus']): + return '前臂' + if 'rectus_abdominis' in s or 'external_oblique' in s: + return '腹肌/核心' + if 'gluteus' in s: + return '臀部' + if any(k in s for k in ['rectus_femoris', 'vastus', 'sartoris', 'sartorius', 'iliotibial']): + return '股四头肌' + if any(k in s for k in ['semitendinosus', 'semimembranosus', 'biceps_femoris']): + return '腘绳肌' + if any(k in s for k in ['adductor', 'pectineus', 'gracilis']): + return '内收肌' + if any(k in s for k in ['sternocleidomastoid', 'platysma', 'sternohyoid']): + return '颈部' + return '其他' + + +def parse_ids(path): + with open(path, encoding='utf-8') as f: + txt = f.read() + return re.findall(r'\bid="([^"]+)"', txt) + + +files = { + 'front': os.path.join(ROOT, 'muscle_layer_front.svg'), + 'back': os.path.join(ROOT, 'muscle_layer_back.svg'), +} + +entries = [] +for side, fp in files.items(): + for mid in parse_ids(fp): + if mid in EXCLUDE or mid in CONTAINERS: + continue + b = base_name(mid) + g_zh = fitness_group(mid) + g_key = GROUP_ZH_TO_KEY.get(g_zh, 'other') + entries.append({ + 'id': mid, + 'side': side, + 'fitnessGroup': g_key, + 'zh': BASE_CN.get(b, b), + 'en': BASE_EN.get(b, b), + }) + +entries.sort(key=lambda x: (x['side'], x['fitnessGroup'], x['id'])) + +lines = [] +lines.append('/*') +lines.append(' * svgMuscleCatalog.ts —— SVG 肌肉路径目录(自动生成)') +lines.append(' * 来源:muscle_analysis/muscle_layer_front.svg + muscle_layer_back.svg') +lines.append(' * 共 {} 条路径,14 个健身群,中英双语。'.format(len(entries))) +lines.append(' */') +lines.append('') +lines.append("export interface SvgMuscleEntry {") +lines.append(" id: string;") +lines.append(" side: 'front' | 'back';") +lines.append(" fitnessGroup: string;") +lines.append(" zh: string;") +lines.append(" en: string;") +lines.append("}") +lines.append('') +lines.append("export const SVG_CATALOG_VERSION = 'flutter-body-atlas@main-2026-07-12';") +lines.append('') +lines.append('export const FITNESS_GROUPS: { key: string; zh: string; en: string }[] = [') +for g_key in ['chest', 'back', 'traps', 'shoulders', 'biceps', 'triceps', 'forearms', 'abs', 'glutes', 'quads', 'hamstrings', 'adductors', 'calves', 'neck']: + en = GROUP_KEY_TO_EN[g_key] + zh = [k for k, v in GROUP_ZH_TO_KEY.items() if v == g_key][0] + lines.append(" {{ key: '{}', zh: '{}', en: '{}' }},".format(g_key, zh, en)) +lines.append('];') +lines.append('') +lines.append('export const SVG_MUSCLE_CATALOG: SvgMuscleEntry[] = [') +for e in entries: + lines.append(" {{ id: '{}', side: '{}', fitnessGroup: '{}', zh: '{}', en: '{}' }},".format( + e['id'], e['side'], e['fitnessGroup'], e['zh'], e['en'])) +lines.append('];') +lines.append('') +lines.append("// 渲染时隐藏的 SVG 组/区域 id(头部与面部轮廓,非肌肉)") +lines.append("export const HIDDEN_SVG_GROUP_IDS = ['head', 'face'];") +lines.append('') + +out = os.path.join(ROOT, '..', 'src', 'data', 'svgMuscleCatalog.ts') +with open(out, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + +print('Wrote', len(entries), 'entries to', out) diff --git a/muscle_analysis/muscle_layer_back.svg b/muscle_analysis/muscle_layer_back.svg new file mode 100644 index 0000000..cf8cf9e --- /dev/null +++ b/muscle_analysis/muscle_layer_back.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/muscle_analysis/muscle_layer_front.svg b/muscle_analysis/muscle_layer_front.svg new file mode 100644 index 0000000..646cb15 --- /dev/null +++ b/muscle_analysis/muscle_layer_front.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/muscle_analysis/肌肉统计与分组报告.md b/muscle_analysis/肌肉统计与分组报告.md new file mode 100644 index 0000000..5cb2b3d --- /dev/null +++ b/muscle_analysis/肌肉统计与分组报告.md @@ -0,0 +1,238 @@ +# 肌肉热力图 SVG 肌肉统计与健身肌肉群分组 + +> 数据源:`kit-g/flutter-body-atlas` 的 `muscle_layer_front.svg`(正面)与 `muscle_layer_back.svg`(背面) +> 已下载至 `muscle_analysis/` 目录。 + +## 一、肌肉数量统计(不含面部) + +| 视图 | 肌肉路径块数 | +| --- | ---: | +| 正面 (front) | 89 | +| 背面 (back) | 54 | +| **合计** | **143** | + +- 上述为**单独绘制的肌肉路径块数**(左右侧、分节段各自计为 1 块;已剔除 `face` 面部、`foot`/`ankle`/`hand`/`wrist`/`palm` 等非肌肉部位,以及 `underlayer`/`non_muscle` 底层)。 +- 若将左右侧合并、分节段合并、正反视图去重,**不同的解剖肌肉类型共 46 种**。 + +## 二、健身常用肌肉群分组表 + +下表把 143 块细碎肌肉路径汇总为健身人群常用的 14 个肌肉群。 + +| 健身肌肉群 | 路径块数 | 出现视图 | 包含的具体肌肉(SVG 基础名 → 中文) | +| --- | ---: | --- | --- | +| 胸部 | 2 | 正面 | pectoralis_major → 胸大肌 | +| 背部 | 6 | 正面/背面 | infraspinatus → 冈下肌;latissimus_dorsi → 背阔肌 | +| 斜方肌 | 8 | 正面/背面 | trapezius_lower → 斜方肌(下束);trapezius_middle → 斜方肌(中束);trapezius_upper → 斜方肌(上束) | +| 肩部(三角肌) | 8 | 正面/背面 | anterior_deltoid → 三角肌(前束);lateral_deltoid → 三角肌(中束);posterior_deltoid → 三角肌(后束) | +| 肱二头肌 | 4 | 正面 | biceps_brachii → 肱二头肌 | +| 肱三头肌 | 10 | 正面/背面 | triceps_brachii → 肱三头肌 | +| 前臂 | 24 | 正面/背面 | anconeus → 肘肌;brachioradialis → 肱桡肌;extensor_carpi_radialis_longus → 桡侧腕长伸肌;extensor_carpi_ulnaris → 尺侧腕伸肌;extensor_digitorum → 指总伸肌;flexor_carpi_radialis → 桡侧腕屈肌;flexor_carpi_ulnaris → 尺侧腕屈肌;flexor_digitorum_superficialis → 指浅屈肌;palmaris_longus → 掌长肌;pronator_quadratus → 旋前方肌;pronator_teres → 旋前圆肌 | +| 腹肌/核心 | 25 | 正面/背面 | external_oblique → 腹外斜肌;rectus_abdominis → 腹直肌 | +| 臀部 | 8 | 正面/背面 | gluteus_maximus → 臀大肌;gluteus_medius → 臀中肌 | +| 股四头肌 | 12 | 正面/背面 | iliotibial_tract → 髂胫束(筋膜);rectus_femoris → 股直肌;sartoris → 缝匠肌;vastus_lateralis → 股外侧肌;vastus_medialis → 股内侧肌 | +| 腘绳肌 | 10 | 正面/背面 | biceps_femoris → 股二头肌;semimembranosus → 半膜肌;semitendinosus → 半腱肌 | +| 内收肌 | 8 | 正面/背面 | adductor_longus → 长收肌;adductor_magnus → 大收肌;gracilis → 股薄肌;pectineus → 耻骨肌 | +| 小腿 | 12 | 正面/背面 | extensor_digitorum_longus → 趾长伸肌;extensor_hallucis_longus → 拇长伸肌;fibularis_longus → 腓骨长肌;gastrocnemius → 腓肠肌;tibialis_anterior → 胫骨前肌 | +| 颈部 | 6 | 正面/背面 | platysma → 颈阔肌;sternocleidomastoid → 胸锁乳突肌;sternohyoid → 胸骨舌骨肌 | + +**合计路径块数:** 143(= 143,校验一致) + +## 三、附录:每个健身肌肉群对应的完整 SVG 元素 id(可直接用于热力图填色) + +### 胸部(共 2 块) + +**正面视图(2 块):** + +``` +pectoralis_major_l, pectoralis_major_r +``` + + +### 背部(共 6 块) + +**正面视图(2 块):** + +``` +latissimus_dorsi_l, latissimus_dorsi_r +``` + +**背面视图(4 块):** + +``` +infraspinatus_l, infraspinatus_r, latissimus_dorsi_l, latissimus_dorsi_r +``` + + +### 斜方肌(共 8 块) + +**正面视图(2 块):** + +``` +trapezius_upper_l, trapezius_upper_r +``` + +**背面视图(6 块):** + +``` +trapezius_lower_l, trapezius_lower_r, trapezius_middle_l, trapezius_middle_r, trapezius_upper_l, trapezius_upper_r +``` + + +### 肩部(三角肌)(共 8 块) + +**正面视图(4 块):** + +``` +anterior_deltoid_l, anterior_deltoid_r, lateral_deltoid_l, lateral_deltoid_r +``` + +**背面视图(4 块):** + +``` +lateral_deltoid_l, lateral_deltoid_r, posterior_deltoid_l, posterior_deltoid_r +``` + + +### 肱二头肌(共 4 块) + +**正面视图(4 块):** + +``` +biceps_brachii_caput_breve_l, biceps_brachii_caput_breve_r, biceps_brachii_caput_longum_l, biceps_brachii_caput_longum_r +``` + + +### 肱三头肌(共 10 块) + +**正面视图(4 块):** + +``` +triceps_brachii_caput_laterale_l, triceps_brachii_caput_laterale_r, triceps_brachii_caput_longum_l, triceps_brachii_caput_longum_r +``` + +**背面视图(6 块):** + +``` +triceps_brachii_caput_laterale_l, triceps_brachii_caput_laterale_r, triceps_brachii_caput_longum_l, triceps_brachii_caput_longum_r, triceps_brachii_caput_mediale_l, triceps_brachii_caput_mediale_r +``` + + +### 前臂(共 24 块) + +**正面视图(14 块):** + +``` +brachioradialis_l, brachioradialis_r, extensor_carpi_radialis_longus_l, extensor_carpi_radialis_longus_r, flexor_carpi_radialis_l, flexor_carpi_radialis_r, flexor_digitorum_superficialis_l, flexor_digitorum_superficialis_r, palmaris_longus_l, palmaris_longus_r, pronator_quadratus_l, pronator_quadratus_r, pronator_teres_l, pronator_teres_r +``` + +**背面视图(10 块):** + +``` +anconeus_l, anconeus_r, brachioradialis_l, brachioradialis_r, extensor_carpi_ulnaris_l, extensor_carpi_ulnaris_r, extensor_digitorum_l, extensor_digitorum_r, flexor_carpi_ulnaris_l, flexor_carpi_ulnaris_r +``` + + +### 腹肌/核心(共 25 块) + +**正面视图(23 块):** + +``` +external_oblique_1_l, external_oblique_1_r, external_oblique_2_l, external_oblique_2_r, external_oblique_3_l, external_oblique_3_r, external_oblique_4_l, external_oblique_4_r, external_oblique_5_l, external_oblique_5_r, external_oblique_6_l, external_oblique_6_r, external_oblique_7_l, external_oblique_7_r, external_oblique_8_l, external_oblique_8_r, rectus_abdominis_1, rectus_abdominis_2_l, rectus_abdominis_2_r, rectus_abdominis_3_l, rectus_abdominis_3_r, rectus_abdominis_4_l, rectus_abdominis_4_r +``` + +**背面视图(2 块):** + +``` +external_oblique_1_l, external_oblique_1_r +``` + + +### 臀部(共 8 块) + +**正面视图(2 块):** + +``` +gluteus_medius_2_l, gluteus_medius_2_r +``` + +**背面视图(6 块):** + +``` +gluteus_maximus_l, gluteus_maximus_r, gluteus_medius_1_l, gluteus_medius_1_r, gluteus_medius_2_l, gluteus_medius_2_r +``` + + +### 股四头肌(共 12 块) + +**正面视图(10 块):** + +``` +iliotibial_tract_l, iliotibial_tract_r, rectus_femoris_l, rectus_femoris_r, sartoris_l, sartoris_r, vastus_lateralis_l, vastus_lateralis_r, vastus_medialis_l, vastus_medialis_r +``` + +**背面视图(2 块):** + +``` +iliotibial_tract_l, iliotibial_tract_r +``` + + +### 腘绳肌(共 10 块) + +**正面视图(2 块):** + +``` +semitendinosus_l, semitendinosus_r +``` + +**背面视图(8 块):** + +``` +biceps_femoris_l, biceps_femoris_r, semimembranosus_1_l, semimembranosus_1_r, semimembranosus_2_l, semimembranosus_2_r, semitendinosus_l, semitendinosus_r +``` + + +### 内收肌(共 8 块) + +**正面视图(6 块):** + +``` +adductor_longus_l, adductor_longus_r, gracilis_l, gracilis_r, pectineus_l, pectineus_r +``` + +**背面视图(2 块):** + +``` +adductor_magnus_l, adductor_magnus_r +``` + + +### 小腿(共 12 块) + +**正面视图(10 块):** + +``` +extensor_digitorum_longus_l, extensor_digitorum_longus_r, extensor_hallucis_longus_l, extensor_hallucis_longus_r, fibularis_longus_l, fibularis_longus_r, gastrocnemius_l, gastrocnemius_r, tibialis_anterior_l, tibialis_anterior_r +``` + +**背面视图(2 块):** + +``` +gastrocnemius_l, gastrocnemius_r +``` + + +### 颈部(共 6 块) + +**正面视图(4 块):** + +``` +platysma, sternocleidomastoid_l, sternocleidomastoid_r, sternohyoid +``` + +**背面视图(2 块):** + +``` +sternocleidomastoid_l, sternocleidomastoid_r +``` + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d73ce79 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3062 @@ +{ + "name": "workout-plugin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workout-plugin", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "obsidian": "^1.5.0", + "papaparse": "^5.4.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/papaparse": "^5.3.14", + "@vitest/coverage-v8": "^1.0.0", + "esbuild": "^0.20.0", + "jsdom": "^29.1.1", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "license": "MIT", + "peer": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "license": "MIT", + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "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-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "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/get-func-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.29.4", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "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/npm-run-path": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/papaparse": { + "version": "5.5.4", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.16", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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/react-is": { + "version": "18.3.1", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "license": "MIT", + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "license": "MIT", + "peer": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a7d2701 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "workout-block", + "version": "1.0.0", + "description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs --production", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "keywords": [ + "obsidian", + "workout", + "fitness" + ], + "author": "", + "license": "MIT", + "dependencies": { + "obsidian": "^1.5.0", + "papaparse": "^5.4.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/papaparse": "^5.3.14", + "@vitest/coverage-v8": "^1.0.0", + "esbuild": "^0.20.0", + "jsdom": "^29.1.1", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} \ No newline at end of file diff --git a/src/assets/muscle_layer_back.svg b/src/assets/muscle_layer_back.svg new file mode 100644 index 0000000..cf8cf9e --- /dev/null +++ b/src/assets/muscle_layer_back.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/muscle_layer_front.svg b/src/assets/muscle_layer_front.svg new file mode 100644 index 0000000..646cb15 --- /dev/null +++ b/src/assets/muscle_layer_front.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/codeBlockDefs.ts b/src/codeBlockDefs.ts new file mode 100644 index 0000000..f8d6679 --- /dev/null +++ b/src/codeBlockDefs.ts @@ -0,0 +1,92 @@ +/* + * codeBlockDefs.ts —— 快捷插入代码块的元数据与文本生成 + * 这是「插入代码块」功能的单一数据源:主弹窗(卡片列表)和参数弹窗(表单) + * 都从这里读取定义,后续新增代码块只需在此追加一条,无需改弹窗逻辑。 + * + * 重要:params[].key 必须使用各代码块解析器实际接受的「下划线风格」命名 + * (见 src/codeblock/workoutLog.ts 等 parseParams 的 switch case), + * 例如 workout-log 接受的是 group_by / show_add 而非 groupBy / showAdd, + * 否则生成的参数不会被解析器识别而失效。 + */ + +export type ParamType = 'text' | 'number' | 'select'; + +export interface CodeBlockParamDef { + key: string; // 写入代码块正文、解析器可识别的参数名(下划线风格) + label: string; // 参数中文标签 + desc?: string; // 参数说明(显示在标签下方) + type: ParamType; // 控件类型 + required?: boolean; // 是否必填(本功能统一非必填,保留字段以备扩展) + placeholder?: string; // 输入框占位提示 + options?: string[]; // type=select 时的静态选项(值) + optionLabels?: Record;// select 选项值 → 中文展示 + dynamic?: 'exercise' | 'plan' | 'metric'; // select 选项来自 DataManager 配置(运行时填充) +} + +export interface CodeBlockDef { + id: string; // 代码块类型名(即 ``` 后的语言标识) + icon: string; // Obsidian 图标名(用 setIcon 渲染) + title: string; // 中文名称 + desc: string; // 功能说明 + params: CodeBlockParamDef[]; +} + +export const CODE_BLOCK_DEFS: CodeBlockDef[] = [ + { + id: 'workout-log', + icon: 'table', + title: '训练记录表', + desc: '按训练项/时间筛选,展示历史训练记录明细表格', + params: [ + { key: 'exercise', label: '训练项', type: 'text', placeholder: '如:卧推(留空显示全部)' }, + { key: 'day', label: '最近天数', type: 'number', placeholder: '如 30,只显示最近 N 天' }, + { key: 'group_by', label: '分组方式', type: 'select', options: ['date', 'week'], optionLabels: { date: '按日期', week: '按周' } }, + { key: 'sort', label: '排序', type: 'select', options: ['desc', 'asc'], optionLabels: { desc: '最新在前', asc: '最早在前' } }, + { key: 'number', label: '最近条数', type: 'number', placeholder: '只显示最新 N 条' }, + { key: 'limit', label: '最多渲染', type: 'number', placeholder: '最多渲染 N 条(默认 50)' }, + { key: 'show_add', label: '显示添加按钮', type: 'select', options: ['true', 'false'], optionLabels: { true: '显示', false: '隐藏' } }, + ], + }, + { + id: 'workout-day', + icon: 'calendar', + title: '当日训练', + desc: '展示指定某一天的训练总览(不写则显示今天,随真实日期滚动)', + params: [ + { key: 'day', label: '日期', type: 'text', placeholder: 'today 或 2026-07-17(留空=今天)' }, + ], + }, + { + id: 'workout-heatmap', + icon: 'flame', + title: '肌肉热力图', + desc: '用颜色深浅展示各肌肉的训练强度分布', + params: [ + { key: 'metric', label: '统计指标', type: 'select', dynamic: 'metric', placeholder: '留空用默认指标' }, + { key: 'range', label: '时间范围', type: 'text', placeholder: '30 / all / 2026-01-01..2026-12-31' }, + ], + }, + { + id: 'workout-plan', + icon: 'clipboard-list', + title: '训练计划面板', + desc: '展示某个训练计划的完成进度与每组打卡', + params: [ + { key: 'plan', label: '计划名称', type: 'select', dynamic: 'plan' }, + ], + }, +]; + +// 根据定义与用户填写的值,生成最终插入的代码块纯文本。 +// 值为空(或纯空白)的参数会被跳过,从而落到代码块的默认值逻辑上。 +export function buildCodeBlock(def: CodeBlockDef, values: Record): string { + const lines = [def.id]; + for (const p of def.params) { + const v = values[p.key]; + if (v === undefined || v === null) continue; + const trimmed = v.trim(); + if (trimmed === '') continue; + lines.push(`${p.key}: ${trimmed}`); + } + return '```' + lines.join('\n') + '\n```\n'; +} diff --git a/src/codeblock/registry.ts b/src/codeblock/registry.ts new file mode 100644 index 0000000..084cc0d --- /dev/null +++ b/src/codeblock/registry.ts @@ -0,0 +1,185 @@ +import { App, MarkdownPostProcessorContext } from 'obsidian'; +import { WorkoutConfig } from '../data/types'; +import { resolveExerciseIdByName } from '../data/display'; + +/* + * registry.ts —— 代码块(Code Block)渲染注册表 + * 背景:在 Obsidian 笔记里用 ```workout-log 这样的「围栏代码块」, + * 插件会把它渲染成表格。Obsidian 管这种渲染叫 MarkdownPostProcessor。 + * 本文件用两个全局容器管理: + * 1) registry:类型名(如 'workout-log') → 渲染函数 的映射,相当于「登记每种代码块怎么画」。 + * 2) renderedBlocks:记录笔记里「所有已渲染出来的代码块」,方便数据变化时统一重画。 + */ + +// 渲染函数类型:接收代码块原文 source、要往里塞内容的 DOM 容器 el、以及上下文 ctx +export type CodeBlockHandler = (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void; + +// 类型 → 渲染函数 的注册表(Map 像字典,key 是代码块类型名) +const registry = new Map(); +// 持有 Obsidian App 引用,用于重渲染后把焦点还给编辑器(修复「代码块按钮被重渲染移除导致光标丢失」)。 +// 由 main.ts 在 onload 时注入。 +let appRef: App | null = null; +export function setRegistryApp(app: App): void { + appRef = app; +} +// 已渲染代码块清单:每个元素记下了它的 DOM 容器、类型、原文、上下文,便于之后整体重渲染 +const renderedBlocks: Array<{ el: HTMLElement; type: string; source: string; ctx: MarkdownPostProcessorContext }> = []; + +// 注册一种代码块类型及其渲染函数(插件启动时调用,例如把 'workout-log' 关联到对应渲染器) +export function registerCodeBlock(type: string, handler: CodeBlockHandler): void { + registry.set(type, handler); +} + +// 根据类型名取出对应的渲染函数;没有就返回 undefined +export function getCodeBlockHandler(type: string): CodeBlockHandler | undefined { + return registry.get(type); +} + +// 返回所有已注册的类型名列表(例如 ['workout-log']),用于初始化时告诉 Obsidian 要接管哪些代码块 +export function getAllRegisteredTypes(): string[] { + return Array.from(registry.keys()); +} + +// 登记一个「已经渲染完」的代码块,把它记进 renderedBlocks,供后续重渲染使用 +// 注意:同一个 el 只允许存在一条记录,避免数据变化时重复渲染同一个 DOM。 +export function registerRenderedBlock(el: HTMLElement, type: string, source: string, ctx: MarkdownPostProcessorContext): void { + // 如果该 el 之前已经登记过,先移除旧条目,再 push 新条目(更新 source/ctx)。 + unregisterRenderedBlock(el); + renderedBlocks.push({ el, type, source, ctx }); +} + +// 从清单里移除某个已渲染块(DOM 被销毁时调用,避免残留导致重复渲染) +export function unregisterRenderedBlock(el: HTMLElement): void { + const index = renderedBlocks.findIndex((block) => block.el === el); + if (index !== -1) { + renderedBlocks.splice(index, 1); + } +} + +// 重渲染防抖:把短时间内的多次重渲染请求合并成一次,避免数据变化/语言切换时反复刷新。 +// 关键点:用「待执行过滤器列表」累积多个请求,而非单一 shouldRender。 +// 否则连续调用 rerenderBlocksByType('workout-day') 和 rerenderBlocksByType('workout-heatmap') +// 时,第二次会 clearTimeout 掉第一次的定时器,导致只有后者真正执行,前者被静默吞掉。 +let rerenderTimer: number | null = null; +const RERENDER_DEBOUNCE_MS = 50; +const pendingRerenderFilters: Array<(source: string, type: string) => boolean> = []; + +// 调度一次重渲染:shouldRender(source, type) 决定哪些代码块需要被重画。 +// 多次调用会被合并:防抖窗口内任一过滤器命中即重渲染该代码块。 +// 先复制 renderedBlocks 快照再遍历,防止 handler 内部修改数组导致遍历异常或漏渲染。 +function scheduleRerender(shouldRender: (source: string, type: string) => boolean): void { + pendingRerenderFilters.push(shouldRender); + if (rerenderTimer !== null) { + // 已有待执行定时器:仅累积过滤器,不重置定时器,保证先前请求不会被吞。 + return; + } + rerenderTimer = window.setTimeout(() => { + const filters = pendingRerenderFilters.slice(); + pendingRerenderFilters.length = 0; + rerenderTimer = null; + const blocks = [...renderedBlocks]; + // 焦点修复(机制 A):重渲染会 block.el.empty() 物理移除代码块内所有子节点, + // 若用户刚点的删除/完成按钮正持有焦点,焦点会被带回 document.body, + // 导致 Live Preview 下的 Obsidian 编辑器丢失光标、无法输入。 + // 故先标记「是否有待重渲染的块当前持有焦点」,重渲染后统一把焦点还给编辑器。 + // —— 光标修复(机制 A,稳健版)—— + // 现象复盘:上一版只在循环末尾调一次 ed.focus(),但 Obsidian 在代码块 DOM 变动后 + // 会「异步」再重渲染一次 markdown,把刚还回的焦点再次夺走,光标又丢 + // (用户只能靠切窗再切回、由 OS 窗口焦点事件在稳定后恢复)。本版三重保险: + // ① 预判抢先:若焦点在「即将被 empty() 移除的按钮」上,先把焦点交给编辑器本体, + // 按钮被移除时焦点已在编辑器上,不再掉回 document.body(从源头消除「被夺」)。 + // ② 容错:每个 handler 用 try/catch 包住,单块渲染异常不得中断焦点恢复。 + // ③ 兜底:下一绘制帧(rAF)与短暂延时后再 focus 一次——与「切窗再切回」机制同源, + // 且只在焦点仍「空」(body/null)时才抢回,不打扰用户已主动转移的焦点。 + // 注意:只在该块「确实会被重渲染」时才判定/抢先,避免误夺走别的代码块里 + // 用户仍在用的按钮焦点。 + let focusOwnerWillRerender = false; + for (const block of blocks) { + if (block.el.contains(document.activeElement)) { + if (filters.some((f) => f(block.source, block.type))) { + focusOwnerWillRerender = true; + break; + } + } + } + + // ① 抢先把焦点从按钮移到编辑器本体(仅当该块确会被重渲染) + if (focusOwnerWillRerender && appRef) { + const ed = appRef.workspace.activeEditor?.editor; + if (ed) ed.focus(); + } + + for (const block of blocks) { + // 安全检查:代码块所在视图可能已在 50ms 防抖窗口内被卸载(笔记切换/重载)。 + // 此时 el 已脱离文档,若仍去 empty()/重渲染会操作已销毁的 DOM,破坏 Obsidian + // 内部(CodeMirror)状态,引发 RangeError 与卡死。故跳过并清理该登记。 + if (!block.el.isConnected) { + unregisterRenderedBlock(block.el); + continue; + } + // 任一过滤器命中即重渲染(多请求合并语义) + const should = filters.some((f) => f(block.source, block.type)); + if (should) { + const handler = registry.get(block.type); + if (handler) { + try { + block.el.empty(); + handler(block.source, block.el, block.ctx); + } catch (e) { + console.error('[workout] 代码块重渲染失败,已跳过该块以避免中断焦点恢复:', e); + } + } + } + } + + // ③ 兜底:对抗 Obsidian 异步二次夺焦(与 OS 窗口焦点恢复同源) + if (focusOwnerWillRerender && appRef) { + const restore = () => { + const ae = document.activeElement; + // 只在焦点仍「空」(body/null) 时才抢回,不打扰用户已主动转移的焦点 + if (ae === document.body || ae === null) { + const e2 = appRef?.workspace.activeEditor?.editor; + if (e2) e2.focus(); + } + }; + requestAnimationFrame(restore); + window.setTimeout(restore, 80); + } + }, RERENDER_DEBOUNCE_MS); +} + +// 重渲染「所有」已渲染的代码块:用于语言切换、外部改文件等需要全局刷新的场景。 +export function rerenderAllBlocks(): void { + scheduleRerender(() => true); +} + +// 只重渲染「显示指定训练项」的代码块:添加/编辑/删除一条记录时调用, +// 避免无关表格也被重画(记录越多、代码块越多,全量重渲染越卡)。 +// 通过把代码块里的 `exercise:` 参数解析成稳定的 exerciseId 做匹配; +// 无 config 或无法解析时,退化为按名字模糊匹配。 +export function rerenderBlocksForExercise(config: WorkoutConfig | null, exerciseId: string | undefined, exerciseName: string): void { + const name = (exerciseName || '').toLowerCase(); + scheduleRerender((source) => { + const m = source.match(/^\s*exercise:\s*(.+)$/m); + if (!m) return true; // 没有 exercise 参数,无法判断,保守重画 + const q = m[1].trim(); + if (!q) return true; + + // 优先用稳定的 exerciseId 匹配(跨语言可靠)。 + if (config && exerciseId) { + const sourceExerciseId = resolveExerciseIdByName(config, q); + if (sourceExerciseId) { + return sourceExerciseId === exerciseId; + } + } + + // 兜底:按当前语言显示名做模糊匹配(兼容旧数据/自定义训练项)。 + const lowerQ = q.toLowerCase(); + return lowerQ === name || name.includes(lowerQ) || lowerQ.includes(name); + }); +} + +// 只重渲染指定类型的代码块(保留以兼容旧调用,已并入 scheduleRerender)。 +export function rerenderBlocksByType(type: string): void { + scheduleRerender((_source, blockType) => blockType === type); +} \ No newline at end of file diff --git a/src/codeblock/workoutDay.ts b/src/codeblock/workoutDay.ts new file mode 100644 index 0000000..143bea7 --- /dev/null +++ b/src/codeblock/workoutDay.ts @@ -0,0 +1,244 @@ +import { App, Component, MarkdownPostProcessorContext, MarkdownRenderChild, Notice, TFile } from 'obsidian'; +import { LogRow, WorkoutConfig } from '../data/types'; +import { t } from '../i18n'; +import { computeStat, formatStatValue } from '../data/statExpr'; +import { getExerciseName, getMuscleName, resolveLogExerciseName } from '../data/display'; +import { registerRenderedBlock, unregisterRenderedBlock } from './registry'; + +/* + * workoutDay.ts —— 把 ```workout-day 代码块渲染成「当日训练总览表」。 + * + * 用户在笔记里写: + * ```workout-day + * day: 2026-07-12 + * ``` + * 或 day: today(显示当日,活数据)/ 不写 day(同样显示当日,但多出一个「固定为当日」按钮)。 + * + * 表格列(5 列): + * 项目 —— 该日训练到的每个训练项(按训练先后排序) + * 数据统计值 —— 该训练项所属类型下、已启用的数据统计条目(可多条,合并一格) + * 主肌群 —— 该训练项配置的 primary 肌肉 + * 辅助肌群 —— 该训练项配置的 secondary 肌肉 + * 训练方案 —— 该训练项当日记录的 plan 字段(去重后合并展示) + * + * 「固定为当日」按钮:仅在「没有写 day 参数」时出现。点击后把当天的日期 + * 写进代码块的 day 参数(直接改笔记源码),按钮随即消失,表格固定为该日。 + * 注意:当 day=today 时,虽然显示的是当日数据,但因为 day 参数已存在,按钮同样不显示。 + */ + +// 代码块支持的参数 +interface WorkoutDayParams { + // day 的实际取值:'today' 或 'YYYY-MM-DD' 字面日期;缺省时 undefined + dayValue?: string; + // 是否显式写了 day 参数(写了 today 也算 true) + hasDayParam: boolean; +} + +// 解析代码块正文:找出 day 参数及其取值,并记录"是否显式写了 day"。 +function parseParams(source: string): WorkoutDayParams { + const params: WorkoutDayParams = { hasDayParam: false }; + for (const line of source.split('\n')) { + const [key, value] = line.split(':').map((s) => s.trim()); + if (!key || value === undefined) continue; + if (key === 'day') { + params.hasDayParam = true; + params.dayValue = value || undefined; + } + } + return params; +} + +// 把 Date 格式化为本地 'YYYY-MM-DD'(与 CSV 里 timestamp 的日期段同格式,便于直接比对)。 +function formatLocalDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// 校验 'YYYY-MM-DD' 字面日期格式是否合法(仅做格式校验,不校验真实日历)。 +function isValidDateStr(s: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +// 解析目标日期:返回 { target, live } +// - target:用于过滤日志的日期字符串(同 CSV 日期段格式) +// - live:true 表示"随真实日期滚动"(无 day 参数 / day=today),重渲染时会重新取今天 +function resolveTargetDate(params: WorkoutDayParams): { target: string; live: boolean } { + if (!params.hasDayParam) { + return { target: formatLocalDate(new Date()), live: true }; + } + if (params.dayValue === 'today') { + return { target: formatLocalDate(new Date()), live: true }; + } + // 字面日期:无论格式是否合法,都作为 target 用于比对(非法格式下不会命中任何记录) + return { target: params.dayValue ?? '', live: false }; +} + +// 用于追踪已经为哪些 el 注册过 Obsidian Component,防止重渲染时重复 ctx.addChild。 +const registeredComponents = new WeakMap(); + +// 主渲染函数:把代码块渲染成「当日训练总览表」。 +export async function renderWorkoutDay( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + app: App, + logs: LogRow[], + config: WorkoutConfig +): Promise { + // 首次渲染时:注册 Obsidian 子组件,在代码块所在视图卸载时自动从 registry 移除。 + if (!registeredComponents.has(el)) { + const child = new MarkdownRenderChild(el); + child.onunload = () => { + unregisterRenderedBlock(el); + registeredComponents.delete(el); + }; + ctx.addChild(child); + registeredComponents.set(el, true); + registerRenderedBlock(el, 'workout-day', source, ctx); + } + + const params = parseParams(source); + const { target } = resolveTargetDate(params); + + // 点击「固定为当日」:把今天的日期写进代码块源码的 day 参数,使表格固定为该日。 + async function pinDayToToday(): Promise { + const file = ctx.sourcePath ? app.vault.getAbstractFileByPath(ctx.sourcePath) : null; + if (!file || !(file instanceof TFile)) { + new Notice(t('codeblock.day.pinFailed')); + return; + } + const info = ctx.getSectionInfo(el); + if (!info) { + new Notice(t('codeblock.day.pinFailed')); + return; + } + const today = formatLocalDate(new Date()); + const content = await app.vault.read(file); + const lines = content.split('\n'); + let found = false; + // 在代码块体内(开围栏之后、闭围栏之前)查找是否已有 day: 行 + for (let i = info.lineStart + 1; i < info.lineEnd; i++) { + if (/^\s*day\s*:/.test(lines[i])) { + lines[i] = `day: ${today}`; + found = true; + break; + } + } + // 没有就插在闭围栏之前 + if (!found) { + lines.splice(info.lineEnd, 0, `day: ${today}`); + } + await app.vault.modify(file, lines.join('\n')); + } + + // 过滤出目标日期当天的全部记录(timestamp 形如 "2026-07-12 18:30",取空格前的日期段比对)。 + // 防御:timestamp 缺失的记录直接忽略,避免脏数据导致 split(undefined) 崩溃。 + const dayLogs = logs.filter((log) => log.timestamp && log.timestamp.split(' ')[0] === target); + + // 容器与头部 + const container = el.createDiv(); + container.addClass('workout-day-container'); + + const header = container.createDiv(); + header.addClass('workout-day-header'); + header.createSpan({ text: t('codeblock.day.viewing', { date: target }), cls: 'workout-day-date' }); + // 「固定为当日」按钮:仅当「没写 day 参数」时显示(day=today 视为已固定,不显示)。 + if (!params.hasDayParam) { + const pinBtn = header.createEl('button', { text: t('codeblock.day.pinToday') }); + pinBtn.addClass('mod-cta'); + pinBtn.addEventListener('click', () => { + void pinDayToToday(); + }); + } + + // 空状态:该日没有任何训练记录 + if (dayLogs.length === 0) { + container.createDiv({ text: t('codeblock.day.empty', { date: target }), cls: 'workout-empty' }); + return; + } + + // 按训练项(exercise)聚合:以 exerciseId 为稳定 key,无 id 的旧数据回退到显示名。 + interface DayGroup { + key: string; + exerciseId?: string; + category: string; + logs: LogRow[]; + firstTs: string; + } + const groups = new Map(); + for (const log of dayLogs) { + const nameFallback = resolveLogExerciseName(config, log); + const key = log.exerciseId || nameFallback || '(unknown)'; + let group = groups.get(key); + if (!group) { + group = { key, exerciseId: log.exerciseId, category: log.category, logs: [], firstTs: log.timestamp || '' }; + groups.set(key, group); + } + group.logs.push(log); + if (log.timestamp && log.timestamp < group.firstTs) group.firstTs = log.timestamp; + } + + // 按当日首次训练时间排序(训练先后),更符合"今天练了啥"的阅读顺序。 + const sortedGroups = Array.from(groups.values()).sort((a, b) => a.firstTs.localeCompare(b.firstTs)); + + // 画表格 + const table = container.createEl('table'); + table.addClass('workout-day-table'); + + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + headerRow.createEl('th', { text: t('codeblock.day.project') }); + headerRow.createEl('th', { text: t('codeblock.day.statValue') }); + headerRow.createEl('th', { text: t('codeblock.day.primaryMuscles') }); + headerRow.createEl('th', { text: t('codeblock.day.secondaryMuscles') }); + headerRow.createEl('th', { text: t('codeblock.day.plan') }); + + const tbody = table.createEl('tbody'); + for (const group of sortedGroups) { + const row = tbody.createEl('tr'); + + // 1) 项目名:优先用配置里的训练项对象拿显示名,否则回退到日志解析名 + const exercise = group.exerciseId ? config.exercises.find((e) => e.id === group.exerciseId) : undefined; + const name = exercise + ? getExerciseName(exercise) + : (resolveLogExerciseName(config, group.logs[0]) || t('codeblock.day.unknown')); + row.createEl('td', { text: name }); + + // 2) 数据统计值:该训练项所属类型下、已启用的统计条目(多条合并一格)。 + const matchedStats = (config.statistics ?? []).filter( + (s) => s.enabled && s.associatedTypes.includes(group.category) + ); + const statText = matchedStats.length + ? matchedStats.map((s) => `${s.name}: ${formatStatValue(computeStat(s, group.logs))}`).join(' | ') + : '—'; + row.createEl('td', { text: statText }); + + // 3) 主肌群 / 4) 辅助肌群:取自训练项配置的 muscles(primary / secondary) + row.createEl('td', { text: muscleNames(config, exercise, 'primary') }); + row.createEl('td', { text: muscleNames(config, exercise, 'secondary') }); + + // 5) 训练方案:该训练项当日记录的 plan 字段(去重合并;无则显示占位) + const plans = Array.from( + new Set(group.logs.map((l) => l.plan).filter((p): p is string => typeof p === 'string' && p.length > 0)) + ); + row.createEl('td', { text: plans.length ? plans.join('、') : '—' }); + } +} + +// 取出某训练项在指定角色(primary/secondary)下配置的肌肉显示名,用"、"连接;无则 "—"。 +function muscleNames( + config: WorkoutConfig, + exercise: { muscles?: { muscleId: string; role: 'primary' | 'secondary' }[] } | undefined, + role: 'primary' | 'secondary' +): string { + if (!exercise?.muscles || exercise.muscles.length === 0) return '—'; + const names = exercise.muscles + .filter((m) => m.role === role) + .map((m) => { + const muscle = config.muscles.find((mm) => mm.id === m.muscleId); + return muscle ? getMuscleName(muscle) : m.muscleId; + }); + return names.length ? names.join('、') : '—'; +} diff --git a/src/codeblock/workoutHeatmap.ts b/src/codeblock/workoutHeatmap.ts new file mode 100644 index 0000000..a7cffd4 --- /dev/null +++ b/src/codeblock/workoutHeatmap.ts @@ -0,0 +1,437 @@ +import { Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian'; +import { HeatmapLevel, LogRow, Muscle, StatDef, WorkoutConfig } from '../data/types'; +import { computeStat } from '../data/statExpr'; +import { + formatSvgMuscleLabel, + HIDDEN_SVG_GROUP_IDS, + SvgMuscleEntry, + SVG_MUSCLE_CATALOG, +} from '../data/svgMuscleCatalog'; +import { registerRenderedBlock, unregisterRenderedBlock } from './registry'; +import { getLocale, t } from '../i18n'; +import frontSvg from '../assets/muscle_layer_front.svg'; +import backSvg from '../assets/muscle_layer_back.svg'; + +const DEFAULT_HEATMAP_SCALE: HeatmapLevel[] = [ + { color: '#3b82f6', max: 5 }, + { color: '#22c55e', max: 10 }, + { color: '#f97316', max: 20 }, + { color: '#ef4444', max: 40 }, +]; + +/* workoutHeatmap.ts —— 把 ```workout-heatmap 代码块渲染成全身肌肉热力图。 + * 精细度自动跟随肌肉管理里的 svgRegionIds 配置;支持正/背切换、指标与时间窗参数。 */ + +interface HeatmapParams { + metric?: string; + range?: string; +} + +function parseParams(source: string): HeatmapParams { + const params: HeatmapParams = {}; + for (const line of source.split('\n')) { + const [key, value] = line.split(':').map((s) => s.trim()); + if (!key || value === undefined) continue; + if (key === 'metric') params.metric = value; + if (key === 'range') params.range = value; + } + return params; +} + +function todayStr(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function dateWithinRange(dateStr: string, range?: string): boolean { + if (!range) return true; + if (range === 'all') return true; + if (RANGE_OPTIONS.includes(range)) { + const days = parseInt(range); + const today = new Date(`${todayStr()}T00:00:00`); + const cutoff = new Date(today); + cutoff.setDate(cutoff.getDate() - (days - 1)); + const d = new Date(`${dateStr}T00:00:00`); + return d >= cutoff && d <= today; + } + if (range.includes('..')) { + const [start, end] = range.split('..').map((s) => s.trim()); + return dateStr >= start && dateStr <= end; + } + return true; +} + +const RANGE_OPTIONS = ['7d', '30d', '90d']; + +// 静态 SVG 解析缓存:frontSvg / backSvg 是固定字符串(约 320KB),每次重渲染都 innerHTML 解析 +// 会在主线程做昂贵的 HTML 词法分析。改为「模块内只解析一次、之后 cloneNode 复用」,重渲染代价 +// 从「解析 640KB」降为「克隆已解析的 DOM」,对删除记录等高频数据变化意义极大(否则删除即卡顿)。 +// 缓存的是 元素本身,cloneNode 后直接挂到容器下,渲染出的 DOM 结构与原来一致。 +let parsedFrontSvg: SVGElement | null = null; +let parsedBackSvg: SVGElement | null = null; +function getMuscleSvg(side: 'front' | 'back'): SVGElement { + if (side === 'front') { + if (!parsedFrontSvg) { + const holder = document.createElement('div'); + holder.innerHTML = frontSvg; + parsedFrontSvg = holder.firstElementChild as SVGElement; + } + return parsedFrontSvg; + } + if (!parsedBackSvg) { + const holder = document.createElement('div'); + holder.innerHTML = backSvg; + parsedBackSvg = holder.firstElementChild as SVGElement; + } + return parsedBackSvg; +} + +// —— 肌肉悬停提示(自定义黑框气泡,即时显示)—— +// 页面级单例气泡 + 事件委托到容器:进入肌肉即显示、跟随鼠标、移出即隐藏,零延迟。 +// 背景硬编码深色(不依赖主题变量),避免某些主题下渲染成白框;同时不注入 SVG 原生 +// (Windows 下原生提示是白底系统框,会与黑框重复),由本气泡统一承担。 +let heatmapTooltipEl: HTMLElement | null = null; +function ensureHeatmapTooltip(): HTMLElement { + if (heatmapTooltipEl && document.body.contains(heatmapTooltipEl)) return heatmapTooltipEl; + const tip = document.createElement('div'); + tip.id = 'workout-heatmap-tooltip'; + tip.addClass('workout-heatmap-tooltip'); + tip.style.display = 'none'; + document.body.appendChild(tip); + heatmapTooltipEl = tip; + return tip; +} + +function bindMuscleTooltips(wrap: HTMLElement, side: 'front' | 'back'): void { + const tip = ensureHeatmapTooltip(); + + const entryFor = (target: EventTarget | null): SvgMuscleEntry | null => { + let el: Element | null = target as Element | null; + while (el && el !== wrap) { + const id = el.getAttribute('id'); + if (id) { + const entry = SVG_MUSCLE_CATALOG.find((e) => e.id === id && e.side === side); + if (entry) return entry; + } + el = el.parentElement; + } + return null; + }; + + const locate = (me: MouseEvent) => { + tip.style.left = `${me.clientX + 12}px`; + tip.style.top = `${me.clientY + 12}px`; + }; + + // 进入肌肉即时显示(无延迟);在肌肉间移动直接换名;移出隐藏。 + wrap.addEventListener('mouseover', (ev: Event) => { + const entry = entryFor(ev.target); + if (!entry) return; + const me = ev as MouseEvent; + tip.textContent = formatSvgMuscleLabel(entry, getLocale()); + tip.style.display = 'block'; + locate(me); + }); + wrap.addEventListener('mousemove', (ev: Event) => { + if (tip.style.display === 'block') locate(ev as MouseEvent); + }); + wrap.addEventListener('mouseleave', () => { + tip.style.display = 'none'; + }); +} + +function resolveDefaultMetric(config: WorkoutConfig): StatDef | undefined { + return config.statistics.find((s) => s.heatmapDefault) ?? config.statistics.find((s) => s.id === 'count'); +} + +function resolveMetric(config: WorkoutConfig, nameOrId?: string): StatDef | undefined { + if (!nameOrId) return resolveDefaultMetric(config); + const lower = nameOrId.toLowerCase(); + return config.statistics.find((s) => s.id.toLowerCase() === lower || s.name.toLowerCase() === lower); +} + +function resolveRange(muscle: Muscle, codeRange?: string): string { + if (muscle.heatmapRange) return muscle.heatmapRange; + if (codeRange) return codeRange; + return '7d'; +} + +function resolveMetricId(config: WorkoutConfig, muscle: Muscle, codeMetric?: string): StatDef | undefined { + if (muscle.heatmapMetric) return resolveMetric(config, muscle.heatmapMetric); + if (codeMetric) return resolveMetric(config, codeMetric); + return resolveDefaultMetric(config); +} + +function roleWeight(role: 'primary' | 'secondary'): number { + return role === 'primary' ? 1.0 : 0.5; +} + +function computeMuscleValue( + muscle: Muscle, + stat: StatDef, + range: string, + logs: LogRow[], + exerciseMuscleMap: Map +): number { + let total = 0; + for (const log of logs) { + if (!log.timestamp || !log.exerciseId || !dateWithinRange(log.timestamp.split(' ')[0], range)) continue; + // 用预构建的 map 做 O(1) 查找,代替原先 config.exercises.find(每次 O(exercises)), + // 把整体复杂度从 O(muscles × logs × exercises) 降到 O(muscles × logs)。 + const em = exerciseMuscleMap.get(log.exerciseId); + if (!em) continue; + const hit = em.find((m) => m.muscleId === muscle.id); + if (!hit) continue; + const instanceValue = computeStat(stat, [log]); + if (!Number.isFinite(instanceValue)) continue; + total += instanceValue * roleWeight(hit.role); + } + return Math.round(total * 100) / 100; +} + +function colorForValue(value: number, scale?: HeatmapLevel[]): string { + const levels = scale && scale.length > 0 ? scale : DEFAULT_HEATMAP_SCALE; + // 按阈值升序:值 ≤ 某档阈值即取该色;超过所有阈值则取最高档(最重)。 + const sorted = [...levels].sort((a, b) => (a.max ?? Infinity) - (b.max ?? Infinity)); + for (const level of sorted) { + if (value <= (level.max ?? Infinity)) return level.color; + } + return sorted[sorted.length - 1]?.color ?? '#ef4444'; +} + +function colorToCss(color: string): string { + const map: Record = { + blue: '#3b82f6', + green: '#22c55e', + orange: '#f97316', + red: '#ef4444', + }; + if (color.startsWith('#')) return color; // 十六进制直接返回(自定义颜色) + return map[color] ?? color; +} + +const registeredComponents = new WeakMap(); +// 记录每个热力图代码块对应的 IntersectionObserver,便于重渲染/卸载时断开,避免泄漏与重复计算。 +const heatmapObservers = new WeakMap(); + +export async function renderWorkoutHeatmap( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + logs: LogRow[], + config: WorkoutConfig +): Promise { + if (!registeredComponents.has(el)) { + const child = new MarkdownRenderChild(el); + child.onunload = () => { + unregisterRenderedBlock(el); + const ob = heatmapObservers.get(el); + if (ob) ob.disconnect(); + heatmapObservers.delete(el); + registeredComponents.delete(el); + }; + ctx.addChild(child); + registeredComponents.set(el, true); + registerRenderedBlock(el, 'workout-heatmap', source, ctx); + } + + const params = parseParams(source); + const defaultMetric = resolveDefaultMetric(config); + const metric = params.metric ? resolveMetric(config, params.metric) : defaultMetric; + const codeRange = params.range; + + // 无映射兜底:所有肌肉都没有配置 svgRegionIds 时,渲染再多样也上不了色, + // 直接给用户一条可操作的提示,避免「有数据但热力图一片空白」的困惑。 + const hasAnyMapping = config.muscles.some((m) => (m.svgRegionIds?.length ?? 0) > 0); + if (!hasAnyMapping) { + const container = el.createDiv({ cls: 'workout-heatmap-container' }); + container.createDiv({ text: t('codeblock.heatmap.noMapping'), cls: 'workout-heatmap-empty' }); + return; + } + + const container = el.createDiv(); + container.addClass('workout-heatmap-container'); + + // 头部:视图切换 + 图例 + const header = container.createDiv(); + header.addClass('workout-heatmap-header'); + + const viewSwitch = header.createDiv(); + viewSwitch.addClass('workout-heatmap-switch'); + const frontBtn = viewSwitch.createEl('button', { text: t('modal.muscleManager.front') }); + const backBtn = viewSwitch.createEl('button', { text: t('modal.muscleManager.back') }); + frontBtn.addClass('mod-cta'); + + const info = header.createDiv(); + info.addClass('workout-heatmap-info'); + const metricName = metric?.name ?? t('modal.muscleManager.followDefaultMetric'); + const rangeText = codeRange ?? '7d'; + info.createEl('span', { text: `${t('modal.muscleManager.heatmapMetric')}: ${metricName}` }); + info.createEl('span', { text: `${t('modal.muscleManager.heatmapRange')}: ${rangeText}` }); + + const legend = header.createDiv(); + legend.addClass('workout-heatmap-legend'); + // 阈值逐肌可配,单一图例无法呈现各肌不同刻度,故图例仅展示 4 色由轻→重, + // 具体阈值在各肌肉编辑页查看。 + legend.createEl('span', { text: t('codeblock.heatmap.legendLow'), cls: 'workout-heatmap-legend-end' }); + const legendScale = [...DEFAULT_HEATMAP_SCALE].sort((a, b) => (a.max ?? Infinity) - (b.max ?? Infinity)); + for (let i = 0; i < legendScale.length; i++) { + const level = legendScale[i]; + const item = legend.createDiv(); + item.addClass('workout-heatmap-legend-item'); + const dot = item.createEl('span'); + dot.style.width = '14px'; + dot.style.height = '14px'; + dot.style.borderRadius = '50%'; + dot.style.background = colorToCss(level.color); + } + legend.createEl('span', { text: t('codeblock.heatmap.legendHigh'), cls: 'workout-heatmap-legend-end' }); + + const viewsWrap = container.createDiv(); + viewsWrap.addClass('workout-heatmap-views'); + + const frontWrap = viewsWrap.createDiv(); + frontWrap.addClass('workout-heatmap-view'); + frontWrap.appendChild(getMuscleSvg('front').cloneNode(true)); + + const backWrap = viewsWrap.createDiv(); + backWrap.addClass('workout-heatmap-view'); + backWrap.style.display = 'none'; + backWrap.appendChild(getMuscleSvg('back').cloneNode(true)); + + // 隐藏头部/面部轮廓(cloneNode 复用已解析 SVG,代价极低,骨架阶段即可完成) + for (const wrap of [frontWrap, backWrap]) { + for (const hid of HIDDEN_SVG_GROUP_IDS) { + const el2 = wrap.querySelector(`[id="${hid}"]`) as HTMLElement | null; + if (el2) el2.style.display = 'none'; + } + } + + // 给每块肌肉路径绑定悬停提示(骨架阶段绑定一次即可,正/背切换只是显隐,路径元素不变)。 + // try/catch 兜底:提示气泡属增强体验,绝不应拖垮核心渲染(正/背切换、上色)。 + try { + bindMuscleTooltips(frontWrap, 'front'); + bindMuscleTooltips(backWrap, 'back'); + } catch (err) { + console.warn('[workout-heatmap] 绑定悬停提示失败,已跳过(不影响上色与视图切换)', err); + } + + // 懒渲染占位:未进入视口前只显示骨架 + 一句提示,真正的「逐肌肉计算 + 上色」 + // 延迟到代码块滚动进入视口时才执行(按需渲染)。这样即使在有热力图的笔记里删记录, + // 只要热力图不在视野内,就不会跟着每次数据变化做 O(muscles × logs) 的重计算,主线程更轻。 + const lazyTip = container.createDiv({ cls: 'workout-heatmap-lazy' }); + lazyTip.setText(t('codeblock.heatmap.lazy')); + + // —— 以下为「重计算 + 上色」,仅当代码块可见时执行 —— + function applyHeatmap(): void { + if (lazyTip.isConnected) lazyTip.remove(); + + // 预构建 exerciseId → 训练项肌肉映射,供 computeMuscleValue 做 O(1) 查找(避免逐日志 O(exercises) 线性扫描)。 + const exerciseMuscleMap = new Map(); + for (const ex of config.exercises) { + if (ex.muscles && ex.muscles.length) { + exerciseMuscleMap.set(ex.id, ex.muscles.map((m) => ({ muscleId: m.muscleId, role: m.role }))); + } + } + + // 计算每块肌肉的标量值 + const muscleEntries: { muscle: Muscle; stat: StatDef; range: string; value: number; scale: HeatmapLevel[] }[] = []; + for (const muscle of config.muscles) { + const stat = resolveMetricId(config, muscle, params.metric); + if (!stat) continue; + const range = resolveRange(muscle, codeRange); + const value = computeMuscleValue(muscle, stat, range, logs, exerciseMuscleMap); + // 逐肌分档:优先用该肌自己的 heatmapLevels,否则用所选 StatDef 的默认分档 + const scale = muscle.heatmapLevels ?? stat.heatmapScale ?? DEFAULT_HEATMAP_SCALE; + muscleEntries.push({ muscle, stat, range, value, scale }); + } + + const activeLogs = logs.filter((l) => l.timestamp && dateWithinRange(l.timestamp.split(' ')[0], codeRange ?? '7d')); + const hasData = activeLogs.length > 0 && muscleEntries.length > 0 && muscleEntries.some((e) => e.value > 0); + if (!hasData) { + const emptyMsg = codeRange === 'all' + ? t('codeblock.heatmap.emptyAll') + : t('codeblock.heatmap.emptyRange', { range: rangeText }); + container.createDiv({ text: emptyMsg, cls: 'workout-heatmap-empty' }); + } + + function applyColorToView(wrap: HTMLElement, side: 'front' | 'back'): void { + if (!hasData) return; + // 先聚合每个 SVG 路径的累计值,并记下「贡献最大的肌肉」的分档作为该路径的着色刻度。 + // 注意:同一 id 可能在目录里同时存在 front 和 back 两条记录(同一肌群在正/背 + // 两个 SVG 文件里都画了同名路径)。这里用 some() 判断「该 id 是否在目标 side + // 上存在」,避免 find() 命中第一条记录后误把另一侧的同名路径过滤掉。 + const pathInfo = new Map(); + for (const { muscle, value, scale } of muscleEntries) { + if (value <= 0) continue; + const ids = (muscle.svgRegionIds ?? []).filter((id) => + SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === side) + ); + if (ids.length === 0) continue; + for (const id of ids) { + const ex = pathInfo.get(id); + if (!ex) { + pathInfo.set(id, { value, scale, winner: value }); + } else { + ex.value += value; + // 多肌命中同路径时,用「单次贡献最大」的肌肉的分档来着色 + if (value > ex.winner) { + ex.winner = value; + ex.scale = scale; + } + } + } + } + + for (const [id, info] of pathInfo) { + const pathEl = wrap.querySelector(`[id="${id}"]`) as SVGPathElement | SVGGElement | null; + if (!pathEl) { + console.warn(`[workout-heatmap] SVG path not found: ${id}`); + continue; + } + const colorName = colorForValue(info.value, info.scale); + pathEl.style.fill = colorToCss(colorName); + pathEl.style.opacity = '0.85'; + } + } + + applyColorToView(frontWrap, 'front'); + applyColorToView(backWrap, 'back'); + } + + // 视图切换按钮(骨架阶段就可点,不依赖计算完成) + function showSide(side: 'front' | 'back'): void { + frontWrap.style.display = side === 'front' ? 'block' : 'none'; + backWrap.style.display = side === 'back' ? 'block' : 'none'; + frontBtn.toggleClass('mod-cta', side === 'front'); + backBtn.toggleClass('mod-cta', side === 'back'); + frontBtn.toggleClass('mod-muted', side === 'back'); + backBtn.toggleClass('mod-muted', side === 'front'); + } + frontBtn.addEventListener('click', () => showSide('front')); + backBtn.addEventListener('click', () => showSide('back')); + showSide('front'); + + // 旧 observer(重渲染场景下可能仍存在)先断开,再建新的,避免泄漏/重复计算。 + const prevOb = heatmapObservers.get(el); + if (prevOb) prevOb.disconnect(); + + if (typeof IntersectionObserver !== 'undefined') { + // 仅当热力图代码块进入视口(含 200px 预加载边距)时才执行 applyHeatmap 重计算 + 上色。 + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting) { + applyHeatmap(); + io.disconnect(); + heatmapObservers.delete(el); + break; + } + } + }, { rootMargin: '200px' }); + io.observe(el); + heatmapObservers.set(el, io); + } else { + // 环境不支持 IntersectionObserver(极罕见):直接同步计算,行为回退为旧逻辑。 + applyHeatmap(); + } +} diff --git a/src/codeblock/workoutLog.ts b/src/codeblock/workoutLog.ts new file mode 100644 index 0000000..e285cc9 --- /dev/null +++ b/src/codeblock/workoutLog.ts @@ -0,0 +1,346 @@ +import { Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian'; +import { LogRow, FieldDef, WorkoutConfig } from '../data/types'; +import { t } from '../i18n'; +import { getFieldLabel, getFieldUnit, renderFieldValue, resolveLogExerciseName, resolveExerciseIdByName } from '../data/display'; +import { computeStat, formatStatValue } from '../data/statExpr'; +import { registerRenderedBlock, unregisterRenderedBlock } from './registry'; + +/* + * workoutLog.ts —— 把 ```workout-log 代码块渲染成 HTML 表格 + * 这是真正「画表格」的代码。用户在笔记里写如下代码块: + * ```workout-log + * exercise: 深蹲 + * limit: 20 + * group_by: date + * ``` + * 插件解析这些参数 → 过滤出对应训练项的日志记录 → 画表头和数据行, + * 并给每行加「编辑/删除」按钮。数据变化时由 registry 触发的重渲染会再次调用本文件。 + */ + +// 代码块支持的参数(都写在代码块正文里,用 key: value 形式) +interface WorkoutLogParams { + exercise: string; + limit?: number; + number?: number; + day?: number; + groupBy?: 'date' | 'week'; + sort?: 'desc' | 'asc'; + showAdd?: boolean; +} + +// 解析代码块正文:把 "key: value" 每行拆开,填进上面的参数对象 +function parseParams(source: string): WorkoutLogParams { + // 先给一份「默认值」,下面遇到对应行再覆盖 + const params: WorkoutLogParams = { + exercise: '', + limit: 50, + groupBy: 'date', + sort: 'desc', + showAdd: true, + }; + + // 按换行把代码块正文切成多行,逐行读取参数 + const lines = source.split('\n'); + for (const line of lines) { + // 每行按冒号切成 [key, value],并去掉首尾空格 + const [key, value] = line.split(':').map((s) => s.trim()); + // key 为空或没有 value 的行跳过(比如空行) + if (!key || value === undefined) continue; + + // 根据 key 名字分别赋值;switch 让每个参数各走各的处理分支 + switch (key) { + case 'exercise': + params.exercise = value; + break; + case 'limit': + // parseInt 转数字,转不出来或 0 时用默认 50 + params.limit = parseInt(value) || 50; + break; + case 'number': + // 限制渲染的记录条数(取最新 N 条),0 或非法值时忽略 + params.number = parseInt(value) || undefined; + break; + case 'day': + // 仅显示数据中最近 N 天的记录,0 或非法值时忽略 + params.day = parseInt(value) || undefined; + break; + case 'group_by': + params.groupBy = value as 'date' | 'week'; + break; + case 'sort': + params.sort = value as 'desc' | 'asc'; + break; + case 'show_add': + // 只要不是写 "false"(不分大小写)就当作显示 + params.showAdd = value.toLowerCase() !== 'false'; + break; + } + } + + return params; +} + +// 把日志记录按指定维度分组,返回 分组key → 该组日志数组 的 Map +// 例如 group_by=date 时,同一天的记录归到一组,key 就是日期字符串 +function groupLogs(logs: LogRow[], groupBy: 'date' | 'week'): Map { + const groups = new Map(); + + for (const log of logs) { + // 防御:timestamp 缺失的记录无法分组,直接跳过,避免 split(undefined) 崩溃。 + if (!log.timestamp) continue; + + let key: string; + switch (groupBy) { + case 'date': + // timestamp 形如 "2026-07-10 18:30",按空格切,取第 0 段就是日期 + key = log.timestamp.split(' ')[0]; + break; + case 'week': + // 按「第几周」分组:用年份-周号作为 key + const date = new Date(log.timestamp); + const weekNum = getWeekNumber(date); + key = `${date.getFullYear()}-W${weekNum}`; + break; + default: + key = log.timestamp.split(' ')[0]; + } + + // Map 里还没有这个组就先建一个空数组 + if (!groups.has(key)) { + groups.set(key, []); + } + // 把当前记录塞进对应组 + groups.get(key)!.push(log); + } + + return groups; +} + +// 计算某个日期是「当年的第几周」:用日期距元旦的天数 ÷ 7 向上取整 +function getWeekNumber(date: Date): number { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const diff = date.getTime() - startOfYear.getTime(); + const oneWeek = 1000 * 60 * 60 * 24 * 7; // 一周的毫秒数 + return Math.ceil(diff / oneWeek); +} + +// 用于追踪已经为哪些 el 注册过 Obsidian Component,防止重渲染时重复 ctx.addChild。 +const registeredComponents = new WeakMap(); + +// 主渲染函数:把代码块渲染成表格。 +// 参数说明: +// source/logs —— 代码块原文与全部日志记录 +// getTrainingTypeFields —— 根据训练类型取「有哪些字段」的函数 +// unit —— 用户偏好单位 kg/lb +// config —— 聚合配置 +// onAddRecord/onEditRecord/onDeleteRecord —— 点击按钮时回调给上层去弹窗/删数据 +export async function renderWorkoutLog( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + logs: LogRow[], + getTrainingTypeFields: (category: string) => FieldDef[] | Promise, + unit: 'kg' | 'lb', + config: WorkoutConfig, + onAddRecord: (exercise: string, plan?: string) => void, + onEditRecord: (log: LogRow) => void, + onDeleteRecord: (log: LogRow) => void +): Promise { + // 首次渲染时:注册 Obsidian 子组件,在代码块所在视图卸载时自动从 registry 移除。 + // 使用 WeakMap 保证一个 el 只注册一次,避免 data-changed / 语言切换等重绘场景下子组件累积。 + if (!registeredComponents.has(el)) { + const child = new MarkdownRenderChild(el); + child.onunload = () => { + unregisterRenderedBlock(el); + registeredComponents.delete(el); + }; + ctx.addChild(child); + registeredComponents.set(el, true); + registerRenderedBlock(el, 'workout-log', source, ctx); + } + + // 先解析代码块参数 + const params = parseParams(source); + // 没写 exercise 就没法展示,给个提示并登记后返回 + if (!params.exercise) { + el.createDiv({ text: t('codeblock.noRecords', { exercise: t('codeblock.notSpecified') }) }); + return; + } + + // 把代码块里写的训练项名(如 "深蹲" / "Squat")解析成稳定的 exerciseId。 + // 记录只存稳定的 exerciseId,不存显示名,因此必须按 id 匹配, + // 才能在中英文切换、改名后都查到对应记录。 + const targetExerciseId = resolveExerciseIdByName(config, params.exercise); + const query = params.exercise.toLowerCase(); + + // 过滤目标训练项记录:优先按 exerciseId 精确匹配;无 id(旧数据/自定义)时按名字回退。 + let filteredLogs = logs.filter((log) => { + if (targetExerciseId && log.exerciseId === targetExerciseId) return true; + const resolved = resolveLogExerciseName(config, log); + const lowerResolved = resolved.toLowerCase(); + return lowerResolved === query || lowerResolved.includes(query); + }); + + // day 参数:仅保留「最近 N 个有记录的日」(按该动作的去重日期降序取前 N 天,跳过无训练的空白日)。 + // 这样无论数据间隔多大、或测试数据集中在同一天,day:N 都稳定显示最近 N 个训练日, + // 不会出现「填任何数都只有一天」的体感问题。 + if (params.day && params.day > 0) { + const distinctDays = Array.from( + new Set( + filteredLogs + .filter((l) => !!l.timestamp) + .map((l) => l.timestamp.split(' ')[0]) + ) + ).sort(); // 升序(旧→新) + // 取最近 N 天(升序数组的末尾 N 个 = 最新的 N 个训练日) + const keepDays = new Set(distinctDays.slice(-params.day)); + filteredLogs = filteredLogs.filter((l) => !!l.timestamp && keepDays.has(l.timestamp.split(' ')[0])); + } + + // 排序(desc 默认从新到旧);timestamp 缺失的记录排最后,避免 localeCompare(undefined) 异常。 + filteredLogs = filteredLogs.sort((a, b) => { + const ta = a.timestamp || ''; + const tb = b.timestamp || ''; + return params.sort === 'desc' ? tb.localeCompare(ta) : ta.localeCompare(tb); + }); + + // 统计必须基于「完整的、未被 number/limit 裁剪的真实数据」: + // 若统计也跟着用渲染出来的子集,次数/总量等会被渲染条数带偏 + // (例如当天 6 条只显示 3 条,统计却显示 3 而不是 6)。故先按完整数据分组供统计使用。 + const fullGroups = groupLogs(filteredLogs, params.groupBy ?? 'date'); + + // number 参数优先于 limit,仅控制「渲染条数」(取排序后的前 N 条),不影响上面的统计。 + const recordLimit = params.number ?? params.limit ?? 50; + const displayLogs = filteredLogs.slice(0, recordLimit); + + // 用裁剪后的数据分组,决定「画哪几行」 + const groups = groupLogs(displayLogs, params.groupBy ?? 'date'); + + // 从当前笔记路径取出文件名(去掉 .md)作为「训练方案名」,点击添加时传给上层 + const plan = ctx.sourcePath ? ctx.sourcePath.split('/').pop()?.replace('.md', '') : undefined; + + // 一条记录都没有:显示空提示,并按需显示「添加记录」按钮 + if (filteredLogs.length === 0) { + el.createDiv({ text: t('codeblock.noRecords', { exercise: params.exercise }) }); + if (params.showAdd) { + // 空状态下同样提供「添加记录」入口 + const addBtn = el.createEl('button', { text: t('codeblock.addRecord', { exercise: params.exercise }) }); + addBtn.addClass('mod-cta'); + addBtn.addEventListener('click', () => onAddRecord(params.exercise, plan)); + } + return; + } + + // 有数据:开始画界面 + const container = el.createDiv(); + container.addClass('workout-log-container'); + + // 顶部「添加记录」按钮(若开启) + if (params.showAdd) { + const header = container.createDiv(); + header.addClass('workout-log-header'); + const addBtn = header.createEl('button', { text: t('codeblock.addRecord', { exercise: params.exercise }) }); + addBtn.addClass('mod-cta'); + addBtn.addEventListener('click', () => onAddRecord(params.exercise, plan)); + } + + // 创建表格元素 + const table = container.createEl('table'); + table.addClass('workout-log-table'); + + // 取首条记录所属训练类型的字段定义(决定表头画哪些列) + const category = filteredLogs[0].category; + const fields = await getTrainingTypeFields(category); + + // 本代码块训练项所属训练类型下、已启用且关联该类型的数据统计条目。 + const matchedStats = (config.statistics ?? []).filter( + (s) => s.enabled && s.associatedTypes.includes(category) + ); + + // 画表头 + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + + // 第一列固定为日期 + headerRow.createEl('th', { text: t('codeblock.date') }); + // 每个字段画一列,列标题 = 字段标签,若有单位在后面加 (单位) + for (const field of fields) { + const unitText = getFieldUnit(field, unit); + headerRow.createEl('th', { text: `${getFieldLabel(field)}${unitText ? `(${unitText})` : ''}` }); + } + // 备注列、操作列(编辑/删除按钮所在) + headerRow.createEl('th', { text: t('codeblock.note') }); + headerRow.createEl('th', { text: t('codeblock.actions') }); + + // 画表体 + const tbody = table.createEl('tbody'); + + // 按渲染分组(displayLogs 已裁剪)的「分组名」排序 + const groupKeys = Array.from(groups.keys()).sort((a, b) => (params.sort === 'desc' ? b.localeCompare(a) : a.localeCompare(b))); + + // 遍历每个分组,每个分组内逐条记录画一行 + for (const key of groupKeys) { + const groupLogsList = groups.get(key)!; + // 统计基于「完整数据」分组(fullGroups),不受 number/limit 裁剪影响,保证次数/总量等准确 + const statSourceLogs = fullGroups.get(key) ?? groupLogsList; + + // 分组上方:日期 / 统计行。 + // 布局:[日期(分组key) | 统计值(合并字段列) | 备注(空) | 操作(空)] + // 无统计时仅显示日期行作为分组分隔标识。 + const groupHeaderRow = tbody.createEl('tr'); + groupHeaderRow.addClass(matchedStats.length > 0 ? 'workout-log-stat' : 'workout-log-group-header'); + + // 第一格固定为日期(分组 key),不随统计 colspan 吞掉 + groupHeaderRow.createEl('td', { text: key }); + + if (matchedStats.length > 0) { + // 有统计时:统计值合并所有字段列,纯数字、不附加单位。 + // 多条统计用换行排列(\n),同一单元格内每条一行,CSS 用 white-space: pre-line 还原换行。 + const statCells = matchedStats + .map((s) => `${s.name}: ${formatStatValue(computeStat(s, statSourceLogs))}`) + .join('\n'); + const statCell = groupHeaderRow.createEl('td', { text: statCells }); + statCell.colSpan = fields.length; // 显式设置,确保跨列合并所有配置字段 + statCell.addClass('workout-log-stat-cell'); + } else { + // 无统计时:字段列区域空白(合并占位) + const emptyCell = groupHeaderRow.createEl('td'); + emptyCell.colSpan = fields.length; + } + // 备注列与操作列始终为空 + groupHeaderRow.createEl('td', { text: '' }); + groupHeaderRow.createEl('td', { text: '' }); + + for (const log of groupLogsList) { + const row = tbody.createEl('tr'); + + // 第一列:只显示时间部分(HH:mm),日期已在上方分组头显示。 + // 防御:timestamp 缺失时整行不渲染(分组阶段已过滤,此处再兜底)。 + if (!log.timestamp) continue; + const timePart = log.timestamp.split(' ')[1] ?? log.timestamp; + row.createEl('td', { text: timePart }); + + // 中间各字段列:取该记录的字段值,按字段类型渲染(含单位换算)。 + // 按需求:单元格只显示纯数字,单位仅在表头体现;时长字段仍由 renderFieldValue 保持可读文本。 + for (const field of fields) { + const value = log.fields[field.key]; + const cell = row.createEl('td', { text: renderFieldValue(value, field, unit) }); + } + + // 备注列 + row.createEl('td', { text: log.note || '' }); + + // 操作列:编辑按钮 + 删除按钮,样式与训练项管理保持一致(workout-action-btn / workout-danger-btn) + const actionsCell = row.createEl('td'); + const actionsWrap = actionsCell.createEl('div'); + actionsWrap.addClass('workout-card-actions'); + const editBtn = actionsWrap.createEl('button', { text: t('codeblock.edit') }); + editBtn.addClass('workout-action-btn'); + editBtn.addEventListener('click', () => onEditRecord(log)); + const deleteBtn = actionsWrap.createEl('button', { text: t('codeblock.delete') }); + deleteBtn.addClass('workout-danger-btn'); + deleteBtn.addEventListener('click', () => onDeleteRecord(log)); + } + } + +} \ No newline at end of file diff --git a/src/codeblock/workoutPlan.ts b/src/codeblock/workoutPlan.ts new file mode 100644 index 0000000..c0d2868 --- /dev/null +++ b/src/codeblock/workoutPlan.ts @@ -0,0 +1,231 @@ +import { Component, MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownView, TFile } from 'obsidian'; +import { DataManager } from '../data/DataManager'; +import { FieldDef, PlanItem, PlanSet, TrainingPlanInstance, WorkoutConfig } from '../data/types'; +import { t } from '../i18n'; +import { getExerciseNameById, getFieldLabel, getFieldUnit, getTrainingTypeName, renderFieldValue, formatTimeRule } from '../data/display'; +import { registerRenderedBlock, unregisterRenderedBlock, rerenderBlocksByType } from './registry'; +import { PlanSetEditModal } from '../ui/PlanSetEditModal'; + +/* + * workoutPlan.ts —— 把 ```workout-plan 代码块渲染成「训练计划完成面板」。 + * 代码块只需要一个 plan 参数(= 计划名,全局唯一),即决定显示哪个具体计划实例。 + * 无 plan 时渲染「选择计划」下拉框,选中后把计划名写回代码块(变为 plan: 选中的计划名)。 + * 有 plan 时渲染完成面板:每个训练项的每组展示预设值 + [编辑][完成];点「完成」写一条 CSV 记录 + * (时间=点击时刻),点「编辑」弹小窗改该组值(复用编辑记录界面的字段渲染)。 + */ + +interface PlanParams { + plan?: string; +} + +function parseParams(source: string): PlanParams { + const params: PlanParams = {}; + for (const line of source.split('\n')) { + // 只在第一个冒号处切分,保证值里带冒号(如计划名「推胸: A」)也能完整保留, + // 否则 `line.split(':')` 只取第二段会导致写回代码块后无法正确解析。 + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (!key) continue; + if (key === 'plan') params.plan = value; + } + return params; +} + +// 把一组的字段渲染成「值 单位」空格分隔的展示文字(如 `60 kg 8 次`)。 +function formatSetFields(fields: Record, typeFields: FieldDef[], unit: 'kg' | 'lb'): string { + return typeFields + .map((f) => { + const v = fields[f.key]; + if (v === undefined || v === null) return ''; + const rv = renderFieldValue(v, f, unit); + if (f.mass) return `${rv} ${unit}`; // 质量字段附上单位(kg/lb,跟随设置) + const u = getFieldUnit(f, unit); + return u ? `${rv} ${u}` : rv; + }) + .filter(Boolean) + .join(' '); +} + +const registeredComponents = new WeakMap(); + +export async function renderWorkoutPlan( + source: string, + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + dataManager: DataManager +): Promise { + if (!registeredComponents.has(el)) { + const child = new MarkdownRenderChild(el); + child.onunload = () => { + unregisterRenderedBlock(el); + registeredComponents.delete(el); + }; + ctx.addChild(child); + registeredComponents.set(el, true); + registerRenderedBlock(el, 'workout-plan', source, ctx); + } + + const params = parseParams(source); + const config = await dataManager.getConfig(); + const plans = config.plans ?? []; + const plan = params.plan ? plans.find((p) => p.name === params.plan) : undefined; + + if (!plan) { + renderSelectPlan(el, dataManager, ctx, plans); + return; + } + + renderPanel(el, dataManager, plan, config); +} + +// 无 plan(或 plan 找不到):渲染「选择计划」下拉框;选中后把计划名写回代码块。 +function renderSelectPlan(el: HTMLElement, dataManager: DataManager, ctx: MarkdownPostProcessorContext, plans: TrainingPlanInstance[]): void { + const wrap = el.createDiv(); + wrap.addClass('workout-plan-select'); + + const label = wrap.createEl('label', { text: t('codeblock.plan.select') }); + label.addClass('workout-plan-select-label'); + + if (plans.length === 0) { + wrap.createEl('div', { text: t('codeblock.plan.noPlan') }).addClass('workout-hint'); + return; + } + + const select = wrap.createEl('select'); + select.addClass('workout-select'); + select.createEl('option', { value: '', text: t('codeblock.plan.selectPlaceholder') }); + for (const p of plans) { + select.createEl('option', { value: p.name, text: p.name }); + } + select.addEventListener('change', async () => { + const name = select.value; + if (!name) return; + await writePlanToCodeBlock(dataManager, ctx, el, name); + }); +} + +// 把选中的计划名写入代码块源(改写笔记里的 ```workout-plan 块),随后 Obsidian 自动重渲染。 +// 修复机制 B:原先用 vault.modify(file, ...) 整文件改写当前正在编辑的笔记, +// Obsidian 会当成「外部改动」重新加载该笔记,导致编辑器光标/选区/撤销栈全部重置(光标丢失、无法输入)。 +// 改为:若当前文件正好在 Markdown 编辑器(Live Preview/源码)中打开,则仅用 editor.replaceRange +// 做「局部替换」——编辑器内部事务,不触发整文件 reload,焦点与光标得以保留;只有在无法拿到 +// 对应编辑器(如阅读模式、或在别的文件里)时,才退回 vault.modify 兜底(该场景没有可丢失的光标)。 +async function writePlanToCodeBlock(dataManager: DataManager, ctx: MarkdownPostProcessorContext, el: HTMLElement, name: string): Promise { + const section = ctx.getSectionInfo(el); + if (!section) return; + const file = dataManager.app.vault.getAbstractFileByPath(ctx.sourcePath); + if (!(file instanceof TFile)) return; + const newBlock = '```workout-plan\nplan: ' + name.trim() + '\n```'; + + // 找到正在编辑该文件的 Markdown 视图,优先用编辑器事务局部替换。 + const mdView = dataManager.app.workspace + .getLeavesOfType('markdown') + .map((leaf) => leaf.view) + .find((v): v is MarkdownView => v instanceof MarkdownView && v.file?.path === ctx.sourcePath); + if (mdView && mdView.editor) { + const editor = mdView.editor; + const from = { line: section.lineStart, ch: 0 }; + const to = { line: section.lineEnd, ch: editor.getLine(section.lineEnd).length }; + editor.replaceRange(newBlock, from, to); + return; + } + + // 兜底:当前笔记未在可编辑视图打开(如阅读模式),只能用整文件改写; + // 此场景不存在「正在编辑的光标」,故不会造成光标丢失。 + const content = await dataManager.app.vault.read(file); + const lines = content.split('\n'); + lines.splice(section.lineStart, section.lineEnd - section.lineStart + 1, newBlock); + await dataManager.app.vault.modify(file, lines.join('\n')); +} + +function renderPanel(el: HTMLElement, dataManager: DataManager, plan: TrainingPlanInstance, config: WorkoutConfig): void { + const unit = dataManager.getSettings().unit; + const container = el.createDiv(); + container.addClass('workout-plan-panel'); + + // 计算进度:完成状态来自计划配置里持久化的 completedSets(独立于训练记录), + // 这样即使删除了该组产生的训练记录,完成状态依然保留。 + const enabledItems = plan.items.filter((i) => i.enabled); + const completedSets = plan.completedSets ?? {}; + const completedKeys = new Set(Object.keys(completedSets)); + let totalSets = 0; + let doneSets = 0; + for (const item of enabledItems) { + for (const set of item.sets) { + totalSets++; + if (completedKeys.has(`${item.exerciseId}#${set.id}`)) doneSets++; + } + } + + // 计划头 + const header = container.createDiv(); + header.addClass('workout-plan-header'); + header.createSpan({ text: plan.name }).addClass('workout-plan-name'); + const timeText = formatTimeRule(plan.timeRule); + if (timeText) { + header.createSpan({ text: timeText }).addClass('workout-plan-time'); + } + header.createSpan({ text: t('codeblock.plan.progress', { done: String(doneSets), total: String(totalSets) }) }).addClass('workout-plan-progress'); + + // 训练项 + 组 + for (const item of enabledItems) { + const type = config.trainingTypes.find((tt) => tt.id === item.category); + const exerciseName = getExerciseNameById(config.exercises, item.exerciseId); + const typeName = getTrainingTypeName(type); + + const itemEl = container.createDiv(); + itemEl.addClass('workout-plan-item'); + itemEl.createEl('div', { text: `${exerciseName}${typeName ? ` [${typeName}]` : ''}` }).addClass('workout-plan-item-title'); + + const setsEl = itemEl.createDiv(); + setsEl.addClass('workout-plan-sets'); + + item.sets.forEach((set, sidx) => { + const isCompleted = completedKeys.has(`${item.exerciseId}#${set.id}`); + const row = setsEl.createDiv(); + row.addClass('workout-row', 'workout-plan-set'); + + row.createSpan({ text: t('modal.newPlan.setName', { n: String(sidx + 1) }) }).addClass('workout-plan-setno'); + const fieldsText = formatSetFields(set.fields, type?.fields ?? [], unit); + row.createSpan({ text: fieldsText || t('codeblock.plan.emptyFields') }).addClass('workout-plan-setfields'); + + if (isCompleted) { + row.createSpan({ text: t('codeblock.plan.completed') }).addClass('workout-plan-completed'); + } else { + // 编辑按钮(仅未完成时显示:改该组计划预设值) + const editBtn = row.createEl('button', { text: t('codeblock.plan.edit') }); + editBtn.addClass('mod-cta', 'workout-plan-set-edit'); + editBtn.addEventListener('click', () => { + new PlanSetEditModal(dataManager, { + exerciseId: item.exerciseId, + category: item.category, + title: t('codeblock.plan.editTitle', { exercise: exerciseName, n: String(sidx + 1) }), + initialFields: set.fields, + onSave: async (fields) => { + await dataManager.updatePlanSetFields(plan.name.trim(), item.exerciseId, set.id, fields); + rerenderBlocksByType('workout-plan'); + }, + }).open(); + }); + + // 完成按钮(仅未完成显示) + const completeBtn = row.createEl('button', { text: t('codeblock.plan.complete') }); + completeBtn.addClass('mod-cta', 'workout-plan-set-complete'); + completeBtn.addEventListener('click', async () => { + // 1) 持久化「已完成」状态到计划配置(独立于训练记录,删除记录不影响) + await dataManager.markPlanSetCompleted(plan, item.exerciseId, set.id); + // 2) 同时往训练记录库写一条数据;plan 存稳定方案标识(sourceNote 或 id),而非可改的计划名 + await dataManager.addLog({ + exerciseId: item.exerciseId, + category: item.category, + fields: { ...set.fields, _planSet: set.id }, + plan: plan.sourceNote || plan.id, + }); + rerenderBlocksByType('workout-plan'); + }); + } + }); + } +} diff --git a/src/data/CSVStore.ts b/src/data/CSVStore.ts new file mode 100644 index 0000000..7d70d51 --- /dev/null +++ b/src/data/CSVStore.ts @@ -0,0 +1,305 @@ +import Papa from 'papaparse'; +import { App, TFile } from 'obsidian'; +import { LogRow, CSV_FILENAME } from './types'; +import { DataManager } from './DataManager'; +import { generateId } from '../util/id'; + +/* + * CSVStore.ts —— 训练记录的数据层(vault 内 workout_logs.csv)。 + * + * 设计要点: + * - 数据以标准 CSV 存在 vault 中,支持 Dataview 等外部工具查询(满足"两个文件可查询")。 + * - 关键性能优化(根治"添加记录卡顿"): + * 新增记录用 Vault.append(file, line) 做 O(1) 追加,不再每次把整个 CSV 重写一遍; + * Vault.append 走 Obsidian 缓存层(区别于 adapter.append 绕过缓存导致崩溃)。 + * - 删除采用「软删除」:删除时不再整文件重写,而是 O(1) 追加一行墓碑 + * (deleted 列标记为 true),读取时过滤掉被删 id 的行。彻底消灭删除卡顿。 + * 「整文件压缩清理」(真正移除被删行)由设置页按钮在用户主动触发时执行。 + * - 编辑因需改中间行,仍整体重写(writeAll,使用 vault.modify),但属低频操作, + * 且记录少时极快;外部手动改文件也不会丢数据。 + */ +// 第 8 列 deleted:空 = 正常行;'true' = 软删除墓碑行(仅标记 id 被删除,不作为记录)。 +const CSV_HEADER = 'id,timestamp,exerciseId,category,fields,note,plan,deleted'; + +export class CSVStore { + private dm: DataManager; // 数据中枢,用于取设置(CSV 所在目录)与 App 实例 + private app: App; + + constructor(dm: DataManager) { + this.dm = dm; + this.app = dm.app; + } + + // CSV 文件路径(基于设置里的 csvDirectory,空 = vault 根目录) + private get path(): string { + const dir = this.dm.getSettings().csvDirectory || ''; + return dir ? `${dir}/${CSV_FILENAME}` : CSV_FILENAME; + } + + // 解析 CSV 文本为 LogRow[],失败返回空结果(不抛异常,避免影响启动)。 + // 返回值带 dropped:本被丢弃的脏行计数(如超长 fields 的幽灵行),供 init 判断是否需要落盘自愈。 + // 返回值带 deletedIds:被软删除(墓碑行)标记过的 id 集合,供缓存过滤与 id 分配去重。 + parseContent(content: string): { rows: LogRow[]; dropped: number; deletedIds: string[] } { + try { + const result = Papa.parse>(content, { + header: true, + skipEmptyLines: true, + }); + // 收集软删除墓碑标记过的 id(deleted 列 === 'true' 的行)。 + const deletedIds = new Set(); + let dropped = 0; + const rows = result.data + .map((row): LogRow | null => { + // 软删除墓碑行:deleted 列为 true,仅记录「该 id 已被删除」, + // 不计入脏行(dropped),也不作为正常记录返回(避免与同名数据行重复)。 + if (row.deleted === 'true') { + if (row.id) deletedIds.add(row.id); + return null; + } + // 关键兜底:timestamp / category 是后续过滤/分组必须的字段;缺失则无法渲染, + // 且往往是脏 CSV(如未闭合引号把整行吞乱)产生的幽灵行,直接丢弃。 + if (!row.timestamp || !row.category) { + dropped++; + return null; + } + let fields: Record = {}; + const raw = row.fields; + if (raw) { + // 单条记录的 fields 异常大(脏 CSV 未闭合引号把多行吞进一个单元格), + // 整行直接丢弃——而非置空后保留成幽灵行。否则会:① 与正常行形成重复 id; + // ② 被 writeAll 再次写回、撑大文件。丢弃才能从根上消除脏数据。 + if (raw.length > 10000) { + console.warn('[workout] 跳过超长 fields(疑似脏数据):', raw.slice(0, 80)); + dropped++; + return null; + } + // 逐行兜底:某行 fields 解析失败只影响该行,绝不让整文件解析失败导致全部记录丢失。 + try { + fields = JSON.parse(raw); + } catch { + fields = {}; + } + } + return { + id: row.id || generateId(), + timestamp: row.timestamp, + exerciseId: row.exerciseId || undefined, + category: row.category, + fields, + note: row.note || undefined, + plan: row.plan || undefined, + }; + }) + .filter((r): r is LogRow => r !== null) + // 过滤掉「数据行 id 已被墓碑标记删除」的残留行(软删除前的旧数据行), + // 保证内存缓存与渲染看到的都是存活记录。 + .filter((r) => !deletedIds.has(r.id)); + return { rows, dropped, deletedIds: Array.from(deletedIds) }; + } catch { + return { rows: [], dropped: 0, deletedIds: [] }; + } + } + + // 读取 CSV 文本:优先走 vault 缓存层(getAbstractFileByPath + vault.read,写盘安全); + // 兜底走 adapter 直接读磁盘。 + // 关键修复点:Obsidian 在「首次打开仓库」时会在 vault 文件缓存(fileMap)尚未就绪前 + // 就调用插件 onload(),此时 getAbstractFileByPath 漏返回已存在的 CSV → init() 误判 + // 为「无文件」→ logsCache 永远为空 → 首次打开看不到历史记录、添加也只显示当次。 + // 手动重载插件时 vault 已就绪,故能正常显示。改用 adapter.read(不依赖 fileMap)兜底后, + // 即使 fileMap 未就绪也能从磁盘读到历史记录。文件确实不存在时返回 null。 + private async readFileContent(): Promise { + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + return await this.app.vault.read(file); + } + try { + return await this.app.vault.adapter.read(this.path); + } catch { + return null; + } + } + + // 从 vault 读取全部记录,并返回丢弃的脏行数(供 init 落盘自愈使用)。 + async readAllWithStats(): Promise<{ rows: LogRow[]; dropped: number; deletedIds: string[] }> { + const content = await this.readFileContent(); + if (content === null) return { rows: [], dropped: 0, deletedIds: [] }; + return this.parseContent(content); + } + + // 追加一行:优先 Vault.append(缓存安全的 O(1) 追加)。 + // 旧版 Obsidian 无 append 时回退为 read + modify 整文件重写(兜底兼容)。 + // 关键:用 Papa.unparse 生成数据行(header: false),避免手动 escapeCell 对 JSON 内部引号错误双重转义。 + // 例如 JSON {"note":"say \"hello\""} 经 escapeCell 处理会变成 ""say ""hello"""",破坏结构; + // 而 Papa.unparse 能正确处理嵌套引号,输出 "{""note"":""say \""hello\""""}"。 + async appendRow(row: LogRow): Promise { + const line = this.toCsvLine(row); + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + if (typeof this.app.vault.append === 'function') { + await this.app.vault.append(file, line + '\n'); + } else { + const content = await this.app.vault.read(file); + const newContent = content.replace(/\s+$/, '') + '\n' + line + '\n'; + await this.app.vault.modify(file, newContent); + } + } else { + try { + await this.app.vault.create(this.path, CSV_HEADER + '\n' + line + '\n'); + } catch (e: unknown) { + if (String((e as Error)?.message ?? '').includes('File already exists')) { + const f = this.app.vault.getAbstractFileByPath(this.path); + if (f instanceof TFile) { + if (typeof this.app.vault.append === 'function') { + await this.app.vault.append(f, line + '\n'); + } else { + const c = await this.app.vault.read(f); + await this.app.vault.modify(f, c.replace(/\s+$/, '') + '\n' + line + '\n'); + } + } + } else { + throw e; + } + } + } + } + + // 把一行记录转成 CSV 文本。使用 Papa.unparse({ header: false }) 确保转义与 writeAll 完全一致。 + // deleted 列为空(正常行);墓碑行由 appendTombstone 单独构造。 + private toCsvLine(row: LogRow): string { + const columns = CSV_HEADER.split(','); + return Papa.unparse( + [{ + id: row.id, + timestamp: row.timestamp, + exerciseId: row.exerciseId || '', + category: row.category, + fields: JSON.stringify(normalizeFields(row.fields)), + note: row.note || '', + plan: row.plan || '', + deleted: '', + }], + { columns, header: false } + ); + } + + // 追加一行「软删除墓碑」:仅标记 id 为已删除(deleted 列 = true),O(1) 追加,不重写整文件。 + // 读取时 parseContent 据此过滤掉该 id 的残留数据行。用于 deleteLog,根治删除卡顿。 + async appendTombstone(id: string): Promise { + const line = Papa.unparse( + [{ id, timestamp: '', exerciseId: '', category: '', fields: '', note: '', plan: '', deleted: 'true' }], + { columns: CSV_HEADER.split(','), header: false } + ); + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + if (typeof this.app.vault.append === 'function') { + await this.app.vault.append(file, line + '\n'); + } else { + const content = await this.app.vault.read(file); + await this.app.vault.modify(file, content.replace(/\s+$/, '') + '\n' + line + '\n'); + } + } + // 文件不存在(极端:记录所在的 CSV 被外部删除)时静默忽略: + // 删除已反映到内存缓存,后续任何写盘都会以存活记录为准干净重建。 + } + + // 批量追加「软删除墓碑」:把多个 id 的墓碑合并成「一次」O(1) 追加, + // 避免逐条删除时多次 appendTombstone 触发多次写入。用于「删除训练项时级联删除其全部 + // 训练记录」场景,保证与大 CSV 同样无卡顿。文件不存在时静默忽略(删除已反映到内存缓存)。 + async appendTombstones(ids: string[]): Promise { + if (ids.length === 0) return; + const columns = CSV_HEADER.split(','); + const content = ids + .map((id) => + Papa.unparse( + [{ id, timestamp: '', exerciseId: '', category: '', fields: '', note: '', plan: '', deleted: 'true' }], + { columns, header: false } + ) + ) + .join('\n'); + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + if (typeof this.app.vault.append === 'function') { + await this.app.vault.append(file, content + '\n'); + } else { + const existing = await this.app.vault.read(file); + await this.app.vault.modify(file, existing.replace(/\s+$/, '') + '\n' + content + '\n'); + } + } + } + + // 整体写入(编辑/删除/批量导入)。存在则改、否则建。 + // 强制使用规范列序(CSV_HEADER),避免「内存对象键序」泄漏到磁盘导致列顺序漂移: + // 例如 addLog 用 {...row, id, timestamp} 构造,会把 id/timestamp 排到末尾,若直接按首行键序 + // 序列化,磁盘列序会变成 exerciseId,category,...,timestamp,id —— 数据不丢但顺序混乱。 + async writeAll(rows: LogRow[]): Promise { + const columns = CSV_HEADER.split(','); + // 关键:持久化前过滤掉无效行。旧版 bug 可能让 logsCache 中混入 timestamp/category 为空的行; + // 若直接写回磁盘,下次读取会再次产生幽灵行并触发 split(undefined) 报错。只保留有效行。 + const validRows = rows.filter((row) => !!row.timestamp && !!row.category); + let csv: string; + if (validRows.length === 0) { + // 空数据时只写表头,避免 Papa.unparse([]) 返回空字符串导致表头丢失。 + // 删除最后一条记录后文件至少保留表头,下次 appendRow 可正确追加。 + csv = CSV_HEADER; + } else { + csv = Papa.unparse( + validRows.map((row) => ({ + id: row.id, + timestamp: row.timestamp, + exerciseId: row.exerciseId || '', // 缺失则填空,保证 CSV 列对齐 + category: row.category, + fields: JSON.stringify(normalizeFields(row.fields)), // 规范化为干净对象再序列化,避免脏字段被放大 + note: row.note || '', + plan: row.plan || '', + deleted: '', // 正常行:空(writeAll 用于压缩/编辑,不含墓碑) + })), + { columns } + ); + } + await this.createOrModify(csv + '\n'); + } + + // 存在则改、不存在则建;若并发初始化导致「文件已存在」(File already exists), + // 回退为 modify,避免插件加载时抛错中断 onload。 + private async createOrModify(content: string): Promise { + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + await this.app.vault.modify(file, content); + return; + } + try { + await this.app.vault.create(this.path, content); + } catch (e: unknown) { + if (String((e as Error)?.message ?? '').includes('File already exists')) { + const existing = this.app.vault.getAbstractFileByPath(this.path); + if (existing instanceof TFile) await this.app.vault.modify(existing, content); + } else { + throw e; + } + } + } + + // 判断磁盘上的 CSV 表头是否为「旧版结构」(与当前规范表头不一致)。 + // 仅用于迁移自愈:旧文件是 9 列、无 id 列,直接追加新行会导致列错位、数据损坏。 + async isHeaderStale(): Promise { + const content = await this.readFileContent(); + if (!content) return false; + const firstLine = content.split('\n', 1)[0]; + return firstLine !== CSV_HEADER; + } +} + +// 把 fields 规范化为干净的普通对象:已是对象直接返回;是 JSON 字符串则尝试解析; +// 其它情况(undefined / 脏字符串 / 非对象)一律回退为 {},避免序列化出巨型或非法单元格。 +function normalizeFields(f: unknown): Record { + if (f && typeof f === 'object') return f as Record; + if (typeof f === 'string') { + try { + const parsed = JSON.parse(f); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } + } + return {}; +} \ No newline at end of file diff --git a/src/data/ConfigStore.ts b/src/data/ConfigStore.ts new file mode 100644 index 0000000..82dc4b9 --- /dev/null +++ b/src/data/ConfigStore.ts @@ -0,0 +1,160 @@ +import { App, TFile } from 'obsidian'; +import { WorkoutConfig, CONFIG_FILENAME } from './types'; +import { DataManager } from './DataManager'; +import { applyDefaultNameKeys } from './display'; +import { getDefaultConfig } from './seed'; + +/* + * ConfigStore.ts —— 聚合配置(训练类型/训练项/肌肉)的数据层(vault 内 workout-config.json)。 + * + * 配置文件也留在 vault 内,便于用户用 Dataview / 外部工具查询或备份。 + * 配置改动属低频操作,使用 vault.modify 整体写入即可(与记录 CSV 同套缓存安全 API)。 + */ +export class ConfigStore { + private dm: DataManager; // 数据中枢,用于取设置与 App 实例 + private app: App; + private cache: WorkoutConfig | null = null; // 内存缓存;null 表示尚未加载 + + constructor(dm: DataManager) { + this.dm = dm; + this.app = dm.app; + } + + // 配置文件路径(基于设置里的 configDirectory,空 = vault 根目录) + private get path(): string { + const dir = this.dm.getSettings().configDirectory || ''; + return dir ? `${dir}/${CONFIG_FILENAME}` : CONFIG_FILENAME; + } + + // 读取配置文件文本:优先走 vault 缓存层,兜底走 adapter 直接读磁盘。 + // 与 CSVStore 同理:首次打开仓库时 fileMap 可能未就绪,getAbstractFileByPath 漏返回 + // 已存在的配置文件 → 误判为「无配置」→ 表格列定义/训练项缺失。adapter.read 兜底修复。 + // 文件确实不存在时返回 null。 + private async readFileContent(): Promise { + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + return await this.app.vault.read(file); + } + try { + return await this.app.vault.adapter.read(this.path); + } catch { + return null; + } + } + + // 确保配置已加载(带缓存)。首次无配置文件时用默认配置并落盘。 + // legacyConfig:来自旧版插件私有 data 的配置,仅在「vault 内尚无配置文件」时作为兜底导入。 + async ensureLoaded(legacyConfig?: WorkoutConfig): Promise { + if (this.cache) return; + const content = await this.readFileContent(); + if (content !== null) { + try { + this.cache = this.migrate(JSON.parse(content)); + } catch { + // 配置文件损坏:回退到默认(或 legacy)并覆盖写入,避免一直报错 + const fallback = legacyConfig ? this.migrate(legacyConfig) : this.migrate(getDefaultConfig()); + this.cache = fallback; + await this.writeConfig(fallback); + } + return; + } + // vault 内无配置文件:优先用 legacy(旧版迁移),否则默认配置 + const initial = legacyConfig ? this.migrate(legacyConfig) : this.migrate(getDefaultConfig()); + this.cache = initial; + await this.writeConfig(initial); + } + + // 读取配置:有缓存直接返回,否则加载。 + async load(): Promise { + await this.ensureLoaded(); + return this.cache!; + } + + // 写入配置:更新缓存 + vault 文件。由 DataManager.saveConfig 在 selfWriting 期间调用。 + async save(config: WorkoutConfig): Promise { + this.cache = config; + await this.writeConfig(config); + } + + // 真正写盘:存在则改、否则建。 + private async writeConfig(config: WorkoutConfig): Promise { + const content = JSON.stringify(config, null, 2); + const file = this.app.vault.getAbstractFileByPath(this.path); + if (file instanceof TFile) { + await this.app.vault.modify(file, content); + } else { + try { + await this.app.vault.create(this.path, content); + } catch (e: unknown) { + if (String((e as Error)?.message ?? '').includes('File already exists')) { + // 并发初始化竞态:文件已在,回退为 modify + const existing = this.app.vault.getAbstractFileByPath(this.path); + if (existing instanceof TFile) await this.app.vault.modify(existing, content); + } else { + throw e; + } + } + } + } + + // 清空缓存(配置被外部改动或路径变化时调用,迫使下次重新读取)。 + clearCache(): void { + this.cache = null; + } + + // 获取当前缓存(可能为 null)。 + getCache(): WorkoutConfig | null { + return this.cache; + } + + // 数据迁移(migrate):补全缺失结构,保证向后兼容。 + private migrate(config: WorkoutConfig): WorkoutConfig { + if (!config.version) config.version = 1; + if (!config.trainingTypes) config.trainingTypes = getDefaultConfig().trainingTypes; + if (!config.exercises) config.exercises = getDefaultConfig().exercises; + if (!config.muscles) config.muscles = getDefaultConfig().muscles; + // 数据统计:旧配置缺 statistics 时,按每个训练类型注入一条种子"总组数"。 + // 用 count()(不引用字段),单类型关联即可复现"每个块都有总组数"; + // 用户想跨类型可手动把 associatedTypes 改为多个类型。 + if (!config.statistics) { + config.statistics = config.trainingTypes.map((type) => ({ + id: `seed-total-sets-${type.id}`, + name: '总组数', + associatedTypes: [type.id], + formula: { mode: 'builder', builder: { kind: 'count' } }, + granularity: 'daily', + enabled: true, + })); + } + // 训练计划:旧配置缺 plans 时置空数组(聚合进既有配置,不单独建文件) + if (!config.plans) config.plans = []; + // 2026-07-14 大改:把种子新增的训练项 / 计划 / 训练总量指标,幂等地并入既有配置。 + // 按 id(训练项/统计)或 name(计划)去重,绝不覆盖用户已改/已建的数据;重复执行安全。 + { + const seed = getDefaultConfig(); + const exIds = new Set(config.exercises.map((e) => e.id)); + for (const ex of seed.exercises) if (!exIds.has(ex.id)) config.exercises.push(ex); + const planNames = new Set(config.plans.map((p) => p.name)); + for (const pl of seed.plans ?? []) if (!planNames.has(pl.name)) config.plans.push(pl); + const statIds = new Set(config.statistics.map((s) => s.id)); + for (const st of seed.statistics) if (!statIds.has(st.id)) config.statistics.push(st); + } + // 字段单位模型迁移:旧 unit 枚举(none/mass/length/count/time/custom) -> mass + unitLabel。 + // 幂等:已迁移的数据没有 unit/customUnit 字段,重复执行无副作用。 + const migrateUnit = (fields: any[] | undefined): void => { + if (!fields) return; + for (const f of fields) { + if (!f) continue; + if (f.unit === 'mass') f.mass = true; + else if (f.unit === 'custom') f.unitLabel = f.customUnit ?? ''; + else if (f.unit === 'count') f.unitLabel = '次'; + // length/time/none/未定义 -> 全部清空(不再保留死值) + delete f.unit; + delete f.customUnit; + } + }; + for (const type of config.trainingTypes ?? []) migrateUnit(type.fields); + + return applyDefaultNameKeys(config); + } +} diff --git a/src/data/DataManager.ts b/src/data/DataManager.ts new file mode 100644 index 0000000..610bc76 --- /dev/null +++ b/src/data/DataManager.ts @@ -0,0 +1,703 @@ +import { App, Events, Plugin } from 'obsidian'; +import { LogRow, WorkoutConfig, TrainingType, Exercise, Muscle, PluginSettings, DEFAULT_SETTINGS, CSV_FILENAME, CONFIG_FILENAME, TrainingPlanInstance } from './types'; +import { generateId } from '../util/id'; +import { CSVStore } from './CSVStore'; +import { ConfigStore } from './ConfigStore'; + +/* + * DataManager.ts —— 插件的「数据中枢」。 + * + * 存储分工(回到「两个文件 + 插件设置」的经典结构): + * - settings:插件私有 data.json(Plugin.loadData / saveData),仅存放用户偏好, + * 不进 vault、不影响查询、也不触发 vault 文件写入导致的卡顿。 + * - logs:vault 内 workout_logs.csv(支持 Dataview 等查询)。 + * - config:vault 内 workout-config.json(同样支持查询)。 + * + * 性能要点(根治"添加记录卡顿"): + * - 添加记录走 CSVStore.appendRow,内部用 Vault.append 做 O(1) 追加,不再整文件重写。 + * - selfWriting 标志:插件自身写盘期间,main.ts 的 vault.on('modify') 监听完全跳过 + * 重载 + 重渲染(data-changed 已做精准重渲染),避免重复开销与卡顿。 + * + * 数据迁移:若 CSV 为空但旧版插件私有 data 里有 logs / config,则一次性导入 vault 文件, + * 不丢用户在上一版测试期间产生的数据。 + */ +// 取今天日期(YYYY-MM-DD),用于完成状态打标。 +function todayStr(): string { + const n = new Date(); + const pad = (x: number) => String(x).padStart(2, '0'); + return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`; +} + +export class DataManager { + app: App; // Obsidian 应用实例 + private plugin: Plugin; // 插件实例,用于 loadData/saveData(私有 settings) + private events: Events; // 事件总线 + private csvStore: CSVStore; // 记录数据层(vault CSV) + private configStore: ConfigStore; // 配置数据层(vault JSON) + private settings: PluginSettings; // 设置内存副本(来自插件私有 data) + private logsCache: LogRow[] = []; // 记录内存缓存(存活记录,已排除软删除) + private deletedIds = new Set(); // 软删除标记过的 id 集合(墓碑行),用于缓存过滤与 id 去重 + private selfWriting = false; // 自身写盘标志:写盘期间为 true + private lastSelfWriteAt = 0; // 最近一次自身写盘时间戳(ms):用于 vault.on('modify') 漏判兜底 + private logsFlushTimer: number | null = null; // 记录写盘防抖定时器(合并多次删除/编辑为一次整文件写) + + constructor(plugin: Plugin) { + this.plugin = plugin; + this.app = plugin.app; + this.events = new Events(); + this.settings = { ...DEFAULT_SETTINGS }; + this.csvStore = new CSVStore(this); + this.configStore = new ConfigStore(this); + } + + // 初始化:加载设置(私有 data)、迁移、加载记录(CSV)与配置(vault JSON)。 + async init(): Promise { + const saved = (await this.plugin.loadData()) as (Partial & { logs?: LogRow[]; config?: WorkoutConfig }) | null; + this.settings = { ...DEFAULT_SETTINGS, ...(saved || {}) }; + this.applySettingsMigration(); + + // 记录:优先从 vault CSV 读取;若 CSV 为空但旧版私有 data 有 logs,则一次性导入 CSV。 + // readAllWithStats 额外返回丢弃的脏行数(超长 fields 的幽灵行),用于判断是否需要落盘自愈。 + const initial = await this.csvStore.readAllWithStats(); + this.logsCache = initial.rows; + this.deletedIds = new Set(initial.deletedIds); + const dropped = initial.dropped; + const hadRows = this.logsCache.length > 0; + // 自净:剔除损坏行(字段超大——通常因脏 CSV 未闭合引号把多行吞进一个单元格, + // 使某一行 id/fields 变成数百 KB 巨串)与重复 id 的幽灵行。若不清除,每次 writeAll + // 都会把巨串放大写回,导致 Obsidian 主线程长时间阻塞、删除记录时整块卡死。 + const wasDirty = this.sanitizeLogsCache(); + + if (!hadRows && saved?.logs && saved.logs.length > 0) { + this.logsCache = saved.logs; + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } finally { + this.setSelfWriting(false); + } + } else if (wasDirty || dropped > 0) { + // 自净后落盘,确保磁盘 CSV 也被修正(丢弃的脏行不再写回),避免下次读取再次解析出脏行 + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } finally { + this.setSelfWriting(false); + } + } + + // CSV 表头自愈:若磁盘是旧版 9 列表头,整体重写归一为 CSV_HEADER, + // 并把兜底生成的 id 持久化,避免后续 appendRow 追加 7 列行造成列错位/数据损坏。 + await this.normalizeCsvIfNeeded(); + + // 配置:vault JSON 优先;若没有则用旧版私有 data 的 config 兜底导入。 + await this.configStore.ensureLoaded(saved?.config); + } + + // CSV 迁移自愈:若磁盘表头不是当前规范表头(旧版结构),用缓存整体重写, + // 归一表头并持久化兜底 id,防止「旧文件 + 新追加行」列错位导致数据损坏。 + private async normalizeCsvIfNeeded(): Promise { + if (!(await this.csvStore.isHeaderStale())) return; + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } finally { + this.setSelfWriting(false); + } + } + + // 兼容旧版设置字段名(升级保障):把旧的 dataDirectory / csvPath / configPath 映射到新目录字段。 + private applySettingsMigration(): void { + const s = this.settings as unknown as Record; + if (typeof s['dataDirectory'] === 'string' && s['dataDirectory']) { + const oldDir = s['dataDirectory'] as string; + if (!this.settings.csvDirectory) this.settings.csvDirectory = oldDir; + if (!this.settings.configDirectory) this.settings.configDirectory = oldDir; + } + if (typeof s['csvPath'] === 'string' && s['csvPath']) { + const parts = (s['csvPath'] as string).split('/'); + parts.pop(); + const dir = parts.join('/'); + if (!this.settings.csvDirectory) this.settings.csvDirectory = dir; + } + if (typeof s['configPath'] === 'string' && s['configPath']) { + const parts = (s['configPath'] as string).split('/'); + parts.pop(); + const dir = parts.join('/'); + if (!this.settings.configDirectory) this.settings.configDirectory = dir; + } + } + + // 获取设置(仅内存)。 + getSettings(): PluginSettings { + return this.settings; + } + + // 保存设置到私有 data,并广播 settings-changed。 + async saveSettings(): Promise { + await this.plugin.saveData(this.settings); + this.emit('settings-changed', this.settings); + } + + // 重新从 CSV 加载记录到缓存(外部改文件时调用)。 + // 同步刷新 deletedIds,确保软删除墓碑在外部文件变动后仍被正确过滤。 + async reloadLogs(): Promise { + const r = await this.csvStore.readAllWithStats(); + this.logsCache = r.rows; + this.deletedIds = new Set(r.deletedIds); + } + + // 重新从 vault JSON 加载配置到缓存(外部改文件时调用,先清缓存再读)。 + async reloadConfig(): Promise { + this.configStore.clearCache(); + await this.configStore.load(); + } + + // 返回记录副本,避免外部改动影响内部缓存。 + // 防御性过滤软删除 id:即便缓存意外混入已删行,渲染/统计也看不到。 + getLogs(): LogRow[] { + return this.logsCache.filter((r) => !this.deletedIds.has(r.id)); + } + + // —— selfWriting 控制 —— + // 关键修复点:写盘的「开始」与「结束」都刷新 lastSelfWriteAt 时间戳。 + // 旧实现只在 setSelfWriting(true)(写盘开始)时刷新;一旦 CSV 较大、整文件重写本身 + // 耗时超过 1.5s,vault.on('modify') 在写盘【结束后】派发时,时间戳已超出窗口 → 被误判为 + // 「外部手动改文件」→ 触发 reloadLogs()(整文件重读)+ rerenderAllBlocks()(重算 320KB 热力图), + // 造成删除记录后 Obsidian 主线程卡死、丢光标、无法输入。 + // 改为「结束也刷新」后,无论写盘耗时多久,modify 事件派发时时间戳都落在窗口内,保护必然生效。 + private setSelfWriting(v: boolean): void { + this.selfWriting = v; + this.lastSelfWriteAt = Date.now(); + } + // 自身写盘是否「刚刚发生」(默认 2s 内,留足事件派发延迟余量)。 + // 关键:Obsidian 的 vault.on('modify') 事件是在写盘完成后才异步派发的,此时 selfWriting + // 早已被复位为 false,仅靠 isSelfWriting() 会漏判,导致自身写盘被当成「外部改文件」, + // 进而触发整文件重读 + 全量重渲染(对大 CSV / 320KB 肌肉 SVG 热力图是致命卡顿)。 + // 用时间戳兜底(且结束也刷新,见 setSelfWriting),确保自身写盘一定被跳过, + // data-changed 已做的精准重渲染足够。 + wasSelfWrittenRecently(windowMs = 2000): boolean { + return Date.now() - this.lastSelfWriteAt < windowMs; + } + // 供 main.ts 的 vault.on('modify') 监听判断是否「插件自身正在写盘」。 + isSelfWriting(): boolean { + return this.selfWriting; + } + + // 记录 CSV 文件路径(供 main.ts 监听匹配)。 + getCsvPath(): string { + const dir = this.settings.csvDirectory || ''; + return dir ? `${dir}/${CSV_FILENAME}` : CSV_FILENAME; + } + // 配置 JSON 文件路径(供 main.ts 监听匹配)。 + getConfigPath(): string { + const dir = this.settings.configDirectory || ''; + return dir ? `${dir}/${CONFIG_FILENAME}` : CONFIG_FILENAME; + } + + // 生成一个与现有记录不重复的短 id(默认基于内存缓存 + 软删除集合,避免与已删 id 撞车; + // 也可传入已占用集合做批量去重,此时同样并入 deletedIds)。 + private uniqueLogId(taken: Set = new Set([...this.logsCache.map((r) => r.id), ...this.deletedIds])): string { + let id = generateId(); + while (taken.has(id)) id = generateId(); + return id; + } + + // 新增一条记录:补全 id(timestamp 可由调用方覆盖,缺省取当前时间),O(1) 追加到 CSV,同步缓存,广播 data-changed。 + async addLog(row: Omit & { timestamp?: string }): Promise { + const now = new Date(); + const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + const id = this.uniqueLogId(); + const logRow: LogRow = { + id, + timestamp, + ...row, + }; + this.setSelfWriting(true); + try { + await this.csvStore.appendRow(logRow); // Vault.append,O(1),不重写整文件 + } finally { + this.setSelfWriting(false); + } + this.logsCache.push(logRow); + this.emit('data-changed', { type: 'add', row: logRow }); + } + + // 批量新增「计划」记录:一次合并后整体持久化(只写盘一次)。每条独立生成稳定 id。 + async addPlanLogs(rows: Omit[]): Promise { + const now = new Date(); + const taken = new Set([...this.logsCache.map((r) => r.id), ...this.deletedIds]); + const logRows: LogRow[] = rows.map((row, i) => { + const time = new Date(now.getTime() + i * 60000); + const timestamp = `${time.getFullYear()}-${String(time.getMonth() + 1).padStart(2, '0')}-${String(time.getDate()).padStart(2, '0')} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`; + const id = this.uniqueLogId(taken); + taken.add(id); + return { ...row, id, timestamp }; + }); + const merged = [...this.logsCache, ...logRows]; + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(merged); + } finally { + this.setSelfWriting(false); + } + this.logsCache = merged; + this.emit('data-changed', { type: 'add-plan', rows: logRows }); + } + + // 训练记录写盘防抖:把短时间内的多次 updateLog 合并成「一次」整文件写盘。 + // 注意:删除已改为「软删除」(appendTombstone,O(1) 追加墓碑),不再触发本防抖重写, + // 因此本机制如今只为编辑(updateLog)合并写盘用——编辑本就需改中间行、属低频操作。 + // 界面刷新走 data-changed(即时),持久化走此处(合并),互不阻塞。 + // 关键:立即标记 selfWriting 开始时间戳,使 wasSelfWrittenRecently() 能覆盖从「调度写盘」到 + // 「写盘完成」的整个窗口,避免 vault.on('modify') 在防抖期间误判为外部修改触发重复重渲染。 + private scheduleLogsFlush(): void { + if (this.logsFlushTimer !== null) return; + this.lastSelfWriteAt = Date.now(); + this.logsFlushTimer = window.setTimeout(() => { + this.logsFlushTimer = null; + void this.flushLogsToDisk(); + }, 200); + } + + // 实际整文件写盘(防抖到点后执行)。写盘期间 selfWriting=true,避免 vault modify 监听重复刷新。 + // 仅由 updateLog 触发(edit 需改中间行)。软删除走 appendTombstone,不经过此处。 + private async flushLogsToDisk(): Promise { + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } catch (e) { + console.error('[workout] 训练记录写盘失败:', e); + } finally { + this.setSelfWriting(false); + } + } + + // 更新一条记录:按稳定主键 id 定位旧行,合并更新后广播变化,写盘走防抖合并(编辑需改中间行,属低频)。 + async updateLog(id: string, updates: Partial): Promise { + const index = this.logsCache.findIndex((r) => r.id === id); + if (index !== -1) { + this.logsCache[index] = { ...this.logsCache[index], ...updates }; + } + const updated = index !== -1 ? this.logsCache[index] : ({ ...updates, id } as LogRow); + this.emit('data-changed', { type: 'update', row: updated }); + this.scheduleLogsFlush(); + } + + // 软删除一条记录:不再整文件重写,而是「内存移除 + O(1) 追加墓碑」。 + // 1) 从内存缓存移除该行(所有渲染/统计都走 getLogs,自动过滤,界面即时消失); + // 2) 把 id 记入 deletedIds(用于缓存过滤与后续 id 分配去重); + // 3) 仅追加一行 deleted=true 的墓碑到 CSV(vault.append,O(1)),不重写整文件 + // —— 这彻底消灭了「删除必须整文件重写 → 大 CSV 卡死主线程、丢光标、打不了字」的问题; + // 4) 即时广播 data-changed(不依赖写盘),表格里那一行立刻消失。 + // 真正的整文件压缩(移除墓碑、释放体积)由设置页「压缩清理 CSV」按钮按需触发(compactLogs)。 + async deleteLog(id: string): Promise { + const removed = this.logsCache.find((r) => r.id === id); + if (!removed) return; // 已删除或不存在,避免重复追加墓碑 + this.logsCache = this.logsCache.filter((r) => r.id !== id); + this.deletedIds.add(id); + this.setSelfWriting(true); + try { + await this.csvStore.appendTombstone(id); + } catch (e) { + console.error('[workout] 追加删除墓碑失败:', e); + } finally { + this.setSelfWriting(false); + } + this.emit('data-changed', { type: 'delete', row: removed }); + } + + // 整文件压缩清理:以内存中的「存活记录」为准重写 CSV, + // 丢弃所有软删除墓碑行与被删记录的残留行,真正释放体积并规整文件。 + // 由设置页「压缩清理 CSV」按钮在用户主动触发时执行(O(n) 整写一次,可接受)。 + // 返回被彻底移除的删除记录条数(供 UI 提示)。 + async compactLogs(): Promise { + const removed = this.deletedIds.size; + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } catch (e) { + console.error('[workout] 压缩清理 CSV 失败:', e); + return 0; + } finally { + this.setSelfWriting(false); + } + this.deletedIds.clear(); + // 通知所有代码块刷新(记录集变化,覆盖 log / day / heatmap) + this.emit('data-changed', { type: 'bulk-update' }); + return removed; + } + + // 剔除损坏的日志记录:核心字段缺失、超长或重复均视为脏数据丢弃。 + // 相同 id 只保留第一条,后续重复行(脏 CSV 未闭合引号制造的幽灵行)一并丢弃。 + // 返回是否发生过剔除,便于 init() 决定是否落盘修正。 + private sanitizeLogsCache(): boolean { + const MAX_ID = 2000; + const MAX_FIELDS = 10000; + const seen = new Set(); + const before = this.logsCache.length; + this.logsCache = this.logsCache.filter((r) => { + // 关键字段缺失:id / timestamp / category 任意为空即无法正确渲染或持久化,直接丢弃。 + if (!r.id || !r.timestamp || !r.category) return false; + const idLen = String(r.id).length; + const fieldsLen = r.fields ? JSON.stringify(r.fields).length : 0; + if (idLen > MAX_ID || fieldsLen > MAX_FIELDS) return false; + // 去重:重复 id 只保留第一条(幽灵/损坏行丢弃),避免脏数据撑大文件或干扰查询。 + if (seen.has(r.id)) return false; + seen.add(r.id); + return true; + }); + return this.logsCache.length !== before; + } + + // 标记某计划某训练项的某组为「已完成」(持久化到计划配置,独立于训练记录)。 + // 即使之后删除了该组产生的训练记录,完成状态仍保留——满足「完成即完成」的预期。 + async markPlanSetCompleted(plan: TrainingPlanInstance, exerciseId: string, setId: string): Promise { + const config = await this.getConfig(); + const target = config.plans?.find((p) => p.id === plan.id); + if (!target) return; + if (!target.completedSets) target.completedSets = {}; + target.completedSets[`${exerciseId}#${setId}`] = todayStr(); + await this.saveConfig(config); + } + + // 读取配置(来自 vault JSON 缓存或加载)。 + async getConfig(): Promise { + return await this.configStore.load(); + } + + // 保存配置并广播 config-changed(写盘期间 selfWriting=true,避免监听重复刷新)。 + async saveConfig(config: WorkoutConfig): Promise { + this.setSelfWriting(true); + try { + await this.configStore.save(config); + } finally { + this.setSelfWriting(false); + } + this.emit('config-changed', config); + } + + // —— 以下是一组对配置里「训练类型 / 训练项 / 肌肉」的增删改封装 —— + async addTrainingType(type: TrainingType): Promise { + const config = await this.getConfig(); + config.trainingTypes.push(type); + await this.saveConfig(config); + } + + async updateTrainingType(id: string, updates: Partial): Promise { + const config = await this.getConfig(); + const index = config.trainingTypes.findIndex((t) => t.id === id); + if (index !== -1) { + config.trainingTypes[index] = { ...config.trainingTypes[index], ...updates }; + await this.saveConfig(config); + } + } + + // 重命名训练类型 ID,并级联更新所有关联引用: + // 1) 配置里该类型的 id 改为 newId(其余字段按 updates 合并); + // 2) 所有训练记录的 category === oldId → newId; + // 3) 所有训练项的 category === oldId → newId(训练项归属该类型); + // 4) 所有统计的 associatedTypes 里出现的 oldId → newId(统计按类型关联)。 + // 用于「编辑训练类型时改了 ID」的场景,保证记录/训练项/统计都不被断联。 + async renameTrainingType(oldId: string, newId: string, updates: Partial): Promise { + const config = await this.getConfig(); + const index = config.trainingTypes.findIndex((t) => t.id === oldId); + if (index !== -1) { + config.trainingTypes[index] = { ...config.trainingTypes[index], ...updates, id: newId }; + await this.saveConfig(config); // saveConfig 内部会广播 config-changed + } + + // 级联更新记录里的 category + let changed = false; + this.logsCache = this.logsCache.map((r) => { + if (r.category === oldId) { + changed = true; + return { ...r, category: newId }; + } + return r; + }); + + // 级联更新训练项的 category(训练项归属该类型) + let exercisesChanged = false; + config.exercises = config.exercises.map((e) => { + if (e.category === oldId) { + exercisesChanged = true; + return { ...e, category: newId }; + } + return e; + }); + + // 级联更新统计的 associatedTypes(统计按训练类型关联,可能含多个 id) + let statsChanged = false; + config.statistics = (config.statistics || []).map((s) => { + if (s.associatedTypes && s.associatedTypes.includes(oldId)) { + statsChanged = true; + return { + ...s, + associatedTypes: s.associatedTypes.map((t) => (t === oldId ? newId : t)), + }; + } + return s; + }); + + if (exercisesChanged || statsChanged) { + await this.saveConfig(config); // 二次持久化(配置侧);已含 config-changed 广播 + } + + if (changed) { + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } finally { + this.setSelfWriting(false); + } + // 不带 row,main.ts 会退化为全量重渲染(覆盖所有引用该 ID 的表格) + this.emit('data-changed', { type: 'bulk-update' }); + } + } + + async deleteTrainingType(id: string): Promise { + const config = await this.getConfig(); + config.trainingTypes = config.trainingTypes.filter((t) => t.id !== id); + await this.saveConfig(config); + } + + async addExercise(exercise: Exercise): Promise { + const config = await this.getConfig(); + config.exercises.push(exercise); + await this.saveConfig(config); + } + + async updateExercise(id: string, updates: Partial): Promise { + const config = await this.getConfig(); + const index = config.exercises.findIndex((e) => e.id === id); + if (index !== -1) { + config.exercises[index] = { ...config.exercises[index], ...updates }; + await this.saveConfig(config); + } + } + + // 重命名训练项 ID,并级联更新所有关联记录: + // 1) 配置里该训练项的 id 改为 newId(其余字段按 updates 合并); + // 2) CSV 中所有 exerciseId === oldId 的记录批量改为 newId,整体写盘。 + // 用于「编辑训练项时改了 ID」的场景,保证历史记录不被断联。 + async renameExercise(oldId: string, newId: string, updates: Partial): Promise { + const config = await this.getConfig(); + const index = config.exercises.findIndex((e) => e.id === oldId); + if (index !== -1) { + config.exercises[index] = { ...config.exercises[index], ...updates, id: newId }; + await this.saveConfig(config); // saveConfig 内部会广播 config-changed + } + + // 级联更新记录里的 exerciseId + let changed = false; + this.logsCache = this.logsCache.map((r) => { + if (r.exerciseId === oldId) { + changed = true; + return { ...r, exerciseId: newId }; + } + return r; + }); + + if (changed) { + this.setSelfWriting(true); + try { + await this.csvStore.writeAll(this.logsCache); + } finally { + this.setSelfWriting(false); + } + // 不带 row,main.ts 会退化为全量重渲染(覆盖所有引用该 ID 的表格) + this.emit('data-changed', { type: 'bulk-update' }); + } + } + + // 删除训练项:先级联软删除其关联的全部训练记录(exerciseId === id),再移除训练项本身。 + // 级联删除沿用 deleteLog 的软删除策略(移除缓存行 + 记墓碑 id + O(1) 批量追加墓碑), + // 避免整文件重写卡顿;真正释放体积沿用「压缩清理 CSV」按需执行。 + // 这样「训练项还有训练记录时删除该训练项」不会再留下孤儿记录。 + async deleteExercise(id: string): Promise { + // 1) 找出该训练项关联的全部训练记录并软删除 + const related = this.logsCache.filter((r) => r.exerciseId === id); + if (related.length > 0) { + this.logsCache = this.logsCache.filter((r) => r.exerciseId !== id); + for (const r of related) this.deletedIds.add(r.id); + this.setSelfWriting(true); + try { + await this.csvStore.appendTombstones(related.map((r) => r.id)); + } catch (e) { + console.error('[workout] 级联删除训练记录墓碑失败:', e); + } finally { + this.setSelfWriting(false); + } + // 训练记录变化:无 row → main.ts 退化为全量重渲染所有 log/day/heatmap 代码块 + this.emit('data-changed', { type: 'bulk-update' }); + } + + // 2) 移除训练项本身并落盘(saveConfig 会广播 config-changed,触发项目列表与代码块刷新) + const config = await this.getConfig(); + config.exercises = config.exercises.filter((e) => e.id !== id); + await this.saveConfig(config); + } + + async addMuscle(muscle: Muscle): Promise { + const config = await this.getConfig(); + config.muscles.push(muscle); + await this.saveConfig(config); + } + + async updateMuscle(id: string, updates: Partial): Promise { + const config = await this.getConfig(); + const index = config.muscles.findIndex((m) => m.id === id); + if (index !== -1) { + config.muscles[index] = { ...config.muscles[index], ...updates }; + await this.saveConfig(config); + } + } + + async deleteMuscle(id: string): Promise { + const config = await this.getConfig(); + config.muscles = config.muscles.filter((m) => m.id !== id); + await this.saveConfig(config); + } + + // —— 训练计划(聚合进 workout-config.json 的 plans 字段)—— + // 返回全部训练计划实例(无则空数组)。 + async getPlans(): Promise { + const config = await this.getConfig(); + return config.plans ?? []; + } + + // 按计划名(全局唯一)取单个计划实例。 + async getPlanByName(name: string): Promise { + const config = await this.getConfig(); + return config.plans?.find((p) => p.name === name); + } + + // 校验计划名是否已被占用(编辑时传入 exceptId 排除自身)。 + async isPlanNameTaken(name: string, exceptId?: string): Promise { + const config = await this.getConfig(); + return (config.plans ?? []).some((p) => p.name === name && p.id !== exceptId); + } + + // 新增或更新计划:以 name 为主键(全局唯一)。已存在则覆盖,否则追加。 + async upsertPlan(plan: TrainingPlanInstance): Promise { + const config = await this.getConfig(); + if (!config.plans) config.plans = []; + // 以稳定的 id 为主键(而非 name)。编辑计划时若改名,按 name 匹配会找不到旧条目、 + // 导致新增一条同 id 的重复计划、旧条目残留(配置损坏);按 id 匹配则原地更新。 + const idx = config.plans.findIndex((p) => p.id === plan.id); + const oldName = idx >= 0 ? config.plans[idx].name : undefined; + if (idx >= 0) config.plans[idx] = plan; + else config.plans.push(plan); + await this.saveConfig(config); + // 级联改名:编辑计划时若改了名称,把所有引用旧名的 workout-plan 代码块改写到新名(不动 CSV 历史) + if (oldName && oldName !== plan.name) { + try { + await this.renamePlanInCodeBlocks(oldName, plan.name); + } catch (e) { + console.error('[workout] 级联更新计划代码块失败:', e); + } + } + } + + // 级联改名:扫描 vault 内所有 markdown 文件,把引用了 oldName 的 workout-plan 代码块的 + // plan: 参数改写为 newName。只改代码块、不触碰 CSV 历史记录。 + private async renamePlanInCodeBlocks(oldName: string, newName: string): Promise { + const files = this.app.vault.getMarkdownFiles(); + let updated = 0; + for (const file of files) { + const content = await this.app.vault.read(file); + const lines = content.split('\n'); + let changed = false; + let i = 0; + while (i < lines.length) { + if (lines[i].trim().startsWith('```workout-plan')) { + // 在代码块内查找 plan: 参数行(只处理第一个) + let j = i + 1; + let closing = -1; + while (j < lines.length) { + if (lines[j].trim().startsWith('```')) { closing = j; break; } + const trimmed = lines[j].trim(); + const colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) { + const key = trimmed.slice(0, colonIdx).trim(); + if (key === 'plan') { + const val = trimmed.slice(colonIdx + 1).trim(); + if (val === oldName) { + lines[j] = 'plan: ' + newName; + changed = true; + updated++; + } + break; // 只处理第一个 plan: 参数 + } + } + j++; + } + i = closing >= 0 ? closing + 1 : lines.length; + } else { + i++; + } + } + if (changed) { + await this.app.vault.modify(file, lines.join('\n')); + } + } + return updated; + } + + // 删除计划(不影响已写 CSV 记录)。 + async deletePlan(name: string): Promise { + const config = await this.getConfig(); + if (!config.plans) return; + config.plans = config.plans.filter((p) => p.name !== name); + await this.saveConfig(config); + } + + // 更新某计划内某训练项某组的预设字段(组内编辑「未完成」组保存时调用,只改预设不写库)。 + async updatePlanSetFields(planName: string, exerciseId: string, setId: string, fields: Record): Promise { + const config = await this.getConfig(); + const plan = config.plans?.find((p) => p.name === planName); + if (!plan) return; + const item = plan.items.find((i) => i.exerciseId === exerciseId && i.enabled); + const set = item?.sets.find((s) => s.id === setId); + if (set) set.fields = fields; + await this.saveConfig(config); + } + + // 记忆上次输入值:返回最近一次该训练项的字段值,供下次自动填充。 + getLastValues(exerciseId?: string): Record | null { + if (!this.settings.lastValueMemory) { + return null; + } + if (!exerciseId) { + return null; + } + const logs = this.logsCache.filter((l) => l.exerciseId === exerciseId && l.timestamp); + if (logs.length === 0) { + return null; + } + logs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')); + return logs[0].fields; + } + + // 订阅事件(重载签名:分别约束三种事件的回调参数类型)。 + on(event: 'data-changed', callback: (data: { type: string; row?: LogRow; rows?: LogRow[]; id?: string }) => void): void; + on(event: 'config-changed', callback: (config: WorkoutConfig) => void): void; + on(event: 'settings-changed', callback: (settings: PluginSettings) => void): void; + on(event: string, callback: (...args: any[]) => void): void { + this.events.on(event, callback); + } + + // 取消订阅事件 + off(event: string, callback: (...args: unknown[]) => void): void { + this.events.off(event, callback); + } + + // 内部触发事件 + private emit(event: 'data-changed', data: { type: string; row?: LogRow; rows?: LogRow[]; id?: string }): void; + private emit(event: 'config-changed', config: WorkoutConfig): void; + private emit(event: 'settings-changed', settings: PluginSettings): void; + private emit(event: string, ...args: unknown[]): void { + this.events.trigger(event, ...args); + } +} diff --git a/src/data/display.test.ts b/src/data/display.test.ts new file mode 100644 index 0000000..669583b --- /dev/null +++ b/src/data/display.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +/* + * display.test.ts —— display.ts 单元测试 + * 重点覆盖:训练项显示名解析、多语言反向查找、记录字段格式化等纯函数逻辑。 + */ + +describe('display helpers', async () => { + const { getDefaultConfig } = await import('./seed'); + const { setLocale } = await import('../i18n'); + const { resolveExerciseByName, resolveExerciseIdByName } = await import('./display'); + + beforeEach(() => { + // 每个用例前重置回中文,避免用例间语言状态互相影响 + setLocale('zh'); + }); + + // 用例 L1:中文界面下,用中文名能解析到默认训练项 + it('L1: resolves default exercise by Chinese name in zh locale', () => { + const config = getDefaultConfig(); + const exercise = resolveExerciseByName(config, '深蹲'); + expect(exercise?.id).toBe('squat'); + expect(resolveExerciseIdByName(config, '深蹲')).toBe('squat'); + }); + + // 用例 L2:英文界面下,代码块里仍用中文名(未随语言切换)时,也能解析到默认训练项 + // 这是修复中英文切换后默认训练项查不到记录的关键回归点 + it('L2: resolves default exercise by Chinese name even in en locale', () => { + setLocale('en'); + const config = getDefaultConfig(); + const exercise = resolveExerciseByName(config, '深蹲'); + expect(exercise?.id).toBe('squat'); + expect(resolveExerciseIdByName(config, '深蹲')).toBe('squat'); + }); + + // 用例 L3:中文界面下,用英文名也应能解析到默认训练项 + it('L3: resolves default exercise by English name in zh locale', () => { + const config = getDefaultConfig(); + expect(resolveExerciseByName(config, 'Squat')?.id).toBe('squat'); + expect(resolveExerciseIdByName(config, 'Bench Press')).toBe('bench_press'); + }); + + // 用例 L4:自定义训练项(只有固定 name,没有 nameKey)按 name 匹配 + it('L4: resolves custom exercise by its plain name', () => { + const config = getDefaultConfig(); + config.exercises.push({ + id: 'custom_test', + name: '测试训练', + category: 'strength', + }); + setLocale('en'); + expect(resolveExerciseByName(config, '测试训练')?.id).toBe('custom_test'); + }); + + // 用例 L5:按训练项 id 也能匹配 + it('L5: resolves exercise by its id', () => { + const config = getDefaultConfig(); + expect(resolveExerciseByName(config, 'deadlift')?.id).toBe('deadlift'); + }); +}); diff --git a/src/data/display.ts b/src/data/display.ts new file mode 100644 index 0000000..f29c8bf --- /dev/null +++ b/src/data/display.ts @@ -0,0 +1,173 @@ +import { t, getAllTranslations } from '../i18n'; +import { Exercise, FieldDef, LogRow, Muscle, TimeRule, TrainingType, WorkoutConfig } from './types'; +import { formatDuration } from '../util/duration'; +import { formatMass } from '../util/units'; + +/* + * display.ts —— "显示名解析"工具集。 + * 配置里的名称优先用 nameKey(国际化 key,由 i18n 函数 t 翻译成当前语言), + * 没有 nameKey 才用 name 字段。本文件还提供字段标签、单位、字段值的格式化函数, + * 负责把"程序存起来的数据"变成"用户看得懂的文字"。 + */ + +// 默认肌肉 -> 国际化 key 的映射表(当配置里没写 nameKey 时作为兜底) +export const DEFAULT_MUSCLE_NAME_KEYS: Record = { + chest: 'muscle.chest', + front_delt: 'muscle.front_delt', + biceps: 'muscle.biceps', + quads: 'muscle.quads', + front_calf: 'muscle.front_calf', + abs: 'muscle.abs', + lats: 'muscle.lats', + traps: 'muscle.traps', + rear_delt: 'muscle.rear_delt', + triceps: 'muscle.triceps', + hamstrings: 'muscle.hamstrings', + glutes: 'muscle.glutes', + back_calf: 'muscle.back_calf', +}; + +// 默认训练项 -> 国际化 key 映射 +export const DEFAULT_EXERCISE_NAME_KEYS: Record = { + squat: 'exercise.squat', + deadlift: 'exercise.deadlift', + bench_press: 'exercise.bench_press', + pushup: 'exercise.pushup', + pullup: 'exercise.pullup', + plank: 'exercise.plank', + running: 'exercise.running', + jumping_rope: 'exercise.jumping_rope', + cycling: 'exercise.cycling', +}; + +// 默认训练类型 -> 国际化 key 映射 +export const DEFAULT_TRAINING_TYPE_NAME_KEYS: Record = { + strength: 'type.strength', + aerobic: 'type.aerobic', + bodyweight: 'type.bodyweight', +}; + +// 取训练类型的显示名:nameKey 优先(翻译成当前语言),否则用 name/id 兜底。 +export function getTrainingTypeName(type?: TrainingType): string { + if (!type) return ''; + return type.nameKey ? t(type.nameKey) : type.name || type.id; +} + +// 取训练项显示名 +export function getExerciseName(exercise?: Exercise): string { + if (!exercise) return ''; + return exercise.nameKey ? t(exercise.nameKey) : exercise.name || exercise.id; +} + +// 取肌肉显示名 +export function getMuscleName(muscle?: Muscle): string { + if (!muscle) return ''; + return muscle.nameKey ? t(muscle.nameKey) : muscle.name || muscle.id; +} + +// 取字段标签(展示给用户看的字段名) +export function getFieldLabel(field: FieldDef): string { + return field.labelKey ? t(field.labelKey) : field.label || field.key; +} + +// 取字段单位文字:质量类返回用户设置的 kg/lb;其余返回自由单位文字(unitLabel)。 +export function getFieldUnit(field: FieldDef, unit: 'kg' | 'lb'): string { + if (field.mass) return unit; // 质量跟随插件单位设置(kg 或 lb) + if (field.unitLabel) return field.unitLabel; // 自由单位文字(如"次""层""圈") + return ''; +} + +// 把字段值格式化成展示文字:时长用 formatDuration,质量用 formatMass(含单位换算),其余直接转字符串。 +export function renderFieldValue(value: unknown, field: FieldDef, unit: 'kg' | 'lb'): string { + if (value === undefined || value === null) return ''; + + switch (field.inputType) { + case 'duration': + return formatDuration(Number(value)); // 秒数 -> "1分30秒"这类可读文本 + case 'number': + if (field.mass) { + return formatMass(Number(value), unit); // 质量按 kg/lb 换算并显示单位 + } + return String(value); + case 'text': + return String(value); + default: + return String(value); + } +} + +// 按 id 在训练项列表里查找并返回显示名(找不到返回空字符串) +export function getExerciseNameById(exercises: Exercise[], id?: string): string { + if (!id) return ''; + const exercise = exercises.find((e) => e.id === id); + return exercise ? getExerciseName(exercise) : ''; +} + +// 把计划时间规则渲染成可读文字:指定日期直接返回日期;每周模式按 ISO 周几 +// (周一=1 … 周日=7)拼成「每周 一、三、五」这样的提示。供「新增训练计划」弹窗 +// 与 workout-plan 完成面板复用,避免各模块各自拼装造成表述不一致。 +export function formatTimeRule(rule: TimeRule): string { + if (rule.type === 'date') { + if (!rule.date) return ''; + return rule.date; + } + const labels = ['一', '二', '三', '四', '五', '六', '日']; // ISO 周一=1 … 周日=7 + const days = (rule.weekdays ?? []) + .slice() + .sort((a, b) => a - b) + .map((d) => (d >= 1 && d <= 7 ? labels[d - 1] : String(d))); + if (days.length === 0) return t('modal.newPlan.weekdayNone'); + return t('modal.newPlan.weekdayHint', { days: days.join('、') }); +} + +// 按「名字/显示名/id」在配置里反查训练项对象。 +// 用于把代码块里的 `exercise: 深蹲` 或 `exercise: Squat` 解析成稳定的训练项 id, +// 解决多语言切换后按「当前语言显示名」匹配不到历史记录的问题。 +export function resolveExerciseByName(config: WorkoutConfig, name: string): Exercise | undefined { + if (!name) return undefined; + const lower = name.toLowerCase(); + return config.exercises.find((e) => { + if (e.id.toLowerCase() === lower) return true; + if ((e.name || '').toLowerCase() === lower) return true; + // 默认训练项用 nameKey 做国际化;按全部支持语言的显示名匹配, + // 这样中英文切换后,代码块里用任意一种语言写的名字都能查到记录。 + if (e.nameKey && getAllTranslations(e.nameKey).some((n) => n.toLowerCase() === lower)) return true; + return false; + }); +} + +// 同上,但直接返回训练项 id(找不到返回 undefined)。 +export function resolveExerciseIdByName(config: WorkoutConfig, name: string): string | undefined { + return resolveExerciseByName(config, name)?.id; +} + +// 解析日志记录的运动显示名: +// 仅用 exerciseId(稳定,改名/切换语言后自动解析为最新显示名)。 +// 无 exerciseId 的旧数据无法关联配置,返回空串,由调用方决定兜底展示。 +export function resolveLogExerciseName(config: WorkoutConfig, log: LogRow): string { + if (log.exerciseId) { + const name = getExerciseNameById(config.exercises, log.exerciseId); + if (name) return name; // 找到了就返回最新显示名(始终随配置更新) + } + return ''; // 无 exerciseId(旧数据)时返回空 +} + +// 给配置里的训练类型/训练项/肌肉补齐默认 nameKey(仅在还没有 nameKey 时补,不覆盖已有值)。 +export function applyDefaultNameKeys(config: WorkoutConfig): WorkoutConfig { + config.trainingTypes = config.trainingTypes.map((type) => ({ + ...type, + nameKey: type.nameKey || DEFAULT_TRAINING_TYPE_NAME_KEYS[type.id], + })); + + config.exercises = config.exercises.map((exercise) => ({ + ...exercise, + nameKey: exercise.nameKey || DEFAULT_EXERCISE_NAME_KEYS[exercise.id], + })); + + config.muscles = config.muscles.map((muscle) => ({ + ...muscle, + nameKey: muscle.nameKey || DEFAULT_MUSCLE_NAME_KEYS[muscle.id], + })); + + return config; +} diff --git a/src/data/muscleMapping.ts b/src/data/muscleMapping.ts new file mode 100644 index 0000000..29abd41 --- /dev/null +++ b/src/data/muscleMapping.ts @@ -0,0 +1,98 @@ +import { Muscle, WorkoutConfig } from './types'; +import { SVG_MUSCLE_CATALOG } from './svgMuscleCatalog'; + +// 默认 13 块肌肉各自归属的健身群 key(用于「默认」档自动映射) +export const MUSCLE_FITNESS_GROUP: Record = { + chest: 'chest', + front_delt: 'shoulders', + biceps: 'biceps', + quads: 'quads', + front_calf: 'calves', + abs: 'abs', + lats: 'back', + traps: 'traps', + rear_delt: 'shoulders', + triceps: 'triceps', + hamstrings: 'hamstrings', + glutes: 'glutes', + back_calf: 'calves', +}; + +// 「精简」档:每块肌肉只保留 1~2 条代表性主路径 +export const MINIMAL_MAP: Record = { + chest: ['pectoralis_major_l', 'pectoralis_major_r'], + front_delt: ['anterior_deltoid_l', 'anterior_deltoid_r'], + biceps: ['biceps_brachii_caput_longum_l', 'biceps_brachii_caput_longum_r'], + quads: ['vastus_lateralis_l', 'vastus_lateralis_r', 'rectus_femoris_l', 'rectus_femoris_r'], + front_calf: ['tibialis_anterior_l', 'tibialis_anterior_r'], + abs: ['rectus_abdominis_1', 'rectus_abdominis_2_l', 'rectus_abdominis_2_r'], + lats: ['latissimus_dorsi_l', 'latissimus_dorsi_r'], + traps: ['trapezius_upper_l', 'trapezius_upper_r'], + rear_delt: ['posterior_deltoid_l', 'posterior_deltoid_r'], + triceps: ['triceps_brachii_caput_longum_l', 'triceps_brachii_caput_longum_r'], + hamstrings: ['biceps_femoris_l', 'biceps_femoris_r'], + glutes: ['gluteus_maximus_l', 'gluteus_maximus_r'], + back_calf: ['gastrocnemius_l', 'gastrocnemius_r'], +}; + +export type MappingTier = 'default' | 'minimal' | 'manual'; + +// 根据档位生成每块默认肌肉的 svgRegionIds +export function buildMappings(tier: MappingTier): Record { + const result: Record = {}; + for (const muscleId of Object.keys(MUSCLE_FITNESS_GROUP)) { + if (tier === 'manual') { + result[muscleId] = []; + continue; + } + if (tier === 'minimal') { + result[muscleId] = [...(MINIMAL_MAP[muscleId] ?? [])]; + continue; + } + // default:取该肌所属健身群的全部路径。 + // 同一 id 可能同时出现在 front 和 back 两个 SVG 文件里(目录里就有两条记录), + // 这里去重,避免热力图聚合时对同一路径重复累加 value 造成双倍计数。 + const group = MUSCLE_FITNESS_GROUP[muscleId]; + const seen = new Set(); + const ids: string[] = []; + for (const entry of SVG_MUSCLE_CATALOG) { + if (entry.fitnessGroup !== group) continue; + if (seen.has(entry.id)) continue; + seen.add(entry.id); + ids.push(entry.id); + } + result[muscleId] = ids; + } + return result; +} + +// 把某档映射直接写进配置(首次引导 / 重新套用预设用) +export function applyMappingTier(config: WorkoutConfig, tier: MappingTier): void { + const mappings = buildMappings(tier); + for (const muscle of config.muscles) { + if (mappings[muscle.id]) { + muscle.svgRegionIds = mappings[muscle.id]; + } + } +} + +// 读取某肌肉的 SVG 路径 id 集合(渲染层统一入口) +export function getSvgRegionIds(muscle: Muscle): string[] { + return muscle.svgRegionIds ?? []; +} + +// 由已映射路径的 side 派生肌肉 side(front / back / both)。 +// 同一 id 在目录里可能同时存在 front 和 back 两条记录,故需用 some() 收集两侧: +// 只要该 id 在某一侧出现过,就认为该肌肉在该侧有覆盖。 +export function deriveMuscleSide(muscle: Muscle): 'front' | 'back' | 'both' { + const ids = getSvgRegionIds(muscle); + const sides = new Set<'front' | 'back'>(); + for (const id of ids) { + if (SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === 'front')) sides.add('front'); + if (SVG_MUSCLE_CATALOG.some((e) => e.id === id && e.side === 'back')) sides.add('back'); + } + if (sides.size === 1) { + return sides.has('front') ? 'front' : 'back'; + } + return 'both'; +} diff --git a/src/data/planScanner.ts b/src/data/planScanner.ts new file mode 100644 index 0000000..a6a1f35 --- /dev/null +++ b/src/data/planScanner.ts @@ -0,0 +1,74 @@ +import { App, TFile } from 'obsidian'; +import { WorkoutConfig } from './types'; +import { resolveExerciseByName } from './display'; + +/* + * planScanner.ts —— 训练方案(笔记)扫描工具 + * 「训练方案」= 含有 ```workout-plan 代码块的笔记。本文件提供两个能力: + * 1) findSchemeNotes:扫描 vault,找出所有训练方案笔记(供「新增训练计划」下拉选择)。 + * 2) extractSchemeExercises:从某方案笔记里提取所有 workout-log 代码块的 exercise, + * 解析为训练项列表(去重),作为计划的初始训练项。 + * 两者都用 cachedRead(Obsidian 缓存层),避免反复读盘;结果做进程内缓存。 + */ + +export interface SchemeNote { + path: string; + name: string; // basename(去掉 .md),即方案名 +} + +// 进程内缓存:扫描结果在本次会话内复用,避免每次打开弹窗都全量扫描。 +let schemeCache: SchemeNote[] | null = null; + +// 重置缓存(配置/笔记变化后可调用,强制下次重新扫描)。 +export function invalidateSchemeCache(): void { + schemeCache = null; +} + +// 找出所有含 ```workout-plan 代码块的笔记。 +export async function findSchemeNotes(app: App): Promise { + if (schemeCache) return schemeCache; + const notes: SchemeNote[] = []; + if (typeof app.vault.getMarkdownFiles !== 'function') return notes; + const files = app.vault.getMarkdownFiles(); + for (const file of files) { + try { + const content = await app.vault.cachedRead(file); + if (/```\s*workout-plan\b/.test(content)) { + notes.push({ path: file.path, name: file.basename }); + } + } catch { + // 单文件读取失败不影响整体 + } + } + notes.sort((a, b) => a.name.localeCompare(b.name)); + schemeCache = notes; + return notes; +} + +// 从方案笔记提取训练项:遍历所有 ```workout-log 代码块,解析 exercise 参数 → 训练项 id。 +export async function extractSchemeExercises( + app: App, + notePath: string, + config: WorkoutConfig +): Promise<{ exerciseId: string; category: string }[]> { + if (typeof app.vault.getAbstractFileByPath !== 'function') return []; + const file = app.vault.getAbstractFileByPath(notePath); + if (!(file instanceof TFile)) return []; + const content = await app.vault.cachedRead(file); + + const result: { exerciseId: string; category: string }[] = []; + const seen = new Set(); + const re = /```\s*workout-log[\s\S]*?```/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content))) { + const block = m[0]; + const exLine = block.match(/^\s*exercise:\s*(.+)$/m); + if (!exLine) continue; + const ex = resolveExerciseByName(config, exLine[1].trim()); + if (ex && !seen.has(ex.id)) { + seen.add(ex.id); + result.push({ exerciseId: ex.id, category: ex.category }); + } + } + return result; +} diff --git a/src/data/seed.ts b/src/data/seed.ts new file mode 100644 index 0000000..96ef62d --- /dev/null +++ b/src/data/seed.ts @@ -0,0 +1,468 @@ +import { Exercise, Muscle, StatDef, TrainingType, WorkoutConfig, PlanItem, TrainingPlanInstance } from './types'; +import { buildMappings } from './muscleMapping'; + +/* + * seed.ts —— 默认配置数据("种子数据")。 + * 插件首次运行、配置文件不存在时,就用这里的默认训练类型、肌肉、训练项生成初始配置。 + * 想增加默认动作或肌肉,改这里即可。每个对象用稳定 id 标识,改名不影响历史记录。 + */ + +// 默认采用「default」档映射,使热力图开箱即用,无需用户手动打开肌肉管理触发引导。 +const DEFAULT_MUSCLE_MAPPINGS = buildMappings('default'); + +// 默认训练类型:力量、有氧、自重三类,各自带要记录的字段。 +export const DEFAULT_TRAINING_TYPES: TrainingType[] = [ + { + id: 'strength', + nameKey: 'type.strength', + icon: 'dumbbell', + fields: [ + { key: 'weight', labelKey: 'field.weight', inputType: 'number', mass: true, required: true }, // 重量(参与 kg/lb 换算) + { key: 'reps', labelKey: 'field.reps', inputType: 'number', unitLabel: '次', required: true }, // 次数 + ], + contributesToCoverage: true, + }, + { + id: 'aerobic', + nameKey: 'type.aerobic', + icon: 'wind', + fields: [ + { key: 'duration_sec', labelKey: 'field.duration_sec', inputType: 'duration', required: true }, // 时长(秒)——跑步/骑行/跳绳等都先记时长 + { key: 'distance_km', labelKey: 'field.distance', inputType: 'number', unitLabel: '公里', required: false }, // 距离(跑步/骑行等,跳绳可留空) + ], + contributesToCoverage: false, + }, + { + id: 'bodyweight', + nameKey: 'type.bodyweight', + icon: 'person-standing', + fields: [ + { key: 'reps', labelKey: 'field.reps', inputType: 'number', unitLabel: '次', required: false }, // 次数(俯卧撑/引体向上等) + { key: 'duration_sec', labelKey: 'field.duration_sec', inputType: 'duration', required: false }, // 时长(平板支撑等支撑/静力类) + ], + contributesToCoverage: true, + }, +]; + +// 默认肌肉列表。svgRegionIds 已预填默认映射,使热力图开箱即用; +// 用户仍可在「肌肉管理」里重新套用「精简/手动」档覆盖。 +export const DEFAULT_MUSCLES: Muscle[] = [ + { id: 'chest', nameKey: 'muscle.chest', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.chest ?? [] }, + { id: 'front_delt', nameKey: 'muscle.front_delt', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.front_delt ?? [] }, + { id: 'biceps', nameKey: 'muscle.biceps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.biceps ?? [] }, + { id: 'quads', nameKey: 'muscle.quads', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.quads ?? [] }, + { id: 'front_calf', nameKey: 'muscle.front_calf', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.front_calf ?? [] }, + { id: 'abs', nameKey: 'muscle.abs', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.abs ?? [] }, + { id: 'lats', nameKey: 'muscle.lats', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.lats ?? [] }, + { id: 'traps', nameKey: 'muscle.traps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.traps ?? [] }, + { id: 'rear_delt', nameKey: 'muscle.rear_delt', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.rear_delt ?? [] }, + { id: 'triceps', nameKey: 'muscle.triceps', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.triceps ?? [] }, + { id: 'hamstrings', nameKey: 'muscle.hamstrings', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.hamstrings ?? [] }, + { id: 'glutes', nameKey: 'muscle.glutes', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.glutes ?? [] }, + { id: 'back_calf', nameKey: 'muscle.back_calf', contributesToCoverage: true, restThresholdDays: 7, svgRegionIds: DEFAULT_MUSCLE_MAPPINGS.back_calf ?? [] }, +]; + +// 默认数据统计:「组数」= 每条记录即一组,count() 即组数,作为热力图默认指标。 +export const DEFAULT_COUNT_STAT: StatDef = { + id: 'count', + name: '组数', + associatedTypes: ['strength', 'aerobic', 'bodyweight'], + formula: { mode: 'builder', builder: { kind: 'count' } }, + granularity: 'daily', + enabled: true, + heatmapDefault: true, + heatmapScale: [ + { color: '#3b82f6', max: 5 }, + { color: '#22c55e', max: 10 }, + { color: '#f97316', max: 20 }, + { color: '#ef4444', max: 40 }, + ], +}; + +// 力量训练「训练总量」:每日 Σ(次数 × 重量),衡量训练负荷。 +export const DEFAULT_VOLUME_STAT: StatDef = { + id: 'volume', + name: '训练总量', + associatedTypes: ['strength'], + formula: { mode: 'builder', builder: { kind: 'productSum', fieldA: 'reps', fieldB: 'weight' } }, + granularity: 'daily', + enabled: true, + heatmapDefault: false, +}; + +// 力量训练「1RM 估算」(Epley 公式):取最佳组的估算一次最大重量,量化进步。 +// 公式:weight × (1 + reps/30),对每条记录计算后取 max。 +// 适用范围: reps ≤ 10 时较准确;高次数组(>12)会高估,属已知局限。 +export const DEFAULT_1RM_STAT: StatDef = { + id: 'oneRepMax', + name: '1RM 估算', + associatedTypes: ['strength'], + formula: { mode: 'builder', builder: { kind: 'oneRepMax', weightField: 'weight', repsField: 'reps' } }, + granularity: 'daily', + enabled: true, + heatmapDefault: false, +}; + +// 默认训练项:每个动作归属某训练类型,并标注练到哪些肌肉、主练还是辅练。 +export const DEFAULT_EXERCISES: Exercise[] = [ + { + id: 'squat', + nameKey: 'exercise.squat', + category: 'strength', + muscles: [ + { muscleId: 'quads', role: 'primary' }, + { muscleId: 'glutes', role: 'secondary' }, + { muscleId: 'hamstrings', role: 'secondary' }, + ], + }, + { + id: 'deadlift', + nameKey: 'exercise.deadlift', + category: 'strength', + muscles: [ + { muscleId: 'hamstrings', role: 'primary' }, + { muscleId: 'glutes', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'bench_press', + nameKey: 'exercise.bench_press', + category: 'strength', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'front_delt', role: 'secondary' }, + { muscleId: 'triceps', role: 'secondary' }, + ], + }, + { + id: 'pushup', + nameKey: 'exercise.pushup', + category: 'bodyweight', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'triceps', role: 'secondary' }, + { muscleId: 'front_delt', role: 'secondary' }, + ], + }, + { + id: 'pullup', + nameKey: 'exercise.pullup', + category: 'bodyweight', + muscles: [ + { muscleId: 'lats', role: 'primary' }, + { muscleId: 'biceps', role: 'secondary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'plank', + nameKey: 'exercise.plank', + category: 'bodyweight', + muscles: [ + { muscleId: 'abs', role: 'primary' }, + { muscleId: 'quads', role: 'secondary' }, + ], + }, + { + id: 'running', + nameKey: 'exercise.running', + category: 'aerobic', + muscles: [ + { muscleId: 'quads', role: 'primary' }, + { muscleId: 'hamstrings', role: 'secondary' }, + { muscleId: 'front_calf', role: 'secondary' }, + ], + }, + { + id: 'jumping_rope', + nameKey: 'exercise.jumping_rope', + category: 'aerobic', + muscles: [ + { muscleId: 'front_calf', role: 'primary' }, + { muscleId: 'abs', role: 'secondary' }, + ], + }, + { + id: 'cycling', + nameKey: 'exercise.cycling', + category: 'aerobic', + muscles: [ + { muscleId: 'quads', role: 'primary' }, + { muscleId: 'hamstrings', role: 'secondary' }, + ], + }, + + // ===== 新增训练项(2026-07-14 大改:扩充力量训练动作库)===== + // 说明:以下动作均属「力量训练」(strength)。已与种子原有动作(深蹲/硬拉/卧推/引体向上/平板支撑/跑步/跳绳/骑行) + // 去重;「罗马尼亚硬拉」与表格里的「直腿硬拉」(romanian_deadlift) 视为同一动作,仅保留直腿硬拉。 + + // —— 推 / 胸 & 肩 & 肱三头 —— + { + id: 'dumbbell_bench_press', + nameKey: 'exercise.dumbbell_bench_press', + category: 'strength', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'front_delt', role: 'secondary' }, + { muscleId: 'triceps', role: 'secondary' }, + ], + }, + { + id: 'incline_dumbbell_press', + nameKey: 'exercise.incline_dumbbell_press', + category: 'strength', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'front_delt', role: 'secondary' }, + { muscleId: 'triceps', role: 'secondary' }, + ], + }, + { + id: 'close_grip_bench_press', + nameKey: 'exercise.close_grip_bench_press', + category: 'strength', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'triceps', role: 'secondary' }, + ], + }, + { + id: 'dip', + nameKey: 'exercise.dip', + category: 'strength', + muscles: [ + { muscleId: 'chest', role: 'primary' }, + { muscleId: 'triceps', role: 'secondary' }, + { muscleId: 'front_delt', role: 'secondary' }, + ], + }, + { + id: 'dumbbell_press', + nameKey: 'exercise.dumbbell_press', + category: 'strength', + muscles: [ + { muscleId: 'front_delt', role: 'primary' }, + { muscleId: 'triceps', role: 'secondary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'dumbbell_lateral_raise', + nameKey: 'exercise.dumbbell_lateral_raise', + category: 'strength', + muscles: [ + { muscleId: 'front_delt', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'barbell_front_raise', + nameKey: 'exercise.barbell_front_raise', + category: 'strength', + muscles: [ + { muscleId: 'front_delt', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'barbell_push_press', + nameKey: 'exercise.barbell_push_press', + category: 'strength', + muscles: [ + { muscleId: 'front_delt', role: 'primary' }, + { muscleId: 'triceps', role: 'secondary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'barbell_lying_triceps', + nameKey: 'exercise.barbell_lying_triceps', + category: 'strength', + muscles: [ + { muscleId: 'triceps', role: 'primary' }, + ], + }, + { + id: 'dumbbell_overhead_triceps', + nameKey: 'exercise.dumbbell_overhead_triceps', + category: 'strength', + muscles: [ + { muscleId: 'triceps', role: 'primary' }, + ], + }, + + // —— 拉 / 背 & 肱二头 —— + { + id: 'barbell_row', + nameKey: 'exercise.barbell_row', + category: 'strength', + muscles: [ + { muscleId: 'lats', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + { muscleId: 'biceps', role: 'secondary' }, + ], + }, + { + id: 'dumbbell_row', + nameKey: 'exercise.dumbbell_row', + category: 'strength', + muscles: [ + { muscleId: 'lats', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + { muscleId: 'biceps', role: 'secondary' }, + ], + }, + { + id: 'dumbbell_reverse_fly', + nameKey: 'exercise.dumbbell_reverse_fly', + category: 'strength', + muscles: [ + { muscleId: 'rear_delt', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'cable_face_pull', + nameKey: 'exercise.cable_face_pull', + category: 'strength', + muscles: [ + { muscleId: 'rear_delt', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + { muscleId: 'front_delt', role: 'secondary' }, + ], + }, + { + id: 'barbell_upright_row', + nameKey: 'exercise.barbell_upright_row', + category: 'strength', + muscles: [ + { muscleId: 'traps', role: 'primary' }, + { muscleId: 'front_delt', role: 'secondary' }, + { muscleId: 'biceps', role: 'secondary' }, + ], + }, + { + id: 'barbell_curl', + nameKey: 'exercise.barbell_curl', + category: 'strength', + muscles: [ + { muscleId: 'biceps', role: 'primary' }, + ], + }, + { + id: 'dumbbell_curl', + nameKey: 'exercise.dumbbell_curl', + category: 'strength', + muscles: [ + { muscleId: 'biceps', role: 'primary' }, + ], + }, + + // —— 腿 & 核心 —— + { + id: 'lunge', + nameKey: 'exercise.lunge', + category: 'strength', + muscles: [ + { muscleId: 'quads', role: 'primary' }, + { muscleId: 'glutes', role: 'secondary' }, + { muscleId: 'hamstrings', role: 'secondary' }, + ], + }, + { + id: 'romanian_deadlift', + nameKey: 'exercise.romanian_deadlift', + category: 'strength', + muscles: [ + { muscleId: 'hamstrings', role: 'primary' }, + { muscleId: 'glutes', role: 'primary' }, + { muscleId: 'traps', role: 'secondary' }, + ], + }, + { + id: 'hanging_leg_raise', + nameKey: 'exercise.hanging_leg_raise', + category: 'strength', + muscles: [ + { muscleId: 'abs', role: 'primary' }, + ], + }, + { + id: 'ab_crunch', + nameKey: 'exercise.ab_crunch', + category: 'strength', + muscles: [ + { muscleId: 'abs', role: 'primary' }, + ], + }, +]; + +// 计划项构造辅助:每个动作默认带 1 个空预设组(用户训练时再填重量/次数)。 +function pItem(exerciseId: string, category: string, enabled = true): PlanItem { + return { + exerciseId, + category, + enabled, + sets: [{ id: 's1', fields: {} }], + }; +} + +// 默认训练计划:三分化(背 / 胸 / 腿)。时间规则为经典的一周三练排期(可在设置里改)。 +export const DEFAULT_PLANS: TrainingPlanInstance[] = [ + { + id: '374890bb-4fd8-449b-8b1e-60dd652eed32', + name: '三分化-背', + timeRule: { type: 'weekday', weekdays: [1, 4] }, // 周一、周四 + createdAt: '2026-07-14', + items: [ + pItem('barbell_row', 'strength'), + pItem('dumbbell_row', 'strength'), + pItem('pullup', 'bodyweight'), + pItem('dumbbell_reverse_fly', 'strength'), + pItem('cable_face_pull', 'strength'), + pItem('barbell_curl', 'strength'), + pItem('dumbbell_curl', 'strength'), + ], + }, + { + id: '1200e157-ae12-4394-b523-bc62218c15df', + name: '三分化-胸', + timeRule: { type: 'weekday', weekdays: [2, 5] }, // 周二、周五 + createdAt: '2026-07-14', + items: [ + pItem('dumbbell_bench_press', 'strength'), + pItem('incline_dumbbell_press', 'strength'), + pItem('dumbbell_press', 'strength'), + pItem('dumbbell_lateral_raise', 'strength'), + pItem('dip', 'strength', false), // 可选 + pItem('close_grip_bench_press', 'strength', false), // 可选 + pItem('dumbbell_overhead_triceps', 'strength', false), // 可选 + ], + }, + { + id: '44db2031-cba7-414b-83f5-72dcc62d90fd', + name: '三分化-腿', + timeRule: { type: 'weekday', weekdays: [3, 6] }, // 周三、周六 + createdAt: '2026-07-14', + items: [ + pItem('squat', 'strength'), + pItem('lunge', 'strength'), + pItem('romanian_deadlift', 'strength'), + pItem('ab_crunch', 'strength'), + pItem('hanging_leg_raise', 'strength'), + ], + }, +]; + +// 汇总成完整默认配置(version = 1,表示第一版结构) +export function getDefaultConfig(): WorkoutConfig { + return { + version: 1, + trainingTypes: DEFAULT_TRAINING_TYPES, + exercises: DEFAULT_EXERCISES, + muscles: DEFAULT_MUSCLES, + statistics: [DEFAULT_COUNT_STAT, DEFAULT_VOLUME_STAT, DEFAULT_1RM_STAT], + plans: DEFAULT_PLANS, + }; +} diff --git a/src/data/statExpr.test.ts b/src/data/statExpr.test.ts new file mode 100644 index 0000000..bd33aff --- /dev/null +++ b/src/data/statExpr.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { + computeStat, + validateExpression, + builderToExpr, + exprToBuilder, + allowedStatFields, +} from './statExpr'; +import { StatDef, WorkoutConfig, LogRow } from './types'; + +// 构造一条最小 LogRow(computeStat 只用到 fields) +function log(fields: Record): LogRow { + return { + id: 'log-id', + timestamp: '2026-07-10 18:00', + exerciseId: 'squat', + category: 'strength', + fields, + }; +} + +const stat = (over: Partial): StatDef => ({ + id: 'st1', + name: '测试统计', + associatedTypes: ['strength'], + formula: { mode: 'expression', expression: 'sum(reps * weight)' }, + granularity: 'daily', + enabled: true, + ...over, +}); + +describe('computeStat 计算', () => { + const records = [log({ reps: 1, weight: 10 }), log({ reps: 3, weight: 20 }), log({ reps: 5, weight: 30 })]; + + it('sum(reps * weight) = 训练量', () => { + const s = stat({ formula: { mode: 'expression', expression: 'sum(reps * weight)' } }); + expect(computeStat(s, records)).toBe(1 * 10 + 3 * 20 + 5 * 30); + }); + + it('count() = 记录条数', () => { + const s = stat({ formula: { mode: 'expression', expression: 'count()' } }); + expect(computeStat(s, records)).toBe(3); + }); + + it('avg(weight) = 平均值', () => { + const s = stat({ formula: { mode: 'expression', expression: 'avg(weight)' } }); + expect(computeStat(s, records)).toBe((10 + 20 + 30) / 3); + }); + + it('max/min(weight) 取极值', () => { + expect(computeStat(stat({ formula: { mode: 'expression', expression: 'max(weight)' } }), records)).toBe(30); + expect(computeStat(stat({ formula: { mode: 'expression', expression: 'min(weight)' } }), records)).toBe(10); + }); + + it('builder 模式的乘积求和等价 sum(a*b)', () => { + const s = stat({ + formula: { mode: 'builder', builder: { kind: 'productSum', fieldA: 'reps', fieldB: 'weight' } }, + }); + expect(computeStat(s, records)).toBe(1 * 10 + 3 * 20 + 5 * 30); + }); + + it('时长字段按秒数计算(保持可读与计算不冲突)', () => { + const durRecords = [log({ duration_sec: 90 }), log({ duration_sec: 30 })]; + const s = stat({ formula: { mode: 'expression', expression: 'sum(duration_sec)' } }); + expect(computeStat(s, durRecords)).toBe(120); + }); + + it('缺失字段视为 0(不崩)', () => { + const partial = [log({ reps: 2, weight: 10 }), log({ reps: 4 })]; + const s = stat({ formula: { mode: 'expression', expression: 'sum(reps * weight)' } }); + expect(computeStat(s, partial)).toBe(2 * 10 + 4 * 0); + }); + + it('非法表达式返回 NaN(渲染时兜底)', () => { + const s = stat({ formula: { mode: 'expression', expression: 'sum((' } }); + expect(Number.isNaN(computeStat(s, records))).toBe(true); + }); +}); + +describe('validateExpression 校验', () => { + it('合法表达式通过', () => { + expect(() => validateExpression('sum(reps * weight)', ['reps', 'weight'])).not.toThrow(); + expect(() => validateExpression('count()', [])).not.toThrow(); + // 四则运算写在聚合函数内部(针对单条记录)即合法 + expect(() => validateExpression('sum(reps / 2)', ['reps'])).not.toThrow(); + }); + + it('未授权字段报错', () => { + expect(() => validateExpression('sum(reps)', ['weight'])).toThrow(/不在可用字段范围/); + }); + + it('未知函数报错', () => { + expect(() => validateExpression('median(reps)', ['reps'])).toThrow(/未知函数/); + }); + + it('聚合函数嵌套报错', () => { + expect(() => validateExpression('sum(sum(reps))', ['reps'])).toThrow(/不能嵌套/); + }); + + it('非聚合根节点报错', () => { + expect(() => validateExpression('reps * weight', ['reps', 'weight'])).toThrow(/聚合函数/); + }); + + it('count 带参数报错', () => { + expect(() => validateExpression('count(reps)', ['reps'])).toThrow(); + }); +}); + +describe('builderToExpr / exprToBuilder 双向转换', () => { + it('builder → expr', () => { + expect(builderToExpr({ kind: 'sum', field: 'reps' })).toBe('sum(reps)'); + expect(builderToExpr({ kind: 'productSum', fieldA: 'reps', fieldB: 'weight' })).toBe('sum(reps * weight)'); + expect(builderToExpr({ kind: 'count' })).toBe('count()'); + expect(builderToExpr({ kind: 'avg', field: 'weight' })).toBe('avg(weight)'); + }); + + it('expr → builder 匹配四种形状', () => { + expect(exprToBuilder('sum(reps)')).toEqual({ kind: 'sum', field: 'reps' }); + expect(exprToBuilder('sum(reps * weight)')).toEqual({ kind: 'productSum', fieldA: 'reps', fieldB: 'weight' }); + expect(exprToBuilder('avg(weight)')).toEqual({ kind: 'avg', field: 'weight' }); + expect(exprToBuilder('count()')).toEqual({ kind: 'count' }); + }); + + it('复杂嵌套表达式无法降维时返回 null', () => { + expect(exprToBuilder('sum(reps) / count()')).toBeNull(); + expect(exprToBuilder('reps * weight')).toBeNull(); + }); +}); + +describe('allowedStatFields 字段交集', () => { + const config: WorkoutConfig = { + version: 1, + trainingTypes: [ + { id: 'strength', fields: [{ key: 'reps' }, { key: 'weight' }, { key: 'duration_sec' }] } as any, + { id: 'cardio', fields: [{ key: 'distance' }, { key: 'duration_sec' }, { key: 'pace' }] } as any, + ], + exercises: [], + muscles: [], + statistics: [], + }; + + it('单类型返回其全部字段', () => { + const s = stat({ associatedTypes: ['strength'] }); + expect(allowedStatFields(s, config).sort()).toEqual(['duration_sec', 'reps', 'weight']); + }); + + it('多类型取字段交集(如「时长」)', () => { + const s = stat({ associatedTypes: ['strength', 'cardio'] }); + expect(allowedStatFields(s, config)).toEqual(['duration_sec']); + }); + + it('无交集类型返回空', () => { + const s = stat({ associatedTypes: [] }); + expect(allowedStatFields(s, config)).toEqual([]); + }); +}); diff --git a/src/data/statExpr.ts b/src/data/statExpr.ts new file mode 100644 index 0000000..aa0861c --- /dev/null +++ b/src/data/statExpr.ts @@ -0,0 +1,344 @@ +import { LogRow, WorkoutConfig, StatDef, StatAggregation } from './types'; + +/* + * statExpr.ts —— 数据统计功能的「受限表达式引擎」。 + * + * 安全底线:彻底禁用 eval / new Function,手写「词法 + 递归下降语法分析」, + * 把表达式解析成 AST 后解释执行。函数白名单仅 sum/avg/max/min/count; + * 操作数只能是已授权字段(key),支持 + - * / 四则运算与括号嵌套。 + * + * 语义约定:一条统计(stat)在最外层必须是一个「聚合函数调用」, + * 其参数为「针对单条记录」的表达式(字段引用的四则组合)。 + * 计算时:对每条记录求参数值,再由聚合函数跨记录汇总。 + * 例:sum(reps * weight) = 对每条记录算 次数×重量 后求和 = 训练量。 + * + * 时长字段(duration):底层存的是秒数(number),表达式按字段 key 引用, + * 引擎直接对原始数值(秒)运算;可读性(如「1分30秒」)由展示层负责,不参与计算。 + */ + +// ===== AST 节点 ===== +type Node = + | { type: 'num'; value: number } + | { type: 'field'; name: string } + | { type: 'binop'; op: '+' | '-' | '*' | '/'; left: Node; right: Node } + | { type: 'func'; name: string; args: Node[] }; + +const FUNCS = new Set(['sum', 'avg', 'max', 'min', 'count']); + +// ===== 词法分析(Tokenizer)===== +type Tok = + | { t: 'num'; v: number } + | { t: 'ident'; v: string } + | { t: 'op'; v: '+' | '-' | '*' | '/' } + | { t: 'lparen' } + | { t: 'rparen' } + | { t: 'comma' } + | { t: 'eof' }; + +function tokenize(input: string): Tok[] { + const toks: Tok[] = []; + const s = input; + let i = 0; + while (i < s.length) { + const c = s[i]; + // 跳过空白 + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; } + // 数字(十进制,允许一个小数点) + if (c >= '0' && c <= '9') { + let j = i; + while (j < s.length && /[0-9.]/.test(s[j])) j++; + const num = Number(s.slice(i, j)); + if (Number.isNaN(num)) throw new Error('非法数字'); + toks.push({ t: 'num', v: num }); + i = j; + continue; + } + // 标识符(字段 key / 函数名):字母或下划线开头,后续可含字母数字下划线 + if (/[a-zA-Z_]/.test(c)) { + let j = i; + while (j < s.length && /[a-zA-Z0-9_]/.test(s[j])) j++; + toks.push({ t: 'ident', v: s.slice(i, j) }); + i = j; + continue; + } + // 运算符 / 括号 / 逗号 + if (c === '+' || c === '-' || c === '*' || c === '/') { toks.push({ t: 'op', v: c }); i++; continue; } + if (c === '(') { toks.push({ t: 'lparen' }); i++; continue; } + if (c === ')') { toks.push({ t: 'rparen' }); i++; continue; } + if (c === ',') { toks.push({ t: 'comma' }); i++; continue; } + throw new Error(`非法字符:${c}`); + } + toks.push({ t: 'eof' }); + return toks; +} + +// ===== 递归下降语法分析(Parser)===== +class Parser { + private toks: Tok[]; + private pos = 0; + constructor(toks: Tok[]) { this.toks = toks; } + private peek(): Tok { return this.toks[this.pos]; } + private next(): Tok { return this.toks[this.pos++]; } + private expectRparen(): void { + if (this.next().t !== 'rparen') throw new Error('缺少右括号 ")"'); + } + + parse(): Node { + const node = this.parseExpr(); + if (this.peek().t !== 'eof') throw new Error('表达式存在多余内容'); + return node; + } + + private parseExpr(): Node { + let left = this.parseTerm(); + let tk = this.peek(); + while (tk.t === 'op' && (tk.v === '+' || tk.v === '-')) { + const op = (this.next() as Extract).v; + left = { type: 'binop', op, left, right: this.parseTerm() }; + tk = this.peek(); + } + return left; + } + + private parseTerm(): Node { + let left = this.parseFactor(); + let tk = this.peek(); + while (tk.t === 'op' && (tk.v === '*' || tk.v === '/')) { + const op = (this.next() as Extract).v; + left = { type: 'binop', op, left, right: this.parseFactor() }; + tk = this.peek(); + } + return left; + } + + private parseFactor(): Node { + const t = this.peek(); + if (t.t === 'num') { this.next(); return { type: 'num', value: t.v }; } + if (t.t === 'lparen') { + this.next(); + const e = this.parseExpr(); + this.expectRparen(); + return e; + } + if (t.t === 'ident') { + // 标识符后紧跟 '(' 视为函数调用,否则视为字段引用 + const ahead = this.toks[this.pos + 1]; + if (ahead && ahead.t === 'lparen') return this.parseFunc(); + this.next(); + return { type: 'field', name: t.v }; + } + throw new Error('表达式不完整或存在非法符号'); + } + + private parseFunc(): Node { + const nameTok = this.next(); + if (nameTok.t !== 'ident') throw new Error('函数名非法'); + const name = nameTok.v; + if (!FUNCS.has(name)) throw new Error(`未知函数:${name}(仅支持 sum/avg/max/min/count)`); + if (this.next().t !== 'lparen') throw new Error(`函数 ${name} 缺少左括号`); + const args: Node[] = []; + if (this.peek().t !== 'rparen') { + args.push(this.parseExpr()); + while (this.peek().t === 'comma') { + this.next(); + args.push(this.parseExpr()); + } + } + this.expectRparen(); + return { type: 'func', name, args }; + } +} + +// ===== 结构校验(最外层必须是单聚合函数,且不能嵌套)===== +function validateStructure(ast: Node): void { + if (ast.type !== 'func') { + throw new Error('表达式必须是一个聚合函数调用(如 sum(...))'); + } + const f = ast as Extract; + if (f.name === 'count') { + if (f.args.length !== 0) throw new Error('count() 不接受参数'); + } else { + if (f.args.length !== 1) throw new Error(`${f.name}() 需要 1 个参数`); + if (f.args[0].type === 'func') throw new Error('聚合函数不能嵌套'); + } +} + +// 收集表达式中引用的字段名(递归遍历,顺带拒绝嵌套函数) +function collectFields(node: Node, out: Set): void { + switch (node.type) { + case 'field': + out.add(node.name); + break; + case 'num': + break; + case 'binop': + collectFields(node.left, out); + collectFields(node.right, out); + break; + case 'func': + for (const a of node.args) { + if (a.type === 'func') throw new Error('聚合函数不能嵌套'); + collectFields(a, out); + } + break; + } +} + +// ===== 对外:校验表达式 ===== +export function validateExpression(expr: string, allowedFields: string[]): void { + const ast = new Parser(tokenize(expr)).parse(); + validateStructure(ast); + const fields = new Set(); + collectFields(ast, fields); + const allowed = new Set(allowedFields); + for (const f of fields) { + if (!allowed.has(f)) { + throw new Error(`字段 "${f}" 不在可用字段范围内(请检查关联的训练类型)`); + } + } +} + +// ===== 对外:计算某分组的统计值 ===== +// 对每条记录求「聚合函数参数」的值,再由聚合函数跨记录汇总。 +function evalNode(node: Node, record: LogRow): number { + switch (node.type) { + case 'num': + return node.value; + case 'field': { + const raw = record.fields[node.name]; + const n = typeof raw === 'number' ? raw : Number(raw); + return Number.isNaN(n) ? 0 : n; // 缺失/非法字段视为 0 + } + case 'binop': { + const l = evalNode(node.left, record); + const r = evalNode(node.right, record); + switch (node.op) { + case '+': return l + r; + case '-': return l - r; + case '*': return l * r; + case '/': return r === 0 ? 0 : l / r; // 除零保护 + } + } + case 'func': + throw new Error('聚合函数不能嵌套'); // 理论上不会到达 + } + throw new Error('表达式无效'); +} + +export function computeStat(stat: StatDef, records: LogRow[]): number { + // 解析表达式:builder 模式先转成表达式字符串 + const expr = stat.formula.mode === 'builder' + ? builderToExpr(stat.formula.builder) + : (stat.formula.expression ?? ''); + + let ast: Node; + try { + ast = new Parser(tokenize(expr)).parse(); + } catch { + return NaN; // 表达式非法:渲染时兜底,不崩 + } + if (ast.type !== 'func') return NaN; + const f = ast as Extract; + + // count:记录条数 + if (f.name === 'count') { + return records.length; + } + + // 其余:先对每条记录求值,再聚合 + const values: number[] = []; + for (const rec of records) { + try { + values.push(evalNode(f.args[0], rec)); + } catch { + values.push(0); + } + } + + let result: number; + switch (f.name) { + case 'sum': + result = values.reduce((a, b) => a + b, 0); + break; + case 'avg': + result = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; + break; + case 'max': + result = values.length ? Math.max(...values) : 0; + break; + case 'min': + result = values.length ? Math.min(...values) : 0; + break; + default: + result = NaN; + } + if (!Number.isFinite(result)) return 0; + // 防浮点噪声 + return Math.round(result * 100) / 100; +} + +// 把统计值规整成展示字符串(纯数字,不附加任何单位);非法值显示 "-"。 +export function formatStatValue(n: number): string { + if (Number.isNaN(n)) return '-'; + return String(Math.round(n * 100) / 100); +} + +// ===== 引导式 ↔ 表达式 双向转换 ===== +export function builderToExpr(b?: StatAggregation): string { + if (!b) return ''; + switch (b.kind) { + case 'sum': return `sum(${b.field})`; + case 'productSum': return `sum(${b.fieldA} * ${b.fieldB})`; + case 'oneRepMax': return `max(${b.weightField} * (1 + ${b.repsField} / 30))`; + case 'count': return 'count()'; + case 'avg': return `avg(${b.field})`; + case 'max': return `max(${b.field})`; + case 'min': return `min(${b.field})`; + } +} + +// 表达式 → 引导式(尽力而为):匹配四种形状则回填 builder;否则返回 null(保留 expression 模式)。 +export function exprToBuilder(expr: string): StatAggregation | null { + let ast: Node; + try { + ast = new Parser(tokenize(expr)).parse(); + } catch { + return null; + } + if (ast.type !== 'func') return null; + const f = ast as Extract; + + if (f.name === 'count' && f.args.length === 0) return { kind: 'count' }; + if (f.args.length !== 1) return null; + const arg = f.args[0]; + + // sum(field) / avg|max|min(field) + if (arg.type === 'field') { + if (f.name === 'sum') return { kind: 'sum', field: arg.name }; + if (f.name === 'avg' || f.name === 'max' || f.name === 'min') { + return { kind: f.name, field: arg.name }; + } + return null; + } + + // sum(fieldA * fieldB) → productSum + if (f.name === 'sum' && arg.type === 'binop' && arg.op === '*' && + arg.left.type === 'field' && arg.right.type === 'field') { + return { kind: 'productSum', fieldA: arg.left.name, fieldB: arg.right.name }; + } + return null; +} + +// ===== 关联类型的字段「交集」===== +// 编辑公式时,可选字段 = 所有 associatedTypes 对应训练类型的 fields 之交集。 +// 保证公式引用的字段在每个关联类型里都有效(多类型共享字段,如「时长」)。 +export function allowedStatFields(stat: StatDef, config: WorkoutConfig): string[] { + const typeFields = stat.associatedTypes + .map((id) => config.trainingTypes.find((t) => t.id === id)?.fields ?? []) + .filter((f) => f.length > 0); + if (typeFields.length === 0) return []; + // 以第一个类型的字段为基准,保留「在每个其他类型里都存在」的 key + return typeFields[0] + .filter((f) => typeFields.slice(1).every((fields) => fields.some((x) => x.key === f.key))) + .map((f) => f.key); +} diff --git a/src/data/svgMuscleCatalog.test.ts b/src/data/svgMuscleCatalog.test.ts new file mode 100644 index 0000000..1a6ce02 --- /dev/null +++ b/src/data/svgMuscleCatalog.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { formatSvgMuscleLabel, SvgMuscleEntry } from './svgMuscleCatalog'; + +/* + * svgMuscleCatalog 单元测试 + * 关键概念: + * - formatSvgMuscleLabel: 把目录中的 SVG 肌肉条目格式化为 UI 显示标签。 + * 同一块肌肉可能被 SVG 拆成多条 path(external_oblique_1_l..8_l、 + * rectus_abdominis_1..4_r),函数需从 id 提取段号并追加左右标识, + * 避免编辑肌肉弹窗里出现大量重名复选框。 + */ + +describe('formatSvgMuscleLabel', () => { + it('distinguishes segmented abs paths in Chinese', () => { + const oblique: SvgMuscleEntry = { + id: 'external_oblique_1_l', + side: 'front', + fitnessGroup: 'abs', + zh: '腹外斜肌', + en: 'External Oblique', + }; + expect(formatSvgMuscleLabel(oblique, 'zh')).toBe('腹外斜肌 1(左)'); + + const obliqueRight: SvgMuscleEntry = { + id: 'external_oblique_8_r', + side: 'front', + fitnessGroup: 'abs', + zh: '腹外斜肌', + en: 'External Oblique', + }; + expect(formatSvgMuscleLabel(obliqueRight, 'zh')).toBe('腹外斜肌 8(右)'); + + const rectusCenter: SvgMuscleEntry = { + id: 'rectus_abdominis_1', + side: 'front', + fitnessGroup: 'abs', + zh: '腹直肌', + en: 'Rectus Abdominis', + }; + expect(formatSvgMuscleLabel(rectusCenter, 'zh')).toBe('腹直肌 1'); + + const rectusLeft: SvgMuscleEntry = { + id: 'rectus_abdominis_2_l', + side: 'front', + fitnessGroup: 'abs', + zh: '腹直肌', + en: 'Rectus Abdominis', + }; + expect(formatSvgMuscleLabel(rectusLeft, 'zh')).toBe('腹直肌 2(左)'); + }); + + it('keeps non-segmented muscle labels concise in Chinese', () => { + const chest: SvgMuscleEntry = { + id: 'pectoralis_major_l', + side: 'front', + fitnessGroup: 'chest', + zh: '胸大肌', + en: 'Pectoralis Major', + }; + expect(formatSvgMuscleLabel(chest, 'zh')).toBe('胸大肌(左)'); + }); + + it('distinguishes segmented paths in English', () => { + const oblique: SvgMuscleEntry = { + id: 'external_oblique_1_l', + side: 'front', + fitnessGroup: 'abs', + zh: '腹外斜肌', + en: 'External Oblique', + }; + expect(formatSvgMuscleLabel(oblique, 'en')).toBe('External Oblique 1 (L)'); + }); +}); diff --git a/src/data/svgMuscleCatalog.ts b/src/data/svgMuscleCatalog.ts new file mode 100644 index 0000000..53cf16d --- /dev/null +++ b/src/data/svgMuscleCatalog.ts @@ -0,0 +1,202 @@ +/* + * svgMuscleCatalog.ts —— SVG 肌肉路径目录(自动生成) + * 来源:muscle_analysis/muscle_layer_front.svg + muscle_layer_back.svg + * 共 143 条路径,14 个健身群,中英双语。 + */ + +export interface SvgMuscleEntry { + id: string; + side: 'front' | 'back'; + fitnessGroup: string; + zh: string; + en: string; +} + +export const SVG_CATALOG_VERSION = 'flutter-body-atlas@main-2026-07-12'; + +export const FITNESS_GROUPS: { key: string; zh: string; en: string }[] = [ + { key: 'chest', zh: '胸部', en: 'Chest' }, + { key: 'back', zh: '背部', en: 'Back' }, + { key: 'traps', zh: '斜方肌', en: 'Traps' }, + { key: 'shoulders', zh: '肩部(三角肌)', en: 'Shoulders (Delts)' }, + { key: 'biceps', zh: '肱二头肌', en: 'Biceps' }, + { key: 'triceps', zh: '肱三头肌', en: 'Triceps' }, + { key: 'forearms', zh: '前臂', en: 'Forearms' }, + { key: 'abs', zh: '腹肌/核心', en: 'Abs / Core' }, + { key: 'glutes', zh: '臀部', en: 'Glutes' }, + { key: 'quads', zh: '股四头肌', en: 'Quads' }, + { key: 'hamstrings', zh: '腘绳肌', en: 'Hamstrings' }, + { key: 'adductors', zh: '内收肌', en: 'Adductors' }, + { key: 'calves', zh: '小腿', en: 'Calves' }, + { key: 'neck', zh: '颈部', en: 'Neck' }, +]; + +export const SVG_MUSCLE_CATALOG: SvgMuscleEntry[] = [ + { id: 'external_oblique_1_l', side: 'back', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_1_r', side: 'back', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'adductor_magnus_l', side: 'back', fitnessGroup: 'adductors', zh: '大收肌', en: 'Adductor Magnus' }, + { id: 'adductor_magnus_r', side: 'back', fitnessGroup: 'adductors', zh: '大收肌', en: 'Adductor Magnus' }, + { id: 'infraspinatus_l', side: 'back', fitnessGroup: 'back', zh: '冈下肌', en: 'Infraspinatus' }, + { id: 'infraspinatus_r', side: 'back', fitnessGroup: 'back', zh: '冈下肌', en: 'Infraspinatus' }, + { id: 'latissimus_dorsi_l', side: 'back', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' }, + { id: 'latissimus_dorsi_r', side: 'back', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' }, + { id: 'gastrocnemius_l', side: 'back', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' }, + { id: 'gastrocnemius_r', side: 'back', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' }, + { id: 'anconeus_l', side: 'back', fitnessGroup: 'forearms', zh: '肘肌', en: 'Anconeus' }, + { id: 'anconeus_r', side: 'back', fitnessGroup: 'forearms', zh: '肘肌', en: 'Anconeus' }, + { id: 'brachioradialis_l', side: 'back', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' }, + { id: 'brachioradialis_r', side: 'back', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' }, + { id: 'extensor_carpi_ulnaris_l', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕伸肌', en: 'Extensor Carpi Ulnaris' }, + { id: 'extensor_carpi_ulnaris_r', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕伸肌', en: 'Extensor Carpi Ulnaris' }, + { id: 'extensor_digitorum_l', side: 'back', fitnessGroup: 'forearms', zh: '指总伸肌', en: 'Extensor Digitorum' }, + { id: 'extensor_digitorum_r', side: 'back', fitnessGroup: 'forearms', zh: '指总伸肌', en: 'Extensor Digitorum' }, + { id: 'flexor_carpi_ulnaris_l', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕屈肌', en: 'Flexor Carpi Ulnaris' }, + { id: 'flexor_carpi_ulnaris_r', side: 'back', fitnessGroup: 'forearms', zh: '尺侧腕屈肌', en: 'Flexor Carpi Ulnaris' }, + { id: 'gluteus_maximus_l', side: 'back', fitnessGroup: 'glutes', zh: '臀大肌', en: 'Gluteus Maximus' }, + { id: 'gluteus_maximus_r', side: 'back', fitnessGroup: 'glutes', zh: '臀大肌', en: 'Gluteus Maximus' }, + { id: 'gluteus_medius_1_l', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'gluteus_medius_1_r', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'gluteus_medius_2_l', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'gluteus_medius_2_r', side: 'back', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'biceps_femoris_l', side: 'back', fitnessGroup: 'hamstrings', zh: '股二头肌', en: 'Biceps Femoris' }, + { id: 'biceps_femoris_r', side: 'back', fitnessGroup: 'hamstrings', zh: '股二头肌', en: 'Biceps Femoris' }, + { id: 'semimembranosus_1_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' }, + { id: 'semimembranosus_1_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' }, + { id: 'semimembranosus_2_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' }, + { id: 'semimembranosus_2_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半膜肌', en: 'Semimembranosus' }, + { id: 'semitendinosus_l', side: 'back', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' }, + { id: 'semitendinosus_r', side: 'back', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' }, + { id: 'sternocleidomastoid_l', side: 'back', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' }, + { id: 'sternocleidomastoid_r', side: 'back', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' }, + { id: 'iliotibial_tract_l', side: 'back', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' }, + { id: 'iliotibial_tract_r', side: 'back', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' }, + { id: 'lateral_deltoid_l', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' }, + { id: 'lateral_deltoid_r', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' }, + { id: 'posterior_deltoid_l', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(后束)', en: 'Deltoid (Posterior)' }, + { id: 'posterior_deltoid_r', side: 'back', fitnessGroup: 'shoulders', zh: '三角肌(后束)', en: 'Deltoid (Posterior)' }, + { id: 'trapezius_lower_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(下束)', en: 'Trapezius (Lower)' }, + { id: 'trapezius_lower_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(下束)', en: 'Trapezius (Lower)' }, + { id: 'trapezius_middle_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(中束)', en: 'Trapezius (Middle)' }, + { id: 'trapezius_middle_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(中束)', en: 'Trapezius (Middle)' }, + { id: 'trapezius_upper_l', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' }, + { id: 'trapezius_upper_r', side: 'back', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' }, + { id: 'triceps_brachii_caput_laterale_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_laterale_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_longum_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_longum_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_mediale_l', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_mediale_r', side: 'back', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'external_oblique_1_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_1_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_2_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_2_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_3_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_3_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_4_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_4_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_5_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_5_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_6_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_6_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_7_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_7_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_8_l', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'external_oblique_8_r', side: 'front', fitnessGroup: 'abs', zh: '腹外斜肌', en: 'External Oblique' }, + { id: 'rectus_abdominis_1', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_2_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_2_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_3_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_3_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_4_l', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'rectus_abdominis_4_r', side: 'front', fitnessGroup: 'abs', zh: '腹直肌', en: 'Rectus Abdominis' }, + { id: 'adductor_longus_l', side: 'front', fitnessGroup: 'adductors', zh: '长收肌', en: 'Adductor Longus' }, + { id: 'adductor_longus_r', side: 'front', fitnessGroup: 'adductors', zh: '长收肌', en: 'Adductor Longus' }, + { id: 'gracilis_l', side: 'front', fitnessGroup: 'adductors', zh: '股薄肌', en: 'Gracilis' }, + { id: 'gracilis_r', side: 'front', fitnessGroup: 'adductors', zh: '股薄肌', en: 'Gracilis' }, + { id: 'pectineus_l', side: 'front', fitnessGroup: 'adductors', zh: '耻骨肌', en: 'Pectineus' }, + { id: 'pectineus_r', side: 'front', fitnessGroup: 'adductors', zh: '耻骨肌', en: 'Pectineus' }, + { id: 'latissimus_dorsi_l', side: 'front', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' }, + { id: 'latissimus_dorsi_r', side: 'front', fitnessGroup: 'back', zh: '背阔肌', en: 'Latissimus Dorsi' }, + { id: 'biceps_brachii_caput_breve_l', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' }, + { id: 'biceps_brachii_caput_breve_r', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' }, + { id: 'biceps_brachii_caput_longum_l', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' }, + { id: 'biceps_brachii_caput_longum_r', side: 'front', fitnessGroup: 'biceps', zh: '肱二头肌', en: 'Biceps Brachii' }, + { id: 'extensor_digitorum_longus_l', side: 'front', fitnessGroup: 'calves', zh: '趾长伸肌', en: 'Extensor Digitorum Longus' }, + { id: 'extensor_digitorum_longus_r', side: 'front', fitnessGroup: 'calves', zh: '趾长伸肌', en: 'Extensor Digitorum Longus' }, + { id: 'extensor_hallucis_longus_l', side: 'front', fitnessGroup: 'calves', zh: '拇长伸肌', en: 'Extensor Hallucis Longus' }, + { id: 'extensor_hallucis_longus_r', side: 'front', fitnessGroup: 'calves', zh: '拇长伸肌', en: 'Extensor Hallucis Longus' }, + { id: 'fibularis_longus_l', side: 'front', fitnessGroup: 'calves', zh: '腓骨长肌', en: 'Fibularis Longus' }, + { id: 'fibularis_longus_r', side: 'front', fitnessGroup: 'calves', zh: '腓骨长肌', en: 'Fibularis Longus' }, + { id: 'gastrocnemius_l', side: 'front', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' }, + { id: 'gastrocnemius_r', side: 'front', fitnessGroup: 'calves', zh: '腓肠肌', en: 'Gastrocnemius' }, + { id: 'tibialis_anterior_l', side: 'front', fitnessGroup: 'calves', zh: '胫骨前肌', en: 'Tibialis Anterior' }, + { id: 'tibialis_anterior_r', side: 'front', fitnessGroup: 'calves', zh: '胫骨前肌', en: 'Tibialis Anterior' }, + { id: 'pectoralis_major_l', side: 'front', fitnessGroup: 'chest', zh: '胸大肌', en: 'Pectoralis Major' }, + { id: 'pectoralis_major_r', side: 'front', fitnessGroup: 'chest', zh: '胸大肌', en: 'Pectoralis Major' }, + { id: 'brachioradialis_l', side: 'front', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' }, + { id: 'brachioradialis_r', side: 'front', fitnessGroup: 'forearms', zh: '肱桡肌', en: 'Brachioradialis' }, + { id: 'extensor_carpi_radialis_longus_l', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕长伸肌', en: 'Extensor Carpi Radialis Longus' }, + { id: 'extensor_carpi_radialis_longus_r', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕长伸肌', en: 'Extensor Carpi Radialis Longus' }, + { id: 'flexor_carpi_radialis_l', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕屈肌', en: 'Flexor Carpi Radialis' }, + { id: 'flexor_carpi_radialis_r', side: 'front', fitnessGroup: 'forearms', zh: '桡侧腕屈肌', en: 'Flexor Carpi Radialis' }, + { id: 'flexor_digitorum_superficialis_l', side: 'front', fitnessGroup: 'forearms', zh: '指浅屈肌', en: 'Flexor Digitorum Superficialis' }, + { id: 'flexor_digitorum_superficialis_r', side: 'front', fitnessGroup: 'forearms', zh: '指浅屈肌', en: 'Flexor Digitorum Superficialis' }, + { id: 'palmaris_longus_l', side: 'front', fitnessGroup: 'forearms', zh: '掌长肌', en: 'Palmaris Longus' }, + { id: 'palmaris_longus_r', side: 'front', fitnessGroup: 'forearms', zh: '掌长肌', en: 'Palmaris Longus' }, + { id: 'pronator_quadratus_l', side: 'front', fitnessGroup: 'forearms', zh: '旋前方肌', en: 'Pronator Quadratus' }, + { id: 'pronator_quadratus_r', side: 'front', fitnessGroup: 'forearms', zh: '旋前方肌', en: 'Pronator Quadratus' }, + { id: 'pronator_teres_l', side: 'front', fitnessGroup: 'forearms', zh: '旋前圆肌', en: 'Pronator Teres' }, + { id: 'pronator_teres_r', side: 'front', fitnessGroup: 'forearms', zh: '旋前圆肌', en: 'Pronator Teres' }, + { id: 'gluteus_medius_2_l', side: 'front', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'gluteus_medius_2_r', side: 'front', fitnessGroup: 'glutes', zh: '臀中肌', en: 'Gluteus Medius' }, + { id: 'semitendinosus_l', side: 'front', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' }, + { id: 'semitendinosus_r', side: 'front', fitnessGroup: 'hamstrings', zh: '半腱肌', en: 'Semitendinosus' }, + { id: 'platysma', side: 'front', fitnessGroup: 'neck', zh: '颈阔肌', en: 'Platysma' }, + { id: 'sternocleidomastoid_l', side: 'front', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' }, + { id: 'sternocleidomastoid_r', side: 'front', fitnessGroup: 'neck', zh: '胸锁乳突肌', en: 'Sternocleidomastoid' }, + { id: 'sternohyoid', side: 'front', fitnessGroup: 'neck', zh: '胸骨舌骨肌', en: 'Sternohyoid' }, + { id: 'iliotibial_tract_l', side: 'front', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' }, + { id: 'iliotibial_tract_r', side: 'front', fitnessGroup: 'quads', zh: '髂胫束(筋膜)', en: 'Iliotibial Tract' }, + { id: 'rectus_femoris_l', side: 'front', fitnessGroup: 'quads', zh: '股直肌', en: 'Rectus Femoris' }, + { id: 'rectus_femoris_r', side: 'front', fitnessGroup: 'quads', zh: '股直肌', en: 'Rectus Femoris' }, + { id: 'sartoris_l', side: 'front', fitnessGroup: 'quads', zh: '缝匠肌', en: 'Sartorius' }, + { id: 'sartoris_r', side: 'front', fitnessGroup: 'quads', zh: '缝匠肌', en: 'Sartorius' }, + { id: 'vastus_lateralis_l', side: 'front', fitnessGroup: 'quads', zh: '股外侧肌', en: 'Vastus Lateralis' }, + { id: 'vastus_lateralis_r', side: 'front', fitnessGroup: 'quads', zh: '股外侧肌', en: 'Vastus Lateralis' }, + { id: 'vastus_medialis_l', side: 'front', fitnessGroup: 'quads', zh: '股内侧肌', en: 'Vastus Medialis' }, + { id: 'vastus_medialis_r', side: 'front', fitnessGroup: 'quads', zh: '股内侧肌', en: 'Vastus Medialis' }, + { id: 'anterior_deltoid_l', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(前束)', en: 'Deltoid (Anterior)' }, + { id: 'anterior_deltoid_r', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(前束)', en: 'Deltoid (Anterior)' }, + { id: 'lateral_deltoid_l', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' }, + { id: 'lateral_deltoid_r', side: 'front', fitnessGroup: 'shoulders', zh: '三角肌(中束)', en: 'Deltoid (Lateral)' }, + { id: 'trapezius_upper_l', side: 'front', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' }, + { id: 'trapezius_upper_r', side: 'front', fitnessGroup: 'traps', zh: '斜方肌(上束)', en: 'Trapezius (Upper)' }, + { id: 'triceps_brachii_caput_laterale_l', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_laterale_r', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_longum_l', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, + { id: 'triceps_brachii_caput_longum_r', side: 'front', fitnessGroup: 'triceps', zh: '肱三头肌', en: 'Triceps Brachii' }, +]; + +// 渲染时隐藏的 SVG 组/区域 id(头部与面部轮廓,非肌肉) +export const HIDDEN_SVG_GROUP_IDS = ['head', 'face']; + +// 生成 SVG 肌肉路径在 UI 中的显示标签。 +// 同一块肌肉在 SVG 里可能被拆成多条 path(如 external_oblique_1_l..8_l), +// 目录里只保存统一基础名;显示时从 id 提取段号,避免列表中出现大量重名。 +// 例如:external_oblique_1_l -> "腹外斜肌 1(左)" +// rectus_abdominis_2_r -> "腹直肌 2(右)" +// pectoralis_major_l -> "胸大肌(左)" +export function formatSvgMuscleLabel(entry: SvgMuscleEntry, locale: 'zh' | 'en' | string): string { + let base = locale === 'zh' ? entry.zh : entry.en; + const segmentMatch = entry.id.match(/_(\d+)(?:_l|_r)?$/); + if (segmentMatch) { + const segment = segmentMatch[1]; + base += locale === 'zh' ? ` ${segment}` : ` ${segment}`; + } + if (entry.id.endsWith('_l')) { + base += locale === 'zh' ? '(左)' : ' (L)'; + } else if (entry.id.endsWith('_r')) { + base += locale === 'zh' ? '(右)' : ' (R)'; + } + return base; +} diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..fbdeb9a --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,186 @@ +/* + * types.ts —— 数据层的"数据结构(图纸)"定义文件。 + * 这里集中声明了插件用到的所有数据类型:字段定义、训练类型、肌肉、训练项、 + * 单条训练记录、完整配置、插件设置等。其他所有文件都从这里 import 这些类型, + * 保证整个插件的数据格式统一、不会混乱。 + * 概念:TypeScript 里的 interface 就像给数据"画图纸",规定一个对象里有哪些字段、 + * 每个字段是什么类型;export const 则是导出一份固定不变的常量值。 + */ + +// 字段定义:描述一个训练类型里"要记录哪些数据项",比如力量训练要记"重量"和"次数"。 +export interface FieldDef { + key: string; // 字段的唯一内部标识(如 "weight")。切勿随意改名,否则旧数据就无法对应 + labelKey?: string; // 国际化 key(多语言用),如 "field.weight",由 i18n 翻译成"重量" + label?: string; // 没有 labelKey 时的兜底显示名 + inputType: 'number' | 'duration' | 'text' | 'select'; // 输入控件类型:数字 / 时长 / 文本 / 下拉选择 + mass?: boolean; // 是否参与 kg/lb 换算(仅数字字段有意义;选中"质量"即自动 true) + unitLabel?: string; // 单位显示文字(自由文本,如"次""层""圈""公里"),留空表示无单位 + required?: boolean; // 是否为必填项 + options?: string[]; // 当 inputType 为 'select'(下拉)时的可选项列表 +} + +// 训练类型:如"力量训练""有氧训练"。每个类型定义一组要记录的字段(fields)。 +export interface TrainingType { + id: string; // 唯一标识(如 "strength")。建议稳定不变,改名会断掉历史记录关联 + nameKey?: string; // 国际化显示名 key(优先级高于 name) + name?: string; // 没有 nameKey 时的兜底名称 + icon?: string; // 显示的图标名(Obsidian/Lucide 图标) + fields: FieldDef[]; // 该训练类型要记录的字段列表 + contributesToCoverage?: boolean; // 是否计入"肌肉覆盖/训练频率"统计 +} + +// 肌肉:人体部位,用于训练覆盖统计和身体示意图高亮。 +export interface Muscle { + id: string; // 唯一标识(如 "chest") + nameKey?: string; // 国际化显示名 key + name?: string; // 兜底名称 + contributesToCoverage: boolean; // 是否计入覆盖统计 + // 超过该天数未练即提醒/标红;默认 7(v2.3 由通用设置下放到每块肌肉) + restThresholdDays?: number; + // 该肌肉在身体示意图 SVG 中对应的全部路径 id(1→N 可配置映射) + svgRegionIds: string[]; + // 热力图每块肌肉可覆盖的指标、时间窗、颜色分档;留空则跟随代码块/全局默认(三级回退) + heatmapMetric?: string; // 引用 StatDef.id;空=跟随默认 + heatmapRange?: string; // 7d / 30d / 90d / all / 日期区间;空=跟随默认 + heatmapLevels?: HeatmapLevel[]; // 该肌 4 色分档(蓝/绿/橙/红)阈值;空=跟随所选指标的默认分档(逐肌可配) +} + +// 训练项与肌肉的关联:一个动作练到哪些肌肉,以及是主练还是辅练。 +export interface ExerciseMuscle { + muscleId: string; // 关联到的 Muscle 的 id + role: 'primary' | 'secondary'; // 主练(primary)还是辅练(secondary) + contributesToCoverage?: boolean; // 该关联是否计入覆盖统计 +} + +// 训练项(动作):如"深蹲""卧推"。属于某个训练类型,并关联若干肌肉。 +export interface Exercise { + id: string; // 唯一标识(如 "squat")。记录用此 id 关联,改名不影响历史 + nameKey?: string; // 国际化显示名 key + name?: string; // 兜底名称 + category: string; // 所属训练类型的 id(对应 TrainingType.id) + muscles?: ExerciseMuscle[]; // 该动作涉及的肌肉及其角色 +} + +// 一条训练记录(CSV 里的一行)。这是用户每次锻炼实际产生的数据。 +export interface LogRow { + id: string; // 稳定唯一标识(12 位 base36 短随机 id),作为记录主键,替代 timestamp 的定位职责 + timestamp: string; // 记录时间,格式 "YYYY-MM-DD HH:mm",仅用于展示/排序/分组 + exerciseId?: string; // 训练项 id,用于稳定关联配置(改名/换语言后仍能解析到最新名) + category: string; // 训练类型 id + fields: Record; // 各字段值,统一以 JSON 对象存储(含义由训练类型决定) + note?: string; // 备注 + plan?: string; // 计划/方案名(预留,当前未消费) +} + +// 训练计划:从训练方案(笔记)或手动创建的持久化配置实例。 +// 一组训练项 + 每项的多个组(每组字段独立预设)。时间规则支持具体日期或每周某几天。 +export interface PlanSet { + id: string; // 组内稳定 id(完成状态匹配用) + fields: Record; // 该组字段预设值(用户自定义,如 {weight:60, reps:8}) +} + +export interface PlanItem { + exerciseId: string; // 训练项 id(稳定关联配置) + category: string; // 所属训练类型 id(决定字段) + enabled: boolean; // 是否纳入本次计划(全选默认 true) + sets: PlanSet[]; // 多组,每组字段独立 +} + +export interface TimeRule { + type: 'date' | 'weekday'; + date?: string; // type='date':YYYY-MM-DD + weekdays?: number[]; // type='weekday':ISO 周几,周一=1 … 周日=7,如 [1,3,5] +} + +export interface TrainingPlanInstance { + id: string; // uuid + name: string; // 全局唯一,即 workout-plan 代码块的 plan 参数值 + timeRule: TimeRule; // 计划时间 + sourceNote?: string; // 来源方案笔记 basename(可选;手动创建时为空) + createdAt: string; // YYYY-MM-DD + items: PlanItem[]; + // 已完成组持久化:key = `${exerciseId}#${setId}`,value = 完成日期(YYYY-MM-DD)。 + // 独立于训练记录存储——即使删除了该组产生的训练记录,完成状态仍保留(完成即完成)。 + completedSets?: Record; +} + +// 完整配置:聚合了训练类型、训练项、肌肉三块,整体存进 workout-config.json。 +// 数据统计(数据分析)条目:让用户对训练记录的字段做数学聚合。 +// 作用域 = 关联一个或多个训练类型(其字段交集即公式可选字段); +// 显示仅在「训练项 category ∈ associatedTypes」的代码块里出现; +// 计算始终落在当前代码块训练项、当前分组的记录上。 +export type StatGranularity = 'daily' | 'weekly' | 'monthly'; + +export type StatAggregation = + | { kind: 'sum'; field: string } // 字段求和:Σ 每条记录的字段值 + | { kind: 'productSum'; fieldA: string; fieldB: string } // 乘积求和:Σ(字段A×字段B) + | { kind: 'oneRepMax'; weightField: string; repsField: string } // 1RM 估算:取最佳组的 Epley 公式估算值 + | { kind: 'avg' | 'max' | 'min'; field: string } // 均值 / 最大 / 最小 + | { kind: 'count' }; // 记录条数(不引用字段) + +// 热力图颜色档:颜色标识 + 该档上限(含);末档省略 max 表示 +∞ +export interface HeatmapLevel { + color: string; + max?: number; +} + +export interface StatDef { + id: string; // 唯一标识 + name: string; // 显示名(如「总训练量」「总时长」) + associatedTypes: string[]; // 关联的训练类型 id 列表(支持多个) + formula: { + mode: 'builder' | 'expression'; // 引导式构建器 / 自由表达式 + builder?: StatAggregation; // mode='builder' 时有效 + expression?: string; // mode='expression' 时有效,如 "sum(reps * weight)" + }; + granularity: StatGranularity; + enabled: boolean; + // 作为热力图指标时的【默认】颜色分档模板;每块肌肉可在编辑页基于此单独覆盖(见 §5.4.1) + heatmapScale?: HeatmapLevel[]; + // 标记是否为热力图默认指标(种子置「次数」为 true) + heatmapDefault?: boolean; +} + +export interface WorkoutConfig { + version: number; // 配置版本号,用于数据迁移(migrate) + trainingTypes: TrainingType[]; + exercises: Exercise[]; + muscles: Muscle[]; + statistics: StatDef[]; + plans?: TrainingPlanInstance[]; // 训练计划实例列表(聚合进既有配置文件,不单独建文件) +} + +// 插件设置:存放路径、单位、语言、各种集成开关等用户偏好。 +export interface PluginSettings { + csvDirectory: string; // 训练记录 CSV 所在目录(vault 内相对路径,空 = 根目录) + configDirectory: string; // 配置文件所在目录(空 = 根目录) + unit: 'kg' | 'lb'; // 重量单位:公斤 / 磅 + language: 'zh' | 'en'; // 界面语言:中文 / 英文 + dataviewIntegration: boolean; // 是否启用 Dataview 集成 + dailyNotesIntegration: boolean; // 是否启用日记集成 + templaterIntegration: boolean; // 是否启用 Templater 集成 + lastValueMemory: boolean; // 是否记忆上次输入值(下次自动填充) + // 肌肉管理首次引导是否已完成(v2.3) + muscleMappingInitialized: boolean; + // 设置页「训练设置」区块下五个管理条目的显示顺序(仅影响设置页排序,不影响功能逻辑) + managerOrder: string[]; // 取值见 renderManagersSection:['types','exercises','muscles','statistics','plans'] +} + +// 默认设置:首次安装时使用。之后用户改过的设置会覆盖其中对应项。 +export const DEFAULT_SETTINGS: PluginSettings = { + csvDirectory: '', + configDirectory: '', + unit: 'kg', + language: 'zh', + dataviewIntegration: false, + dailyNotesIntegration: false, + templaterIntegration: false, + lastValueMemory: true, + muscleMappingInitialized: false, + managerOrder: ['types', 'exercises', 'muscles', 'statistics', 'plans'], +}; + +// 训练记录 CSV 的文件名(存于 vault 中) +export const CSV_FILENAME = 'workout_logs.csv'; +// 聚合配置 JSON 的文件名 +export const CONFIG_FILENAME = 'workout-config.json'; diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..99a86bc --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,483 @@ +/* + * en.ts —— 英文文案字典(i18n 英文包) + * 与 zh.ts 结构完全对称,存放插件所有英文界面文字。 + * key(如 common.save)的层级和命名必须和 zh.ts 保持一致, + * 这样 index.ts 的 t('common.save') 无论切到中文还是英文都能取到对应文案。 + * 想新增/修改某条英文文案,直接在本文件对应位置改即可。 + */ + +export const en = { + pluginName: 'Workout Block', + pluginDescription: 'Lightweight, data-owning, customizable workout tracking plugin', + + command: { + recordSet: 'Training Plan: Record Set', + newPlan: 'Training Plan: New Plan', + newExercise: 'Training Plan: New Exercise', + newType: 'Training Plan: New Training Type', + settings: 'Training Plan: Settings', + insertCodeblock: 'Insert Code Block', + }, + + type: { + strength: 'Strength', + aerobic: 'Aerobic', + bodyweight: 'Bodyweight', + }, + + exercise: { + squat: 'Squat', + deadlift: 'Deadlift', + bench_press: 'Bench Press', + pushup: 'Push-Up', + pullup: 'Pull-Up', + plank: 'Plank', + running: 'Running', + jumping_rope: 'Jump Rope', + cycling: 'Cycling', + // —— added (2026-07-14) —— + dumbbell_bench_press: 'Dumbbell Bench Press', + incline_dumbbell_press: 'Incline Dumbbell Press', + close_grip_bench_press: 'Close-Grip Bench Press', + dip: 'Dip', + dumbbell_press: 'Dumbbell Press', + dumbbell_lateral_raise: 'Dumbbell Lateral Raise', + barbell_front_raise: 'Barbell Front Raise', + barbell_push_press: 'Barbell Push Press', + barbell_lying_triceps: 'Barbell Lying Triceps Extension', + dumbbell_overhead_triceps: 'Dumbbell Overhead Triceps Extension', + barbell_row: 'Barbell Row', + dumbbell_row: 'Dumbbell Row', + dumbbell_reverse_fly: 'Dumbbell Reverse Fly', + cable_face_pull: 'Cable Face Pull', + barbell_upright_row: 'Barbell Upright Row', + barbell_curl: 'Barbell Curl', + dumbbell_curl: 'Dumbbell Curl', + lunge: 'Lunge', + romanian_deadlift: 'Romanian Deadlift', + hanging_leg_raise: 'Hanging Leg Raise', + ab_crunch: 'Ab Crunch', + }, + + muscle: { + chest: 'Chest', + front_delt: 'Front Delts', + biceps: 'Biceps', + quads: 'Quads', + front_calf: 'Front Calves', + abs: 'Abs', + lats: 'Lats', + traps: 'Traps', + rear_delt: 'Rear Delts', + triceps: 'Triceps', + hamstrings: 'Hamstrings', + glutes: 'Glutes', + back_calf: 'Back Calves', + }, + + field: { + weight: 'Weight', + reps: 'Reps', + bodyweight: 'Body Weight', + duration_sec: 'Duration', + distance: 'Distance', + }, + + unit: { + kg: 'kg', + lb: 'lb', + }, + + duration: { + hour: 'h', + minute: 'm', + second: 's', + }, + + fieldInputType: { + number: 'Number', + duration: 'Duration', + text: 'Text', + select: 'Select', + }, + + fieldUnit: { + none: 'None', + mass: 'Mass', + custom: 'Custom', + }, + + codeblock: { + addRecord: 'Add record for "{exercise}"', + edit: 'Edit', + delete: 'Delete', + confirmDelete: 'Are you sure you want to delete this record?', + date: 'Date', + note: 'Note', + actions: 'Actions', + totalSets: 'Total Sets', + maxWeight: 'Max Weight', + noRecords: 'No records for "{exercise}" yet. Click the button above to start adding.', + notSpecified: 'Not specified', + plan: { + select: 'Select plan', + noPlan: 'No training plan yet. Create one via "New Training Plan".', + selectPlaceholder: '— Select a plan —', + progress: 'Progress {done}/{total}', + completed: 'Completed', + complete: 'Complete', + edit: 'Edit', + editTitle: 'Edit · {exercise} · Set {n}', + emptyFields: '(no presets set)', + }, + day: { + viewing: 'Viewing date: {date}', + project: 'Exercise', + statValue: 'Stats', + primaryMuscles: 'Primary', + secondaryMuscles: 'Secondary', + plan: 'Plan', + pinToday: 'Pin to today', + empty: 'No training records on "{date}"', + unknown: 'Unknown exercise', + pinFailed: 'Pin failed: could not locate code block', + }, + heatmap: { + emptyAll: 'No training records', + emptyRange: 'No training records in the last {range}', + noMapping: 'Muscles have no SVG mapping yet. Open "Muscle Manager" and apply a preset (default/minimal) to see the heatmap.', + lazy: 'Heatmap will render automatically when scrolled into view…', + legendLow: 'Light', + legendHigh: 'Heavy', + }, + }, + + modal: { + recordSet: { + title: 'Record Set', + exercise: 'Exercise', + searchExercisePlaceholder: 'Search exercises...', + type: 'Training Type', + newExercise: '+ New', + note: 'Note (optional)', + save: 'Save', + inputPlaceholder: 'Enter', + selectType: 'Please select a training type', + requiredField: 'is required', + saved: 'Record saved', + updated: 'Record updated', + saveFailed: 'Save failed', + time: 'Time', + plan: 'Training Scheme', + scheme: 'Training Scheme', + planPlaceholder: 'Leave empty if not tied to a scheme', + noSchemeOption: '— No Scheme —', + schemeNotFound: 'deleted/renamed', + exerciseLocked: '(cannot be changed when editing)', + noMatchingExercise: 'No matching exercises', + selectExercise: 'Please select a valid exercise', + }, + insertCodeblock: { + title: 'Insert Workout Code Block', + searchPlaceholder: 'Search code blocks...', + noMatch: 'No matching code block', + paramTitle: 'Set parameters (all optional; blank = default)', + optional: 'optional', + skip: 'Skip parameters', + insert: 'Insert at cursor', + inserted: 'Code block inserted at cursor', + noEditor: 'Open a Markdown note first', + paramsCount: '{n} optional params', + }, + newPlan: { + title: 'New Training Plan', + editTitle: 'Edit Training Plan', + name: 'Plan name', + namePlaceholder: 'Enter plan name', + defaultPrefix: 'Training Plan', + nameRequired: 'Please enter a plan name', + nameDuplicate: 'Plan name already exists', + time: 'Plan schedule', + timeDate: 'On a date', + timeWeek: 'Weekly', + date: 'Date', + weekdayHint: 'Weekly on {days}', + weekdayNone: 'No weekday selected', + selectPlan: 'Training scheme', + noScheme: '(Add manually / no scheme)', + selected: 'Selected {x} / {y}', + addItem: '+ Add exercise', + removeItem: 'Remove', + addSet: '+ Add set', + setName: 'Set {n}', + setSaved: 'Saved', + saved: 'Training plan saved', + updated: 'Training plan updated', + saveFailed: 'Save failed', + atLeastOne: 'Please select at least one exercise', + noExercise: 'No exercises yet. Add one under "Exercises" first.', + }, + newExercise: { + title: 'New Exercise', + editTitle: 'Edit Exercise', + name: 'Name', + id: 'Exercise ID', + idPlaceholder: 'Auto-generated from name if blank', + idDuplicate: 'Exercise ID already exists, please choose another', + idInvalid: 'ID cannot contain comma, quote, or newline', + category: 'Training Type', + primaryMuscles: 'Primary', + secondaryMuscles: 'Secondary', + save: 'Save', + nameRequired: 'Please enter exercise name', + categoryRequired: 'Please select a training type', + saved: 'Exercise saved', + updated: 'Exercise updated', + saveFailed: 'Save failed', + }, + newType: { + title: 'New Training Type', + editTitle: 'Edit Training Type', + name: 'Type Name', + id: 'Category ID', + idPlaceholder: 'Auto-generated from name if blank', + idDuplicate: 'Category ID already exists, please choose another', + idInvalid: 'ID cannot contain comma, quote, or newline', + fields: 'Field List', + addField: '+ Add Field', + removeField: '- Remove', + fieldKey: 'Field Key', + fieldLabel: 'Field Label', + inputType: 'Input Type', + unit: 'Unit', + customUnit: 'Unit text', + options: 'Options (dropdown)', + optionsPlaceholder: 'Comma-separated, e.g. Light, Medium, Heavy', + save: 'Save', + nameRequired: 'Please enter type name', + saved: 'Training type saved', + saveFailed: 'Save failed', + }, + editRecord: { + title: 'Edit Record', + save: 'Save', + }, + muscleManager: { + title: 'Muscle Manager', + name: 'Name', + side: 'Part', + sideLabel: 'Part', + coverage: 'Count toward coverage', + coverageLabel: 'Coverage', + idLabel: 'ID', + add: '+ Add Muscle', + search: 'Search muscle name / ID', + edit: 'Edit', + editTitle: 'Edit Muscle', + delete: 'Delete', + front: 'Front', + back: 'Back', + both: 'Both', + idRequired: 'Please enter an ID', + nameRequired: 'Please enter a name', + saved: 'Muscle saved', + saveFailed: 'Save failed', + deleted: 'Deleted', + onboardingTitle: 'Set up muscle mapping', + onboardingDesc: 'Choose an initial detail level. You can change it anytime in the edit dialog.', + tierDefault: 'Default (by fitness group)', + tierDefaultDesc: 'Map each muscle to all paths in its fitness group.', + tierMinimal: 'Minimal (main paths)', + tierMinimalDesc: 'Keep only representative main paths for a cleaner chart.', + tierManual: 'Manual', + tierManualDesc: 'Import empty mappings and pick paths yourself.', + mappingApplied: 'Preset mapping applied', + reapplyPreset: 'Re-apply preset', + restThreshold: 'Rest threshold (days)', + mappedPaths: 'Mapped paths', + svgMapping: 'SVG Muscle Mapping', + searchPaths: 'Search id / zh / en', + clearMapping: 'Clear', + applyGroupPreset: 'Apply group preset', + selectedCount: 'Selected {count}', + noPaths: 'No matching paths', + heatmapSettings: 'Heatmap Settings', + heatmapMetric: 'Metric', + followDefaultMetric: 'Follow default (sets)', + heatmapRange: 'Range', + followDefaultRange: 'Follow default (7d)', + customRange: 'Custom date range', + colorLevels: 'Color levels', + colorHex: 'Hex color, e.g. #3b82f6', + scaleHint: 'Color light→heavy, each level has a threshold: left is the color (click the swatch to pick, or type a hex), right is the threshold (≤). Level count sets how many color tiers.', + initScale: 'Use default 4 levels', + scaleMaxHint: '≤', + scaleUnbounded: '(unbounded)', + }, + typeManager: { + title: 'Training Type Manager', + name: 'Type Name', + fields: 'Fields', + coverage: 'Contributes to Coverage', + coverageLabel: 'Coverage', + add: '+ Add Training Type', + search: 'Search training type name / ID', + edit: 'Edit', + delete: 'Delete', + }, + exerciseManager: { + title: 'Exercise Manager', + name: 'Exercise Name', + category: 'Type', + primaryMuscles: 'Primary', + secondaryMuscles: 'Secondary', + add: '+ Add Exercise', + search: 'Search exercise name / ID', + edit: 'Edit', + delete: 'Delete', + }, + statManager: { + title: 'Stat Manager', + add: '+ Add Stat', + search: 'Search stat name', + edit: 'Edit', + delete: 'Delete', + name: 'Stat Name', + types: 'Associated Types', + none: 'None', + formula: 'Formula', + mode: 'Mode', + builder: 'Builder', + expression: 'Expression', + op: 'Operation', + opSum: 'Sum', + opProductSum: 'Product Sum', + opOneRepMax: '1RM Est. (Epley)', + opAvg: 'Average', + opMax: 'Max', + opMin: 'Min', + opCount: 'Count', + field: 'Field', + fieldA: 'Field A', + fieldB: 'Field B', + weightField: 'Weight Field', + repsField: 'Reps Field', + preview: 'Expression Preview', + allowedFields: 'Allowed Fields', + granularity: 'Granularity', + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + enabled: 'Enabled', + showInStats: 'Show in stats', + countHint: 'count() needs no field', + noTypes: 'No training types yet. Add one under "Training Types" first.', + selectField: 'Please select a field', + exprPlaceholder: 'e.g. sum(reps * weight)', + exprError: 'Invalid expression: ', + }, + stat: { + title: 'Add Stat', + editTitle: 'Edit Stat', + nameRequired: 'Please enter a stat name', + typesRequired: 'Please associate at least one training type', + selectField: 'Please select a field', + saved: 'Stat saved', + }, + }, + + settings: { + title: 'Training Plan Settings', + chinese: '中文', + english: 'English', + dataPath: 'Data File Locations', + csvDirectory: 'Training Log CSV Directory', + csvDirectoryDesc: 'Directory for workout_logs.csv', + configDirectory: 'Aggregate Config JSON Directory', + configDirectoryDesc: 'Directory for workout-config.json', + dataDirectoryPlaceholder: 'Leave empty for vault root', + browse: 'Browse', + trainingSettings: 'Training Settings', + dragToReorder: 'Drag to reorder', + moveUp: 'Move up', + moveDown: 'Move down', + trainingTypes: 'Training Types', + exercises: 'Exercises', + muscles: 'Muscles', + typeManager: 'Training Type Manager', + exerciseManager: 'Exercise Manager', + muscleManager: 'Muscle Manager', + openManager: 'Open Manager', + totalTypes: 'Total', + totalExercises: 'Total', + totalMuscles: 'Total', + totalPlans: 'Total', + noMuscles: 'No muscles yet. Click the button below to add.', + noTypes: 'No training types yet. Click the button below to add.', + noExercises: 'No exercises yet. Click the button below to add.', + noFields: 'No fields yet. Click the button below to add.', + statistics: 'Statistics', + statisticsManager: 'Stat Manager', + totalStatistics: 'Total', + noStatistics: 'No statistics yet. Click the button below to add.', + atLeastOneField: 'Training type requires at least 1 field', + confirmDeleteType: 'Confirm delete training type', + confirmDeleteExercise: 'Confirm delete exercise', + confirmDeleteExerciseRecords: 'Delete exercise "{name}"? Its {count} associated training record(s) will also be permanently removed.', + confirmDelete: 'Are you sure', + muscleReferencedBy: 'This muscle is referenced by', + typeReferencedBy: 'This training type is referenced by', + days: 'days', + none: 'None', + reminderThreshold: 'Reminder Threshold', + reminderThresholdDays: 'Days since last training threshold', + reminderThresholdDesc: 'Muscles not trained for more than this number of days will be marked as reminder', + unit: 'Unit', + language: 'Language', + integrations: 'Integrations', + dataview: 'Dataview', + dailyNotes: 'Daily Notes', + templater: 'Templater', + lastValueMemory: 'Last Value Memory', + trainingPlans: { + title: 'Training Plans', + add: 'New Training Plan', + manage: 'Manage Training Plans', + search: 'Search training plan name', + total: 'Total', + schedule: 'Schedule', + itemCount: 'Exercises', + source: 'Source', + manual: 'Manual', + edit: 'Edit', + delete: 'Delete', + noPlans: 'No training plans yet', + confirmDelete: 'Delete training plan "{name}"? Existing training records will not be removed.', + }, + maintenance: 'Data Maintenance', + compactCsv: 'Compact & Clean CSV', + compactCsvDesc: 'Deletion uses soft-delete (marked, not rewritten — instant). This compacts the whole file, permanently removing deleted records and reclaiming space.', + confirmCompact: 'Compact & clean the CSV? This permanently removes all deleted records.', + compactDone: 'Compacted: removed {n} deleted record(s)', + softDeleteHint: 'Record deleted (soft-delete). To reclaim file space, run "Compact & Clean CSV" in settings.', + general: 'General Settings', + on: 'On', + off: 'Off', + add: '+ Add', + edit: 'Edit', + delete: 'Delete', + }, + + common: { + cancel: 'Cancel', + close: 'Close', + ok: 'OK', + save: 'Save', + edit: 'Edit', + delete: 'Delete', + add: 'Add', + noMatch: 'No matching results', + name: 'Name', + type: 'Type', + }, +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..647ac55 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,85 @@ +import { zh } from './zh'; +import { en } from './en'; + +/* + * index.ts —— i18n(国际化)核心模块 + * 职责:统一管理「当前语言」并提供 t() 取文案的函数。 + * 工作流程:setLocale() 切换 zh/en 并记录当前字典 → 任意界面调用 t('key.path') 取文字。 + * 字典本体在 zh.ts(中文)和 en.ts(英文)。本文件不直接存文字,只负责「按 key 找文字」。 + */ + +// 由 zh 字典的顶层 key 推导出语言种类类型('zh' | 'en' 的等价别名),仅用于类型约束 +export type LocaleKey = keyof typeof zh; + +// 当前语言,默认中文 +let currentLocale: 'zh' | 'en' = 'zh'; +// 当前使用的字典对象(zh 或 en),默认指向中文字典 +let currentDict = zh; + +// 切换语言:把全局变量改成指定的字典,后续 t() 就会从新字典取文字 +export function setLocale(locale: 'zh' | 'en') { + currentLocale = locale; + currentDict = locale === 'zh' ? zh : en; +} + +// 获取当前语言(其他地方需要按语言做格式化的判断时会用到,例如 duration.ts) +export function getLocale(): 'zh' | 'en' { + return currentLocale; +} + +// 获取某个 i18n key 在全部支持语言(zh / en)中的翻译结果,用于多语言反向查找。 +// 例如 getAllTranslations('exercise.squat') 会返回 ['深蹲', 'Squat']。 +export function getAllTranslations(key: string): string[] { + const values = new Set(); + for (const dict of [zh, en]) { + const keys = key.split('.'); + let value: unknown = dict; + for (const k of keys) { + if (typeof value === 'object' && value !== null && k in value) { + value = (value as Record)[k]; + } else { + value = undefined; + break; + } + } + if (typeof value === 'string' && value) { + values.add(value); + } + } + return Array.from(values); +} + +// 核心取文案函数:t('codeblock.edit') 返回当前语言里对应的文字 +// 第二个参数 params 用于替换文案里的占位符,例如 t('codeblock.addRecord', { exercise: '深蹲' }) +// 会把文案里的 {exercise} 替换成 '深蹲' +export function t(key: string, params?: Record): string { + // 把 'a.b.c' 这样的路径按点号拆成数组 ['a','b','c'],便于一层层往下找 + const keys = key.split('.'); + let value: unknown = currentDict; + + // 逐层进入嵌套对象:先 currentDict['a'],再 ['b'],再 ['c'] + for (const k of keys) { + // 当前层是对象且存在该 key 时,继续往里走;否则说明 key 写错了 + if (typeof value === 'object' && value !== null && k in value) { + value = (value as Record)[k]; + } else { + // 找不到就原样返回 key 本身,方便在界面上立刻发现「文案缺失」 + return key; + } + } + + // 最终取到的值必须是字符串才算成功,不是字符串也原样返回 key + if (typeof value !== 'string') { + return key; + } + + // 有占位符参数时,做正则替换 + // 正则 /{(\w+)}/g 匹配所有 {...} 占位符,(\w+) 捕获花括号里的名字 + // 例如 {exercise} 被替换成 params.exercise 的值;若 params 里没有该参数则保留 {exercise} 原样 + if (params) { + return value.replace(/{(\w+)}/g, (_, paramName) => params[paramName] || `{${paramName}}`); + } + + // 没有占位符,直接返回文案 + return value; +} \ No newline at end of file diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts new file mode 100644 index 0000000..cc614a6 --- /dev/null +++ b/src/i18n/zh.ts @@ -0,0 +1,484 @@ +/* + * zh.ts —— 中文文案字典(i18n 中文包) + * 这个文件存放插件所有中文界面文字。它是个嵌套的对象(object), + * 用「点号路径」作为 key,例如 common.save 对应下面的 common → save。 + * 这些 key 与 index.ts 里的 t('key.path') 一一对应:t() 按当前语言来这里取文字。 + * 想新增/修改某条中文文案,直接在本文件对应位置改即可,无需碰其他文件。 + * 注意:英文版在 en.ts,两边 key 结构必须保持一致。 + */ + +export const zh = { + pluginName: '训练块', + pluginDescription: '轻量、数据自有、可自定义的训练记录插件', + + command: { + recordSet: '记录训练', + newPlan: '新增训练计划', + newExercise: '新建训练项', + newType: '新建训练类型', + settings: '设置', + insertCodeblock: '插入代码块', + }, + + type: { + strength: '力量训练', + aerobic: '有氧训练', + bodyweight: '自重训练', + }, + + exercise: { + squat: '深蹲', + deadlift: '硬拉', + bench_press: '卧推', + pushup: '俯卧撑', + pullup: '引体向上', + plank: '平板支撑', + running: '跑步', + jumping_rope: '跳绳', + cycling: '骑行', + // —— 新增(2026-07-14 大改)—— + dumbbell_bench_press: '哑铃卧推', + incline_dumbbell_press: '上斜哑铃卧推', + close_grip_bench_press: '窄距卧推', + dip: '臂屈伸', + dumbbell_press: '哑铃推举', + dumbbell_lateral_raise: '哑铃侧平举', + barbell_front_raise: '杠铃前平举', + barbell_push_press: '杠铃推举', + barbell_lying_triceps: '杠铃仰卧臂屈伸', + dumbbell_overhead_triceps: '哑铃颈后臂屈伸', + barbell_row: '杠铃划船', + dumbbell_row: '哑铃划船', + dumbbell_reverse_fly: '哑铃俯身飞鸟', + cable_face_pull: '绳索面拉', + barbell_upright_row: '杠铃提拉', + barbell_curl: '杠铃弯举', + dumbbell_curl: '哑铃弯举', + lunge: '箭步蹲', + romanian_deadlift: '直腿硬拉', + hanging_leg_raise: '悬垂举腿', + ab_crunch: '平板卷腹', + }, + + muscle: { + chest: '胸大肌', + front_delt: '三角肌前束', + biceps: '肱二头', + quads: '股四头', + front_calf: '小腿前侧', + abs: '腹肌', + lats: '背阔肌', + traps: '斜方肌', + rear_delt: '三角肌后束', + triceps: '肱三头', + hamstrings: '腘绳肌', + glutes: '臀肌', + back_calf: '小腿后侧', + }, + + field: { + weight: '重量', + reps: '次数', + bodyweight: '体重', + duration_sec: '时长', + distance: '距离', + }, + + unit: { + kg: 'kg', + lb: 'lb', + }, + + duration: { + hour: '时', + minute: '分', + second: '秒', + }, + + fieldInputType: { + number: '数字', + duration: '时长', + text: '文本', + select: '下拉选择', + }, + + fieldUnit: { + none: '无', + mass: '质量', + custom: '自定义', + }, + + codeblock: { + addRecord: '为“{exercise}”添加记录', + edit: '编辑', + delete: '删除', + confirmDelete: '确定删除这条记录吗?', + date: '日期', + note: '备注', + actions: '操作', + totalSets: '总组数', + maxWeight: '最高重量', + noRecords: '“{exercise}”暂无记录,点击上方按钮开始添加。', + notSpecified: '未指定', + plan: { + select: '选择计划', + noPlan: '暂无训练计划,请先通过「新增训练计划」创建', + selectPlaceholder: '— 请选择计划 —', + progress: '进度 {done}/{total}', + completed: '已完成', + complete: '完成', + edit: '编辑', + editTitle: '编辑 · {exercise} · 第 {n} 组', + emptyFields: '(未设置预设值)', + }, + day: { + viewing: '查看日期:{date}', + project: '项目', + statValue: '数据统计值', + primaryMuscles: '主肌群', + secondaryMuscles: '辅助肌群', + plan: '训练方案', + pinToday: '固定为当日', + empty: '“{date}” 暂无训练记录', + unknown: '未知动作', + pinFailed: '固定失败:无法定位代码块位置', + }, + heatmap: { + emptyAll: '暂无训练记录', + emptyRange: '最近 {range} 暂无训练记录', + noMapping: '肌肉尚未配置 SVG 映射,请在「肌肉管理」里套用默认/精简档预设后再查看热力图', + lazy: '滚动到此处时自动渲染热力图…', + legendLow: '轻', + legendHigh: '重', + }, + }, + + modal: { + recordSet: { + title: '记录训练', + exercise: '训练项', + searchExercisePlaceholder: '搜索训练项...', + type: '训练类型', + newExercise: '+ 新建', + note: '备注(可选)', + save: '保存', + inputPlaceholder: '请输入', + selectType: '请选择训练类型', + requiredField: '为必填项', + saved: '记录已保存', + updated: '记录已更新', + saveFailed: '保存失败', + time: '时间', + plan: '训练方案', + scheme: '训练方案', + planPlaceholder: '留空则不关联方案', + noSchemeOption: '— 不关联方案 —', + schemeNotFound: '已删除或重命名', + exerciseLocked: '(编辑时不可修改)', + noMatchingExercise: '无匹配的训练项', + selectExercise: '请选择有效的训练项', + }, + insertCodeblock: { + title: '插入训练代码块', + searchPlaceholder: '搜索代码块...', + noMatch: '无匹配的代码块', + paramTitle: '设置参数(全部可选,留空即用默认)', + optional: '可选', + skip: '跳过参数', + insert: '插入到光标处', + inserted: '已插入代码块到光标处', + noEditor: '请先打开一个 Markdown 笔记', + paramsCount: '{n} 个可选参数', + }, + newPlan: { + title: '新增训练计划', + editTitle: '编辑训练计划', + name: '计划名称', + namePlaceholder: '请输入计划名称', + defaultPrefix: '训练计划', + nameRequired: '请输入计划名称', + nameDuplicate: '计划名称已存在,请换一个', + time: '计划时间', + timeDate: '指定日期', + timeWeek: '每周', + date: '日期', + weekdayHint: '每周 {days}', + weekdayNone: '未选择星期', + selectPlan: '选择训练方案', + noScheme: '(手动添加 / 不关联方案)', + selected: '已选 {x} / 共 {y}', + addItem: '+ 添加训练项', + removeItem: '移除', + addSet: '+ 添加组', + setName: '第 {n} 组', + setSaved: '已保存', + saved: '训练计划已保存', + updated: '训练计划已更新', + saveFailed: '保存失败', + atLeastOne: '请至少选择一个训练项', + noExercise: '暂无训练项,请先到「训练项」添加', + }, + newExercise: { + title: '新建训练项', + editTitle: '编辑训练项', + name: '名称', + id: '训练项ID', + idPlaceholder: '留空则自动取名称', + idDuplicate: '训练项ID已存在,请换一个', + idInvalid: 'ID 不能包含逗号、引号或换行', + category: '所属训练类型', + primaryMuscles: '主练', + secondaryMuscles: '辅助', + save: '保存', + nameRequired: '请输入训练项名称', + categoryRequired: '请选择训练类型', + saved: '训练项已保存', + updated: '训练项已更新', + saveFailed: '保存失败', + }, + newType: { + title: '新建训练类型', + editTitle: '编辑训练类型', + name: '类型名称', + id: '类别ID', + idPlaceholder: '留空则自动取类型名称', + idDuplicate: '类别ID已存在,请换一个', + idInvalid: 'ID 不能包含逗号、引号或换行', + fields: '字段列表', + addField: '+ 添加字段', + removeField: '- 移除', + fieldKey: '字段键', + fieldLabel: '字段标签', + inputType: '输入类型', + unit: '单位', + customUnit: '单位文字', + options: '选项(下拉)', + optionsPlaceholder: '用英文逗号分隔,如:轻,中,重', + save: '保存', + nameRequired: '请输入类型名称', + saved: '训练类型已保存', + saveFailed: '保存失败', + }, + editRecord: { + title: '编辑记录', + save: '保存', + }, + muscleManager: { + title: '肌肉管理', + name: '名称', + side: '部位', + sideLabel: '部位', + coverage: '计入覆盖', + coverageLabel: '覆盖', + idLabel: 'ID', + add: '+ 新增肌肉', + search: '搜索肌肉名称 / ID', + edit: '编辑', + editTitle: '编辑肌肉', + delete: '删除', + front: '正面', + back: '背面', + both: '双侧', + idRequired: '请输入 ID', + nameRequired: '请输入名称', + saved: '肌肉已保存', + saveFailed: '保存失败', + deleted: '已删除', + onboardingTitle: '首次使用肌肉映射', + onboardingDesc: '请选择初始详细程度。之后仍可在编辑弹窗随时调整。', + tierDefault: '默认(按健身群)', + tierDefaultDesc: '每块肌肉映射到所属健身群的全部路径,最完整。', + tierMinimal: '精简(主路径)', + tierMinimalDesc: '每块肌肉只保留代表性主路径,图表更干净。', + tierManual: '手动', + tierManualDesc: '先导入空映射,由你逐个在编辑弹窗勾选。', + mappingApplied: '已套用预设映射', + reapplyPreset: '重新套用预设', + restThreshold: '未练天数阈值', + mappedPaths: '已映射路径', + svgMapping: 'SVG 肌肉映射', + searchPaths: '搜索 id / 中文 / 英文', + clearMapping: '清空', + applyGroupPreset: '套用健身群预设', + selectedCount: '已选 {count} 块', + noPaths: '没有匹配的路径', + heatmapSettings: '热力图设置', + heatmapMetric: '指标', + followDefaultMetric: '跟随默认(组数)', + heatmapRange: '时间窗', + followDefaultRange: '跟随默认(7d)', + customRange: '自定义日期区间', + colorLevels: '颜色分级', + colorHex: '十六进制颜色,如 #3b82f6', + scaleHint: '颜色由轻到重,每档对应一个阈值:左侧为颜色(点色块可拾取,或直接填十六进制),右侧为阈值(≤)。分级数决定颜色档数量。', + initScale: '用默认 4 档', + scaleMaxHint: '≤', + scaleUnbounded: '(无穷大)', + }, + typeManager: { + title: '训练类型管理', + name: '类型名称', + fields: '字段', + coverage: '计入覆盖', + coverageLabel: '覆盖', + add: '+ 新增训练类型', + search: '搜索训练类型名称 / ID', + edit: '编辑', + delete: '删除', + }, + exerciseManager: { + title: '训练项管理', + name: '训练项名称', + category: '所属类型', + primaryMuscles: '主练', + secondaryMuscles: '辅助', + add: '+ 新增训练项', + search: '搜索训练项名称 / ID', + edit: '编辑', + delete: '删除', + }, + statManager: { + title: '数据统计管理', + add: '+ 新增统计', + search: '搜索统计名称', + edit: '编辑', + delete: '删除', + name: '统计名称', + types: '关联训练类型', + none: '无', + formula: '公式', + mode: '模式', + builder: '引导式', + expression: '表达式', + op: '运算', + opSum: '求和', + opProductSum: '乘积求和', + opOneRepMax: '1RM 估算(Epley)', + opAvg: '平均', + opMax: '最大', + opMin: '最小', + opCount: '计数', + field: '字段', + fieldA: '字段 A', + fieldB: '字段 B', + weightField: '重量字段', + repsField: '次数字段', + preview: '表达式预览', + allowedFields: '可用字段', + granularity: '时间粒度', + daily: '每日', + weekly: '每周', + monthly: '每月', + enabled: '启用', + showInStats: '在统计中显示', + countHint: 'count() 无需选择字段', + noTypes: '暂无训练类型,请先到「训练类型」添加', + selectField: '请选择字段', + exprPlaceholder: '如 sum(reps * weight)', + exprError: '表达式无效:', + }, + stat: { + title: '新增统计', + editTitle: '编辑统计', + nameRequired: '请输入统计名称', + typesRequired: '请至少关联一个训练类型', + selectField: '请选择字段', + saved: '统计已保存', + }, + }, + + settings: { + title: '训练计划设置', + chinese: '中文', + english: 'English', + dataPath: '数据文件位置', + csvDirectory: '训练记录 CSV 目录', + csvDirectoryDesc: 'workout_logs.csv 所在目录', + configDirectory: '聚合配置 JSON 目录', + configDirectoryDesc: 'workout-config.json 所在目录', + dataDirectoryPlaceholder: '留空表示根目录', + browse: '浏览', + trainingSettings: '训练设置', + dragToReorder: '拖拽排序', + moveUp: '上移', + moveDown: '下移', + trainingTypes: '训练类型', + exercises: '训练项', + muscles: '肌肉', + typeManager: '训练类型管理', + exerciseManager: '训练项管理', + muscleManager: '肌肉管理', + openManager: '打开管理', + totalTypes: '总数', + totalExercises: '总数', + totalMuscles: '总数', + totalPlans: '总数', + noMuscles: '暂无肌肉配置,点击下方按钮新增。', + noTypes: '暂无训练类型,点击下方按钮新增。', + noExercises: '暂无训练项,点击下方按钮新增。', + noFields: '暂无字段,点击下方按钮新增。', + statistics: '数据统计', + statisticsManager: '数据统计管理', + totalStatistics: '总数', + noStatistics: '暂无统计,点击下方按钮新增。', + atLeastOneField: '训练类型至少需要 1 个字段', + confirmDeleteType: '确定删除训练类型', + confirmDeleteExercise: '确定删除训练项', + confirmDeleteExerciseRecords: '确定删除训练项「{name}」吗?它关联的 {count} 条训练记录也会被一并删除,且无法恢复。', + confirmDelete: '确定删除吗', + muscleReferencedBy: '该肌肉已被以下训练项引用', + typeReferencedBy: '该训练类型已被以下训练项引用', + days: '天', + none: '无', + reminderThreshold: '提醒阈值', + reminderThresholdDays: '未练天数阈值', + reminderThresholdDesc: '超过此天数未练的肌肉将被标记为提醒', + unit: '单位', + language: '语言', + integrations: '联动开关', + dataview: 'Dataview', + dailyNotes: 'Daily Notes', + templater: 'Templater', + lastValueMemory: '上次值记忆', + trainingPlans: { + title: '训练计划', + add: '新增训练计划', + manage: '训练计划管理', + search: '搜索训练计划名称', + total: '总数', + schedule: '计划时间', + itemCount: '训练项数', + source: '来源', + manual: '手动创建', + edit: '编辑', + delete: '删除', + noPlans: '暂无训练计划', + confirmDelete: '确定删除训练计划「{name}」吗?这不会删除已产生的训练记录。', + }, + maintenance: '数据维护', + compactCsv: '压缩清理 CSV', + compactCsvDesc: '删除记录采用软删除(仅标记、不重写文件,删除瞬时完成)。此操作将整文件压缩,永久移除被删记录并释放体积。', + confirmCompact: '确定要压缩清理 CSV 吗?这将永久移除所有已删除的记录。', + compactDone: '已压缩清理,移除 {n} 条被删记录', + softDeleteHint: '记录已删除(软删除);如需彻底清理文件体积,可到设置页「压缩清理 CSV」。', + general: '通用设置', + on: '开', + off: '关', + add: '+ 添加', + edit: '编辑', + delete: '删除', + }, + + common: { + cancel: '取消', + close: '关闭', + ok: '确定', + save: '保存', + edit: '编辑', + delete: '删除', + add: '添加', + noMatch: '没有匹配的结果', + name: '名称', + type: '类型', + }, +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..91d8b9d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,333 @@ +import { Plugin, Notice } from 'obsidian'; +import { DataManager } from './data/DataManager'; +import { getExerciseNameById } from './data/display'; +import { setLocale, t } from './i18n'; +import { registerCodeBlock, rerenderAllBlocks, rerenderBlocksForExercise, rerenderBlocksByType, setRegistryApp } from './codeblock/registry'; +import { renderWorkoutLog } from './codeblock/workoutLog'; +import { renderWorkoutDay } from './codeblock/workoutDay'; +import { renderWorkoutHeatmap } from './codeblock/workoutHeatmap'; +import { renderWorkoutPlan } from './codeblock/workoutPlan'; +import { LogRow, FieldDef } from './data/types'; +import { applyMappingTier } from './data/muscleMapping'; +import { RecordModal } from './ui/RecordModal'; +import { confirmWithModal } from './ui/Confirm'; +import { NewPlanModal } from './ui/NewPlanModal'; +import { ExerciseModal } from './ui/ExerciseModal'; +import { TypeModal } from './ui/TypeModal'; +import { SettingsTab } from './ui/SettingsTab'; +import { InsertCodeBlockModal } from './ui/InsertCodeBlockModal'; + +/* + * main.ts —— 插件入口文件(核心枢纽) + * 本文件定义了 WorkoutPlugin 类,它继承自 Obsidian 的 Plugin 基类。 + * 插件的"生命周期"(加载、卸载)和各种功能入口(命令、侧边栏图标、 + * 代码块渲染、设置页、视图、事件监听)都在这里集中注册。 + * 可以把它理解成一座"调度中心":它自己不画界面,而是负责把各个 + * 子模块(数据管理、弹窗、代码块、视图)连接起来并对外暴露功能。 + */ +export default class WorkoutPlugin extends Plugin { + // 数据管理器:负责读写训练记录 CSV、配置文件、语言设置等。几乎所有功能都依赖它。 + private dataManager!: DataManager; + // 设置页对象(Obsidian 的 SettingTab),可能为空(尚未创建)。 + private settingsTab: SettingsTab | null = null; + + // 插件加载时由 Obsidian 自动调用。这是初始化所有功能的入口。 + async onload(): Promise { + // 1) 创建并初始化数据管理器(读取/准备 CSV 与配置文件) + this.dataManager = new DataManager(this); + await this.dataManager.init(); + + // 1.1) 把 App 引用注入代码块注册表,供重渲染后恢复编辑器焦点(修复光标丢失) + setRegistryApp(this.app); + + // 2) 根据配置文件里的语言设置,切换 i18n 当前语言(t() 之后会返回对应语言文案) + const settings = this.dataManager.getSettings(); + setLocale(settings.language); + + // 2.1) 旧版本迁移:若用户从未打开过肌肉管理引导,且存在 svgRegionIds 为空的默认肌肉, + // 自动套用「default」档映射,使热力图开箱即用,避免用户困惑「热力图无颜色」。 + await this.ensureMuscleMappingInitialized(); + + // 3) 依次注册插件的各个组成部分 + this.registerCommands(); // 命令面板里的命令(如"记录一组""记录训练方案") + this.registerRibbon(); // 左侧栏的图标按钮 + this.registerCodeBlocks(); // ```workout-log 代码块的接管渲染 + this.registerSettingsTab(); // 设置页 + this.registerEventListeners(); // 数据变化 / 文件修改时的自动刷新 + + // 移动端适配:在 body 注入 .is-mobile 根类,供 styles.css 中所有 `.is-mobile` 作用域规则生效。 + // 弹窗 / 代码块 / 设置页 DOM 均在 body 之下,一处注入即可全局覆盖;桌面端不注入,零回归。 + if (this.app.isMobile) document.body.classList.add('is-mobile'); + + console.log('Workout plugin loaded'); + } + + // 旧版本兼容:为尚未初始化肌肉映射的存量配置自动套用默认映射。 + // 仅在 muscleMappingInitialized === false 且存在空 svgRegionIds 时执行一次, + // 完成后立即标记 true,避免重复迁移或覆盖用户已手动配置的映射。 + private async ensureMuscleMappingInitialized(): Promise { + const settings = this.dataManager.getSettings(); + if (settings.muscleMappingInitialized) return; + const config = await this.dataManager.getConfig(); + const hasEmpty = config.muscles.some((m) => (m.svgRegionIds?.length ?? 0) === 0); + if (!hasEmpty) { + // 所有肌肉已有映射(可能是新装用户用预填默认值),仅补标记位 + settings.muscleMappingInitialized = true; + await this.dataManager.saveSettings(); + return; + } + applyMappingTier(config, 'default'); + await this.dataManager.saveConfig(config); + settings.muscleMappingInitialized = true; + await this.dataManager.saveSettings(); + console.log('[workout-block] 已自动套用默认肌肉映射(热力图开箱即用)'); + } + + // 插件卸载(禁用/重载)时调用。这里只需做简单的清理日志;子模块会在 Obsidian 内部被自动回收。 + onunload(): void { + document.body.classList.remove('is-mobile'); + console.log('Workout plugin unloaded'); + } + + // 注册命令面板中的命令。每个 addCommand 会在 Obsidian 命令面板(Ctrl/Cmd+P)里出现一条。 + // callback 指定点击该命令时执行的函数(这里都转发给对应的 openXxx 方法)。 + private registerCommands(): void { + // "记录一组"命令:打开单条记录录入弹窗 + this.addCommand({ + id: 'workout-record-set', + name: t('command.recordSet'), + icon: 'dumbbell', + callback: () => this.openRecordModal(), + }); + + // "新增训练计划"命令:打开配置弹窗,从已有方案或手动添加训练项并预设每组字段 + this.addCommand({ + id: 'workout-record-plan', + name: t('command.newPlan'), + icon: 'list-checks', + callback: () => this.openNewPlanModal(), + }); + + // "新建训练项"命令:打开训练项(动作)新建/编辑弹窗 + this.addCommand({ + id: 'workout-new-exercise', + name: t('command.newExercise'), + icon: 'plus-circle', + callback: () => this.openNewExerciseModal(), + }); + + // "新建训练类型"命令:打开训练类型(带字段定义)新建/编辑弹窗 + this.addCommand({ + id: 'workout-new-type', + name: t('command.newType'), + icon: 'layers', + callback: () => this.openNewTypeModal(), + }); + + // "设置"命令:打开插件设置页 + this.addCommand({ + id: 'workout-settings', + name: t('command.settings'), + icon: 'settings', + callback: () => this.openSettings(), + }); + } + + // 注册左侧边栏(ribbon)的图标按钮。点击后同样打开单条记录录入弹窗。 + private registerRibbon(): void { + this.addRibbonIcon('dumbbell', t('command.recordSet'), () => { + this.openRecordModal(); + }); + this.addRibbonIcon('code', t('command.insertCodeblock'), () => { + this.openInsertCodeBlockModal(); + }); + } + + // 注册 ```workout-log 代码块的接管渲染逻辑。 + // 当用户笔记里出现 ```workout-log ... ``` 代码块时,Obsidian 会把代码块内容 + // 交给这里的处理函数,由插件渲染成带交互(点击记录/编辑/删除)的表格。 + private registerCodeBlocks(): void { + // codeBlockHandler 是真正的渲染逻辑:读取所有记录、单位、配置,然后交给 renderWorkoutLog 渲染。 + // source: 代码块的原始文本(用户写在 ``` 之间的内容) + // el: 插件用来往页面里插入渲染结果的 DOM 容器 + // ctx: Obsidian 的渲染上下文(一般无需关心) + const codeBlockHandler = (source: string, el: HTMLElement, ctx: any) => { + const logs = this.dataManager.getLogs(); + const unit = this.dataManager.getSettings().unit; + this.dataManager.getConfig().then((config) => { + // 根据训练类型(category)找到该类型下定义的所有字段(FieldDef[]),用于表格列展示 + const getTrainingTypeFields = (category: string): FieldDef[] => { + const type = config.trainingTypes.find((tt) => tt.id === category); + return type?.fields || []; + }; + + // 调用真正渲染代码块的函数,并传入若干回调(点击行时打开哪个弹窗) + renderWorkoutLog( + source, + el, + ctx, + logs, + getTrainingTypeFields, + unit, + config, + (exercise, plan) => this.openRecordModal(exercise, plan), // 点击"记录"按钮 + (log) => this.openEditRecordModal(log), // 点击"编辑" + (log) => this.deleteRecord(log) // 点击"删除" + ); + }); + }; + + // 把上面这个处理函数登记到代码块注册表(供后续 rerenderAllBlocks 统一重渲染使用) + registerCodeBlock('workout-log', codeBlockHandler); + + // 官方方式:告诉 Obsidian,workout-log 这个代码块类型由我们接管渲染 + this.registerMarkdownCodeBlockProcessor('workout-log', async (source, el, ctx) => { + codeBlockHandler(source, el, ctx); + }); + + // 同样的模式注册 workout-day 代码块(当日训练总览表)。 + const dayHandler = (source: string, el: HTMLElement, ctx: any) => { + const logs = this.dataManager.getLogs(); + this.dataManager.getConfig().then((config) => { + renderWorkoutDay(source, el, ctx, this.app, logs, config); + }); + }; + registerCodeBlock('workout-day', dayHandler); + this.registerMarkdownCodeBlockProcessor('workout-day', async (source, el, ctx) => { + dayHandler(source, el, ctx); + }); + + // 注册 workout-heatmap 代码块(全身肌肉热力图) + const heatmapHandler = (source: string, el: HTMLElement, ctx: any) => { + const logs = this.dataManager.getLogs(); + this.dataManager.getConfig().then((config) => { + renderWorkoutHeatmap(source, el, ctx, logs, config); + }); + }; + registerCodeBlock('workout-heatmap', heatmapHandler); + this.registerMarkdownCodeBlockProcessor('workout-heatmap', async (source, el, ctx) => { + heatmapHandler(source, el, ctx); + }); + + // 注册 workout-plan 代码块(训练计划完成面板)。无 plan 参数时渲染「选择计划」下拉, + // 选中后写回代码块;有 plan 时渲染完成面板(每组 [编辑][完成] + 进度)。 + const planHandler = (source: string, el: HTMLElement, ctx: any) => { + renderWorkoutPlan(source, el, ctx, this.dataManager); + }; + registerCodeBlock('workout-plan', planHandler); + this.registerMarkdownCodeBlockProcessor('workout-plan', async (source, el, ctx) => { + planHandler(source, el, ctx); + }); + } + + // 注册设置页(Settings → 找到本插件)。SettingsTab 负责插件的可视化配置界面。 + private registerSettingsTab(): void { + this.settingsTab = new SettingsTab(this.app, this, this.dataManager); + this.addSettingTab(this.settingsTab); + } + + // 注册事件监听:当数据或配置变化时,自动重渲染相关 workout-log 代码块,保证页面显示最新数据。 + private registerEventListeners(): void { + // 数据(记录)变化后:若知道是哪条记录变了,只重渲染显示该训练项的表格; + // 否则(如批量导入)退化为全量重渲染。这样添加一条记录就不会重建所有表格。 + // 传入 config 是为了让 rerenderBlocksForExercise 按稳定的 exerciseId 匹配代码块, + // 避免多语言下源码写中文名、记录存英文显示名时重渲染不到对应表格。 + this.dataManager.on('data-changed', async (data) => { + const row = data?.row; + if (row) { + const config = await this.dataManager.getConfig(); + const name = row.exerciseId ? getExerciseNameById(config.exercises, row.exerciseId) : ''; + rerenderBlocksForExercise(config, row.exerciseId, name); + } else { + rerenderAllBlocks(); + } + // workout-day 按日聚合所有训练项,任何一条记录变化都可能改变某日的展示,统一重渲染 + rerenderBlocksByType('workout-day'); + // 训练记录变化影响热力图数据,统一重渲染 + rerenderBlocksByType('workout-heatmap'); + }); + + // 配置(训练项/类型/语言)变化后,标签与字段都可能变,需要全量重渲染 + this.dataManager.on('config-changed', () => { + rerenderAllBlocks(); + // 训练项/肌肉/统计配置变化同样影响 workout-day 的展示,统一重渲染 + rerenderBlocksByType('workout-day'); + // 肌肉映射与统计配置变化影响热力图,统一重渲染 + rerenderBlocksByType('workout-heatmap'); + }); + + // 单位/语言等设置变化后,单位相关的代码块(完成面板、记录表)需刷新以反映新单位 + this.dataManager.on('settings-changed', () => { + rerenderBlocksByType('workout-plan'); + rerenderBlocksByType('workout-log'); + }); + + // 监听仓库文件被修改的事件:仅当用户「在插件之外」手动改了 CSV / 配置文件时才需要 + // 整体重读 + 重渲染。插件自身写盘期间(或刚写完后很短窗口内)selfWriting / wasSelfWrittenRecently + // 为真,此时 data-changed 已经做了精准重渲染,这里直接跳过,避免重复开销与卡顿。 + // 注意:vault.on('modify') 在写盘完成后才异步派发,届时 selfWriting 已被复位为 false, + // 故必须叠加 wasSelfWrittenRecently 时间戳兜底,否则自身删除/编辑写盘会触发整文件重读 + + // 全量重渲染(含 320KB 肌肉 SVG 热力图),导致删除记录后 Obsidian 主线程卡死、无法立即编辑。 + this.app.vault.on('modify', async (file) => { + const selfWritten = this.dataManager.isSelfWriting() || this.dataManager.wasSelfWrittenRecently(); + if (file.path === this.dataManager.getCsvPath()) { + if (!selfWritten) { + await this.dataManager.reloadLogs(); + rerenderAllBlocks(); + } + } + if (file.path === this.dataManager.getConfigPath()) { + if (!selfWritten) { + await this.dataManager.reloadConfig(); + rerenderAllBlocks(); + } + } + }); + } + + // 打开"插入代码块"弹窗:列出全部训练代码块,选择后带参插入到光标处。 + private openInsertCodeBlockModal(): void { + new InsertCodeBlockModal(this.dataManager).open(); + } + + // 打开"记录一组"弹窗。可选传入 exercise(预选训练项)和 plan(所属训练方案名)。 + private openRecordModal(exercise?: string, plan?: string): void { + new RecordModal(this.dataManager, { exercise, plan }).open(); + } + + // 打开"新增训练计划"弹窗(配置形态:选择方案 / 手动添加、预设每组字段、设定计划时间) + private openNewPlanModal(): void { + new NewPlanModal(this.dataManager).open(); + } + + // 打开"新建训练项"弹窗 + private openNewExerciseModal(): void { + new ExerciseModal(this.dataManager).open(); + } + + // 打开"新建训练类型"弹窗 + private openNewTypeModal(): void { + new TypeModal(this.dataManager).open(); + } + + // 打开设置页:先打开 Obsidian 设置,再定位到本插件对应的标签页 + private openSettings(): void { + this.app.setting.open(); + this.app.setting.openTabById(this.manifest.id); + } + + // 打开"编辑已有记录"弹窗:把要编辑的记录 editLog 传给 RecordModal + private openEditRecordModal(log: LogRow): void { + new RecordModal(this.dataManager, { editLog: log }).open(); + } + + // 删除一条记录:先用浏览器原生 confirm 确认,再调用 dataManager 删除(dataManager 会触发 data-changed 重渲染) + private async deleteRecord(log: LogRow): Promise { + if (!(await confirmWithModal(this.app, t('codeblock.confirmDelete')))) { + return; + } + await this.dataManager.deleteLog(log.id); + // 软删除:记录已即时从界面移除,但磁盘文件需到设置页「压缩清理 CSV」才真正回收体积。 + new Notice(t('settings.softDeleteHint')); + } +} diff --git a/src/test/obsidian-shim.ts b/src/test/obsidian-shim.ts new file mode 100644 index 0000000..4b4d191 --- /dev/null +++ b/src/test/obsidian-shim.ts @@ -0,0 +1,270 @@ +/* + * obsidian-shim.ts(测试桩 / mock 文件) + * 文件作用:用假的(fake)类模拟 Obsidian 的 API,让单元测试能在 Node/浏览器环境里运行,而不依赖真正的 Obsidian 软件。 + * 为什么需要它:单元测试跑在 vitest + jsdom 里,没有真实的 Obsidian 运行环境。vitest.config.ts 会把代码里 `import ... from 'obsidian'` + * 这个导入"重定向"到本文件,于是测试使用的就是我们这里写的简化版类,而不是真实的 Obsidian。 + * 概念解释:"测试桩/打桩(mock/stub)"——用假对象替代外部依赖,使代码可独立、可重复地测试。 + * + * 注意:这里只是"够测试用"的简化实现,不是 Obsidian 完整 API。 + */ + +// 收集所有弹出的 Notice(通知)文字,供测试断言;这是真实 Obsidian 没有的测试辅助功能 +const notices: string[] = []; + +// 测试可读取当前累计的通知列表 +export function __getNotices(): string[] { + return notices; +} + +// 测试在每个用例前调用,清空通知记录,保证用例之间互不干扰 +export function __resetNotices(): void { + notices.length = 0; +} + +// 模拟 Obsidian 的事件中心:可按事件名注册(on)、注销(off)、触发(trigger)回调 +export class Events { + private handlers = new Map void>>(); + + on(event: string, callback: (...args: unknown[]) => void): void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)?.add(callback); + } + + off(event: string, callback: (...args: unknown[]) => void): void { + this.handlers.get(event)?.delete(callback); + } + + trigger(event: string, ...args: unknown[]): void { + for (const callback of this.handlers.get(event) ?? []) { + callback(...args); + } + } +} + +// App:极简占位,真实插件里它代表整个 Obsidian 应用上下文;测试里通常不需要内容 +export class App {} + +// Modal:模拟弹窗基类。contentEl 是一个真实
,用于承载弹窗内容。 +export class Modal { + app: any; + contentEl: HTMLDivElement; + + constructor(app: any) { + this.app = app; + this.contentEl = document.createElement('div'); + } + + // 打开弹窗:若子类实现了 onOpen 就调用它(真实 Obsidian 还会把弹窗挂到界面上) + open(): unknown { + return (this as any).onOpen?.(); + } + + // 关闭弹窗:若子类实现了 onClose 就调用它 + close(): unknown { + return (this as any).onClose?.(); + } +} + +// 设置页基类:每个插件在"设置"里看到的页面都继承它 +export class PluginSettingTab { + app: any; + plugin: any; + containerEl: HTMLDivElement; + + constructor(app: any, plugin: any) { + this.app = app; + this.plugin = plugin; + this.containerEl = document.createElement('div'); + } +} + +// 模拟"按钮"UI 组件:自动创建一个