优化了训练计划的来源

This commit is contained in:
wanguliu 2026-07-19 14:03:51 +08:00
parent d996aaab0f
commit f3e12ea26a
4 changed files with 146 additions and 35 deletions

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "workout-block",
"version": "1.0.2",
"version": "1.0.3",
"description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件",
"main": "main.js",
"scripts": {

View file

@ -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
*
* cachedReadObsidian
*/
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。

View file

@ -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();
}
}