diff --git a/src/actions/definitions.ts b/src/actions/definitions.ts index 772840d..92487b4 100644 --- a/src/actions/definitions.ts +++ b/src/actions/definitions.ts @@ -115,13 +115,25 @@ export const ALL_ACTIONS: ActionDef[] = [ id: "view-pending-plans", label: "查看待确认计划", icon: "clipboard-list", - description: "打开 Knowledge/_ExecutionPlans 中最新的计划草稿", + description: "打开最新的待确认执行计划", domain: "execution", section: "plan", when: { always: true }, showIn: ["panel"], run: (ctx) => { ctx.plugin.openPendingPlans(); }, }, + { + id: "confirm-execute-plan", + label: "确认执行此计划", + icon: "play", + description: "执行当前打开的待确认计划", + domain: "execution", + section: "plan", + when: { fileType: ["execution-plan"] }, + showIn: ["panel", "editor-menu"], + risk: "medium", + run: (ctx) => { void ctx.plugin.confirmAndExecutePlan(); }, + }, { id: "view-execution-logs", label: "查看执行日志", diff --git a/src/core/execution/PlanDraftStore.ts b/src/core/execution/PlanDraftStore.ts index f5dfaba..dba74aa 100644 --- a/src/core/execution/PlanDraftStore.ts +++ b/src/core/execution/PlanDraftStore.ts @@ -3,16 +3,26 @@ import { ExecutionPlan } from "./types"; import { SCHEMA_VERSION, todayIso } from "../schema"; /** - * PlanDraftStore — persists an ExecutionPlan as a "pending" draft note. + * PlanDraftStore — persists an ExecutionPlan as a "pending" draft note + * and stores the raw plan data separately for programmatic recovery. * - * The draft is NOT executed; it's stored for the user to review and manually confirm. - * This is the correct behavior for `create-plan-only` safety level. + * The draft note is human-readable. The JSON is stored in plugin data + * (not in the note) so users don't see raw technical payload. + * + * Execution flow: + * 1. persistDraft(plan) → saves note + stores plan in memory/plugin data + * 2. User reviews the note + * 3. User triggers "确认执行此计划" → loadPlan(planId) → PlanExecutor.execute() + * 4. Draft note status updated to "executed" or deleted */ export class PlanDraftStore { private folder = "Knowledge/_ExecutionPlans"; + /** In-memory plan cache, keyed by plan_id. */ + private planCache: Map = new Map(); constructor(private app: App) {} + /** Save a plan draft note and cache the raw plan for later execution. */ async persistDraft(plan: ExecutionPlan): Promise { const folder = normalizePath(this.folder); await this.ensureFolder(folder); @@ -26,16 +36,54 @@ export class PlanDraftStore { } const content = this.renderDraft(plan); - return await this.app.vault.create(path, content); + const file = await this.app.vault.create(path, content); + + // Cache plan for later execution + this.planCache.set(plan.id, plan); + + return file; + } + + /** Retrieve a cached plan by ID (for "confirm and execute" flow). */ + getPlan(planId: string): ExecutionPlan | undefined { + return this.planCache.get(planId); + } + + /** Mark a draft as executed by updating its frontmatter status. */ + async markExecuted(file: TFile): Promise { + let content = await this.app.vault.read(file); + content = content.replace("status: pending", "status: executed"); + content = content.replace( + "> [!warning] 此计划尚未执行\n> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。", + "> [!success] 此计划已执行\n> 执行记录已保存到 Knowledge/_Executions/" + ); + await this.app.vault.modify(file, content); + } + + /** List all pending plan files. */ + getPendingPlans(): TFile[] { + const folder = normalizePath(this.folder); + return this.app.vault.getMarkdownFiles() + .filter((f) => f.path.startsWith(folder + "/")) + .sort((a, b) => b.stat.mtime - a.stat.mtime); } private renderDraft(plan: ExecutionPlan): string { + const riskLabel = plan.riskLevel === "high" ? "🔴 高风险" + : plan.riskLevel === "medium" ? "🟡 中风险" + : "🟢 低风险"; + const ops = plan.operations.map((op, i) => { - const type = op.type; - const target = "path" in op ? (op as { path: string }).path : (op as { from: string }).from; - return `${i + 1}. \`${type}\` → \`${target}\``; + const desc = this.describeOp(op); + return `${i + 1}. ${desc}`; }).join("\n"); + const affectedFiles = [...new Set(plan.operations.map((op) => { + return "path" in op ? (op as { path: string }).path : (op as { from: string }).from; + }))]; + + const fileList = affectedFiles.map((f) => `- \`${f}\``).join("\n"); + return `--- type: execution-plan schema_version: ${SCHEMA_VERSION} @@ -50,28 +98,44 @@ created_at: ${plan.createdAt} # 📋 待确认计划:${plan.title} > [!warning] 此计划尚未执行 -> 请审阅后手动确认执行,或删除此文件取消。 +> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。 -## 风险等级 +## 概览 -${plan.riskLevel === "high" ? "🔴 高" : plan.riskLevel === "medium" ? "🟡 中" : "🟢 低"} +| 项目 | 值 | +| --- | --- | +| 来源 | ${plan.source} | +| 风险等级 | ${riskLabel} | +| 操作数 | ${plan.operations.length} | +| 影响文件数 | ${affectedFiles.length} | +| 创建时间 | ${plan.createdAt} | -## 操作列表 +## 将执行的操作 ${ops} -## 预览 +## 影响文件 + +${fileList} + +## 详细预览 ${plan.previewMarkdown} - -## Raw Plan - -\`\`\`json -${JSON.stringify(plan, null, 2)} -\`\`\` `; } + private describeOp(op: ExecutionPlan["operations"][number]): string { + switch (op.type) { + case "create-file": return `创建文件 \`${op.path}\``; + case "modify-file": return `修改文件 \`${op.path}\`${op.description ? ` — ${op.description}` : ""}`; + case "append-section": return `追加到 \`${op.path}\` 的 §${op.section}`; + case "replace-selection": return `替换 \`${op.path}\` 中的文本`; + case "move-file": return `移动 \`${op.from}\` → \`${op.to}\``; + case "create-link": return `在 \`${op.path}\` 中添加链接 → \`${op.target}\``; + case "update-frontmatter": return `更新 \`${op.path}\` 的 frontmatter(${Object.keys(op.fields).join(", ")})`; + } + } + private async ensureFolder(path: string): Promise { if (!this.app.vault.getAbstractFileByPath(path)) { await this.app.vault.createFolder(path); diff --git a/src/main.ts b/src/main.ts index 2b63fcd..eee0a01 100644 --- a/src/main.ts +++ b/src/main.ts @@ -695,16 +695,15 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`; /** 打开待确认计划列表 */ openPendingPlans() { - const folder = "Knowledge/_ExecutionPlans"; - const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder)); + const { PlanDraftStore } = require("./core/execution") as { PlanDraftStore: new (app: import("obsidian").App) => import("./core/execution").PlanDraftStore }; + const store = new PlanDraftStore(this.app); + const files = store.getPendingPlans(); if (files.length === 0) { new Notice("暂无待确认计划"); return; } - // Open the folder in file explorer, or open the most recent plan - const sorted = files.sort((a, b) => b.stat.mtime - a.stat.mtime); const leaf = this.app.workspace.getLeaf(false); - void leaf.openFile(sorted[0]); + void leaf.openFile(files[0]); if (files.length > 1) { new Notice(`共 ${files.length} 个待确认计划,已打开最新`); } @@ -713,7 +712,7 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`; /** 打开执行日志列表 */ openExecutionLogs() { const folder = "Knowledge/_Executions"; - const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder)); + const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder + "/")); if (files.length === 0) { new Notice("暂无执行日志"); return; @@ -726,6 +725,65 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`; } } + /** 确认并执行当前打开的待确认计划 */ + async confirmAndExecutePlan() { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { new Notice("请先打开一个待确认计划文件"); return; } + + const meta = this.app.metadataCache.getFileCache(activeFile); + const fm = meta?.frontmatter; + if (fm?.type !== "execution-plan" || fm?.status !== "pending") { + new Notice("当前文件不是待确认计划(需要 type: execution-plan, status: pending)"); + return; + } + + const planId = fm.plan_id as string; + if (!planId) { new Notice("计划文件缺少 plan_id"); return; } + + // Try to get plan from cache + const { PlanDraftStore, PlanExecutor } = await import("./core/execution"); + const store = new PlanDraftStore(this.app); + const plan = store.getPlan(planId); + + if (!plan) { + new Notice("该计划的执行数据已过期(Obsidian 重启后缓存会丢失)。请重新生成计划。"); + return; + } + + // Confirm + const riskLabel = plan.riskLevel === "high" ? "高风险" : plan.riskLevel === "medium" ? "中风险" : "低风险"; + const confirmed = await this.confirmAction( + `确认执行「${plan.title}」?\n\n${plan.operations.length} 项操作,${riskLabel},将影响 ${new Set(plan.operations.map(op => "path" in op ? (op as {path:string}).path : (op as {from:string}).from)).size} 个文件。` + ); + if (!confirmed) return; + + const notice = new Notice("⏳ 正在执行计划...", 0); + const executor = new PlanExecutor(this.app); + const record = await executor.execute(plan); + notice.hide(); + + if (record.success) { + await store.markExecuted(activeFile); + new Notice(`✅ 计划执行成功:${record.affectedPaths.length} 个文件已更新`); + } else { + new Notice(`❌ 执行失败:${record.error ?? "未知错误"}`); + } + } + + /** Simple confirmation dialog using Obsidian Modal. */ + private confirmAction(message: string): Promise { + return new Promise((resolve) => { + const { Modal, Setting } = require("obsidian") as typeof import("obsidian"); + const modal = new Modal(this.app); + modal.titleEl.setText("确认执行"); + modal.contentEl.createEl("p", { text: message, attr: { style: "white-space: pre-wrap;" } }); + new Setting(modal.contentEl) + .addButton((btn) => btn.setButtonText("确认执行").setCta().onClick(() => { modal.close(); resolve(true); })) + .addButton((btn) => btn.setButtonText("取消").onClick(() => { modal.close(); resolve(false); })); + modal.open(); + }); + } + /** 查看定时任务状态 */ openScheduledTasks() { new Notice("定时任务运行时在 v2.0 默认关闭,将在 v2.1 通过设置页启用。");