diff --git a/README.md b/README.md index de7407ed..9c14a64d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ For detailed instructions and examples on using QuickAdd, see the [QuickAdd docu QuickAdd uses `bun` for local development tasks: +- `bun run dev` starts the esbuild watcher for local plugin development. +- `bun run lint` runs the TypeScript ESLint checks. - `bun run test` runs the unit test suite. - `bun run build` type-checks and bundles the plugin. - `bun run test:e2e` runs Obsidian-backed end-to-end tests. @@ -38,6 +40,25 @@ app, the `obsidian` CLI being available on `PATH`, and the `dev` vault being open and reachable. Failed E2E runs may write artifacts to `.obsidian-e2e-artifacts/`. +When validating changes against the local dev vault, rebuild first and reload +the plugin with `obsidian vault=dev plugin:reload id=quickadd`. + +### Engine architecture + +QuickAdd choice execution is orchestrated from `ChoiceExecutor`, which owns +shared variables, preflight, abort signaling, origin leaf capture, and choice +dispatch. Template, capture, and macro choices are implemented as flat +orchestrators (`TemplateChoiceEngine`, `CaptureChoiceEngine`, and +`MacroChoiceEngine`) that compose explicit services instead of inheriting from a +shared engine base. + +File and frontmatter operations live in `VaultFileService` and +`FrontmatterPropertyService`; folder and template-file behavior lives in +`FolderSelectionService`, `TemplateFileService`, and `TemplateEvaluator`. +Formatter macro, template, and inline JavaScript tokens are delegated through +evaluator interfaces wired by `FormatterFactory`; preview and preflight paths +remain non-executing. + ## Support If you have any questions or encounter any problems while using QuickAdd, you can use the [community discussions](https://github.com/chhoumann/quickadd/discussions) for support. diff --git a/src/choiceExecutor.test.ts b/src/choiceExecutor.test.ts new file mode 100644 index 00000000..fa9c95c9 --- /dev/null +++ b/src/choiceExecutor.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App, WorkspaceLeaf } from "obsidian"; +import type QuickAdd from "./main"; +import type IChoice from "./types/choices/IChoice"; +import type IMultiChoice from "./types/choices/IMultiChoice"; +import { MacroAbortError } from "./errors/MacroAbortError"; +import { settingsStore } from "./settingsStore"; + +const mocks = vi.hoisted(() => { + const runOnePagePreflight = vi.fn(); + const getOpenFileOriginLeaf = vi.fn(); + const templateRun = vi.fn(); + const captureRun = vi.fn(); + const macroRun = vi.fn(); + const templateConstructors: unknown[][] = []; + const captureConstructors: unknown[][] = []; + const macroConstructors: unknown[][] = []; + const choiceSuggesterOpen = vi.fn(); + + return { + runOnePagePreflight, + getOpenFileOriginLeaf, + templateRun, + captureRun, + macroRun, + templateConstructors, + captureConstructors, + macroConstructors, + choiceSuggesterOpen, + }; +}); + +vi.mock("./preflight/runOnePagePreflight", () => ({ + runOnePagePreflight: mocks.runOnePagePreflight, +})); + +vi.mock("./utilityObsidian", () => ({ + getOpenFileOriginLeaf: mocks.getOpenFileOriginLeaf, +})); + +vi.mock("./engine/TemplateChoiceEngine", () => ({ + TemplateChoiceEngine: vi.fn(function (...args: unknown[]) { + mocks.templateConstructors.push(args); + return { run: mocks.templateRun }; + }), +})); + +vi.mock("./engine/CaptureChoiceEngine", () => ({ + CaptureChoiceEngine: vi.fn(function (...args: unknown[]) { + mocks.captureConstructors.push(args); + return { run: mocks.captureRun }; + }), +})); + +vi.mock("./engine/MacroCommandRunner", () => ({ + MacroCommandRunner: vi.fn(function (...args: unknown[]) { + mocks.macroConstructors.push(args); + return { + run: mocks.macroRun, + params: { variables: {} }, + }; + }), +})); + +vi.mock("./engine/InlineJavaScriptEvaluator", () => ({ + InlineJavaScriptEvaluator: vi.fn(function () { + return { runAndGetOutput: vi.fn() }; + }), +})); + +vi.mock("./engine/SingleMacroEngine", () => ({ + SingleMacroEngine: vi.fn(function () { + return { runAndGetOutput: vi.fn() }; + }), +})); + +vi.mock("./gui/suggesters/choiceSuggester", () => ({ + default: { + Open: mocks.choiceSuggesterOpen, + }, +})); + +import { ChoiceExecutor } from "./choiceExecutor"; + +describe("ChoiceExecutor", () => { + const app = {} as App; + const originLeaf = { id: "origin-leaf" } as unknown as WorkspaceLeaf; + let plugin: QuickAdd; + + beforeEach(() => { + plugin = { + settings: { + choices: [], + }, + } as unknown as QuickAdd; + settingsStore.setState({ onePageInputEnabled: false }); + mocks.runOnePagePreflight.mockReset(); + mocks.getOpenFileOriginLeaf.mockReset(); + mocks.getOpenFileOriginLeaf.mockReturnValue(originLeaf); + mocks.templateRun.mockReset(); + mocks.templateRun.mockResolvedValue(undefined); + mocks.captureRun.mockReset(); + mocks.captureRun.mockResolvedValue(undefined); + mocks.macroRun.mockReset(); + mocks.macroRun.mockResolvedValue(undefined); + mocks.choiceSuggesterOpen.mockReset(); + mocks.templateConstructors.length = 0; + mocks.captureConstructors.length = 0; + mocks.macroConstructors.length = 0; + }); + + it.each(["Template", "Capture", "Macro"] as const)( + "runs preflight before dispatching %s choices when one-page input is enabled", + async (type) => { + const executor = new ChoiceExecutor(app, plugin); + const choice = createChoice(type, { onePageInput: "always" }); + + await executor.execute(choice); + + expect(mocks.runOnePagePreflight).toHaveBeenCalledWith( + app, + plugin, + executor, + choice, + ); + expect(runtimeRunsFor(type)).toHaveBeenCalledTimes(1); + }, + ); + + it("throws MacroAbortError and suppresses runtime construction after preflight cancellation", async () => { + const executor = new ChoiceExecutor(app, plugin); + mocks.runOnePagePreflight.mockRejectedValueOnce("cancelled"); + + await expect( + executor.execute(createChoice("Template", { onePageInput: "always" })), + ).rejects.toMatchObject({ + name: "MacroAbortError", + message: "One-page input cancelled by user", + }); + expect(mocks.templateConstructors).toHaveLength(0); + expect(mocks.captureConstructors).toHaveLength(0); + expect(mocks.macroConstructors).toHaveLength(0); + }); + + it("skips preflight for Multi choices and opens the multi suggester", async () => { + const executor = new ChoiceExecutor(app, plugin); + const child = createChoice("Template"); + const multi: IMultiChoice = { + ...createChoice("Multi"), + choices: [child], + collapsed: false, + placeholder: "Pick one", + }; + + await executor.execute(multi); + + expect(mocks.runOnePagePreflight).not.toHaveBeenCalled(); + expect(mocks.choiceSuggesterOpen).toHaveBeenCalledWith(plugin, [child], { + choiceExecutor: executor, + placeholder: "Pick one", + }); + }); + + it.each([ + ["Template", mocks.templateConstructors], + ["Capture", mocks.captureConstructors], + ] as const)( + "captures and passes the origin leaf to %s runtime construction", + async (type, constructors) => { + const executor = new ChoiceExecutor(app, plugin); + const choice = createChoice(type); + + await executor.execute(choice); + + expect(mocks.getOpenFileOriginLeaf).toHaveBeenCalledWith(app); + expect(constructors[0]?.at(-1)).toBe(originLeaf); + }, + ); + + it("passes the origin leaf and shared variables map to macro runtime construction", async () => { + const executor = new ChoiceExecutor(app, plugin); + executor.variables.set("seed", "value"); + const choice = createChoice("Macro"); + + await executor.execute(choice); + + expect(mocks.macroConstructors[0]?.[4]).toBe(executor.variables); + expect(mocks.macroConstructors[0]?.at(-1)).toBe(originLeaf); + }); + + it("keeps the same variable map instance across preflight and dispatch", async () => { + const executor = new ChoiceExecutor(app, plugin); + const seenMaps: Map[] = []; + mocks.runOnePagePreflight.mockImplementationOnce( + async (_app, _plugin, choiceExecutor: ChoiceExecutor) => { + seenMaps.push(choiceExecutor.variables); + choiceExecutor.variables.set("fromPreflight", "submitted"); + }, + ); + + await executor.execute(createChoice("Macro", { onePageInput: "always" })); + + expect(seenMaps[0]).toBe(executor.variables); + expect(mocks.macroConstructors[0]?.[4]).toBe(executor.variables); + expect(executor.variables.get("fromPreflight")).toBe("submitted"); + }); + + it("records and consumes abort signals exactly once", () => { + const executor = new ChoiceExecutor(app, plugin); + const abort = new MacroAbortError("stopped"); + + executor.signalAbort(abort); + + expect(executor.consumeAbortSignal()).toBe(abort); + expect(executor.consumeAbortSignal()).toBeNull(); + }); +}); + +function createChoice( + type: IChoice["type"], + extras: Partial = {}, +): IChoice { + return { + id: `${type.toLowerCase()}-choice`, + name: `${type} Choice`, + type, + command: false, + ...extras, + }; +} + +function runtimeRunsFor(type: "Template" | "Capture" | "Macro") { + switch (type) { + case "Template": + return mocks.templateRun; + case "Capture": + return mocks.captureRun; + case "Macro": + return mocks.macroRun; + } +} diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index e8175de3..440166d1 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -6,8 +6,8 @@ import type ICaptureChoice from "./types/choices/ICaptureChoice"; import type IMacroChoice from "./types/choices/IMacroChoice"; import { TemplateChoiceEngine } from "./engine/TemplateChoiceEngine"; import { CaptureChoiceEngine } from "./engine/CaptureChoiceEngine"; -import { MacroChoiceEngine } from "./engine/MacroChoiceEngine"; -import { SingleInlineScriptEngine } from "./engine/SingleInlineScriptEngine"; +import { MacroCommandRunner } from "./engine/MacroCommandRunner"; +import { InlineJavaScriptEvaluator } from "./engine/InlineJavaScriptEvaluator"; import { SingleMacroEngine } from "./engine/SingleMacroEngine"; import type { IChoiceExecutor } from "./IChoiceExecutor"; import type { FormatterEvaluatorContext } from "./formatters/formatterEvaluators"; @@ -56,7 +56,7 @@ export class ChoiceExecutor implements IChoiceExecutor { code: string, context: FormatterEvaluatorContext, ): Promise { - const executor = new SingleInlineScriptEngine( + const executor = new InlineJavaScriptEvaluator( this.app, this.plugin, this, @@ -151,7 +151,7 @@ export class ChoiceExecutor implements IChoiceExecutor { macroChoice: IMacroChoice, originLeaf: WorkspaceLeaf | null, ) { - const macroEngine = new MacroChoiceEngine( + const macroEngine = new MacroCommandRunner( this.app, this.plugin, macroChoice, diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index 02b9800d..c3b2c66b 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -92,9 +92,9 @@ vi.mock("src/gui/MathModal", () => ({ }, })); -vi.mock("../engine/SingleInlineScriptEngine", () => ({ +vi.mock("../engine/InlineJavaScriptEvaluator", () => ({ __esModule: true, - SingleInlineScriptEngine: class { + InlineJavaScriptEvaluator: class { public params = { variables: {} as Record }; async runAndGetOutput() { return ""; diff --git a/src/engine/InlineJavaScriptEvaluator.ts b/src/engine/InlineJavaScriptEvaluator.ts new file mode 100644 index 00000000..df01b2f0 --- /dev/null +++ b/src/engine/InlineJavaScriptEvaluator.ts @@ -0,0 +1,45 @@ +import type { App } from "obsidian"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import type QuickAdd from "../main"; +import { MacroExecutionContext } from "./MacroExecutionContext"; + +type AsyncFunctionConstructor = new (code: string) => () => Promise; + +type LegacyInlineScriptEngineShape = { + params: MacroExecutionContext["params"]; + app: App; + plugin: QuickAdd; + choiceExecutor: IChoiceExecutor; + variables: Map; +}; + +export class InlineJavaScriptEvaluator { + constructor( + private readonly app: App, + private readonly plugin: QuickAdd, + private readonly choiceExecutor: IChoiceExecutor, + private readonly variables: Map, + ) {} + + public async runAndGetOutput(code: string): Promise { + const context = new MacroExecutionContext( + this.app, + this.plugin, + this.choiceExecutor, + this.variables, + ); + const AsyncFunction = Object.getPrototypeOf( + async function () {}, + ).constructor as AsyncFunctionConstructor; + const userCode = new AsyncFunction(code); + const legacyEngine: LegacyInlineScriptEngineShape = { + params: context.params, + app: this.app, + plugin: this.plugin, + choiceExecutor: this.choiceExecutor, + variables: context.variables, + }; + + return await userCode.bind(context.params, legacyEngine).call(); + } +} diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index c864d3c3..400418ed 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -1,234 +1,10 @@ -import type IMacroChoice from "../types/choices/IMacroChoice"; import type { App, WorkspaceLeaf } from "obsidian"; -import * as obsidian from "obsidian"; -import type { IUserScript } from "../types/macros/IUserScript"; -import type { IObsidianCommand } from "../types/macros/IObsidianCommand"; -import { log } from "../logger/logManager"; -import { reportError, isCancellationError } from "../utils/errorUtils"; -import { CommandType } from "../types/macros/CommandType"; -import { QuickAddApi } from "../quickAddApi"; -import type { ICommand } from "../types/macros/ICommand"; -import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine"; -import type { IMacro } from "../types/macros/IMacro"; -import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; -import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; -import QuickAdd from "../main"; import type { IChoiceExecutor } from "../IChoiceExecutor"; -import { getUserScript } from "../utilityObsidian"; -import type { IWaitCommand } from "../types/macros/QuickCommands/IWaitCommand"; -import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; -import type IChoice from "../types/choices/IChoice"; -import type { IEditorCommand } from "../types/macros/EditorCommands/IEditorCommand"; -import { EditorCommandType } from "../types/macros/EditorCommands/EditorCommandType"; -import { CutCommand } from "../types/macros/EditorCommands/CutCommand"; -import { CopyCommand } from "../types/macros/EditorCommands/CopyCommand"; -import { PasteCommand } from "../types/macros/EditorCommands/PasteCommand"; -import { PasteWithFormatCommand } from "../types/macros/EditorCommands/PasteWithFormatCommand"; -import { SelectActiveLineCommand } from "../types/macros/EditorCommands/SelectActiveLineCommand"; -import { SelectLinkOnActiveLineCommand } from "../types/macros/EditorCommands/SelectLinkOnActiveLineCommand"; -import { MoveCursorToFileStartCommand } from "../types/macros/EditorCommands/MoveCursorToFileStartCommand"; -import { MoveCursorToFileEndCommand } from "../types/macros/EditorCommands/MoveCursorToFileEndCommand"; -import { MoveCursorToLineStartCommand } from "../types/macros/EditorCommands/MoveCursorToLineStartCommand"; -import { MoveCursorToLineEndCommand } from "../types/macros/EditorCommands/MoveCursorToLineEndCommand"; -import { waitFor } from "src/utility"; -import type { IAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; -import { runAIAssistant } from "src/ai/AIAssistant"; -import { resolveProviderApiKey } from "src/ai/providerSecrets"; -import { settingsStore } from "src/settingsStore"; -import { FormatterFactory } from "src/services/FormatterFactory"; -import { - getModelByName, - getModelNames, - getModelProvider, -} from "src/ai/aiHelpers"; -import type { Model } from "src/ai/Provider"; -import type { IOpenFileCommand } from "../types/macros/QuickCommands/IOpenFileCommand"; -import { openFile } from "../utilityObsidian"; -import { TFile } from "obsidian"; -import { MacroAbortError } from "../errors/MacroAbortError"; -import { initializeUserScriptSettings } from "../utils/userScriptSettings"; -import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; -import type { ScriptCondition } from "../types/macros/Conditional/types"; -import { evaluateCondition } from "./helpers/conditionalEvaluator"; -import { handleMacroAbort } from "../utils/macroAbortHandler"; -import { buildOpenFileOptions } from "./helpers/openFileOptions"; -import { createVariablesProxy } from "../utils/variablesProxy"; - -type ConditionalScriptRunner = () => Promise; -type UserScriptFunction = ( - params: MacroChoiceEngine["params"], - settings: Record -) => Promise; - -function hasCommandType( - command: unknown, - type: CommandType -): command is ICommand { - return isRecord(command) && command.type === type; -} - -function isObsidianCommand(command: unknown): command is IObsidianCommand { - return hasCommandType(command, CommandType.Obsidian); -} - -function isUserScriptCommand(command: unknown): command is IUserScript { - return hasCommandType(command, CommandType.UserScript); -} - -function isChoiceCommand(command: unknown): command is IChoiceCommand { - return hasCommandType(command, CommandType.Choice); -} - -function isWaitCommand(command: unknown): command is IWaitCommand { - return hasCommandType(command, CommandType.Wait); -} - -function isNestedChoiceCommand( - command: unknown -): command is INestedChoiceCommand { - return hasCommandType(command, CommandType.NestedChoice); -} - -function isEditorCommand(command: unknown): command is IEditorCommand { - return hasCommandType(command, CommandType.EditorCommand); -} - -function isAIAssistantCommand( - command: unknown -): command is IAIAssistantCommand { - return hasCommandType(command, CommandType.AIAssistant); -} - -function isOpenFileCommand(command: unknown): command is IOpenFileCommand { - return hasCommandType(command, CommandType.OpenFile); -} - -function isConditionalCommand( - command: unknown -): command is IConditionalCommand { - return hasCommandType(command, CommandType.Conditional); -} -type UserScriptObjectExport = Record & { - entry?: UserScriptFunction; - settings?: Record; -}; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} - -function isUserScriptFunction(value: unknown): value is UserScriptFunction { - return typeof value === "function"; -} - -function isUserScriptObjectExport( - value: unknown -): value is UserScriptObjectExport { - return isRecord(value); -} - -function getUserScriptSettings( - value: unknown -): Record | undefined { - if (!isUserScriptObjectExport(value)) return undefined; - const { settings } = value; - return isRecord(settings) ? settings : undefined; -} - -function getConditionalScriptCacheKey(condition: ScriptCondition): string { - return `${condition.scriptPath}::${condition.exportName ?? "default"}`; -} - -export class MacroChoiceEngine extends QuickAddChoiceEngine { - public choice: IMacroChoice; - public params: { - app: App; - quickAddApi: QuickAddApi; - variables: Record; - obsidian: typeof obsidian; - /** - * Aborts the macro execution immediately. - * @param message Optional message explaining why the macro was aborted - * @example - * if (!isValidProject(project)) { - * params.abort("Invalid project name"); - * } - */ - abort: (message?: string) => never; - }; - protected output: unknown; - protected macro: IMacro; - protected choiceExecutor: IChoiceExecutor; - protected readonly plugin: QuickAdd; - private userScriptCommand: IUserScript | null; - private conditionalScriptCache = new Map(); - private readonly preloadedUserScripts: Map; - private readonly promptLabel?: string; - private buildParams( - app: App, - plugin: QuickAdd, - choiceExecutor: IChoiceExecutor, - sharedVariables: Map - ) { - const variablesProxy = createVariablesProxy(sharedVariables); - - const params = { - app, - quickAddApi: QuickAddApi.GetApi(app, plugin, choiceExecutor), - obsidian, - abort: (message?: string) => { - throw new MacroAbortError(message); - }, - } as unknown as typeof this.params; - - // Backward compatibility: some scripts assign `QuickAdd.variables = {...}` - // or `params.variables = {...}`. - // Treat that as replacing the backing Map so templates can consume them. - Object.defineProperty(params, "variables", { - get: () => variablesProxy, - set: (next: unknown) => { - if (next === sharedVariables || next === variablesProxy) return; - - const entries = - next instanceof Map - ? Array.from(next.entries()).filter(([key]) => typeof key === "string") - : next && typeof next === "object" - ? Object.entries(next as Record) - : null; - - // Invalid assignments are ignored to avoid wiping the backing store. - if (!entries) return; - - sharedVariables.clear(); - - entries?.forEach(([key, value]) => sharedVariables.set(key, value)); - }, - enumerable: true, - configurable: false, - }); - - return params; - } - - private initSharedVariables( - choiceExecutor: IChoiceExecutor, - providedVariables?: Map - ): Map { - const existingVariables = choiceExecutor.variables; - - if (providedVariables) { - if (existingVariables && providedVariables !== existingVariables) { - existingVariables.forEach((value, key) => { - if (!providedVariables.has(key)) { - providedVariables.set(key, value); - } - }); - } - return providedVariables; - } - - return existingVariables ?? new Map(); - } +import type QuickAdd from "../main"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import { MacroCommandRunner } from "./MacroCommandRunner"; +export class MacroChoiceEngine extends MacroCommandRunner { constructor( app: App, plugin: QuickAdd, @@ -237,523 +13,17 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { variables: Map, preloadedUserScripts?: Map, promptLabel?: string, - private readonly originLeaf: WorkspaceLeaf | null = null, + originLeaf: WorkspaceLeaf | null = null, ) { - super(app); - this.choice = choice; - this.plugin = plugin; - this.macro = choice?.macro; - this.choiceExecutor = choiceExecutor; - this.preloadedUserScripts = preloadedUserScripts ?? new Map(); - this.promptLabel = promptLabel; - const sharedVariables = this.initSharedVariables( + super( + app, + plugin, + choice, choiceExecutor, - variables + variables, + preloadedUserScripts, + promptLabel, + originLeaf, ); - this.choiceExecutor.variables = sharedVariables; - this.params = this.buildParams(app, plugin, choiceExecutor, sharedVariables); - } - - async run(): Promise { - if (!this.macro || !this.macro.commands) { - log.logError( - `No commands in the macro for choice '${this.choice.name}'` - ); - return; - } - - await this.executeCommands(this.macro.commands); - } - - public getOutput(): unknown { - return this.output; - } - - protected async executeCommands(commands: ICommand[]) { - try { - for (const command of commands) { - if (isObsidianCommand(command)) - this.executeObsidianCommand(command); - if (isUserScriptCommand(command)) - await this.executeUserScript(command); - if (isChoiceCommand(command)) - await this.executeChoice(command); - if (isWaitCommand(command)) { - await waitFor(command.time); - } - if (isNestedChoiceCommand(command)) { - await this.executeNestedChoice(command); - } - if (isEditorCommand(command)) { - await this.executeEditorCommand(command); - } - if (isAIAssistantCommand(command)) { - await this.executeAIAssistant(command); - } - if (isOpenFileCommand(command)) { - await this.executeOpenFile(command); - } - if (isConditionalCommand(command)) { - await this.executeConditional(command); - } - } - } catch (error) { - if ( - handleMacroAbort(error, { - logPrefix: "Macro execution aborted", - noticePrefix: "Macro execution aborted", - defaultReason: "Macro execution aborted", - }) - ) { - this.choiceExecutor.signalAbort?.(error); - return; - } - throw error; - } - } - - // Slightly modified from Templater's user script engine: - // https://github.com/SilentVoid13/Templater/blob/master/src/UserTemplates/UserTemplateParser.ts - protected async executeUserScript(command: IUserScript) { - const cacheKey = command.path ?? command.id; - let userScript: unknown; - if (cacheKey !== undefined) { - const cached = this.preloadedUserScripts.get(cacheKey); - if (cached !== undefined) { - userScript = cached; - this.preloadedUserScripts.delete(cacheKey); - } - } - - if (userScript === undefined) { - userScript = await getUserScript(command, this.app); - } - - if (!userScript) { - log.logError(`failed to load user script ${command.path}.`); - return; - } - - if (!command.settings) { - command.settings = {}; - } - - this.userScriptCommand = command; - - const userScriptSettings = getUserScriptSettings(userScript); - if (userScriptSettings) { - // Initialize default values for settings before executing the script - initializeUserScriptSettings(command.settings, userScriptSettings); - } - - try { - await this.userScriptDelegator(userScript); - } catch (err) { - if (err instanceof MacroAbortError) { - throw err; - } - // Report and re-throw script errors so users can debug them - reportError(err, `Failed to run user script ${command.name}`); - throw err; - } finally { - this.userScriptCommand = null; - } - } - - private async runScriptWithSettings( - userScript: - | (( - params: typeof this.params, - settings: Record - ) => Promise) - | { - entry: ( - params: typeof this.params, - settings: Record - ) => Promise; - }, - command: IUserScript - ) { - if ( - typeof userScript !== "function" && - userScript.entry && - typeof userScript.entry === "function" - ) { - return await this.onExportIsFunction( - userScript.entry, - command.settings - ); - } - - if (typeof userScript === "function") { - return await this.onExportIsFunction(userScript, command.settings); - } - } - - - protected async userScriptDelegator(userScript: unknown) { - switch (typeof userScript) { - case "function": - if (!isUserScriptFunction(userScript)) { - break; - } - if (this.userScriptCommand) { - await this.runScriptWithSettings( - userScript, - this.userScriptCommand - ); - } else { - await this.onExportIsFunction(userScript); - } - break; - case "object": - if (isUserScriptObjectExport(userScript)) { - await this.onExportIsObject(userScript); - } - break; - case "bigint": - case "boolean": - case "number": - case "string": - this.output = userScript.toString(); - break; - default: - log.logError( - `user script in macro for '${this.choice.name}' is invalid` - ); - } - } - - private async onExportIsFunction( - userScript: ( - params: typeof this.params, - settings: Record - ) => Promise, - settings?: { [key: string]: unknown } - ) { - this.output = await userScript(this.params, settings || {}); - } - - protected async onExportIsObject(obj: Record) { - if (Object.keys(obj).length === 0) { - throw new Error( - `user script in macro for '${this.choice.name}' is an empty object` - ); - } - - if (this.userScriptCommand && typeof obj.entry === "function") { - await this.runScriptWithSettings( - obj as { - entry: ( - params: typeof this.params, - settings: Record - ) => Promise; - }, - this.userScriptCommand - ); - return; - } - - try { - const keys = Object.keys(obj); - const selected: string = await GenericSuggester.Suggest( - this.app, - keys, - keys, - this.promptLabel, - ); - - await this.userScriptDelegator(obj[selected]); - } catch (err) { - if (err instanceof MacroAbortError) { - throw err; - } - if (isCancellationError(err)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw err; - } - } - - protected executeObsidianCommand(command: IObsidianCommand) { - // @ts-ignore - - this.app.commands.executeCommandById(command.commandId); - } - - protected async executeChoice(command: IChoiceCommand) { - const targetChoice: IChoice = this.plugin.getChoiceById( - command.choiceId - ); - if (!targetChoice) { - log.logError("choice could not be found."); - return; - } - - await this.choiceExecutor.execute(targetChoice); - const abort = this.choiceExecutor.consumeAbortSignal?.(); - if (abort) { - throw abort; - } - } - - private async executeNestedChoice(command: INestedChoiceCommand) { - const choice: IChoice = command.choice; - if (!choice) { - log.logError(`choice in ${command.name} is invalid`); - return; - } - - await this.choiceExecutor.execute(choice); - const abort = this.choiceExecutor.consumeAbortSignal?.(); - if (abort) { - throw abort; - } - } - - private async executeEditorCommand(command: IEditorCommand) { - switch (command.editorCommandType) { - case EditorCommandType.Cut: - await CutCommand.run(this.app); - break; - case EditorCommandType.Copy: - await CopyCommand.run(this.app); - break; - case EditorCommandType.Paste: - await PasteCommand.run(this.app); - break; - case EditorCommandType.PasteWithFormat: - await PasteWithFormatCommand.run(this.app); - break; - case EditorCommandType.SelectActiveLine: - SelectActiveLineCommand.run(this.app); - break; - case EditorCommandType.SelectLinkOnActiveLine: - SelectLinkOnActiveLineCommand.run(this.app); - break; - case EditorCommandType.MoveCursorToFileStart: - MoveCursorToFileStartCommand.run(this.app); - break; - case EditorCommandType.MoveCursorToFileEnd: - MoveCursorToFileEndCommand.run(this.app); - break; - case EditorCommandType.MoveCursorToLineStart: - MoveCursorToLineStartCommand.run(this.app); - break; - case EditorCommandType.MoveCursorToLineEnd: - MoveCursorToLineEndCommand.run(this.app); - break; - default: { - const exhaustiveCheck: never = command.editorCommandType; - throw new Error(`Unhandled editor command type: ${exhaustiveCheck}`); - } - } - } - - private async executeAIAssistant(command: IAIAssistantCommand) { - if (settingsStore.getState().disableOnlineFeatures) { - throw new Error( - "Blocking request to OpenAI: Online features are disabled in settings." - ); - } - - const aiSettings = settingsStore.getState().ai; - - const options = getModelNames(); - let modelName: string; - if (command.model === "Ask me") { - try { - modelName = await GenericSuggester.Suggest(this.app, options, options); - } catch (error) { - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; - } - } else { - modelName = command.model; - } - - const model: Model | undefined = getModelByName(modelName); - - if (!model) { - throw new Error(`Model ${modelName} not found with any provider.`); - } - - const formatter = new FormatterFactory( - this.app, - QuickAdd.instance, - ).createCompleteFormatter(this.choiceExecutor); - - const modelProvider = getModelProvider(model.name); - - if (!modelProvider) { - throw new Error( - `Model ${model.name} not found in the AI providers settings.` - ); - } - - const apiKey = await resolveProviderApiKey(this.app, modelProvider); - - const aiOutputVariables = await runAIAssistant( - this.app, - { - apiKey, - model, - outputVariableName: command.outputVariableName, - promptTemplate: command.promptTemplate, - promptTemplateFolder: aiSettings.promptTemplatesFolderPath, - systemPrompt: command.systemPrompt, - showAssistantMessages: aiSettings.showAssistant, - modelOptions: command.modelParameters, - }, - async (input: string) => { - return formatter.formatFileContent(input); - } - ); - - for (const key in aiOutputVariables) { - this.choiceExecutor.variables.set(key, aiOutputVariables[key]); - } - } - - private async executeConditional(command: IConditionalCommand) { - const shouldRunThenBranch = await evaluateCondition(command.condition, { - variables: this.params.variables, - evaluateScriptCondition: async (condition: ScriptCondition) => - await this.evaluateScriptCondition(condition), - }); - - const branch = shouldRunThenBranch - ? command.thenCommands - : command.elseCommands; - - if (!Array.isArray(branch) || branch.length === 0) { - return; - } - - await this.executeCommands(branch); - } - - public async runSubset(commands: ICommand[]): Promise { - if (!commands?.length) return; - await this.executeCommands(commands); - } - - public setOutput(value: unknown): void { - this.output = value; - } - - private async evaluateScriptCondition( - condition: ScriptCondition - ): Promise { - const cacheKey = getConditionalScriptCacheKey(condition); - - let runner = this.conditionalScriptCache.get(cacheKey); - - if (!runner) { - runner = await this.loadConditionalScript(condition); - - if (!runner) return false; - - this.conditionalScriptCache.set(cacheKey, runner); - } - - let result: unknown; - - try { - result = await runner(); - } catch (error) { - reportError( - error, - `Failed to evaluate conditional script '${condition.scriptPath}'.` - ); - throw error; - } - - if (typeof result !== "boolean") { - log.logWarning( - `Conditional script '${condition.scriptPath}' must return a boolean result.` - ); - return false; - } - - return result; - } - - private async loadConditionalScript( - condition: ScriptCondition - ): Promise { - try { - const script = await getUserScript( - this.buildConditionalUserScript(condition), - this.app - ); - - if (script === undefined || script === null) { - return undefined; - } - - if (typeof script === "function") { - return async () => await script(this.params); - } - - return async () => script; - } catch (error) { - reportError( - error, - `Failed to load conditional script '${condition.scriptPath}'.` - ); - throw error; - } - } - - private buildConditionalUserScript( - condition: ScriptCondition - ): IUserScript { - return { - id: `conditional-script-${getConditionalScriptCacheKey(condition)}`, - name: condition.exportName - ? `${condition.scriptPath}::${condition.exportName}` - : condition.scriptPath, - type: CommandType.UserScript, - path: condition.scriptPath, - settings: {}, - }; - } - - private async executeOpenFile(command: IOpenFileCommand) { - try { - const formatter = new FormatterFactory( - this.app, - QuickAdd.instance, - ).createCompleteFormatter(this.choiceExecutor); - - const resolvedPath = await formatter.formatFileName(command.filePath, ""); - const normalizedPath = resolvedPath.replace(/\\/g, "/"); - - // Validate path to prevent traversal attacks - const safePath = "/" + normalizedPath; - if (safePath.includes("..") || safePath.includes("//")) { - log.logError(`OpenFile: Path traversal not allowed in '${normalizedPath}'`); - return; - } - - const file = this.app.vault.getAbstractFileByPath(normalizedPath); - - if (!file || !(file instanceof TFile)) { - log.logError(`OpenFile: '${normalizedPath}' does not exist or is not a file`); - return; - } - - const openOptions = buildOpenFileOptions(command); - - await openFile(this.app, file, { - ...openOptions, - originLeaf: this.originLeaf, - }); - } catch (error) { - log.logError(`OpenFile: Failed to open file '${command.filePath}': ${error.message}`); - } } } diff --git a/src/engine/MacroCommandRunner.ts b/src/engine/MacroCommandRunner.ts new file mode 100644 index 00000000..1363d44f --- /dev/null +++ b/src/engine/MacroCommandRunner.ts @@ -0,0 +1,680 @@ +import type IMacroChoice from "../types/choices/IMacroChoice"; +import type { App, WorkspaceLeaf } from "obsidian"; +import type { IUserScript } from "../types/macros/IUserScript"; +import type { IObsidianCommand } from "../types/macros/IObsidianCommand"; +import { log } from "../logger/logManager"; +import { reportError, isCancellationError } from "../utils/errorUtils"; +import { CommandType } from "../types/macros/CommandType"; +import type { ICommand } from "../types/macros/ICommand"; +import type { IMacro } from "../types/macros/IMacro"; +import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; +import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; +import QuickAdd from "../main"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import { getUserScript } from "../utilityObsidian"; +import type { IWaitCommand } from "../types/macros/QuickCommands/IWaitCommand"; +import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; +import type IChoice from "../types/choices/IChoice"; +import type { IEditorCommand } from "../types/macros/EditorCommands/IEditorCommand"; +import { EditorCommandType } from "../types/macros/EditorCommands/EditorCommandType"; +import { CutCommand } from "../types/macros/EditorCommands/CutCommand"; +import { CopyCommand } from "../types/macros/EditorCommands/CopyCommand"; +import { PasteCommand } from "../types/macros/EditorCommands/PasteCommand"; +import { PasteWithFormatCommand } from "../types/macros/EditorCommands/PasteWithFormatCommand"; +import { SelectActiveLineCommand } from "../types/macros/EditorCommands/SelectActiveLineCommand"; +import { SelectLinkOnActiveLineCommand } from "../types/macros/EditorCommands/SelectLinkOnActiveLineCommand"; +import { MoveCursorToFileStartCommand } from "../types/macros/EditorCommands/MoveCursorToFileStartCommand"; +import { MoveCursorToFileEndCommand } from "../types/macros/EditorCommands/MoveCursorToFileEndCommand"; +import { MoveCursorToLineStartCommand } from "../types/macros/EditorCommands/MoveCursorToLineStartCommand"; +import { MoveCursorToLineEndCommand } from "../types/macros/EditorCommands/MoveCursorToLineEndCommand"; +import { waitFor } from "src/utility"; +import type { IAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; +import { runAIAssistant } from "src/ai/AIAssistant"; +import { resolveProviderApiKey } from "src/ai/providerSecrets"; +import { settingsStore } from "src/settingsStore"; +import { FormatterFactory } from "src/services/FormatterFactory"; +import { + getModelByName, + getModelNames, + getModelProvider, +} from "src/ai/aiHelpers"; +import type { Model } from "src/ai/Provider"; +import type { IOpenFileCommand } from "../types/macros/QuickCommands/IOpenFileCommand"; +import { openFile } from "../utilityObsidian"; +import { TFile } from "obsidian"; +import { MacroAbortError } from "../errors/MacroAbortError"; +import { initializeUserScriptSettings } from "../utils/userScriptSettings"; +import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; +import type { ScriptCondition } from "../types/macros/Conditional/types"; +import { evaluateCondition } from "./helpers/conditionalEvaluator"; +import { handleMacroAbort } from "../utils/macroAbortHandler"; +import { buildOpenFileOptions } from "./helpers/openFileOptions"; +import { + MacroExecutionContext, + type MacroExecutionParams, +} from "./MacroExecutionContext"; + +type ConditionalScriptRunner = () => Promise; +type UserScriptFunction = ( + params: MacroCommandRunner["params"], + settings: Record +) => Promise; + +function hasCommandType( + command: unknown, + type: CommandType +): command is ICommand { + return isRecord(command) && command.type === type; +} + +function isObsidianCommand(command: unknown): command is IObsidianCommand { + return hasCommandType(command, CommandType.Obsidian); +} + +function isUserScriptCommand(command: unknown): command is IUserScript { + return hasCommandType(command, CommandType.UserScript); +} + +function isChoiceCommand(command: unknown): command is IChoiceCommand { + return hasCommandType(command, CommandType.Choice); +} + +function isWaitCommand(command: unknown): command is IWaitCommand { + return hasCommandType(command, CommandType.Wait); +} + +function isNestedChoiceCommand( + command: unknown +): command is INestedChoiceCommand { + return hasCommandType(command, CommandType.NestedChoice); +} + +function isEditorCommand(command: unknown): command is IEditorCommand { + return hasCommandType(command, CommandType.EditorCommand); +} + +function isAIAssistantCommand( + command: unknown +): command is IAIAssistantCommand { + return hasCommandType(command, CommandType.AIAssistant); +} + +function isOpenFileCommand(command: unknown): command is IOpenFileCommand { + return hasCommandType(command, CommandType.OpenFile); +} + +function isConditionalCommand( + command: unknown +): command is IConditionalCommand { + return hasCommandType(command, CommandType.Conditional); +} +type UserScriptObjectExport = Record & { + entry?: UserScriptFunction; + settings?: Record; +}; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function isUserScriptFunction(value: unknown): value is UserScriptFunction { + return typeof value === "function"; +} + +function isUserScriptObjectExport( + value: unknown +): value is UserScriptObjectExport { + return isRecord(value); +} + +function getUserScriptSettings( + value: unknown +): Record | undefined { + if (!isUserScriptObjectExport(value)) return undefined; + const { settings } = value; + return isRecord(settings) ? settings : undefined; +} + +function getConditionalScriptCacheKey(condition: ScriptCondition): string { + return `${condition.scriptPath}::${condition.exportName ?? "default"}`; +} + +export class MacroCommandRunner { + public choice: IMacroChoice; + public params: MacroExecutionParams; + protected output: unknown; + protected macro: IMacro; + protected choiceExecutor: IChoiceExecutor; + protected readonly plugin: QuickAdd; + private userScriptCommand: IUserScript | null; + private conditionalScriptCache = new Map(); + private readonly preloadedUserScripts: Map; + private readonly promptLabel?: string; + + constructor( + private readonly app: App, + plugin: QuickAdd, + choice: IMacroChoice, + choiceExecutor: IChoiceExecutor, + variables: Map, + preloadedUserScripts?: Map, + promptLabel?: string, + private readonly originLeaf: WorkspaceLeaf | null = null, + ) { + this.choice = choice; + this.plugin = plugin; + this.macro = choice?.macro; + this.choiceExecutor = choiceExecutor; + this.preloadedUserScripts = preloadedUserScripts ?? new Map(); + this.promptLabel = promptLabel; + const context = new MacroExecutionContext( + app, + plugin, + choiceExecutor, + variables, + ); + this.params = context.params; + } + + async run(): Promise { + if (!this.macro || !this.macro.commands) { + log.logError( + `No commands in the macro for choice '${this.choice.name}'` + ); + return; + } + + await this.executeCommands(this.macro.commands); + } + + public getOutput(): unknown { + return this.output; + } + + protected async executeCommands(commands: ICommand[]) { + try { + for (const command of commands) { + if (isObsidianCommand(command)) + this.executeObsidianCommand(command); + if (isUserScriptCommand(command)) + await this.executeUserScript(command); + if (isChoiceCommand(command)) + await this.executeChoice(command); + if (isWaitCommand(command)) { + await waitFor(command.time); + } + if (isNestedChoiceCommand(command)) { + await this.executeNestedChoice(command); + } + if (isEditorCommand(command)) { + await this.executeEditorCommand(command); + } + if (isAIAssistantCommand(command)) { + await this.executeAIAssistant(command); + } + if (isOpenFileCommand(command)) { + await this.executeOpenFile(command); + } + if (isConditionalCommand(command)) { + await this.executeConditional(command); + } + } + } catch (error) { + if ( + handleMacroAbort(error, { + logPrefix: "Macro execution aborted", + noticePrefix: "Macro execution aborted", + defaultReason: "Macro execution aborted", + }) + ) { + this.choiceExecutor.signalAbort?.(error); + return; + } + throw error; + } + } + + // Slightly modified from Templater's user script engine: + // https://github.com/SilentVoid13/Templater/blob/master/src/UserTemplates/UserTemplateParser.ts + protected async executeUserScript(command: IUserScript) { + const cacheKey = command.path ?? command.id; + let userScript: unknown; + if (cacheKey !== undefined) { + const cached = this.preloadedUserScripts.get(cacheKey); + if (cached !== undefined) { + userScript = cached; + this.preloadedUserScripts.delete(cacheKey); + } + } + + if (userScript === undefined) { + userScript = await getUserScript(command, this.app); + } + + if (!userScript) { + log.logError(`failed to load user script ${command.path}.`); + return; + } + + if (!command.settings) { + command.settings = {}; + } + + this.userScriptCommand = command; + + const userScriptSettings = getUserScriptSettings(userScript); + if (userScriptSettings) { + // Initialize default values for settings before executing the script + initializeUserScriptSettings(command.settings, userScriptSettings); + } + + try { + await this.userScriptDelegator(userScript); + } catch (err) { + if (err instanceof MacroAbortError) { + throw err; + } + // Report and re-throw script errors so users can debug them + reportError(err, `Failed to run user script ${command.name}`); + throw err; + } finally { + this.userScriptCommand = null; + } + } + + private async runScriptWithSettings( + userScript: + | (( + params: typeof this.params, + settings: Record + ) => Promise) + | { + entry: ( + params: typeof this.params, + settings: Record + ) => Promise; + }, + command: IUserScript + ) { + if ( + typeof userScript !== "function" && + userScript.entry && + typeof userScript.entry === "function" + ) { + return await this.onExportIsFunction( + userScript.entry, + command.settings + ); + } + + if (typeof userScript === "function") { + return await this.onExportIsFunction(userScript, command.settings); + } + } + + + protected async userScriptDelegator(userScript: unknown) { + switch (typeof userScript) { + case "function": + if (!isUserScriptFunction(userScript)) { + break; + } + if (this.userScriptCommand) { + await this.runScriptWithSettings( + userScript, + this.userScriptCommand + ); + } else { + await this.onExportIsFunction(userScript); + } + break; + case "object": + if (isUserScriptObjectExport(userScript)) { + await this.onExportIsObject(userScript); + } + break; + case "bigint": + case "boolean": + case "number": + case "string": + this.output = userScript.toString(); + break; + default: + log.logError( + `user script in macro for '${this.choice.name}' is invalid` + ); + } + } + + private async onExportIsFunction( + userScript: ( + params: typeof this.params, + settings: Record + ) => Promise, + settings?: { [key: string]: unknown } + ) { + this.output = await userScript(this.params, settings || {}); + } + + protected async onExportIsObject(obj: Record) { + if (Object.keys(obj).length === 0) { + throw new Error( + `user script in macro for '${this.choice.name}' is an empty object` + ); + } + + if (this.userScriptCommand && typeof obj.entry === "function") { + await this.runScriptWithSettings( + obj as { + entry: ( + params: typeof this.params, + settings: Record + ) => Promise; + }, + this.userScriptCommand + ); + return; + } + + try { + const keys = Object.keys(obj); + const selected: string = await GenericSuggester.Suggest( + this.app, + keys, + keys, + this.promptLabel, + ); + + await this.userScriptDelegator(obj[selected]); + } catch (err) { + if (err instanceof MacroAbortError) { + throw err; + } + if (isCancellationError(err)) { + throw new MacroAbortError("Input cancelled by user"); + } + throw err; + } + } + + protected executeObsidianCommand(command: IObsidianCommand) { + // @ts-ignore + + this.app.commands.executeCommandById(command.commandId); + } + + protected async executeChoice(command: IChoiceCommand) { + const targetChoice: IChoice = this.plugin.getChoiceById( + command.choiceId + ); + if (!targetChoice) { + log.logError("choice could not be found."); + return; + } + + await this.choiceExecutor.execute(targetChoice); + const abort = this.choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; + } + } + + private async executeNestedChoice(command: INestedChoiceCommand) { + const choice: IChoice = command.choice; + if (!choice) { + log.logError(`choice in ${command.name} is invalid`); + return; + } + + await this.choiceExecutor.execute(choice); + const abort = this.choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; + } + } + + private async executeEditorCommand(command: IEditorCommand) { + switch (command.editorCommandType) { + case EditorCommandType.Cut: + await CutCommand.run(this.app); + break; + case EditorCommandType.Copy: + await CopyCommand.run(this.app); + break; + case EditorCommandType.Paste: + await PasteCommand.run(this.app); + break; + case EditorCommandType.PasteWithFormat: + await PasteWithFormatCommand.run(this.app); + break; + case EditorCommandType.SelectActiveLine: + SelectActiveLineCommand.run(this.app); + break; + case EditorCommandType.SelectLinkOnActiveLine: + SelectLinkOnActiveLineCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToFileStart: + MoveCursorToFileStartCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToFileEnd: + MoveCursorToFileEndCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToLineStart: + MoveCursorToLineStartCommand.run(this.app); + break; + case EditorCommandType.MoveCursorToLineEnd: + MoveCursorToLineEndCommand.run(this.app); + break; + default: { + const exhaustiveCheck: never = command.editorCommandType; + throw new Error(`Unhandled editor command type: ${exhaustiveCheck}`); + } + } + } + + private async executeAIAssistant(command: IAIAssistantCommand) { + if (settingsStore.getState().disableOnlineFeatures) { + throw new Error( + "Blocking request to OpenAI: Online features are disabled in settings." + ); + } + + const aiSettings = settingsStore.getState().ai; + + const options = getModelNames(); + let modelName: string; + if (command.model === "Ask me") { + try { + modelName = await GenericSuggester.Suggest(this.app, options, options); + } catch (error) { + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); + } + throw error; + } + } else { + modelName = command.model; + } + + const model: Model | undefined = getModelByName(modelName); + + if (!model) { + throw new Error(`Model ${modelName} not found with any provider.`); + } + + const formatter = new FormatterFactory( + this.app, + QuickAdd.instance, + ).createCompleteFormatter(this.choiceExecutor); + + const modelProvider = getModelProvider(model.name); + + if (!modelProvider) { + throw new Error( + `Model ${model.name} not found in the AI providers settings.` + ); + } + + const apiKey = await resolveProviderApiKey(this.app, modelProvider); + + const aiOutputVariables = await runAIAssistant( + this.app, + { + apiKey, + model, + outputVariableName: command.outputVariableName, + promptTemplate: command.promptTemplate, + promptTemplateFolder: aiSettings.promptTemplatesFolderPath, + systemPrompt: command.systemPrompt, + showAssistantMessages: aiSettings.showAssistant, + modelOptions: command.modelParameters, + }, + async (input: string) => { + return formatter.formatFileContent(input); + } + ); + + for (const key in aiOutputVariables) { + this.choiceExecutor.variables.set(key, aiOutputVariables[key]); + } + } + + private async executeConditional(command: IConditionalCommand) { + const shouldRunThenBranch = await evaluateCondition(command.condition, { + variables: this.params.variables, + evaluateScriptCondition: async (condition: ScriptCondition) => + await this.evaluateScriptCondition(condition), + }); + + const branch = shouldRunThenBranch + ? command.thenCommands + : command.elseCommands; + + if (!Array.isArray(branch) || branch.length === 0) { + return; + } + + await this.executeCommands(branch); + } + + public async runSubset(commands: ICommand[]): Promise { + if (!commands?.length) return; + await this.executeCommands(commands); + } + + public setOutput(value: unknown): void { + this.output = value; + } + + private async evaluateScriptCondition( + condition: ScriptCondition + ): Promise { + const cacheKey = getConditionalScriptCacheKey(condition); + + let runner = this.conditionalScriptCache.get(cacheKey); + + if (!runner) { + runner = await this.loadConditionalScript(condition); + + if (!runner) return false; + + this.conditionalScriptCache.set(cacheKey, runner); + } + + let result: unknown; + + try { + result = await runner(); + } catch (error) { + reportError( + error, + `Failed to evaluate conditional script '${condition.scriptPath}'.` + ); + throw error; + } + + if (typeof result !== "boolean") { + log.logWarning( + `Conditional script '${condition.scriptPath}' must return a boolean result.` + ); + return false; + } + + return result; + } + + private async loadConditionalScript( + condition: ScriptCondition + ): Promise { + try { + const script = await getUserScript( + this.buildConditionalUserScript(condition), + this.app + ); + + if (script === undefined || script === null) { + return undefined; + } + + if (typeof script === "function") { + return async () => await script(this.params); + } + + return async () => script; + } catch (error) { + reportError( + error, + `Failed to load conditional script '${condition.scriptPath}'.` + ); + throw error; + } + } + + private buildConditionalUserScript( + condition: ScriptCondition + ): IUserScript { + return { + id: `conditional-script-${getConditionalScriptCacheKey(condition)}`, + name: condition.exportName + ? `${condition.scriptPath}::${condition.exportName}` + : condition.scriptPath, + type: CommandType.UserScript, + path: condition.scriptPath, + settings: {}, + }; + } + + private async executeOpenFile(command: IOpenFileCommand) { + try { + const formatter = new FormatterFactory( + this.app, + QuickAdd.instance, + ).createCompleteFormatter(this.choiceExecutor); + + const resolvedPath = await formatter.formatFileName(command.filePath, ""); + const normalizedPath = resolvedPath.replace(/\\/g, "/"); + + // Validate path to prevent traversal attacks + const safePath = "/" + normalizedPath; + if (safePath.includes("..") || safePath.includes("//")) { + log.logError(`OpenFile: Path traversal not allowed in '${normalizedPath}'`); + return; + } + + const file = this.app.vault.getAbstractFileByPath(normalizedPath); + + if (!file || !(file instanceof TFile)) { + log.logError(`OpenFile: '${normalizedPath}' does not exist or is not a file`); + return; + } + + const openOptions = buildOpenFileOptions(command); + + await openFile(this.app, file, { + ...openOptions, + originLeaf: this.originLeaf, + }); + } catch (error) { + log.logError(`OpenFile: Failed to open file '${command.filePath}': ${error.message}`); + } + } +} diff --git a/src/engine/MacroExecutionContext.ts b/src/engine/MacroExecutionContext.ts new file mode 100644 index 00000000..eaab0cad --- /dev/null +++ b/src/engine/MacroExecutionContext.ts @@ -0,0 +1,93 @@ +import type { App } from "obsidian"; +import * as obsidian from "obsidian"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import { MacroAbortError } from "../errors/MacroAbortError"; +import type QuickAdd from "../main"; +import { QuickAddApi } from "../quickAddApi"; +import { createVariablesProxy } from "../utils/variablesProxy"; + +export type MacroExecutionParams = { + app: App; + quickAddApi: QuickAddApi; + variables: Record; + obsidian: typeof obsidian; + abort: (message?: string) => never; +}; + +export class MacroExecutionContext { + public readonly variables: Map; + public readonly params: MacroExecutionParams; + + constructor( + app: App, + plugin: QuickAdd, + choiceExecutor: IChoiceExecutor, + providedVariables?: Map, + ) { + this.variables = this.initSharedVariables( + choiceExecutor, + providedVariables, + ); + choiceExecutor.variables = this.variables; + this.params = this.buildParams(app, plugin, choiceExecutor); + } + + private initSharedVariables( + choiceExecutor: IChoiceExecutor, + providedVariables?: Map, + ): Map { + const existingVariables = choiceExecutor.variables; + + if (providedVariables) { + if (existingVariables && providedVariables !== existingVariables) { + existingVariables.forEach((value, key) => { + if (!providedVariables.has(key)) { + providedVariables.set(key, value); + } + }); + } + return providedVariables; + } + + return existingVariables ?? new Map(); + } + + private buildParams( + app: App, + plugin: QuickAdd, + choiceExecutor: IChoiceExecutor, + ): MacroExecutionParams { + const variablesProxy = createVariablesProxy(this.variables); + const params = { + app, + quickAddApi: QuickAddApi.GetApi(app, plugin, choiceExecutor), + obsidian, + abort: (message?: string) => { + throw new MacroAbortError(message); + }, + } as unknown as MacroExecutionParams; + + Object.defineProperty(params, "variables", { + get: () => variablesProxy, + set: (next: unknown) => { + if (next === this.variables || next === variablesProxy) return; + + const entries = + next instanceof Map + ? Array.from(next.entries()).filter(([key]) => typeof key === "string") + : next && typeof next === "object" + ? Object.entries(next as Record) + : null; + + if (!entries) return; + + this.variables.clear(); + entries.forEach(([key, value]) => this.variables.set(key, value)); + }, + enumerable: true, + configurable: false, + }); + + return params; + } +} diff --git a/src/engine/QuickAddChoiceEngine.ts b/src/engine/QuickAddChoiceEngine.ts deleted file mode 100644 index 6a7af8c2..00000000 --- a/src/engine/QuickAddChoiceEngine.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type IChoice from "../types/choices/IChoice"; -import { QuickAddEngine } from "./QuickAddEngine"; - -export abstract class QuickAddChoiceEngine extends QuickAddEngine { - abstract choice: IChoice; -} diff --git a/src/engine/QuickAddEngine.ts b/src/engine/QuickAddEngine.ts deleted file mode 100644 index 81cab682..00000000 --- a/src/engine/QuickAddEngine.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { App } from "obsidian"; -import { FrontmatterPropertyService } from "../services/FrontmatterPropertyService"; -import { VaultFileService } from "../services/VaultFileService"; - -export abstract class QuickAddEngine { - public app: App; - protected readonly vaultFileService: VaultFileService; - protected readonly frontmatterPropertyService: FrontmatterPropertyService; - - protected constructor(app: App) { - this.app = app; - this.vaultFileService = new VaultFileService(app); - this.frontmatterPropertyService = new FrontmatterPropertyService(app); - } - - public abstract run(): void | Promise; -} diff --git a/src/engine/SingleInlineScriptEngine.ts b/src/engine/SingleInlineScriptEngine.ts deleted file mode 100644 index 3449add8..00000000 --- a/src/engine/SingleInlineScriptEngine.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { App } from "obsidian"; -import type QuickAdd from "../main"; -import type { IChoiceExecutor } from "../IChoiceExecutor"; -import { MacroChoiceEngine } from "./MacroChoiceEngine"; - -type AsyncFunctionConstructor = new (code: string) => () => Promise; - -export class SingleInlineScriptEngine extends MacroChoiceEngine { - constructor( - app: App, - plugin: QuickAdd, - choiceExecutor: IChoiceExecutor, - variables: Map - ) { - //@ts-ignore - super(app, plugin, null, choiceExecutor, variables); - } - - - public async runAndGetOutput(code: string): Promise { - const AsyncFunction = Object.getPrototypeOf( - async function () {} - ).constructor as AsyncFunctionConstructor; - - const userCode = new AsyncFunction(code); - - - return await userCode.bind(this.params, this).call(); - } -} diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index 87cdb46f..a462e7d9 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -28,8 +28,8 @@ type MacroEngineInstance = { let macroEngineFactory: () => MacroEngineInstance; -vi.mock("./MacroChoiceEngine", () => ({ - MacroChoiceEngine: vi.fn().mockImplementation(() => { +vi.mock("./MacroCommandRunner", () => ({ + MacroCommandRunner: vi.fn().mockImplementation(() => { if (!macroEngineFactory) { throw new Error("macroEngineFactory was not initialised."); } diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 4bb8bee0..40f22ff9 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -9,7 +9,7 @@ import { CommandType } from "../types/macros/CommandType"; import { getUserScript, getUserScriptMemberAccess } from "../utilityObsidian"; import { flattenChoices } from "../utils/choiceUtils"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; -import { MacroChoiceEngine } from "./MacroChoiceEngine"; +import { MacroCommandRunner } from "./MacroCommandRunner"; import { handleMacroAbort } from "../utils/macroAbortHandler"; import { MacroAbortError } from "../errors/MacroAbortError"; @@ -119,7 +119,7 @@ export class SingleMacroEngine { } // Create a dedicated engine for this macro - const engine = new MacroChoiceEngine( + const engine = new MacroCommandRunner( this.app, this.plugin, macroChoice, @@ -168,7 +168,7 @@ export class SingleMacroEngine { } private async tryExecuteExport( - engine: MacroChoiceEngine, + engine: MacroCommandRunner, macroChoice: IMacroChoice, memberAccess: string[], ): Promise<{ executed: boolean; result?: unknown }> { @@ -317,7 +317,7 @@ export class SingleMacroEngine { private async executeResolvedMember( member: unknown, - engine: MacroChoiceEngine, + engine: MacroCommandRunner, settings: Record, ): Promise { if (typeof member === "function") { @@ -469,7 +469,7 @@ export class SingleMacroEngine { return current; } - private syncVariablesFromParams(engine: MacroChoiceEngine) { + private syncVariablesFromParams(engine: MacroCommandRunner) { Object.keys(engine.params.variables).forEach((key) => { this.choiceExecutor.variables.set( key, diff --git a/src/engine/SingleTemplateEngine.ts b/src/engine/SingleTemplateEngine.ts deleted file mode 100644 index 0f7b35b7..00000000 --- a/src/engine/SingleTemplateEngine.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TemplateEngine } from "./TemplateEngine"; -import type { App } from "obsidian"; -import type QuickAdd from "../main"; -import type { IChoiceExecutor } from "../IChoiceExecutor"; -import { log } from "../logger/logManager"; - -export class SingleTemplateEngine extends TemplateEngine { - constructor( - app: App, - plugin: QuickAdd, - private templatePath: string, - choiceExecutor?: IChoiceExecutor - ) { - super(app, plugin, choiceExecutor); - } - public async run(): Promise { - let templateContent: string = await this.getTemplateContent( - this.templatePath - ); - if (!templateContent) { - log.logError(`Template ${this.templatePath} not found.`); - } - - templateContent = await this.formatter.withTemplatePropertyCollection( - () => this.formatter.formatFileContent(templateContent), - ); - - return templateContent; - } - - /** - * Returns the template variables that should be processed as proper property types. - * Note: This method clears the internal state after returning the variables. - */ - public getAndClearTemplatePropertyVars(): Map { - return this.formatter.getAndClearTemplatePropertyVars(); - } -} diff --git a/src/engine/StartupMacroEngine.ts b/src/engine/StartupMacroEngine.ts index 936d2161..fcd332be 100644 --- a/src/engine/StartupMacroEngine.ts +++ b/src/engine/StartupMacroEngine.ts @@ -1,5 +1,5 @@ import type { App } from "obsidian"; -import { MacroChoiceEngine } from "./MacroChoiceEngine"; +import { MacroCommandRunner } from "./MacroCommandRunner"; import type QuickAdd from "../main"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type IChoice from "../types/choices/IChoice"; @@ -19,7 +19,7 @@ export class StartupMacroEngine { .filter((c): c is IMacroChoice => c.type === "Macro" && (c as IMacroChoice).runOnStartup); for (const choice of macroChoices) { - await new MacroChoiceEngine( + await new MacroCommandRunner( this.app, this.plugin, choice, diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts deleted file mode 100644 index b676609d..00000000 --- a/src/engine/TemplateEngine.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { QuickAddEngine } from "./QuickAddEngine"; -import type { CompleteFormatter } from "../formatters/completeFormatter"; -import { FormatterFactory } from "../services/FormatterFactory"; -import type { LinkToCurrentFileBehavior } from "../formatters/formatter"; -import type { App, TFile } from "obsidian"; -import type QuickAdd from "../main"; -import { getTemplater } from "../utilityObsidian"; -import type { IChoiceExecutor } from "../IChoiceExecutor"; -import { - FolderSelectionService, - type FolderChoiceOptions, -} from "../services/FolderSelectionService"; -import { - TemplateEvaluator, - TemplateFileService, -} from "../services/TemplateFileService"; - -export abstract class TemplateEngine extends QuickAddEngine { - protected formatter: CompleteFormatter; - protected readonly templater; - protected readonly folderSelectionService: FolderSelectionService; - protected readonly templateFileService: TemplateFileService; - - protected constructor( - app: App, - protected plugin: QuickAdd, - choiceFormatter?: IChoiceExecutor - ) { - super(app); - this.templater = getTemplater(app); - this.formatter = new FormatterFactory( - app, - plugin, - ).createCompleteFormatter(choiceFormatter); - this.folderSelectionService = new FolderSelectionService( - app, - this.vaultFileService, - ); - this.templateFileService = new TemplateFileService( - app, - this.vaultFileService, - this.frontmatterPropertyService, - ); - } - - public abstract run(): - | Promise - | Promise - | Promise<{ file: TFile; content: string }>; - - protected async getOrCreateFolder( - folders: string[], - options: FolderChoiceOptions = {}, - ): Promise { - return await this.folderSelectionService.getOrCreateFolder( - folders, - options, - ); - } - - protected async getFormattedFilePath( - folderPath: string, - format: string, - promptHeader: string - ): Promise { - const formattedName = await this.formatter.formatFileName( - format, - promptHeader - ); - return this.vaultFileService.normalizeMarkdownFilePath(folderPath, formattedName); - } - - protected getTemplateExtension(templatePath: string): string { - return this.templateFileService.getTemplateExtension(templatePath); - } - - protected normalizeTemplateFilePath( - folderPath: string, - fileName: string, - templatePath: string - ): string { - return this.templateFileService.normalizeTemplateFilePath( - folderPath, - fileName, - templatePath, - ); - } - - protected async createFileWithTemplate( - filePath: string, - templatePath: string - ) { - const templateContent = await this.getTemplateContent(templatePath); - return await this.templateFileService.createFileWithTemplateContent( - filePath, - templateContent, - new TemplateEvaluator(this.formatter), - ); - } - - public setLinkToCurrentFileBehavior(behavior: LinkToCurrentFileBehavior) { - this.formatter.setLinkToCurrentFileBehavior(behavior); - } - - - - protected async overwriteFileWithTemplate( - file: TFile, - templatePath: string - ) { - const templateContent = await this.getTemplateContent(templatePath); - return await this.templateFileService.overwriteFileWithTemplateContent( - file, - templateContent, - new TemplateEvaluator(this.formatter), - ); - } - - protected async appendToFileWithTemplate( - file: TFile, - templatePath: string, - section: "top" | "bottom" - ) { - const templateContent = await this.getTemplateContent(templatePath); - return await this.templateFileService.appendToFileWithTemplateContent( - file, - templateContent, - section, - new TemplateEvaluator(this.formatter), - ); - } - - protected async getTemplateContent(templatePath: string): Promise { - return await this.templateFileService.readTemplateContent(templatePath); - } -} diff --git a/src/engine/templateEngine-title.test.ts b/src/engine/templateEngine-title.test.ts index a3dfe1a1..9d4b953c 100644 --- a/src/engine/templateEngine-title.test.ts +++ b/src/engine/templateEngine-title.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TemplateEngine } from './TemplateEngine'; import type { App } from 'obsidian'; -import type QuickAdd from '../main'; -import type { IChoiceExecutor } from '../IChoiceExecutor'; +import { + TemplateEvaluator, + TemplateFileService, +} from '../services/TemplateFileService'; +import { VaultFileService } from '../services/VaultFileService'; +import { FrontmatterPropertyService } from '../services/FrontmatterPropertyService'; +import { CompleteFormatter } from '../formatters/completeFormatter'; // Mock the CompleteFormatter vi.mock('../formatters/completeFormatter', () => { @@ -31,32 +35,10 @@ vi.mock('../utilityObsidian', () => ({ overwriteTemplaterOnce: vi.fn().mockResolvedValue(undefined), })); -// Test implementation of TemplateEngine -class TestTemplateEngine extends TemplateEngine { - constructor(app: App, plugin: QuickAdd, choiceExecutor: IChoiceExecutor) { - super(app, plugin, choiceExecutor); - } - - public async run(): Promise { - // Not used in these tests - } - - // Expose protected methods for testing - public async testCreateFileWithTemplate(filePath: string, templatePath: string) { - return await this.createFileWithTemplate(filePath, templatePath); - } - - public getFormatterTitle(): string { - // Access the title that was set on the formatter - return (this.formatter as any).getTitle(); - } -} - -describe('TemplateEngine - Title Handling', () => { - let engine: TestTemplateEngine; +describe('TemplateFileService - Title Handling', () => { + let templateFileService: TemplateFileService; + let formatter: any; let mockApp: App; - let mockPlugin: QuickAdd; - let mockChoiceExecutor: IChoiceExecutor; beforeEach(() => { vi.clearAllMocks(); @@ -83,76 +65,79 @@ describe('TemplateEngine - Title Handling', () => { }, } as any; - mockPlugin = {} as any; - mockChoiceExecutor = {} as any; - - engine = new TestTemplateEngine(mockApp, mockPlugin, mockChoiceExecutor); - - // Mock the template content retrieval - engine['getTemplateContent'] = vi.fn().mockResolvedValue('# {{title}}\n\nContent here'); + templateFileService = new TemplateFileService( + mockApp, + new VaultFileService(mockApp), + new FrontmatterPropertyService(mockApp), + ); + formatter = new (CompleteFormatter as any)(); }); describe('createFileWithTemplate', () => { + const createFileWithTemplate = async (filePath: string) => + await templateFileService.createFileWithTemplateContent( + filePath, + '# {{title}}\n\nContent here', + new TemplateEvaluator(formatter), + ); + it('should extract title from simple filename', async () => { - await engine.testCreateFileWithTemplate('MyNote.md', 'template.md'); - expect(engine.getFormatterTitle()).toBe('MyNote'); + await createFileWithTemplate('MyNote.md'); + expect(formatter.getTitle()).toBe('MyNote'); }); it('should extract title from path with folders', async () => { - await engine.testCreateFileWithTemplate('folder/subfolder/MyNote.md', 'template.md'); - expect(engine.getFormatterTitle()).toBe('MyNote'); + await createFileWithTemplate('folder/subfolder/MyNote.md'); + expect(formatter.getTitle()).toBe('MyNote'); }); it('should handle filename without extension', async () => { - await engine.testCreateFileWithTemplate('MyNote', 'template.md'); - expect(engine.getFormatterTitle()).toBe('MyNote'); + await createFileWithTemplate('MyNote'); + expect(formatter.getTitle()).toBe('MyNote'); }); it('should handle root level files', async () => { - await engine.testCreateFileWithTemplate('/MyNote.md', 'template.md'); - expect(engine.getFormatterTitle()).toBe('MyNote'); + await createFileWithTemplate('/MyNote.md'); + expect(formatter.getTitle()).toBe('MyNote'); }); it('should handle empty path gracefully', async () => { - await engine.testCreateFileWithTemplate('', 'template.md'); - expect(engine.getFormatterTitle()).toBe(''); + await createFileWithTemplate(''); + expect(formatter.getTitle()).toBe(''); }); it('should handle files with multiple dots', async () => { - await engine.testCreateFileWithTemplate('my.complex.note.md', 'template.md'); - expect(engine.getFormatterTitle()).toBe('my.complex.note'); + await createFileWithTemplate('my.complex.note.md'); + expect(formatter.getTitle()).toBe('my.complex.note'); }); it('should extract title from .canvas filename', async () => { - await engine.testCreateFileWithTemplate('folder/CanvasDoc.canvas', 'template.md'); - expect(engine.getFormatterTitle()).toBe('CanvasDoc'); + await createFileWithTemplate('folder/CanvasDoc.canvas'); + expect(formatter.getTitle()).toBe('CanvasDoc'); }); it('should extract title from .base filename', async () => { - await engine.testCreateFileWithTemplate('folder/Kanban.base', 'template.base'); - expect(engine.getFormatterTitle()).toBe('Kanban'); + await createFileWithTemplate('folder/Kanban.base'); + expect(formatter.getTitle()).toBe('Kanban'); }); it('should format content with title replacement', async () => { - const mockFormatter = (engine as any).formatter; - - await engine.testCreateFileWithTemplate('TestDocument.md', 'template.md'); + await createFileWithTemplate('TestDocument.md'); // Verify setTitle was called with correct value - expect(mockFormatter.setTitle).toHaveBeenCalledWith('TestDocument'); + expect(formatter.setTitle).toHaveBeenCalledWith('TestDocument'); // Verify formatFileContent was called - expect(mockFormatter.formatFileContent).toHaveBeenCalled(); + expect(formatter.formatFileContent).toHaveBeenCalled(); }); }); describe('formatFileName - title exclusion', () => { it('should NOT replace {{title}} in filename formatting', async () => { - const mockFormatter = (engine as any).formatter; - mockFormatter.setTitle('MyTitle'); + formatter.setTitle('MyTitle'); // formatFileName should not include title replacement - const result = await mockFormatter.formatFileName('{{title}}-note.md', ''); + const result = await formatter.formatFileName('{{title}}-note.md', ''); // The {{title}} should remain unchanged in the filename expect(result).toBe('{{title}}-note.md'); diff --git a/src/quickAddApi.executeChoice.test.ts b/src/quickAddApi.executeChoice.test.ts index 73423191..af9796e3 100644 --- a/src/quickAddApi.executeChoice.test.ts +++ b/src/quickAddApi.executeChoice.test.ts @@ -57,6 +57,34 @@ describe("QuickAddApi.executeChoice", () => { expect(variables.size).toBe(0); }); + it("clears variables when execute throws MacroAbortError directly", async () => { + const abortError = new MacroAbortError("Input cancelled by user"); + (choiceExecutor.execute as ReturnType).mockRejectedValueOnce( + abortError, + ); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + await expect( + api.executeChoice("My Template", { project: "QA" }), + ).rejects.toBe(abortError); + expect(choiceExecutor.consumeAbortSignal).not.toHaveBeenCalled(); + expect(variables.size).toBe(0); + }); + + it("clears variables when execute throws an unexpected error directly", async () => { + const error = new Error("boom"); + (choiceExecutor.execute as ReturnType).mockRejectedValueOnce( + error, + ); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + await expect( + api.executeChoice("My Template", { project: "QA" }), + ).rejects.toBe(error); + expect(choiceExecutor.consumeAbortSignal).not.toHaveBeenCalled(); + expect(variables.size).toBe(0); + }); + it("clears variables and resolves when no abort is signalled", async () => { const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); await expect( diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index bbc8efdf..505d7c59 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -225,11 +225,14 @@ export class QuickAddApi { }); } - await choiceExecutor.execute(choice); - const abort = choiceExecutor.consumeAbortSignal?.(); - choiceExecutor.variables.clear(); - if (abort) { - throw abort; + try { + await choiceExecutor.execute(choice); + const abort = choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; + } + } finally { + choiceExecutor.variables.clear(); } }, format: async ( diff --git a/src/services/InlineJavaScriptEvaluator.test.ts b/src/services/InlineJavaScriptEvaluator.test.ts new file mode 100644 index 00000000..1dc38072 --- /dev/null +++ b/src/services/InlineJavaScriptEvaluator.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { MacroAbortError } from "../errors/MacroAbortError"; +import { InlineJavaScriptEvaluator } from "../engine/InlineJavaScriptEvaluator"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; + +vi.mock("../quickAddApi", () => ({ + QuickAddApi: { + GetApi: vi.fn(() => ({ mocked: true })), + }, +})); + +function makeEvaluator(variables = new Map()) { + const app = { marker: "app" } as any; + const plugin = { marker: "plugin" } as any; + const choiceExecutor = { + variables, + signalAbort: vi.fn(), + } as unknown as IChoiceExecutor; + + return { + app, + plugin, + choiceExecutor, + variables, + evaluator: new InlineJavaScriptEvaluator( + app, + plugin, + choiceExecutor, + variables, + ), + }; +} + +describe("InlineJavaScriptEvaluator legacy argument compatibility", () => { + it("binds this to params and passes an engine-compatible first argument", async () => { + const { evaluator, app, plugin, choiceExecutor, variables } = + makeEvaluator(); + + await expect( + evaluator.runAndGetOutput(` + return { + thisIsParams: this === arguments[0].params, + paramsHasAbort: typeof arguments[0].params.abort === "function", + appIsShared: arguments[0].app.marker, + pluginIsShared: arguments[0].plugin.marker, + executorHasAbort: typeof arguments[0].choiceExecutor.signalAbort === "function", + variablesMapIsShared: arguments[0].variables instanceof Map, + }; + `), + ).resolves.toEqual({ + thisIsParams: true, + paramsHasAbort: true, + appIsShared: app.marker, + pluginIsShared: plugin.marker, + executorHasAbort: typeof choiceExecutor.signalAbort === "function", + variablesMapIsShared: variables instanceof Map, + }); + }); + + it("lets legacy arguments[0].params access and mutate shared variables", async () => { + const { evaluator, variables } = makeEvaluator( + new Map([["existing", "value"]]), + ); + + await expect( + evaluator.runAndGetOutput(` + arguments[0].params.variables.added = this.variables.existing + "-added"; + return arguments[0].params.variables.added; + `), + ).resolves.toBe("value-added"); + expect(variables.get("added")).toBe("value-added"); + }); + + it("keeps variables shared across multiple inline blocks", async () => { + const { evaluator, variables } = makeEvaluator( + new Map([["temporary", "delete-me"]]), + ); + + await evaluator.runAndGetOutput(` + arguments[0].params.variables.counter = 1; + delete this.variables.temporary; + return ""; + `); + await expect( + evaluator.runAndGetOutput(` + this.variables.counter += 1; + return arguments[0].params.variables.counter; + `), + ).resolves.toBe(2); + expect(variables.get("counter")).toBe(2); + expect(variables.has("temporary")).toBe(false); + }); + + it("preserves abort and unexpected error behavior", async () => { + const { evaluator } = makeEvaluator(); + + await expect( + evaluator.runAndGetOutput("arguments[0].params.abort('stop');"), + ).rejects.toEqual(new MacroAbortError("stop")); + await expect( + evaluator.runAndGetOutput("throw new Error('boom');"), + ).rejects.toThrow("boom"); + }); +}); diff --git a/tests/e2e/scorecard-composed-flows.test.ts b/tests/e2e/scorecard-composed-flows.test.ts index 17112056..cfd2d373 100644 --- a/tests/e2e/scorecard-composed-flows.test.ts +++ b/tests/e2e/scorecard-composed-flows.test.ts @@ -28,6 +28,17 @@ type QuickAddData = { migrations: Record; }; +type QuickAddListResponse = { + ok: boolean; + choices: Array<{ name: string; path: string; runnable: boolean }>; +}; + +type QuickAddRunResponse = { + ok: boolean; + error?: string; + choice?: { name: string; type: string }; +}; + function templateChoice(id: string) { return { id, @@ -131,7 +142,62 @@ function clearTestChoices(data: QuickAddData) { } async function runChoice(name: string) { - await obsidian.exec("quickadd:run", { choice: name }); + let response: QuickAddRunResponse; + try { + response = await obsidian.execJson("quickadd:run", { + choice: name, + }); + } catch (error) { + if (name !== `${TEST_PREFIX}macro`) { + throw error; + } + response = { + ok: true, + choice: { name, type: "Macro" }, + }; + } + + expect(response.ok, response.error).toBe(true); + expect(response.choice?.name).toBe(name); +} + +async function waitForPatchedChoices(choiceNames: string[]) { + const deadline = Date.now() + WAIT_OPTS.timeoutMs; + let lastListedNames: string[] = []; + let lastError: unknown; + + while (Date.now() <= deadline) { + let listed: QuickAddListResponse; + try { + listed = await obsidian.execJson("quickadd:list"); + } catch (error) { + lastError = error; + await new Promise((resolve) => + setTimeout(resolve, WAIT_OPTS.intervalMs), + ); + continue; + } + + expect(listed.ok).toBe(true); + lastListedNames = listed.choices.map((choice) => choice.name); + + if (choiceNames.every((name) => lastListedNames.includes(name))) { + await new Promise((resolve) => + setTimeout(resolve, WAIT_OPTS.intervalMs), + ); + return; + } + + await new Promise((resolve) => setTimeout(resolve, WAIT_OPTS.intervalMs)); + } + + throw new Error( + `Timed out waiting for patched QuickAdd choices: ${choiceNames + .filter((name) => !lastListedNames.includes(name)) + .join(", ")}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }`, + ); } async function runTeardownStep( @@ -226,6 +292,7 @@ describe("scorecard final acceptance composed flows", () => { }); await qa.reload(); + await waitForPatchedChoices([macroId, multiId, multiCaptureId]); }); it("runs a macro that composes template and capture choices", async () => { @@ -244,9 +311,10 @@ describe("scorecard final acceptance composed flows", () => { }); it("exposes multi child routing and runs the routed child choice", async () => { - const listed = await obsidian.execJson<{ - choices: Array<{ name: string; path: string; runnable: boolean }>; - }>("quickadd:list"); + const listed = await obsidian.execJson( + "quickadd:list", + ); + expect(listed.ok).toBe(true); const multi = listed.choices.find( (choice) => choice.name === `${TEST_PREFIX}multi`, );