mirror of
https://github.com/wanguliux/workout-block.git
synced 2026-07-22 08:33:12 +00:00
优化了训练计划的来源
This commit is contained in:
parent
d996aaab0f
commit
f3e12ea26a
4 changed files with 146 additions and 35 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "workout-block",
|
||||
"name": "Workout Block",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.12.7",
|
||||
"description": "极致自由度的训练记录工具:训练类型、记录字段、衍生统计、肌肉热力图与训练计划全部可由你自定义。A flexible workout tracker: customize workout types, log fields, derived stats, muscle heatmaps, and training plans.",
|
||||
"author": "wanguliux",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "workout-block",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { resolveExerciseByName } from './display';
|
|||
|
||||
/*
|
||||
* planScanner.ts —— 训练方案(笔记)扫描工具
|
||||
* 「训练方案」= 含有 ```workout-plan 代码块的笔记。本文件提供两个能力:
|
||||
* 1) findSchemeNotes:扫描 vault,找出所有训练方案笔记(供「新增训练计划」下拉选择)。
|
||||
* 「训练方案」= 含有 ≥2 个 ```workout-log 代码块的笔记(即「训练记录表」模板)。
|
||||
* 本文件提供两类能力:
|
||||
* 1) ensureSchemeIndex / getSchemeNotes / onSchemeIndexChanged:维护一个「方案笔记」索引。
|
||||
* 索引在首次打开「新增训练计划」弹窗时构建(ensureSchemeIndex),之后通过 vault 的
|
||||
* create/modify/delete/rename 事件对「单个文件」增量更新,避免每次全库扫描;
|
||||
* 索引变化时通过 onSchemeIndexChanged 广播,弹窗可即时刷新下拉。
|
||||
* 2) extractSchemeExercises:从某方案笔记里提取所有 workout-log 代码块的 exercise,
|
||||
* 解析为训练项列表(去重),作为计划的初始训练项。
|
||||
* 两者都用 cachedRead(Obsidian 缓存层),避免反复读盘;结果做进程内缓存。
|
||||
*/
|
||||
|
||||
export interface SchemeNote {
|
||||
|
|
@ -16,33 +19,127 @@ export interface SchemeNote {
|
|||
name: string; // basename(去掉 .md),即方案名
|
||||
}
|
||||
|
||||
// 进程内缓存:扫描结果在本次会话内复用,避免每次打开弹窗都全量扫描。
|
||||
let schemeCache: SchemeNote[] | null = null;
|
||||
// 进程内索引:所有「方案笔记」(含 ≥2 个 workout-log 代码块)。null = 尚未构建。
|
||||
let schemeIndex: SchemeNote[] | null = null;
|
||||
|
||||
// 重置缓存(配置/笔记变化后可调用,强制下次重新扫描)。
|
||||
export function invalidateSchemeCache(): void {
|
||||
schemeCache = null;
|
||||
// 索引变化监听器集合(弹窗打开时订阅,关闭时退订)。
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// vault 事件钩子是否已注册(整会话只注册一次)。
|
||||
let vaultHooked = false;
|
||||
|
||||
// 统计一段内容里 ```workout-log 代码块(起始围栏)的数量。
|
||||
function countWorkoutLogBlocks(content: string): number {
|
||||
const re = /```\s*workout-log\b/g;
|
||||
let count = 0;
|
||||
while (re.exec(content) !== null) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// 找出所有含 ```workout-plan 代码块的笔记。
|
||||
export async function findSchemeNotes(app: App): Promise<SchemeNote[]> {
|
||||
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) {
|
||||
// 广播索引变化(单个订阅者异常不影响其他订阅者)。
|
||||
function emitChanged(): void {
|
||||
for (const cb of listeners) {
|
||||
try {
|
||||
const content = await app.vault.cachedRead(file);
|
||||
if (/```\s*workout-plan\b/.test(content)) {
|
||||
notes.push({ path: file.path, name: file.basename });
|
||||
}
|
||||
cb();
|
||||
} catch {
|
||||
// 单文件读取失败不影响整体
|
||||
/* 忽略订阅者异常 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重新评估单个文件是否仍属于方案笔记,并增量更新索引;状态变化则广播。
|
||||
async function updateFileIndex(app: App, file: TFile): Promise<void> {
|
||||
if (schemeIndex === null) return; // 索引尚未构建,稍后全量构建会覆盖
|
||||
if (file.extension !== 'md') return;
|
||||
const idx = schemeIndex.findIndex((n) => n.path === file.path);
|
||||
let qualifies = false;
|
||||
try {
|
||||
const content = await app.vault.read(file);
|
||||
qualifies = countWorkoutLogBlocks(content) >= 2;
|
||||
} catch {
|
||||
qualifies = false;
|
||||
}
|
||||
if (qualifies && idx === -1) {
|
||||
schemeIndex.push({ path: file.path, name: file.basename });
|
||||
schemeIndex.sort((a, b) => a.name.localeCompare(b.name));
|
||||
emitChanged();
|
||||
} else if (!qualifies && idx !== -1) {
|
||||
schemeIndex.splice(idx, 1);
|
||||
emitChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// 全量构建索引(仅在首次 / 失效时调用,读取所有 markdown 文件一次)。
|
||||
async function buildSchemeIndex(app: App): Promise<void> {
|
||||
const notes: SchemeNote[] = [];
|
||||
if (typeof app.vault.getMarkdownFiles === 'function') {
|
||||
for (const file of app.vault.getMarkdownFiles()) {
|
||||
if (file.extension !== 'md') continue;
|
||||
try {
|
||||
const content = await app.vault.cachedRead(file);
|
||||
if (countWorkoutLogBlocks(content) >= 2) {
|
||||
notes.push({ path: file.path, name: file.basename });
|
||||
}
|
||||
} catch {
|
||||
// 单文件读取失败不影响整体
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
schemeCache = notes;
|
||||
return notes;
|
||||
schemeIndex = notes;
|
||||
}
|
||||
|
||||
// 注册 vault 事件钩子,做单文件增量更新(整会话只注册一次)。
|
||||
function hookVault(app: App): void {
|
||||
if (vaultHooked) return;
|
||||
vaultHooked = true;
|
||||
const onFileChanged = (file: TFile) => {
|
||||
if (file.extension !== 'md') return;
|
||||
void updateFileIndex(app, file);
|
||||
};
|
||||
app.vault.on('create', onFileChanged);
|
||||
app.vault.on('modify', onFileChanged);
|
||||
app.vault.on('delete', (file) => {
|
||||
if (schemeIndex === null || !(file instanceof TFile)) return;
|
||||
const idx = schemeIndex.findIndex((n) => n.path === file.path);
|
||||
if (idx !== -1) {
|
||||
schemeIndex.splice(idx, 1);
|
||||
emitChanged();
|
||||
}
|
||||
});
|
||||
app.vault.on('rename', (file, oldPath) => {
|
||||
if (schemeIndex === null || !(file instanceof TFile)) return;
|
||||
const idx = schemeIndex.findIndex((n) => n.path === oldPath);
|
||||
if (idx !== -1) schemeIndex.splice(idx, 1);
|
||||
void updateFileIndex(app, file); // 以新路径重新评估是否仍属方案笔记
|
||||
});
|
||||
}
|
||||
|
||||
// 确保索引已构建(首次会全量扫描并注册事件钩子);返回当前索引。
|
||||
export async function ensureSchemeIndex(app: App): Promise<SchemeNote[]> {
|
||||
if (schemeIndex === null) {
|
||||
await buildSchemeIndex(app);
|
||||
hookVault(app);
|
||||
}
|
||||
return schemeIndex!;
|
||||
}
|
||||
|
||||
// 返回当前索引(若尚未构建则返回空数组,不会触发扫描)。
|
||||
export function getSchemeNotes(): SchemeNote[] {
|
||||
return schemeIndex ?? [];
|
||||
}
|
||||
|
||||
// 订阅索引变化,返回退订函数。
|
||||
export function onSchemeIndexChanged(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
// 重置索引(强制下次重新全量构建)。保留订阅者集合。
|
||||
export function invalidateSchemeCache(): void {
|
||||
schemeIndex = null;
|
||||
}
|
||||
|
||||
// 从方案笔记提取训练项:遍历所有 ```workout-log 代码块,解析 exercise 参数 → 训练项 id。
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { t } from '../i18n';
|
|||
import { parseDuration, secondsToParts } from '../util/duration';
|
||||
import { formatMass, parseMass } from '../util/units';
|
||||
import { generateId } from '../util/id';
|
||||
import { findSchemeNotes, extractSchemeExercises, invalidateSchemeCache, SchemeNote } from '../data/planScanner';
|
||||
import { ensureSchemeIndex, getSchemeNotes, extractSchemeExercises, invalidateSchemeCache, onSchemeIndexChanged, SchemeNote } from '../data/planScanner';
|
||||
|
||||
/*
|
||||
* NewPlanModal.ts —— 「新增训练计划」弹窗(配置形态)
|
||||
|
|
@ -43,6 +43,7 @@ export class NewPlanModal extends Modal {
|
|||
private timeTypeSelect!: HTMLSelectElement;
|
||||
private timeControlsEl!: HTMLDivElement;
|
||||
private schemeSelect!: HTMLSelectElement;
|
||||
private unsubScheme?: () => void; // 方案索引变化订阅的退订函数
|
||||
private selectAllCheckbox!: HTMLInputElement;
|
||||
private selectLabel!: HTMLSpanElement;
|
||||
private itemsContainer!: HTMLDivElement;
|
||||
|
|
@ -121,7 +122,12 @@ export class NewPlanModal extends Modal {
|
|||
schemeField.createEl('label', { text: t('modal.newPlan.selectPlan') });
|
||||
this.schemeSelect = schemeField.createEl('select');
|
||||
this.schemeSelect.addClass('workout-select');
|
||||
await this.populateSchemeSelect();
|
||||
// change 监听只挂一次(populateSchemeSelect 会反复重建 option,但 select 自身监听不丢)
|
||||
this.schemeSelect.addEventListener('change', () => { void this.onSchemeChange(); });
|
||||
// 构建方案索引(首次全量扫描,之后增量维护)并填充下拉
|
||||
await this.refreshSchemeList();
|
||||
// 订阅索引变化:笔记里新增/删除 workout-log 代码块时即时刷新下拉,无需重载插件
|
||||
this.unsubScheme = onSchemeIndexChanged(() => { void this.refreshSchemeList(); });
|
||||
|
||||
// 全选行
|
||||
const selectAllRow = contentEl.createDiv();
|
||||
|
|
@ -164,23 +170,29 @@ export class NewPlanModal extends Modal {
|
|||
return `${t('modal.newPlan.defaultPrefix')} ${this.todayStr()}`;
|
||||
}
|
||||
|
||||
private async populateSchemeSelect(): Promise<void> {
|
||||
// 构建/维护方案索引(首次全量扫描 + 注册增量监听),随后刷新下拉。
|
||||
private async refreshSchemeList(): Promise<void> {
|
||||
await ensureSchemeIndex(this.app);
|
||||
this.populateSchemeSelect();
|
||||
}
|
||||
|
||||
// 重建「选择训练方案」下拉选项(同步;索引已由 ensureSchemeIndex 构建/维护)。
|
||||
// 反复填充时尽量保留当前选中项,避免索引刷新打断用户已做的选择。
|
||||
private populateSchemeSelect(): void {
|
||||
const prev = this.schemeSelect.value;
|
||||
this.schemeSelect.empty();
|
||||
this.schemeSelect.createEl('option', { value: '', text: t('modal.newPlan.noScheme') });
|
||||
let notes: SchemeNote[] = [];
|
||||
try {
|
||||
notes = await findSchemeNotes(this.app);
|
||||
} catch {
|
||||
notes = [];
|
||||
}
|
||||
const notes = getSchemeNotes();
|
||||
for (const note of notes) {
|
||||
this.schemeSelect.createEl('option', { value: note.path, text: note.name });
|
||||
}
|
||||
if (this.sourceNote) {
|
||||
// 优先恢复上次选中的 path;否则尝试用已有的 sourceNote 反查
|
||||
if (prev && notes.some((n) => n.path === prev)) {
|
||||
this.schemeSelect.value = prev;
|
||||
} else if (this.sourceNote) {
|
||||
const match = notes.find((n) => n.name === this.sourceNote);
|
||||
if (match) this.schemeSelect.value = match.path;
|
||||
}
|
||||
this.schemeSelect.addEventListener('change', () => { void this.onSchemeChange(); });
|
||||
}
|
||||
|
||||
private async onSchemeChange(): Promise<void> {
|
||||
|
|
@ -481,6 +493,8 @@ export class NewPlanModal extends Modal {
|
|||
}
|
||||
|
||||
onClose(): void {
|
||||
this.unsubScheme?.();
|
||||
this.unsubScheme = undefined;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue