diff --git a/docs/src/content/docs/docs/Choices/MacroChoice.md b/docs/src/content/docs/docs/Choices/MacroChoice.md index 9eb36f3f6..d0d290850 100644 --- a/docs/src/content/docs/docs/Choices/MacroChoice.md +++ b/docs/src/content/docs/docs/Choices/MacroChoice.md @@ -300,7 +300,9 @@ commands share the same name, rename one before using the selector form. Same [Which day](/docs/Choices/TemplateChoice/#date-origin) setting as a Template. Child choices in the macro inherit that day, so a weekly pack can -ask once and then write every template for last week. +ask once and then write every template for last week. A child set to **A +variable…** keeps its variable's day, even when you run the macro with +"Name (pick a day)". ### Run on startup {#run-on-startup} diff --git a/src/IChoiceExecutor.ts b/src/IChoiceExecutor.ts index 4fc46d726..8b15596a1 100644 --- a/src/IChoiceExecutor.ts +++ b/src/IChoiceExecutor.ts @@ -4,7 +4,7 @@ import type ICaptureChoice from "./types/choices/ICaptureChoice"; import type { MacroAbortError } from "./errors/MacroAbortError"; import type { ChoiceOutcome } from "./types/ChoiceOutcome"; import type { FrontmatterPropertyTarget } from "./utils/frontmatterPropertyLinks"; -import type { RunClocks } from "./types/dateOrigin"; +import type { CallerDay, RunClocks } from "./types/dateOrigin"; import type { QuickAddTriggerContext } from "./types/QuickAddTriggerContext"; import type { PromptProvider } from "./interactive/promptProvider"; import type IMacroChoice from "./types/choices/IMacroChoice"; @@ -58,10 +58,12 @@ export interface IChoiceExecutor { */ clocks?: RunClocks; /** - * When true, this run asks for a day even if the choice is set to Today - * (or another fixed day). Used by `{name} (pick a day)` and `date=ask`. + * Replaces the Which day of the next choice to resolve one: `"pick"` asks + * even if it is set to Today, a date is that day. Choices it runs inherit + * the result like any enclosing day. Set by `{name} (pick a day)`, Shift in + * the menu, and `date=`. */ - pickDate?: boolean; + callerDay?: CallerDay; /** * Sets {@link clocks} from the choice's Which day setting, asking when it is * Ask each time. A Template that searches existing notes calls this after its diff --git a/src/choiceExecutor.dateScope.test.ts b/src/choiceExecutor.dateScope.test.ts index 8f4744a2a..e631b3d78 100644 --- a/src/choiceExecutor.dateScope.test.ts +++ b/src/choiceExecutor.dateScope.test.ts @@ -7,6 +7,8 @@ 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 { MultiChoice } from "./types/choices/MultiChoice"; +import { QA_INTERNAL_DATE_ORIGIN } from "./constants"; import { ChoiceAbortError } from "./errors/ChoiceAbortError"; vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); @@ -21,6 +23,16 @@ vi.mock("./utils/fileOpening", async (importOriginal) => ({ ...await importOriginal>(), getOpenFileOriginLeaf: () => null, })); +const datePrompt = vi.fn(async () => "@date:2026-03-01"); +vi.mock("./gui/VDateInputPrompt/VDateInputPrompt", () => ({ default: { Prompt: datePrompt } })); +vi.mock("./gui/suggesters/choiceSuggester", async (importOriginal) => ({ + ...await importOriginal>(), + default: { + Open: (_plugin: unknown, choices: IChoice[], options: { choiceExecutor: IChoiceExecutor; completion: (error?: unknown) => void }) => { + options.choiceExecutor.execute(choices[0]).then(() => options.completion(), options.completion); + }, + }, +})); const runTemplate = vi.fn<(executor: IChoiceExecutor, choice: IChoice) => Promise>(); vi.mock("./engine/TemplateChoiceEngine", () => ({ TemplateChoiceEngine: class { @@ -31,6 +43,7 @@ vi.mock("./engine/TemplateChoiceEngine", () => ({ const { ChoiceExecutor } = await import("./choiceExecutor"); const { QuickAddApi } = await import("./quickAddApi"); +const { applyInvocationDate } = await import("./utils/resolveDateOrigin"); const originalMoment = window.moment; beforeAll(() => { window.moment = realMoment; }); afterAll(() => { window.moment = originalMoment; }); @@ -48,7 +61,10 @@ describe("ChoiceExecutor date scope", () => { return choice; } - beforeEach(() => { runTemplate.mockReset(); }); + beforeEach(() => { + runTemplate.mockReset(); + datePrompt.mockClear(); + }); function recordDays() { const days: Record = {}; @@ -88,6 +104,114 @@ describe("ChoiceExecutor date scope", () => { expect(days).toEqual({ First: "2026-01-02", Second: "2026-01-02" }); }); + function macroOf(...children: IChoice[]) { + const macro = new MacroChoice("Macro"); + macro.macro.commands = children.map((child) => new NestedChoiceCommand(child)); + return macro; + } + + it("keeps a nested variable day when the macro is run with Pick a day", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set("testDate", "2026-01-02"); + runner.callerDay = "pick"; + + await runner.execute(macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Today"), + )); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ Variable: "2026-01-02", Today: "2026-03-01" }); + }); + + it("keeps a nested variable day under a macro's own Which day", async () => { + const runner = executor(); + const days = recordDays(); + const macro = macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Yesterday", { kind: "relative", offset: -1, unit: "days" }), + ); + macro.dateOrigin = { kind: "variable", name: "macroDay" }; + runner.variables.set("macroDay", "2026-01-05"); + runner.variables.set("testDate", "2026-01-02"); + + await runner.execute(macro); + + expect(days).toEqual({ Variable: "2026-01-02", Yesterday: "2026-01-05" }); + }); + + it("keeps a nested variable day under an invocation date", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set("testDate", "2026-01-02"); + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Today"), + )); + + expect(days).toEqual({ Variable: "2026-01-02", Today: "2026-03-01" }); + }); + + it("lets an invocation date replace the invoked choice's own variable day", async () => { + const runner = executor(); + const days = recordDays(); + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(datedTemplate("Variable", { kind: "variable", name: "unset" })); + + expect(days).toEqual({ Variable: "2026-03-01" }); + }); + + it("asks for an executeChoice date of ask under the caller's day", async () => { + const runner = executor(); + const days = recordDays(); + const child = datedTemplate("Child"); + const plugin = { settings: { choices: [child] } } 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-05"); + runTemplate.mockImplementation(async (active, choice) => { + if (choice.name === "Caller") { + await api.executeChoice("Child", {}, { date: "ask" }); + } + days[choice.name] = day(active.clocks?.date); + }); + + await runner.execute(caller); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ Child: "2026-03-01", Caller: "2026-01-05" }); + }); + + it("hands a caller's day past a folder to the picked choice", async () => { + const runner = executor(); + const days = recordDays(); + const folder = new MultiChoice("Folder"); + folder.choices = [datedTemplate("Variable", { kind: "variable", name: "unset" })]; + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(folder); + + expect(days).toEqual({ Variable: "2026-03-01" }); + }); + + it("uses a collected date answer for one ask only", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set(QA_INTERNAL_DATE_ORIGIN, "@date:2026-01-05"); + + await runner.execute(macroOf( + datedTemplate("First", { kind: "ask" }), + datedTemplate("Second", { kind: "ask" }), + )); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ First: "2026-01-05", Second: "2026-03-01" }); + }); + it("gives each executeChoice call its own day and keeps it out of the caller", async () => { const runner = executor(); const days = recordDays(); diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index df566644f..3c4e6751a 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -28,13 +28,15 @@ import { type FrontmatterPropertyTarget, } from "./utils/frontmatterPropertyLinks"; import type { QuickAddTriggerContext } from "./types/QuickAddTriggerContext"; -import type { RunClocks } from "./types/dateOrigin"; -import { normalizeDateOrigin } from "./types/dateOrigin"; -import { dateOriginForPick } from "./types/dateOriginPresets"; +import type { CallerDay, RunClocks } from "./types/dateOrigin"; import { childChoicesOf } from "./utils/choiceUtils"; import { QA_INTERNAL_DATE_ORIGIN, VALUE_SYNTAX } from "./constants"; import VDateInputPrompt from "./gui/VDateInputPrompt/VDateInputPrompt"; -import { planDateOrigin, dateFromStoredValue } from "./utils/resolveDateOrigin"; +import { + planDateOrigin, + dateFromStoredValue, + dateOriginInput, +} from "./utils/resolveDateOrigin"; import { log } from "./logger/logManager"; import type { ICommand } from "./types/macros/ICommand"; import type { LoadedUserScript } from "./utils/userScript"; @@ -61,7 +63,7 @@ export class ChoiceExecutor implements IChoiceExecutor { public focusedProperty: FrontmatterPropertyTarget | null = null; public triggerContext: QuickAddTriggerContext | null = null; public clocks?: RunClocks; - public pickDate = false; + public callerDay?: CallerDay; private pendingAbort: MacroAbortError | null = null; private pendingResult: ChoiceOutcome | null = null; private executionDepth = 0; @@ -131,10 +133,7 @@ export class ChoiceExecutor implements IChoiceExecutor { this.triggerContextOverride !== undefined ? this.triggerContextOverride : { activeFile: this.app.workspace.getActiveFile() }; - this.clocks = { - now: this.clocks?.now ?? new Date(), - date: this.clocks?.date, - }; + this.clocks = { now: new Date() }; } this.executionDepth++; return enclosingClocks; @@ -152,7 +151,7 @@ export class ChoiceExecutor implements IChoiceExecutor { // not to a later run on this executor (a script that kept its quickAddApi). this.triggerContextOverride = undefined; this.clocks = undefined; - this.pickDate = false; + this.callerDay = undefined; // Preloaded script modules are scoped to ONE outermost execution: a // cancelled/aborted run must not strand its entries, or a later // trigger on a long-lived executor (api.executeChoice callers reuse @@ -321,6 +320,8 @@ export class ChoiceExecutor implements IChoiceExecutor { } private async applyDateOrigin(choice: IChoice): Promise { + // A folder has no Which day, so the caller's day waits for the picked choice. + if (choice.type === "Multi") return; if ( isTemplateChoice(choice) && shouldRunTemplateNoteDiscovery( @@ -337,21 +338,26 @@ export class ChoiceExecutor implements IChoiceExecutor { } async resolveDateOrigin(choice: IChoice): Promise { - const setting = this.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); + const { setting, clocks } = dateOriginInput(choice, this); + // The caller's day answers this choice's Which day, not the ones it runs. + this.callerDay = undefined; const reservedSeed = setting?.kind === "ask" ? this.variables.get(QA_INTERNAL_DATE_ORIGIN) : undefined; + // One answer per ask, so a later ask in the run still prompts. + if (setting?.kind === "ask") this.variables.delete(QA_INTERNAL_DATE_ORIGIN); const plan = planDateOrigin({ setting, - clocks: this.clocks, + clocks, variables: this.variables, reservedSeed, }); - if (plan.status === "inherit") return; + if (plan.status === "inherit") { + this.clocks = clocks; + return; + } if (plan.status === "error") { log.logError(plan.message); diff --git a/src/gui/suggesters/choiceSuggester.test.ts b/src/gui/suggesters/choiceSuggester.test.ts index db0963478..6666ab7d9 100644 --- a/src/gui/suggesters/choiceSuggester.test.ts +++ b/src/gui/suggesters/choiceSuggester.test.ts @@ -1026,20 +1026,20 @@ describe("ChoiceSuggester", () => { expect(completion).toHaveBeenCalledWith(); }); - it.each([false, true])( - "holds Shift to pick a day for the chosen run only (enclosing pickDate: %s)", + it.each([undefined, "pick" as const])( + "holds Shift to pick a day for the chosen run only (enclosing callerDay: %s)", async (enclosing) => { - let pickDateDuringRun: boolean | undefined; - executor.pickDate = enclosing; - executor.executeWithFocusedProperty = async () => { pickDateDuringRun = executor.pickDate; }; + let callerDayDuringRun: unknown; + executor.callerDay = enclosing; + executor.executeWithFocusedProperty = async () => { callerDayDuringRun = executor.callerDay; }; const suggester = makeSuggester(rootChoices); suggester.onChooseItem( topNote, new MouseEvent("click", { shiftKey: true }), ); await flushMicrotasks(); - expect(pickDateDuringRun).toBe(true); - expect(executor.pickDate).toBe(enclosing); + expect(callerDayDuringRun).toBe("pick"); + expect(executor.callerDay).toBe(enclosing); }, ); diff --git a/src/gui/suggesters/choiceSuggester.ts b/src/gui/suggesters/choiceSuggester.ts index 9e40a8c5f..7f11d7ab9 100644 --- a/src/gui/suggesters/choiceSuggester.ts +++ b/src/gui/suggesters/choiceSuggester.ts @@ -549,9 +549,9 @@ 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; + const enclosingCallerDay = this.choiceExecutor.callerDay; if ("shiftKey" in evt && evt.shiftKey) { - this.choiceExecutor.pickDate = true; + this.choiceExecutor.callerDay = "pick"; } const run = this.choiceExecutor.executeWithFocusedProperty ? this.choiceExecutor.executeWithFocusedProperty( @@ -563,7 +563,7 @@ export default class ChoiceSuggester extends FuzzySuggestModal { : 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.choiceExecutor.callerDay = enclosingCallerDay; }); this.settleCompletion(execute, `Could not run "${item.name}"`); } diff --git a/src/main.ts b/src/main.ts index ec20d038b..0c83bc955 100644 --- a/src/main.ts +++ b/src/main.ts @@ -593,7 +593,7 @@ export default class QuickAdd extends Plugin { private async runRegisteredChoice( choiceId: string, fallbackName: string, - pickDate = false, + pickDay = false, ): Promise { // Resolved outside the try so the failure can name the choice the user // actually ran; a bare UUID tells them nothing. Falls back to the name @@ -602,7 +602,7 @@ export default class QuickAdd extends Plugin { try { current = this.getChoiceById(choiceId); const executor = new ChoiceExecutor(this.app, this); - executor.pickDate = pickDate; + if (pickDay) executor.callerDay = "pick"; await executor.execute(current); } catch (err) { // The outermost handler: the last chance to say which choice failed. diff --git a/src/preflight/collectChoiceRequirements.test.ts b/src/preflight/collectChoiceRequirements.test.ts index ca68677a2..78624e9f9 100644 --- a/src/preflight/collectChoiceRequirements.test.ts +++ b/src/preflight/collectChoiceRequirements.test.ts @@ -1371,7 +1371,7 @@ describe("collectChoiceRequirements - path-context memo (issue #1484 review fix) }); }); -describe("collectChoiceRequirements - pickDate", () => { +describe("collectChoiceRequirements - pick a day", () => { const collect = (choice: IChoice, executor: IChoiceExecutor, options?: Parameters[4]) => collectChoiceRequirements(app, plugin, executor, choice, options); @@ -1381,7 +1381,7 @@ describe("collectChoiceRequirements - pickDate", () => { } as unknown as App; const plugin = createPreflightPlugin(); - it("collects a date field when pickDate is set on a Today choice", async () => { + it("collects a date field when a Today choice is run with Pick a day", async () => { const choice = createCaptureChoice("Inbox.md"); const requirements = await collect( choice, @@ -1389,7 +1389,7 @@ describe("collectChoiceRequirements - pickDate", () => { ...createChoiceExecutor(), execute: vi.fn(), variables: new Map(), - pickDate: true, + callerDay: "pick", }, ); expect( @@ -1397,7 +1397,23 @@ describe("collectChoiceRequirements - pickDate", () => { ).toBe(true); }); - it("does not collect a date field for a Today choice without pickDate", async () => { + it("does not collect a date field for an Ask choice handed a day", async () => { + const choice = Object.assign(createCaptureChoice("Inbox.md"), { dateOrigin: { kind: "ask" } }); + const requirements = await collect( + choice, + { + ...createChoiceExecutor(), + execute: vi.fn(), + variables: new Map(), + callerDay: new Date(2026, 2, 1), + }, + ); + expect( + requirements.some((requirement) => requirement.id === QA_INTERNAL_DATE_ORIGIN), + ).toBe(false); + }); + + it("does not collect a date field for a Today choice without Pick a day", async () => { const choice = createCaptureChoice("Inbox.md"); const requirements = await collect(choice, createChoiceExecutor()); expect( diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 46f7c1c29..30531b2a6 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -10,12 +10,11 @@ import { QA_INTERNAL_DATE_ORIGIN, VALUE_SYNTAX, } from "src/constants"; -import { normalizeDateOrigin } from "src/types/dateOrigin"; -import { dateOriginForPick } from "src/types/dateOriginPresets"; import type QuickAdd from "src/main"; import type ICaptureChoice from "src/types/choices/ICaptureChoice"; import type IChoice from "src/types/choices/IChoice"; import { isMacroChoice, isTemplateChoice } from "src/types/choices/choiceType"; +import { dateOriginInput } from "src/utils/resolveDateOrigin"; import type IMacroChoice from "src/types/choices/IMacroChoice"; import type ITemplateChoice from "src/types/choices/ITemplateChoice"; import type { IUserScript } from "src/types/macros/IUserScript"; @@ -67,6 +66,11 @@ interface CollectChoiceRequirementsOptions { * The engine consumes these entries instead of re-loading (delete-on-use). */ preloadedUserScripts?: Map; + /** + * A step of the choice being run. The caller's day answers that choice, so + * a step only sees the day it will inherit. + */ + step?: boolean; } async function readTemplate(app: App, path: string): Promise { @@ -526,12 +530,10 @@ function registerSiblingCaptureTargets( function dateOriginRequirement( choice: IChoice, - choiceExecutor: IChoiceExecutor, + choiceExecutor: Pick, ): FieldRequirement | undefined { - const dateOrigin = choiceExecutor.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); - if (dateOrigin?.kind !== "ask" || choiceExecutor.clocks?.date) { + const { setting: dateOrigin, clocks } = dateOriginInput(choice, choiceExecutor); + if (dateOrigin?.kind !== "ask" || clocks?.date) { return undefined; } @@ -547,7 +549,7 @@ function dateOriginRequirement( function withDateOriginRequirement( choice: IChoice, - choiceExecutor: IChoiceExecutor, + choiceExecutor: Pick, requirements: FieldRequirement[], ): FieldRequirement[] { const dateRequirement = dateOriginRequirement(choice, choiceExecutor); @@ -582,7 +584,13 @@ export async function collectChoiceRequirements( } else { return []; } - return withDateOriginRequirement(choice, choiceExecutor, requirements); + return withDateOriginRequirement( + choice, + options?.step + ? { clocks: dateOriginInput(choice, choiceExecutor).clocks } + : choiceExecutor, + requirements, + ); } export function listDeferredMacroSteps( diff --git a/src/preflight/discoveryFormPlan.test.ts b/src/preflight/discoveryFormPlan.test.ts index 7a2973b26..97b6eed69 100644 --- a/src/preflight/discoveryFormPlan.test.ts +++ b/src/preflight/discoveryFormPlan.test.ts @@ -257,4 +257,31 @@ describe("discovery form planning", () => { expect(executor.variables.get(QA_INTERNAL_DATE_ORIGIN)).toBe("@date:2026-09-05"); }); }); + + it("asks for a picked day on the macro, not on its steps", async () => { + executor.callerDay = "pick"; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, + macro(nested(template, "note"), nested(capture, "cap"))); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.steps.map((step) => [step.occurrenceId, step.bindings.has(QA_INTERNAL_DATE_ORIGIN)])) + .toEqual([["macro", true], ["note", false], ["cap", false]]); + expect(executor.callerDay).toBe("pick"); + }); + + it("asks no step for a day the macro was handed", async () => { + executor.callerDay = new Date(2026, 2, 1); + capture.dateOrigin = { kind: "ask" }; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, + macro(nested(template, "note"), nested(capture, "cap"))); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.requirements.some((field) => field.id === QA_INTERNAL_DATE_ORIGIN)).toBe(false); + }); + + it("asks for a picked day on a Template run on its own", async () => { + executor.callerDay = "pick"; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, template); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.steps.map((step) => [step.occurrenceId, step.bindings.has(QA_INTERNAL_DATE_ORIGIN)])) + .toEqual([[template.id, true]]); + }); }); diff --git a/src/preflight/discoveryFormPlan.ts b/src/preflight/discoveryFormPlan.ts index c0af7980a..e2e10d24b 100644 --- a/src/preflight/discoveryFormPlan.ts +++ b/src/preflight/discoveryFormPlan.ts @@ -142,6 +142,8 @@ export async function buildDiscoveryFormPlan( steps.push({ occurrenceId: choice.id, choiceId: choice.id, noteId: null, bindings }); } } + // A Template run on its own is its only entry, and the caller's day is its to answer. + const entriesAreSteps = isMacroChoice(choice); for (const entry of entries) { const group = { ...entry.group, id: entry.occurrenceId }; const child = entry.kind === "choice" ? entry.choice : null; @@ -164,7 +166,7 @@ export async function buildDiscoveryFormPlan( if (!collectable) continue; const collected = getUnresolvedRequirements(await collectChoiceRequirements( app, plugin, executor, collectable, - { preloadedUserScripts: executor.preloadedUserScripts }, + { preloadedUserScripts: executor.preloadedUserScripts, step: entriesAreSteps }, ), executor.variables); const existingNoteInputs = new Map(); if (discovery && getExistingNoteAction(discovery.existingNoteAction) !== "open") { @@ -173,7 +175,7 @@ export async function buildDiscoveryFormPlan( fileNameFormat: { enabled: false, format: "" }, folder: { ...discovery.folder, enabled: false }, }; - for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget)) { + for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget, { step: entriesAreSteps })) { existingNoteInputs.set(requirement.id, requirement); } } diff --git a/src/preflight/runOnePagePreflight.filenamePreview.test.ts b/src/preflight/runOnePagePreflight.filenamePreview.test.ts index f086814c0..252737f37 100644 --- a/src/preflight/runOnePagePreflight.filenamePreview.test.ts +++ b/src/preflight/runOnePagePreflight.filenamePreview.test.ts @@ -344,4 +344,18 @@ describe("the one-page preview follows the choice's Which day", () => { const out = await computePreview!({ title: "Standup" }); expect(out[0].text).toBe("Daily/2026-08-21 Standup"); }); + + it("previews the day the caller handed the choice", async () => { + const executor = createExecutor(); + executor.callerDay = new Date(2026, 2, 1); + await runOnePagePreflight( + createApp(), + createPlugin(), + executor, + withDateOrigin({ kind: "relative", offset: -1, unit: "days" }), + ); + + const out = await computePreview!({ title: "Standup" }); + expect(out[0].text).toBe("Daily/2026-03-01 Standup"); + }); }); diff --git a/src/preflight/runOnePagePreflight.ts b/src/preflight/runOnePagePreflight.ts index 6076b6229..2d96759eb 100644 --- a/src/preflight/runOnePagePreflight.ts +++ b/src/preflight/runOnePagePreflight.ts @@ -19,9 +19,8 @@ import { } from "./collectChoiceRequirements"; import type { FormAnswer } from "src/interactive/promptProvider"; import { QA_INTERNAL_DATE_ORIGIN } from "src/constants"; -import { normalizeDateOrigin, type RunClocks } from "src/types/dateOrigin"; -import { dateOriginForPick } from "src/types/dateOriginPresets"; -import { planDateOrigin } from "src/utils/resolveDateOrigin"; +import type { RunClocks } from "src/types/dateOrigin"; +import { dateOriginInput, planDateOrigin } from "src/utils/resolveDateOrigin"; import { buildDiscoveryFormPlan, storeDiscoveryFormAnswers } from "./discoveryFormPlan"; import { hasActivePreparedChoiceInputs } from "./preparedChoiceInputs"; @@ -36,12 +35,10 @@ function previewRunClocks( choiceExecutor: IChoiceExecutor, values: Record, ): RunClocks | undefined { - const setting = choiceExecutor.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); + const { setting, clocks } = dateOriginInput(choice, choiceExecutor); const plan = planDateOrigin({ setting, - clocks: choiceExecutor.clocks, + clocks, variables: new Map([ ...choiceExecutor.variables, ...Object.entries(values), @@ -49,7 +46,7 @@ function previewRunClocks( reservedSeed: setting?.kind === "ask" ? values[QA_INTERNAL_DATE_ORIGIN] : undefined, }); - if (plan.status !== "set") return choiceExecutor.clocks; + if (plan.status !== "set") return clocks; return { now: choiceExecutor.clocks?.now ?? new Date(), date: plan.date }; } diff --git a/src/quickAddApi.test.ts b/src/quickAddApi.test.ts index 5ed8a441c..b3262a9c2 100644 --- a/src/quickAddApi.test.ts +++ b/src/quickAddApi.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { App } from "obsidian"; import { getDateVariableFormat, rememberDateVariableFormat } from "./formatters/helpers/dateTokens"; import type QuickAdd from "./main"; +import type { CallerDay } from "./types/dateOrigin"; // --------------------------------------------------------------------------- // Hoisted mock state / spies. These back the vi.mock factories below. @@ -185,8 +186,7 @@ function makeChoiceExecutor() { consumeAbortSignal: vi.fn( (): InstanceType | null => null, ), - pickDate: false as boolean | undefined, - clocks: undefined as { now: Date; date?: Date } | undefined, + callerDay: undefined as CallerDay | undefined, }; } @@ -1002,15 +1002,14 @@ describe("executeChoice", () => { expect(executor.execute).toHaveBeenCalled(); }); - it("treats date ask as pickDate for that call only", async () => { + it("treats date ask as a pick for that call only", async () => { const executor = makeChoiceExecutor(); - let pickDateDuringRun: boolean | undefined; - executor.execute.mockImplementation(async () => { pickDateDuringRun = executor.pickDate; }); + let callerDayDuringRun: unknown; + executor.execute.mockImplementation(async () => { callerDayDuringRun = executor.callerDay; }); const { api } = getApi(makeApp(), makePlugin(), executor); await api.executeChoice("C", {}, { date: "ask" }); - expect(pickDateDuringRun).toBe(true); - expect(executor.pickDate).toBe(false); - expect(executor.clocks).toBeUndefined(); + expect(callerDayDuringRun).toBe("pick"); + expect(executor.callerDay).toBeUndefined(); }); it("rejects an invalid Date origin and does not execute", async () => { diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index 81ebd0039..5d991445c 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -101,7 +101,7 @@ export class QuickAddApi { return; } - const { clocks, pickDate } = choiceExecutor; + const { callerDay } = choiceExecutor; if (!applyInvocationDate(choiceExecutor, options?.date)) { reportError( new Error(`Could not parse date origin '${String(options?.date)}'`), @@ -126,8 +126,7 @@ export class QuickAddApi { await choiceExecutor.execute(choice, chain); } finally { // options.date is this call's day, not the calling script's. - choiceExecutor.clocks = clocks; - choiceExecutor.pickDate = pickDate; + choiceExecutor.callerDay = callerDay; } const abort = choiceExecutor.consumeAbortSignal?.(); choiceExecutor.variables.clear(); diff --git a/src/types/dateOrigin.ts b/src/types/dateOrigin.ts index 7cbe6bdaa..fb4139ce0 100644 --- a/src/types/dateOrigin.ts +++ b/src/types/dateOrigin.ts @@ -13,6 +13,9 @@ export interface RunClocks { date?: Date; } +/** A caller's answer to a choice's Which day: ask for one, or this day. */ +export type CallerDay = "pick" | Date; + export function isDateOriginUnit(value: unknown): value is DateOriginUnit { return ( typeof value === "string" && diff --git a/src/utils/resolveDateOrigin.test.ts b/src/utils/resolveDateOrigin.test.ts index 416fdb823..3cd62f1fa 100644 --- a/src/utils/resolveDateOrigin.test.ts +++ b/src/utils/resolveDateOrigin.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import realMoment from "moment"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CallerDay } from "../types/dateOrigin"; import { addCalendarOffset, applyInvocationDate, @@ -145,20 +146,16 @@ describe("planDateOrigin", () => { }); describe("applyInvocationDate", () => { - it("treats ask as pickDate and leaves clocks unset", () => { - const executor: { clocks?: { now: Date; date?: Date }; pickDate?: boolean } = - {}; + it("treats ask as a pick", () => { + const executor: { callerDay?: CallerDay } = {}; expect(applyInvocationDate(executor, "ask")).toBe(true); - expect(executor.pickDate).toBe(true); - expect(executor.clocks).toBeUndefined(); + expect(executor.callerDay).toBe("pick"); }); - it("parses a concrete day onto clocks", () => { - const executor: { clocks?: { now: Date; date?: Date }; pickDate?: boolean } = - {}; + it("parses a concrete day", () => { + const executor: { callerDay?: CallerDay } = {}; expect(applyInvocationDate(executor, "2026-08-21")).toBe(true); - expect(executor.pickDate).toBeUndefined(); - expect(executor.clocks?.date).toBeInstanceOf(Date); + expect(executor.callerDay).toBeInstanceOf(Date); }); it("rejects an unreadable value", () => { diff --git a/src/utils/resolveDateOrigin.ts b/src/utils/resolveDateOrigin.ts index 331bb2ccc..9c5086165 100644 --- a/src/utils/resolveDateOrigin.ts +++ b/src/utils/resolveDateOrigin.ts @@ -1,6 +1,13 @@ import { NLDParser } from "../parsers/NLDParser"; -import type { DateOrigin, DateOriginUnit, RunClocks } from "../types/dateOrigin"; -import { isPickDateToken } from "../types/dateOriginPresets"; +import type IChoice from "../types/choices/IChoice"; +import { + normalizeDateOrigin, + type CallerDay, + type DateOrigin, + type DateOriginUnit, + type RunClocks, +} from "../types/dateOrigin"; +import { dateOriginForPick, isPickDateToken } from "../types/dateOriginPresets"; import { parseNaturalLanguageDate } from "./dateParser"; import { resolveExistingVariableKey } from "./valueSyntax"; @@ -42,13 +49,34 @@ export function dateFromStoredValue(stored: unknown): Date | undefined { return undefined; } +/** + * The Which day a choice resolves with, and the clocks it may inherit a day + * from. A caller's day replaces both, even under an enclosing day. + */ +export function dateOriginInput( + choice: IChoice, + executor: { clocks?: RunClocks; callerDay?: CallerDay }, +): { setting?: DateOrigin; clocks?: RunClocks } { + const { callerDay, clocks } = executor; + const own = normalizeDateOrigin(choice.dateOrigin); + if (callerDay === undefined) return { setting: own, clocks }; + const now = clocks?.now ?? new Date(); + if (callerDay === "pick") { + return { setting: dateOriginForPick(own), clocks: { now } }; + } + return { setting: undefined, clocks: { now, date: callerDay } }; +} + export function planDateOrigin(input: { setting?: DateOrigin; clocks?: RunClocks; variables?: Map; reservedSeed?: unknown; }): DateOriginPlan { - if (input.clocks?.date) return { status: "inherit" }; + // A variable names its own day, so an enclosing day doesn't replace it. + if (input.clocks?.date && input.setting?.kind !== "variable") { + return { status: "inherit" }; + } if (input.reservedSeed !== undefined) { const date = dateFromStoredValue(input.reservedSeed); @@ -99,20 +127,17 @@ export function planDateOrigin(input: { } export function applyInvocationDate( - executor: { clocks?: RunClocks; pickDate?: boolean }, + executor: { callerDay?: CallerDay }, raw: unknown, ): boolean { if (raw === undefined || raw === null) return true; if (typeof raw === "string" && !raw.trim()) return true; if (isPickDateToken(raw)) { - executor.pickDate = true; + executor.callerDay = "pick"; return true; } const date = dateFromStoredValue(raw); if (!date) return false; - executor.clocks = { - now: executor.clocks?.now ?? new Date(), - date, - }; + executor.callerDay = date; return true; } diff --git a/tests/e2e/date-origin-scope.test.ts b/tests/e2e/date-origin-scope.test.ts index 916b8dc66..adda013b7 100644 --- a/tests/e2e/date-origin-scope.test.ts +++ b/tests/e2e/date-origin-scope.test.ts @@ -7,9 +7,10 @@ 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"; +import { POLL_OPTS, pressKey, typeInto } from "./uiHelpers"; const getContext = createQuickAddE2EHarness("date-origin-scope"); +const DATE_INPUT = ".qaDatePrompt input.qa-vdate-input"; async function datedTemplate(name: string, folder: string, dateOrigin?: DateOrigin) { const { obsidian, sandbox } = getContext(); @@ -118,3 +119,26 @@ it("keeps an executeChoice options.date out of later macro steps", async () => { 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"); }); + +it("keeps a nested variable day when the macro runs with Pick a day", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const macro = new MacroChoice("Picked macro"); + macro.command = true; + macro.pickDayCommand = true; + macro.macro.commands = [ + await scriptStep("set-test-date.js", `params.variables.testDate = "2026-01-02";`), + new NestedChoiceCommand(await datedTemplate("Variable child", "picked/variable", { kind: "variable", name: "testDate" })), + new NestedChoiceCommand(await datedTemplate("Today child", "picked/today")), + ]; + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices = [macro]; + }); + await plugin.reload({ waitUntilReady: true }); + + await obsidian.exec("command", { id: `quickadd:choice:${macro.id}:pick-day` }); + await typeInto(obsidian, DATE_INPUT, "2026-03-01"); + await pressKey(obsidian, "Enter"); + + await expect.poll(() => sandbox.read("picked/today/2026-03-01.md").catch(() => null), POLL_OPTS).toBe("2026-03-01"); + expect(await sandbox.read("picked/variable/2026-01-02.md")).toBe("2026-01-02"); +});