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
3 changes: 2 additions & 1 deletion docs/docs/Advanced/scriptsWithSettings.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ module.exports = {
},
"API Key": {
type: "secret",
id: "api-key",
placeholder: "Paste API key",
description: "Stored securely with Obsidian SecretStorage.",
},
Expand Down Expand Up @@ -76,7 +77,7 @@ module.exports = {

## Setting types
- `text` and `input`: A text field.
- `secret`: A password-style input stored with Obsidian SecretStorage. QuickAdd stores only a reference in `data.json`; package exports omit secret values and local secret references. The older `text` / `input` plus `secret: true` form is still treated as a secret setting.
- `secret`: A password-style input stored with Obsidian SecretStorage. QuickAdd stores only a reference in `data.json`; package exports omit secret values and local secret references. Add an optional `id` to give the stored secret a stable key if the visible setting label changes later. The older `text` / `input` plus `secret: true` form is still treated as a secret setting.
- `textarea`: A multi-line text area.
- `checkbox` and `toggle`: A checkbox.
- `dropdown` and `select`: A dropdown.
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/UserScripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ For API keys, access tokens, and other sensitive values. Secrets are stored in O
options: {
"API Key": {
type: "secret",
id: "api-key", // Optional stable storage id
placeholder: "Paste API key",
description: "Your API key"
}
Expand All @@ -243,6 +244,8 @@ options: {

Secret values are local to the Obsidian app profile. They are not included in QuickAdd package exports and are not synced through the plugin's `data.json`; users must enter them on each device where the script runs.

The optional `id` controls the SecretStorage reference used for the setting. If omitted, QuickAdd uses the option name. Set `id` when you want to rename the visible setting label later without creating a new saved secret.

#### Toggle/Checkbox
For boolean on/off settings.

Expand Down
1 change: 1 addition & 0 deletions src/ai/tools/providerToolMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("OpenAI mapping", () => {
const body = buildChatBody("openai", "gpt-4o", req) as Record<string, unknown>;
const msgs = body.messages as Array<Record<string, unknown>>;
const assistant = msgs[1];
expect(assistant.content).toBe("");
expect((assistant.tool_calls as Array<Record<string, unknown>>)[0]).toMatchObject({
id: "call_1",
type: "function",
Expand Down
2 changes: 1 addition & 1 deletion src/ai/tools/providerToolMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function openaiMessages(messages: NormalizedMessage[]): Body[] {
if (m.role === "system") out.push({ role: "system", content: m.content });
else if (m.role === "user") out.push({ role: "user", content: m.content });
else if (m.role === "assistant") {
const msg: Body = { role: "assistant", content: m.content || null };
const msg: Body = { role: "assistant", content: m.content ?? "" };
if (m.toolCalls && m.toolCalls.length > 0) {
msg.tool_calls = m.toolCalls.map((c) => ({
id: c.id,
Expand Down
34 changes: 33 additions & 1 deletion src/gui/MacroGUIs/UserScriptSettingsModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function createCommand(settings: Record<string, unknown> = {}): IUserScript {
};
}

function createSettings() {
function createSettings(optionOverrides: Record<string, unknown> = {}) {
return {
name: "Script Settings",
options: {
Expand All @@ -40,6 +40,7 @@ function createSettings() {
defaultValue: "must-not-persist",
placeholder: "Paste API key",
description: "API key",
...optionOverrides,
},
},
};
Expand Down Expand Up @@ -94,6 +95,37 @@ describe("UserScriptSettingsModal secret settings", () => {
expect(input.value).toBe("");
});

it("uses script-defined secret option IDs when saving", async () => {
const app = new App();
const command = createCommand();
const modal = new UserScriptSettingsModal(
app,
command,
createSettings({ id: "readwise-api-key" }),
);
await flushPromises();

const input = modal.contentEl.querySelector("input") as HTMLInputElement;
inputValue(input, "secret-value");

const save = modal.contentEl.querySelector(
'button[aria-label="Save API Key"]',
) as HTMLButtonElement;
save.click();
await flushPromises();

expect(
app.secretStorage.getSecret(
"quickadd-user-script-command-1-readwise-api-key",
),
).toBe("secret-value");
expect(command.settings["API Key"]).toEqual(
createUserScriptSecretRef(
"quickadd-user-script-command-1-readwise-api-key",
),
);
});

it("migrates existing plaintext secret settings after opening", async () => {
const app = new App();
const command = createCommand({
Expand Down
3 changes: 2 additions & 1 deletion src/gui/MacroGUIs/UserScriptSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
storeUserScriptSecret,
} from "../../utils/userScriptSecrets";

type Option = { description?: string } & (
type Option = { description?: string; id?: string } & (
| {
type: "text" | "input";
value: string;
Expand Down Expand Up @@ -224,6 +224,7 @@ export class UserScriptSettingsModal extends Modal {
name,
pendingValue,
getSecretRefFromCommandSetting(this.command, name),
this.settings.options?.[name],
);

if (!secretRef) {
Expand Down
123 changes: 123 additions & 0 deletions src/utils/userScriptSecrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,59 @@ describe("userScriptSecrets", () => {
);
});

it("uses script-defined secret option IDs when provided", () => {
const command = createCommand();
const option = { type: "secret", id: "readwise-api-key" };

expect(
buildUserScriptSecretId(command, "Readwise API key", option),
).toBe("quickadd-user-script-command-1-readwise-api-key");
expect(buildUserScriptSecretId(command, "API token", option)).toBe(
"quickadd-user-script-command-1-readwise-api-key",
);
});

it("keeps SecretStorage IDs within Obsidian's 64 character limit", () => {
const command = createCommand();
command.id = "4e700b0f-1e0c-47c2-aafc-417421efcc73";

const secretId = buildUserScriptSecretId(command, "Readwise API key");

expect(secretId.length).toBeLessThanOrEqual(64);
expect(secretId).toMatch(/^[a-z0-9-]+$/);
expect(secretId).toMatch(/^quickadd-user-script-/);
expect(secretId).toBe(buildUserScriptSecretId(command, "Readwise API key"));
});

it("keeps collision fallback SecretStorage IDs within Obsidian's 64 character limit", async () => {
const command = createCommand();
command.id = "4e700b0f-1e0c-47c2-aafc-417421efcc73";
const existingSecretRef = buildUserScriptSecretId(
command,
"Readwise API key",
);
const storedSecrets = new Map([[existingSecretRef, "old-secret"]]);
const setSecret = vi.fn((name: string, value: string) => {
storedSecrets.set(name, value);
});
const app = createApp({
getSecret: vi.fn((name: string) => storedSecrets.get(name) ?? null),
setSecret,
});

const secretRef = await storeUserScriptSecret(
app,
command,
"Readwise API key",
"new-secret",
);

expect(secretRef).not.toBe(existingSecretRef);
expect(secretRef?.length).toBeLessThanOrEqual(64);
expect(secretRef).toMatch(/^[a-z0-9-]+$/);
expect(setSecret).toHaveBeenCalledWith(secretRef, "new-secret");
});

it("stores a secret and creates a marker without exposing the value", async () => {
const setSecret = vi.fn().mockResolvedValue(undefined);
const getSecret = vi.fn().mockResolvedValue(null);
Expand Down Expand Up @@ -321,6 +374,76 @@ describe("userScriptSecrets", () => {
expect(JSON.stringify(command.settings)).not.toContain("legacy-secret");
});

it("uses script-defined secret option IDs when migrating legacy plaintext settings", async () => {
const command = createCommand({
"Readwise API key": "legacy-secret",
});
const setSecret = vi.fn().mockResolvedValue(undefined);
const app = createApp({
getSecret: vi.fn().mockResolvedValue(null),
setSecret,
});

const changed = await migrateUserScriptSecretSettings(app, command, {
options: {
"Readwise API key": {
type: "secret",
id: "readwise-api-key",
},
},
});

expect(changed).toBe(true);
expect(setSecret).toHaveBeenCalledWith(
"quickadd-user-script-command-1-readwise-api-key",
"legacy-secret",
);
expect(command.settings["Readwise API key"]).toEqual(
createUserScriptSecretRef(
"quickadd-user-script-command-1-readwise-api-key",
),
);
});

it("reattaches script-defined secret IDs when option labels change", async () => {
const stableRef = "quickadd-user-script-command-1-readwise-api-key";
const command = createCommand({
"Readwise API key": createUserScriptSecretRef(stableRef),
});
const app = createApp({
getSecret: vi.fn((name: string) =>
name === stableRef ? "stored-secret" : null,
),
setSecret: vi.fn(),
});
const renamedSettings = {
options: {
"API token": {
type: "secret",
id: "readwise-api-key",
},
},
};

const changed = await migrateUserScriptSecretSettings(
app,
command,
renamedSettings,
);
const resolved = await resolveUserScriptSettings(
app,
command,
renamedSettings,
);

expect(changed).toBe(true);
expect(command.settings["Readwise API key"]).toBeUndefined();
expect(command.settings["API token"]).toEqual(
createUserScriptSecretRef(stableRef),
);
expect(resolved["API token"]).toBe("stored-secret");
});

it("does not migrate or clear legacy plaintext when SecretStorage is unavailable", async () => {
const command = createCommand({
"API Key": "legacy-secret",
Expand Down
Loading