diff --git a/src/choiceExecutor.dateScope.test.ts b/src/choiceExecutor.dateScope.test.ts new file mode 100644 index 000000000..f460b779b --- /dev/null +++ b/src/choiceExecutor.dateScope.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import realMoment from "moment"; +import { App } from "obsidian"; +import type QuickAdd from "./main"; +import type IChoice from "./types/choices/IChoice"; +import type { IChoiceExecutor } from "./IChoiceExecutor"; +import { TemplateChoice } from "./types/choices/TemplateChoice"; +import { MacroChoice } from "./types/choices/MacroChoice"; +import { NestedChoiceCommand } from "./types/macros/QuickCommands/NestedChoiceCommand"; +import { ChoiceAbortError } from "./errors/ChoiceAbortError"; + +vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); +vi.mock("./gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("./main", () => ({ __esModule: true, default: class QuickAddMock {} })); +vi.mock("./quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {} })); +vi.mock("./settingsStore", () => ({ + settingsStore: { getState: () => ({ onePageInputEnabled: false, ai: {}, disableOnlineFeatures: true }) }, +})); +vi.mock("./utils/frontmatterPropertyLinks", () => ({ getFocusedPropertyTarget: () => null })); +vi.mock("./utils/fileOpening", async (importOriginal) => ({ + ...await importOriginal>(), + getOpenFileOriginLeaf: () => null, +})); +const runTemplate = vi.fn<(executor: IChoiceExecutor, choice: IChoice) => Promise>(); +vi.mock("./engine/TemplateChoiceEngine", () => ({ + TemplateChoiceEngine: class { + constructor(_app: unknown, _plugin: unknown, private choice: IChoice, private executor: IChoiceExecutor) {} + async run() { await runTemplate(this.executor, this.choice); } + }, +})); + +const { ChoiceExecutor } = await import("./choiceExecutor"); +const { QuickAddApi } = await import("./quickAddApi"); +const originalMoment = window.moment; +beforeAll(() => { window.moment = realMoment; }); +afterAll(() => { window.moment = originalMoment; }); + +function executor() { + return new ChoiceExecutor(new App(), {} as QuickAdd); +} + +describe("ChoiceExecutor date scope", () => { + const day = (date: Date | undefined) => realMoment(date).format("YYYY-MM-DD"); + + function datedTemplate(name: string, dateOrigin?: IChoice["dateOrigin"]) { + const choice = new TemplateChoice(name); + choice.dateOrigin = dateOrigin; + return choice; + } + + beforeEach(() => { runTemplate.mockReset(); }); + + function recordDays() { + const days: Record = {}; + runTemplate.mockImplementation(async (active, choice) => { days[choice.name] = day(active.clocks?.date); }); + return days; + } + + it("gives each nested Template in a macro its own variable day", async () => { + const runner = executor(); + const days = recordDays(); + const macro = new MacroChoice("Macro"); + macro.macro.commands = [ + new NestedChoiceCommand(datedTemplate("Yesterday", { kind: "variable", name: "yesterday" })), + new NestedChoiceCommand(datedTemplate("Tomorrow", { kind: "variable", name: "tomorrow" })), + ]; + runner.variables.set("yesterday", "2026-01-02"); + runner.variables.set("tomorrow", "2026-01-04"); + + await runner.execute(macro); + + expect(days).toEqual({ Yesterday: "2026-01-02", Tomorrow: "2026-01-04" }); + }); + + it("hands a macro's own day to every nested Template", async () => { + const runner = executor(); + const days = recordDays(); + const macro = new MacroChoice("Macro"); + macro.dateOrigin = { kind: "variable", name: "day" }; + macro.macro.commands = [ + new NestedChoiceCommand(datedTemplate("First")), + new NestedChoiceCommand(datedTemplate("Second")), + ]; + runner.variables.set("day", "2026-01-02"); + + await runner.execute(macro); + + expect(days).toEqual({ First: "2026-01-02", Second: "2026-01-02" }); + }); + + it("gives each executeChoice call its own day and keeps it out of the caller", async () => { + const runner = executor(); + const days = recordDays(); + const choices = [ + datedTemplate("Today template"), + datedTemplate("Variable template", { kind: "variable", name: "day" }), + ]; + const plugin = { getChoiceByName: (name: string) => choices.find((c) => c.name === name) } as unknown as QuickAdd; + const api = QuickAddApi.GetApi(new App(), plugin, runner); + const caller = datedTemplate("Caller"); + runTemplate.mockImplementation(async (active, choice) => { + if (choice.name !== "Caller") { + days[`${choice.name} ${Object.keys(days).length}`] = day(active.clocks?.date); + return; + } + await api.executeChoice("Today template", {}, { date: "2026-01-02" }); + await api.executeChoice("Today template", {}, { date: "2026-01-03" }); + await api.executeChoice("Variable template", { day: "2026-01-04" }); + await api.executeChoice("Variable template", { day: "2026-01-05" }); + days.Caller = active.clocks?.date === undefined ? "today" : day(active.clocks.date); + }); + + await runner.execute(caller); + + expect(days).toEqual({ + "Today template 0": "2026-01-02", + "Today template 1": "2026-01-03", + "Variable template 2": "2026-01-04", + "Variable template 3": "2026-01-05", + Caller: "today", + }); + }); + + it("keeps the caller's day when an executeChoice call fails", async () => { + const runner = executor(); + const failing = datedTemplate("Failing"); + const plugin = { getChoiceByName: () => failing } as unknown as QuickAdd; + const api = QuickAddApi.GetApi(new App(), plugin, runner); + const caller = datedTemplate("Caller", { kind: "variable", name: "callerDay" }); + runner.variables.set("callerDay", "2026-01-10"); + let callerDay: string | undefined; + runTemplate.mockImplementation(async (active, choice) => { + if (choice.name === "Failing") throw new ChoiceAbortError("failed"); + await expect(api.executeChoice("Failing", {}, { date: "2026-01-02" })).rejects.toThrow("failed"); + callerDay = day(active.clocks?.date); + }); + + await runner.execute(caller); + + expect(callerDay).toBe("2026-01-10"); + }); +}); diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index e1388dab3..df566644f 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -105,7 +105,9 @@ export class ChoiceExecutor implements IChoiceExecutor { this.triggerContextOverride = this.triggerContext; } - private beginExecutionContext(): void { + /** Returns the enclosing choice's clocks for {@link endExecutionContext} to restore. */ + private beginExecutionContext(): RunClocks | undefined { + const enclosingClocks = this.clocks; if (this.executionDepth === 0) { this.focusedProperty = this.focusedPropertyOverride !== undefined @@ -135,11 +137,15 @@ export class ChoiceExecutor implements IChoiceExecutor { }; } this.executionDepth++; + return enclosingClocks; } - private endExecutionContext(): void { + private endExecutionContext(enclosingClocks: RunClocks | undefined): void { this.executionDepth = Math.max(0, this.executionDepth - 1); - if (this.executionDepth === 0) { + if (this.executionDepth > 0) { + // A choice's Which day covers the choice and what it runs, not the steps after it. + this.clocks = enclosingClocks; + } else { this.focusedProperty = null; this.triggerContext = null; // A note named with setCurrentFile belongs to the run it was named for, @@ -168,7 +174,7 @@ export class ChoiceExecutor implements IChoiceExecutor { // enclosing executeWithOutcome(): snapshot and restore pendingResult so the nested // choice's recorded result never leaks into the outer choice's reported outcome. const savedResult = this.pendingResult; - this.beginExecutionContext(); + const enclosingClocks = this.beginExecutionContext(); const originLeaf = getOpenFileOriginLeaf(this.app); const promptDraftStore = InputPromptDraftStore.getInstance(); const draftScope = promptDraftStore.beginExecutionScope(); @@ -218,7 +224,7 @@ export class ChoiceExecutor implements IChoiceExecutor { throw error; } finally { this.pendingResult = savedResult; - this.endExecutionContext(); + this.endExecutionContext(enclosingClocks); } } @@ -259,7 +265,7 @@ export class ChoiceExecutor implements IChoiceExecutor { ): Promise { this.pendingAbort = null; this.pendingResult = null; - this.beginExecutionContext(); + const enclosingClocks = this.beginExecutionContext(); const originLeaf = getOpenFileOriginLeaf(this.app); const promptDraftStore = InputPromptDraftStore.getInstance(); const draftScope = promptDraftStore.beginExecutionScope(); @@ -310,7 +316,7 @@ export class ChoiceExecutor implements IChoiceExecutor { reason: error instanceof Error ? error.message : String(error), }; } finally { - this.endExecutionContext(); + this.endExecutionContext(enclosingClocks); } } diff --git a/src/gui/suggesters/choiceSuggester.test.ts b/src/gui/suggesters/choiceSuggester.test.ts index bdf6ee713..677c96090 100644 --- a/src/gui/suggesters/choiceSuggester.test.ts +++ b/src/gui/suggesters/choiceSuggester.test.ts @@ -1020,15 +1020,22 @@ describe("ChoiceSuggester", () => { expect(completion).toHaveBeenCalledWith(); }); - it("holds Shift to pick a day on a Today choice", () => { - const suggester = makeSuggester(rootChoices); - suggester.onChooseItem( - topNote, - new MouseEvent("click", { shiftKey: true }), - ); - expect(executor.pickDate).toBe(true); - expect(executed).toEqual([topNote]); - }); + it.each([false, true])( + "holds Shift to pick a day for the chosen run only (enclosing pickDate: %s)", + async (enclosing) => { + let pickDateDuringRun: boolean | undefined; + executor.pickDate = enclosing; + executor.executeWithFocusedProperty = async () => { pickDateDuringRun = executor.pickDate; }; + const suggester = makeSuggester(rootChoices); + suggester.onChooseItem( + topNote, + new MouseEvent("click", { shiftKey: true }), + ); + await flushMicrotasks(); + expect(pickDateDuringRun).toBe(true); + expect(executor.pickDate).toBe(enclosing); + }, + ); it("rejects with the leaf's own error instance", async () => { const leafError = new Error("leaf blew up"); diff --git a/src/gui/suggesters/choiceSuggester.ts b/src/gui/suggesters/choiceSuggester.ts index 1b3cdd86b..523fc9128 100644 --- a/src/gui/suggesters/choiceSuggester.ts +++ b/src/gui/suggesters/choiceSuggester.ts @@ -536,10 +536,11 @@ export default class ChoiceSuggester extends FuzzySuggestModal { // leaf inherits the executor's still-live context, which holds the same // values this level was handed. Falls back to execute() only for stub // executors without the method. + const enclosingPickDate = this.choiceExecutor.pickDate; if ("shiftKey" in evt && evt.shiftKey) { this.choiceExecutor.pickDate = true; } - const execute = this.choiceExecutor.executeWithFocusedProperty + const run = this.choiceExecutor.executeWithFocusedProperty ? this.choiceExecutor.executeWithFocusedProperty( item, this.focusedProperty, @@ -547,6 +548,10 @@ export default class ChoiceSuggester extends FuzzySuggestModal { this.ancestry, ) : this.choiceExecutor.execute(item, this.ancestry); + // Shift picks a day for this choice, not for the Macro steps after it. + const execute = run.finally(() => { + this.choiceExecutor.pickDate = enclosingPickDate; + }); this.settleCompletion(execute, `Could not run "${item.name}"`); } } diff --git a/src/quickAddApi.test.ts b/src/quickAddApi.test.ts index 9ac50ca31..b3fa4acc8 100644 --- a/src/quickAddApi.test.ts +++ b/src/quickAddApi.test.ts @@ -1007,12 +1007,14 @@ describe("executeChoice", () => { expect(executor.execute).toHaveBeenCalled(); }); - it("treats date ask as pickDate and still executes", async () => { + it("treats date ask as pickDate for that call only", async () => { const executor = makeChoiceExecutor(); + let pickDateDuringRun: boolean | undefined; + executor.execute.mockImplementation(async () => { pickDateDuringRun = executor.pickDate; }); const { api } = getApi(makeApp(), makePlugin(), executor); await api.executeChoice("C", {}, { date: "ask" }); - expect(executor.execute).toHaveBeenCalled(); - expect(executor.pickDate).toBe(true); + expect(pickDateDuringRun).toBe(true); + expect(executor.pickDate).toBe(false); expect(executor.clocks).toBeUndefined(); }); diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index 69d756039..dd19e8ad8 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -105,6 +105,7 @@ export class QuickAddApi { return; } + const { clocks, pickDate } = choiceExecutor; if (!applyInvocationDate(choiceExecutor, options?.date)) { reportError( new Error(`Could not parse date origin '${String(options?.date)}'`), @@ -125,7 +126,13 @@ export class QuickAddApi { // cancelled sub-choice and carries on must not lose them. The cost is // the long-standing quirk that variables seeded into a cancelled call // linger until the next completed one. - await choiceExecutor.execute(choice, chain); + try { + await choiceExecutor.execute(choice, chain); + } finally { + // options.date is this call's day, not the calling script's. + choiceExecutor.clocks = clocks; + choiceExecutor.pickDate = pickDate; + } const abort = choiceExecutor.consumeAbortSignal?.(); choiceExecutor.variables.clear(); restoreDateVariableFormats(choiceExecutor.variables, new Map()); diff --git a/tests/e2e/date-origin-scope.test.ts b/tests/e2e/date-origin-scope.test.ts new file mode 100644 index 000000000..916b8dc66 --- /dev/null +++ b/tests/e2e/date-origin-scope.test.ts @@ -0,0 +1,120 @@ +import { expect, it } from "vitest"; +import { MacroChoice } from "../../src/types/choices/MacroChoice"; +import { TemplateChoice } from "../../src/types/choices/TemplateChoice"; +import type IChoice from "../../src/types/choices/IChoice"; +import type { IUserScript } from "../../src/types/macros/IUserScript"; +import { CommandType } from "../../src/types/macros/CommandType"; +import type { DateOrigin } from "../../src/types/dateOrigin"; +import { NestedChoiceCommand } from "../../src/types/macros/QuickCommands/NestedChoiceCommand"; +import { createQuickAddE2EHarness, seedVaultFile } from "./e2eVault"; +import { POLL_OPTS } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("date-origin-scope"); + +async function datedTemplate(name: string, folder: string, dateOrigin?: DateOrigin) { + const { obsidian, sandbox } = getContext(); + const template = new TemplateChoice(name); + template.templatePath = await seedVaultFile(obsidian, sandbox, "Templates/Dated.md", "{{DATE}}"); + template.fileNameFormat = { enabled: true, format: "{{DATE}}" }; + template.folder = { ...template.folder, enabled: true, folders: [sandbox.path(folder)] }; + template.dateOrigin = dateOrigin; + return template; +} + +async function scriptStep(file: string, body: string): Promise { + const { obsidian, sandbox } = getContext(); + const path = await seedVaultFile(obsidian, sandbox, file, `module.exports = async (params) => { ${body} };`); + return { id: file, name: file, type: CommandType.UserScript, path, settings: {} }; +} + +async function runMacro(choices: IChoice[], macro: MacroChoice) { + const { obsidian, plugin } = getContext(); + macro.command = true; + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices = [...choices, macro]; + }); + await plugin.reload({ waitUntilReady: true }); + await obsidian.exec("command", { id: `quickadd:choice:${macro.id}` }); +} + +it("gives each nested Template its own variable day", async () => { + const { sandbox } = getContext(); + const macro = new MacroChoice("Yesterday and tomorrow"); + macro.macro.commands = [ + await scriptStep("set-days.js", `params.variables.yesterday = "2026-01-02"; params.variables.tomorrow = "2026-01-04";`), + new NestedChoiceCommand(await datedTemplate("Yesterday note", "nested", { kind: "variable", name: "yesterday" })), + new NestedChoiceCommand(await datedTemplate("Tomorrow note", "nested", { kind: "variable", name: "tomorrow" })), + ]; + + await runMacro([], macro); + + await expect.poll(() => sandbox.read("nested/2026-01-04.md").catch(() => null), POLL_OPTS).toBe("2026-01-04"); + expect(await sandbox.read("nested/2026-01-02.md")).toBe("2026-01-02"); +}); + +it("gives each executeChoice call its own options.date", async () => { + const { sandbox } = getContext(); + const template = await datedTemplate("Dated note", "api"); + const macro = new MacroChoice("Two executeChoice days"); + macro.macro.commands = [ + await scriptStep("execute-days.js", ` + await params.quickAddApi.executeChoice("Dated note", {}, { date: "2026-01-02" }); + await params.quickAddApi.executeChoice("Dated note", {}, { date: "2026-01-04" });`), + ]; + + await runMacro([template], macro); + + await expect.poll(() => sandbox.read("api/2026-01-04.md").catch(() => null), POLL_OPTS).toBe("2026-01-04"); + expect(await sandbox.read("api/2026-01-02.md")).toBe("2026-01-02"); +}); + +it("still hands a Macro's own day to its nested Templates", async () => { + const { obsidian, sandbox } = getContext(); + const macro = new MacroChoice("Macro day"); + macro.dateOrigin = { kind: "relative", offset: -1, unit: "days" }; + macro.macro.commands = [ + new NestedChoiceCommand(await datedTemplate("First child", "inherited")), + new NestedChoiceCommand(await datedTemplate("Second child", "inherited/again")), + ]; + const yesterday = await obsidian.dev.evalJson( + `window.moment().subtract(1, "day").format("YYYY-MM-DD")`, + ); + + await runMacro([], macro); + + await expect.poll(() => sandbox.read(`inherited/again/${yesterday}.md`).catch(() => null), POLL_OPTS).toBe(yesterday); + expect(await sandbox.read(`inherited/${yesterday}.md`)).toBe(yesterday); +}); + +it("gives each executeChoice call its own variable day", async () => { + const { sandbox } = getContext(); + const template = await datedTemplate("Variable note", "api-variable", { kind: "variable", name: "day" }); + const macro = new MacroChoice("Two executeChoice variables"); + macro.macro.commands = [ + await scriptStep("execute-variables.js", ` + await params.quickAddApi.executeChoice("Variable note", { day: "2026-01-02" }); + await params.quickAddApi.executeChoice("Variable note", { day: "2026-01-04" });`), + ]; + + await runMacro([template], macro); + + await expect.poll(() => sandbox.read("api-variable/2026-01-04.md").catch(() => null), POLL_OPTS).toBe("2026-01-04"); + expect(await sandbox.read("api-variable/2026-01-02.md")).toBe("2026-01-02"); +}); + +it("keeps an executeChoice options.date out of later macro steps", async () => { + const { sandbox } = getContext(); + const template = await datedTemplate("Dated note", "after-api"); + const macro = new MacroChoice("executeChoice then nested"); + macro.macro.commands = [ + await scriptStep("execute-then-set.js", ` + await params.quickAddApi.executeChoice("Dated note", {}, { date: "2026-01-02" }); + params.variables.tomorrow = "2026-01-04";`), + new NestedChoiceCommand(await datedTemplate("Tomorrow note", "after-api", { kind: "variable", name: "tomorrow" })), + ]; + + await runMacro([template], macro); + + await expect.poll(() => sandbox.read("after-api/2026-01-04.md").catch(() => null), POLL_OPTS).toBe("2026-01-04"); + expect(await sandbox.read("after-api/2026-01-02.md")).toBe("2026-01-02"); +});