Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/src/content/docs/docs/Advanced/scriptsWithSettings.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ How the pieces fit together:
object describing the field. Add a `description` to any field to show help
text beneath it.

### Scripts with several exports {#settings-with-member-access}

A script can export more functions next to `entry` and `settings`. A macro
command named with `::`, such as `my-script::otherExport`, runs that export
instead of `entry`. The export still receives `settings` as its second
argument, and the gear still shows the script's settings. QuickAdd looks for
`settings` on the selected export first, then on each parent up to
`module.exports`, and uses the nearest one it finds - so an export without its
own `settings` uses the script's.
Settings are saved per macro command, so two commands for the same script keep
separate values.

## The field types {#setting-types}

Set each field's `type` to one of these:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The Todoist Script has three exports, `SelectFromAllTasks`, `GetAllTasksFromProj
- `GetAllTasksFromProject` will prompt you for a project and get all tasks from that project, and
- `GetAllTasksFromSection` will prompt you for a section and get all tasks from that section.

If you run the macro as-is, the script asks you which one to run. To always run one of them, reference it in the Capture format with `::`, for example `{{MACRO:Todoist::GetAllTasksFromProject}}`.
If you run the macro as-is, the script asks you which one to run. To always run one of them, reference it in the Capture format with `::`, for example `{{MACRO:Todoist::GetAllTasksFromProject}}`, or name it in the macro's script command, for example `todoistTaskSync::GetAllTasksFromProject`.

:::caution[Imported tasks are completed in Todoist]
By default, the script completes every task it imports, so the same task isn't imported twice. Recurring tasks move to their next occurrence instead. To keep the tasks open in Todoist, untick **Complete imported tasks in Todoist** in the script's settings.
Expand All @@ -29,7 +29,7 @@ By default, the script completes every task it imports, so the same task isn't i
## Setup

1. Save the <a href="/scripts/TodoistScript.js" download>Todoist Script</a> to your vault, for example as `scripts/todoistTaskSync.js`.
2. In **Settings → QuickAdd**, add a [Macro choice](/docs/Choices/MacroChoice/) named `Todoist`, and add the script to its command list. Add the script by its file name only - don't add `::GetAllTasksFromProject` there, because the script's settings are only available when the command points at the whole script.
2. In **Settings → QuickAdd**, add a [Macro choice](/docs/Choices/MacroChoice/) named `Todoist`, and add the script to its command list. Add it by its file name (`todoistTaskSync`) to pick an export when the macro runs, or append an export (`todoistTaskSync::GetAllTasksFromProject`) to always run that one. Either way, the script's settings apply.
3. Click the gear (⚙️) next to the script command, and paste your Todoist API token into **Todoist API token**. QuickAdd keeps it in Obsidian's secret storage, not in `data.json`. Leave **Complete imported tasks in Todoist** ticked, or untick it to leave tasks open in Todoist.

