From f3e12ea26ab5a1b139efbecfa8d339b76fc49289 Mon Sep 17 00:00:00 2001 From: wanguliu <756933962@qq.com> Date: Sun, 19 Jul 2026 14:03:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E7=9A=84=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- package.json | 2 +- src/data/planScanner.ts | 141 +++++++++++++++++++++++++++++++++------- src/ui/NewPlanModal.ts | 36 ++++++---- 4 files changed, 146 insertions(+), 35 deletions(-) diff --git a/manifest.json b/manifest.json index 43a9382..fc7500a 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package.json b/package.json index c2e46b2..38e9334 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workout-block", - "version": "1.0.2", + "version": "1.0.3", "description": "训练块 Workout Block —— 高度可自定义的 Obsidian 训练记录插件", "main": "main.js", "scripts": { diff --git a/src/data/planScanner.ts b/src/data/planScanner.ts index a6a1f35..e0decbc 100644 --- a/src/data/planScanner.ts +++ b/src/data/planScanner.ts @@ -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 { - 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 { + 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 { + 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 { + 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。 diff --git a/src/ui/NewPlanModal.ts b/src/ui/NewPlanModal.ts index 148bd7d..373d370 100644 --- a/src/ui/NewPlanModal.ts +++ b/src/ui/NewPlanModal.ts @@ -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 { + // 构建/维护方案索引(首次全量扫描 + 注册增量监听),随后刷新下拉。 + private async refreshSchemeList(): Promise { + 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 { @@ -481,6 +493,8 @@ export class NewPlanModal extends Modal { } onClose(): void { + this.unsubScheme?.(); + this.unsubScheme = undefined; this.contentEl.empty(); } }