Skip to content
4 changes: 3 additions & 1 deletion docs/src/content/docs/docs/Choices/MacroChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
10 changes: 6 additions & 4 deletions src/IChoiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
126 changes: 125 additions & 1 deletion src/choiceExecutor.dateScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({}));
Expand All @@ -21,6 +23,16 @@ vi.mock("./utils/fileOpening", async (importOriginal) => ({
...await importOriginal<Record<string, unknown>>(),
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<Record<string, unknown>>(),
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<void>>();
vi.mock("./engine/TemplateChoiceEngine", () => ({
TemplateChoiceEngine: class {
Expand All @@ -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; });
Expand All @@ -48,7 +61,10 @@ describe("ChoiceExecutor date scope", () => {
return choice;
}

beforeEach(() => { runTemplate.mockReset(); });
beforeEach(() => {
runTemplate.mockReset();
datePrompt.mockClear();
});

function recordDays() {
const days: Record<string, string> = {};
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 21 additions & 15 deletions src/choiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -321,6 +320,8 @@ export class ChoiceExecutor implements IChoiceExecutor {
}

private async applyDateOrigin(choice: IChoice): Promise<void> {
// 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(
Expand All @@ -337,21 +338,26 @@ export class ChoiceExecutor implements IChoiceExecutor {
}

async resolveDateOrigin(choice: IChoice): Promise<void> {
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);
Expand Down
14 changes: 7 additions & 7 deletions src/gui/suggesters/choiceSuggester.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);

Expand Down
6 changes: 3 additions & 3 deletions src/gui/suggesters/choiceSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,9 @@ export default class ChoiceSuggester extends FuzzySuggestModal<IChoice> {
// 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(
Expand All @@ -563,7 +563,7 @@ export default class ChoiceSuggester extends FuzzySuggestModal<IChoice> {
: 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}"`);
}
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ export default class QuickAdd extends Plugin {
private async runRegisteredChoice(
choiceId: string,
fallbackName: string,
pickDate = false,
pickDay = false,
): Promise<void> {
// 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
Expand All @@ -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.
Expand Down
Loading
Loading