![Todoist script settings](../Images/Todoist-ScriptSettings.png)
Expand All @@ -52,7 +52,7 @@ If there isn't a date set for the task, they'll simply be entered as `- [ ] Buy

## Troubleshooting

- **"Add your Todoist API token in the Todoist script's settings"**: open the macro, click the gear next to the script command, and paste the token. If the script command's name ends in `::SomeExport`, remove that part so the gear shows the settings.
- **"Add your Todoist API token in the Todoist script's settings"**: open the macro, click the gear next to the script command, and paste the token. Settings belong to each script command, so if the macro has several commands for this script, set the token on the one that runs.
- **"Todoist rejected the API token (HTTP 401)"**: the token is wrong or was reset. Copy it again from Todoist's Developer settings.
- **"Secret setting ... is unavailable. Re-enter it on this device."**: secrets are stored per device. Paste the token again on this device.

Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/docs/UserScripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ The `app` object provides access to the entire Obsidian API, including:
### The `settings` object {#settings-object}

`settings` holds the user-configured values for your script's options. It's only
passed when you use the object structure with a `settings` block.
populated when your script defines a `settings` block, as in the object
structure above. An export picked with `::` (for example `my-script::start`)
gets the values too: QuickAdd uses the nearest `settings` block on the export
or its parents, up to `module.exports`.

## Let users configure your script {#configurable-options}

Expand Down
8 changes: 5 additions & 3 deletions src/IChoiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { PromptProvider } from "./interactive/promptProvider";
import type IMacroChoice from "./types/choices/IMacroChoice";
import type { ICommand } from "./types/macros/ICommand";
import type { PreparedChoiceInputState } from "./preflight/preparedChoiceInputs";
import type { LoadedUserScript } from "./utils/userScript";

export interface IChoiceExecutor {
execute(choice: IChoice): Promise<void>;
Expand Down Expand Up @@ -110,13 +111,14 @@ export interface IChoiceExecutor {
* User-script modules already loaded (and therefore already EXECUTED - loading
* a CommonJS user script runs its top-level code) by a requirement-collection
* pass, keyed by `getUserScriptPreloadKey` (`command.path ?? command.id` plus
* any `::` member-drill suffix - stored values are DRILLED exports, so keying
* by path alone would collide different members of one file). MacroChoiceEngine
* any `::` member-drill suffix - stored values hold the DRILLED export plus the
* module's settings definition, so keying by path alone would collide
* different members of one file). MacroChoiceEngine
* consumes an entry (delete-on-use) instead of re-loading the script, so
* introspecting `quickadd.inputs` in the one-page preflight / non-interactive
* CLI does not make a script's top-level side effects run twice per trigger.
* Optional so existing stubs are unaffected; absent means "no preloaded
* modules".
*/
preloadedUserScripts?: Map<string, unknown>;
preloadedUserScripts?: Map<string, LoadedUserScript>;
}
7 changes: 5 additions & 2 deletions src/choiceExecutor.preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ describe("ChoiceExecutor preloadedUserScripts lifecycle", () => {
// ran (cancelled modal / aborted macro): the entry must not survive to
// the NEXT trigger on a long-lived executor, where it would hand the
// engine a module loaded before the user's latest edits.
executor.preloadedUserScripts.set("stale.js", { entry: () => {} });
executor.preloadedUserScripts.set("stale.js", {
script: { entry: () => {} },
settings: undefined,
});

await executor.execute({
id: "unknown",
Expand All @@ -57,7 +60,7 @@ describe("ChoiceExecutor preloadedUserScripts lifecycle", () => {
{ workspace: { getActiveFile: () => null } } as never,
{} as never,
);
executor.preloadedUserScripts.set("outer.js", {});
executor.preloadedUserScripts.set("outer.js", { script: {}, settings: undefined });

// Depth 2 -> 1: a nested execute() ending must NOT wipe the outer
// run's preloaded modules.
Expand Down
5 changes: 3 additions & 2 deletions src/choiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ import VDateInputPrompt from "./gui/VDateInputPrompt/VDateInputPrompt";
import { planDateOrigin, dateFromStoredValue } from "./utils/resolveDateOrigin";
import { log } from "./logger/logManager";
import type { ICommand } from "./types/macros/ICommand";
import type { LoadedUserScript } from "./utils/userScript";
import { withPreparedChoiceInputs, clearPreparedChoiceInputs, createPreparedChoiceInputState, getPreparedTemplateNoteSelection } from "./preflight/preparedChoiceInputs";
import { isTemplateChoice } from "./preflight/macroCommandRole";
import { shouldRunTemplateNoteDiscovery } from "./utils/templateNoteDiscoveryEligibility";

export class ChoiceExecutor implements IChoiceExecutor {
public variables: Map<string, unknown> = new Map<string, unknown>();
public variables: Map<string, unknown> = new Map<string, LoadedUserScript>();
public readonly preparedInputs = createPreparedChoiceInputState();
// Default to interactive so every GUI entry point (command palette, ribbon,
// suggester) keeps its current prompt behaviour. Non-interactive callers (CLI
Expand All @@ -55,7 +56,7 @@ export class ChoiceExecutor implements IChoiceExecutor {
// consumed once by MacroChoiceEngine so a script's top-level code runs a
// single time per trigger instead of once for introspection plus once for
// execution (see IChoiceExecutor.preloadedUserScripts).
public readonly preloadedUserScripts = new Map<string, unknown>();
public readonly preloadedUserScripts = new Map<string, LoadedUserScript>();
public focusedProperty: FrontmatterPropertyTarget | null = null;
public triggerContext: QuickAddTriggerContext | null = null;
public clocks?: RunClocks;
Expand Down
1 change: 1 addition & 0 deletions src/engine/MacroChoiceEngine.aiPromptContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock("../formatters/completeFormatter", () => ({
}));
vi.mock("../utilityObsidian", () => ({
getUserScript: vi.fn(),
loadUserScript: vi.fn(),
openFile: vi.fn(),
}));
vi.mock("../quickAddInstance", () => ({
Expand Down
69 changes: 42 additions & 27 deletions src/engine/MacroChoiceEngine.entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INested
import type IChoice from "../types/choices/IChoice";
import { MacroAbortError } from "../errors/MacroAbortError";
import { QuickAddApi } from "../quickAddApi";
import type * as UserScriptModule from "../utils/userScript";
import type { LoadedUserScript } from "../utils/userScript";

const { mockGetUserScript, mockInitializeUserScriptSettings, mockSuggest, mockGetApi, mockInputPrompt } =
const { mockLoadModuleExports, mockInitializeUserScriptSettings, mockSuggest, mockGetApi, mockInputPrompt } =
vi.hoisted(() => ({
mockGetUserScript: vi.fn(),
mockLoadModuleExports: vi.fn(),
mockInitializeUserScriptSettings: vi.fn(),
mockSuggest: vi.fn(),
mockGetApi: vi.fn(() => ({})),
Expand All @@ -27,10 +29,23 @@ vi.mock("../utilityObsidian", async () => {
const actual = await vi.importActual<Record<string, unknown>>(
"../utilityObsidian",
);
const { getUserScriptMemberAccess, selectUserScriptMember } =
await vi.importActual<typeof UserScriptModule>(
"../utils/userScript",
);

return {
...actual,
getUserScript: mockGetUserScript,
// Fake only reading + evaluating the file (mockLoadModuleExports returns
// `module.exports`); the `::` drill and settings lookup run for real.
loadUserScript: async (command: IUserScript) => {
const moduleExports: unknown = await mockLoadModuleExports(command);
if (!moduleExports) return undefined;
return selectUserScriptMember(
moduleExports,
getUserScriptMemberAccess(command.name).memberAccess ?? [],
);
},
};
});

Expand Down Expand Up @@ -133,7 +148,7 @@ describe("MacroChoiceEngine user script entry handling", () => {

beforeEach(() => {
vi.clearAllMocks();
mockGetUserScript.mockReset();
mockLoadModuleExports.mockReset();
mockInitializeUserScriptSettings.mockReset();
mockSuggest.mockReset();
mockGetApi.mockReset();
Expand Down Expand Up @@ -174,7 +189,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
{ name: "clears previous output when the script returns undefined", callable: true, expected: undefined },
])("$name", async ({ callable, expected }) => {
const script = vi.fn().mockResolvedValue(undefined);
mockGetUserScript.mockResolvedValue(callable ? script : undefined);
mockLoadModuleExports.mockResolvedValue(callable ? script : undefined);
const engine = new MacroChoiceEngine(app, plugin, macroChoice, choiceExecutor, variables);
engine.setOutput("previous");
await engine["executeUserScript"](userScriptCommand);
Expand All @@ -185,7 +200,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
it("runs the entry export without prompting when no settings are defined", async () => {
const entryFn = vi.fn().mockResolvedValue("entry-result");

mockGetUserScript.mockResolvedValue({
mockLoadModuleExports.mockResolvedValue({
entry: entryFn,
});

Expand Down Expand Up @@ -214,8 +229,8 @@ describe("MacroChoiceEngine user script entry handling", () => {
// ONCE so a later run of the same command loads fresh.
it("consumes a preloaded user-script module instead of re-loading it", async () => {
const entryFn = vi.fn().mockResolvedValue("entry-result");
const preloaded = new Map<string, unknown>([
["script.js", { entry: entryFn }],
const preloaded = new Map<string, LoadedUserScript>([
["script.js", { script: { entry: entryFn }, settings: undefined }],
]);

const engine = new MacroChoiceEngine(
Expand All @@ -229,27 +244,27 @@ describe("MacroChoiceEngine user script entry handling", () => {

await engine["executeUserScript"](userScriptCommand);

expect(mockGetUserScript).not.toHaveBeenCalled();
expect(mockLoadModuleExports).not.toHaveBeenCalled();
expect(entryFn).toHaveBeenCalledTimes(1);
// Delete-on-use: the preloaded execution is spent.
expect(preloaded.has("script.js")).toBe(false);

// A second execution of the same command loads (and thus runs) fresh.
mockGetUserScript.mockResolvedValue({ entry: entryFn });
mockLoadModuleExports.mockResolvedValue({ entry: entryFn });
await engine["executeUserScript"](userScriptCommand);
expect(mockGetUserScript).toHaveBeenCalledTimes(1);
expect(mockLoadModuleExports).toHaveBeenCalledTimes(1);
});

// Preloaded values are member-DRILLED exports, so a command drilling a
// Preloaded values hold member-DRILLED exports, so a command drilling a
// different `::` member of the same file must NOT consume another
// member's entry (path-only keying executed the wrong function).
it("does not consume a preloaded entry cached for a different :: member", async () => {
const fooEntry = vi.fn().mockResolvedValue("foo-result");
const barEntry = vi.fn().mockResolvedValue("bar-result");
const preloaded = new Map<string, unknown>([
["script.js::foo", { entry: fooEntry }],
const preloaded = new Map<string, LoadedUserScript>([
["script.js::foo", { script: { entry: fooEntry }, settings: undefined }],
]);
mockGetUserScript.mockResolvedValue({ entry: barEntry });
mockLoadModuleExports.mockResolvedValue({ bar: { entry: barEntry } });

const engine = new MacroChoiceEngine(
app,
Expand All @@ -264,15 +279,15 @@ describe("MacroChoiceEngine user script entry handling", () => {
await engine["executeUserScript"](barCommand);

// bar must load fresh (and run barEntry), leaving foo's entry intact.
expect(mockGetUserScript).toHaveBeenCalledTimes(1);
expect(mockLoadModuleExports).toHaveBeenCalledTimes(1);
expect(barEntry).toHaveBeenCalledTimes(1);
expect(fooEntry).not.toHaveBeenCalled();
expect(preloaded.has("script.js::foo")).toBe(true);

const fooCommand = { ...userScriptCommand, name: "Script::foo" };
await engine["executeUserScript"](fooCommand);
expect(fooEntry).toHaveBeenCalledTimes(1);
expect(mockGetUserScript).toHaveBeenCalledTimes(1);
expect(mockLoadModuleExports).toHaveBeenCalledTimes(1);
expect(preloaded.has("script.js::foo")).toBe(false);
});

Expand All @@ -285,7 +300,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
},
};

mockGetUserScript.mockResolvedValue({
mockLoadModuleExports.mockResolvedValue({
entry: entryFn,
settings,
});
Expand Down Expand Up @@ -327,7 +342,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
"API Key": "legacy-secret",
};

mockGetUserScript.mockResolvedValue({
mockLoadModuleExports.mockResolvedValue({
entry: entryFn,
settings: {
options: {
Expand Down Expand Up @@ -364,7 +379,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
it("ignores malformed primitive settings exports at the user-script boundary", async () => {
const entryFn = vi.fn().mockResolvedValue("entry-result");

mockGetUserScript.mockResolvedValue({
mockLoadModuleExports.mockResolvedValue({
entry: entryFn,
settings: "not-settings",
});
Expand All @@ -389,7 +404,7 @@ describe("MacroChoiceEngine user script entry handling", () => {
it("prompts the user when no entry export is defined", async () => {
const optionFn = vi.fn().mockResolvedValue("option-result");

mockGetUserScript.mockResolvedValue({
mockLoadModuleExports.mockResolvedValue({
option1: optionFn,
});
mockSuggest.mockResolvedValueOnce("option1");
Expand Down Expand Up @@ -422,7 +437,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {

beforeEach(() => {
vi.clearAllMocks();
mockGetUserScript.mockReset();
mockLoadModuleExports.mockReset();
mockInitializeUserScriptSettings.mockReset();
mockSuggest.mockReset();

Expand Down Expand Up @@ -464,7 +479,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {
} as IMacro,
};

mockGetUserScript.mockImplementation((command: IUserScript) => {
mockLoadModuleExports.mockImplementation((command: IUserScript) => {
const nextValueByPath: Record<string, number> = {
"script-1.js": 1,
"script-2.js": 2,
Expand Down Expand Up @@ -548,7 +563,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {
} as IMacro,
};

mockGetUserScript.mockImplementationOnce(() => {
mockLoadModuleExports.mockImplementationOnce(() => {
return Promise.resolve(async (params: { variables: Record<string, unknown> }) => {
params.variables = { foo: "bar" };
});
Expand Down Expand Up @@ -592,7 +607,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {
} as IMacro,
};

mockGetUserScript.mockImplementationOnce(() => {
mockLoadModuleExports.mockImplementationOnce(() => {
return Promise.resolve(async (params: { variables: Record<string, unknown> }) => {
params.variables = params.variables;
params.variables.added = 2;
Expand Down Expand Up @@ -637,7 +652,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {
} as IMacro,
};

mockGetUserScript.mockImplementationOnce(() => {
mockLoadModuleExports.mockImplementationOnce(() => {
return Promise.resolve(async (params: { variables: any }) => {
params.variables = 123;
params.variables.added = "ok";
Expand Down Expand Up @@ -682,7 +697,7 @@ describe("MacroChoiceEngine user script variable propagation", () => {
} as IMacro,
};

mockGetUserScript.mockImplementationOnce(() => {
mockLoadModuleExports.mockImplementationOnce(() => {
return Promise.resolve(async (params: { variables: any }) => {
params.variables = new Map<any, any>([
[1, "nope"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ vi.mock("../formatters/completeFormatter", () => ({
}));
vi.mock("../utilityObsidian", () => ({
getUserScript: vi.fn(),
loadUserScript: vi.fn(),
openFile: openFileMock,
}));
vi.mock("../quickAddInstance", () => ({
Expand Down
5 changes: 3 additions & 2 deletions src/engine/MacroChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { reportError } from "../utils/errorUtils";
import { CommandType } from "../types/macros/CommandType";
import { QuickAddApi } from "../quickAddApi";
import type { ICommand } from "../types/macros/ICommand";
import type { LoadedUserScript } from "../utils/userScript";
import { executeUserScript, type ScriptParameters } from "./userScriptExecution";
import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine";
import type { IMacro } from "../types/macros/IMacro";
Expand Down Expand Up @@ -111,7 +112,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
protected choiceExecutor: IChoiceExecutor;
protected readonly plugin: QuickAdd;
private conditionalScriptCache = new Map<string, ConditionalScriptRunner>();
private readonly preloadedUserScripts: Map<string, unknown>;
private readonly preloadedUserScripts: Map<string, LoadedUserScript>;
private readonly promptLabel?: string;
private buildParams(
app: App,
Expand Down Expand Up @@ -185,7 +186,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine {
choice: IMacroChoice,
choiceExecutor: IChoiceExecutor,
variables: Map<string, unknown>,
preloadedUserScripts?: Map<string, unknown>,
preloadedUserScripts?: Map<string, LoadedUserScript>,
promptLabel?: string,
private readonly originLeaf: WorkspaceLeaf | null = null,
) {
Expand Down
Loading
Loading