Skip to content

Commit a3214a6

Browse files
committed
feat: support markdown user scripts
1 parent f1e2a9f commit a3214a6

File tree

8 files changed

+221
-26
lines changed

8 files changed

+221
-26
lines changed

docs/docs/Choices/MacroChoice.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,21 @@ The **Paste with format** command preserves rich formatting when pasting content
115115

116116
## User Scripts
117117

118-
User scripts are JavaScript files that extend macro functionality. They have access to:
118+
User scripts are JavaScript code stored in `.js` files (or markdown notes with a
119+
single `js`/`javascript` code block). They have access to:
119120
- The Obsidian app object
120121
- The QuickAdd API
121122
- A variables object for passing data between commands
122123

123124
:::warning Script Placement Requirements
124125

125-
User scripts (.js files) must be placed in your Obsidian vault, but **NOT** in the `.obsidian` directory or in hidden folders (folders starting with a dot).
126+
User scripts (`.js` files, or `.md` notes with a single `js`/`javascript` code
127+
block) must be placed in your Obsidian vault, but **NOT** in the `.obsidian`
128+
directory or in hidden folders (folders starting with a dot).
126129

127130
**Valid locations:**
128131
- `/scripts/myScript.js`
132+
- `/scripts/myScript.md`
129133
- `/_quickadd/scripts/myScript.js`
130134
- `/macros/utilities/helper.js`
131135
- `/my-custom-folder/script.js`
@@ -143,6 +147,17 @@ Scripts placed in the `.obsidian` directory or hidden folders are intentionally
143147

144148
:::
145149

150+
### Markdown-Backed Scripts (.md)
151+
152+
QuickAdd also supports scripts stored inside markdown notes. The note must
153+
contain **exactly one** fenced `js` or `javascript` code block; everything else
154+
is ignored. If you need to target an exported member, include it in the command
155+
name:
156+
157+
```
158+
script.md::exportName
159+
```
160+
146161
### Basic Script Structure
147162

148163
```javascript

docs/docs/UserScripts.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# User Scripts
22

3-
User scripts are JavaScript files that extend QuickAdd's functionality with custom code. They can be used within macros to perform complex operations, integrate with external APIs, and automate sophisticated workflows.
3+
User scripts are JavaScript code stored in `.js` files (or markdown notes with a
4+
single `js`/`javascript` code block). They can be used within macros to perform
5+
complex operations, integrate with external APIs, and automate sophisticated
6+
workflows.
47

58
> 📚 **Obsidian API Reference**: This guide references the [Obsidian API](https://docs.obsidian.md/Home). Familiarize yourself with the [App](https://docs.obsidian.md/Reference/TypeScript+API/App), [Vault](https://docs.obsidian.md/Reference/TypeScript+API/Vault), and [Workspace](https://docs.obsidian.md/Reference/TypeScript+API/Workspace) modules for advanced scripting.
69
@@ -33,6 +36,26 @@ async function start(params, settings) {
3336
}
3437
```
3538

39+
## Markdown-Backed Scripts (.md)
40+
41+
QuickAdd can also run scripts stored inside markdown notes. To do this, the note
42+
must contain **exactly one** fenced code block tagged `js` or `javascript`
43+
(case-insensitive). All other text in the note is ignored.
44+
45+
Example note:
46+
47+
````md
48+
# My Script Note
49+
50+
This note documents what the script does.
51+
52+
```js
53+
module.exports = async (params) => {
54+
// Your code here
55+
};
56+
```
57+
````
58+
3659
## Script Parameters
3760

3861
The script receives two parameters:

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const FILE_NAME_OF_CURRENT_FILE_REGEX = new RegExp(/{{FILENAMECURRENT}}/i
9393
export const MARKDOWN_FILE_EXTENSION_REGEX = new RegExp(/\.md$/);
9494
export const CANVAS_FILE_EXTENSION_REGEX = new RegExp(/\.canvas$/);
9595
export const JAVASCRIPT_FILE_EXTENSION_REGEX = new RegExp(/\.js$/);
96+
export const USER_SCRIPT_FILE_EXTENSION_REGEX = new RegExp(/\.(js|md)$/i);
9697
export const MACRO_REGEX = new RegExp(/{{MACRO:([^\n\r}]*)}}/i);
9798
export const TEMPLATE_REGEX = new RegExp(/{{TEMPLATE:([^\n\r}]*.md)}}/i);
9899
export const GLOBAL_VAR_REGEX = new RegExp(/{{GLOBAL_VAR:([^\n\r}]*)}}/i);

src/gui/MacroGUIs/CommandSequenceEditor.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { WaitCommand } from "../../types/macros/QuickCommands/WaitCommand";
1616
import { NestedChoiceCommand } from "../../types/macros/QuickCommands/NestedChoiceCommand";
1717
import { CaptureChoice } from "../../types/choices/CaptureChoice";
1818
import { TemplateChoice } from "../../types/choices/TemplateChoice";
19-
import { JAVASCRIPT_FILE_EXTENSION_REGEX } from "../../constants";
19+
import { USER_SCRIPT_FILE_EXTENSION_REGEX } from "../../constants";
2020
import { UserScript } from "../../types/macros/UserScript";
2121
import { GenericTextSuggester } from "../suggesters/genericTextSuggester";
2222
import GenericYesNoPrompt from "../GenericYesNoPrompt/GenericYesNoPrompt";
@@ -114,7 +114,7 @@ export class CommandSequenceEditor {
114114
private loadJavascriptFiles(): void {
115115
this.javascriptFiles = this.app.vault
116116
.getFiles()
117-
.filter((file) => JAVASCRIPT_FILE_EXTENSION_REGEX.test(file.path));
117+
.filter((file) => USER_SCRIPT_FILE_EXTENSION_REGEX.test(file.path));
118118
}
119119

120120
private renderCommandList(parent: HTMLElement) {
@@ -335,9 +335,38 @@ export class CommandSequenceEditor {
335335
const value: string = input.getValue();
336336
const scriptBasename = getUserScriptMemberAccess(value).basename;
337337

338-
const file = this.javascriptFiles.find(
338+
if (!scriptBasename) return;
339+
340+
const byPath = this.javascriptFiles.find(
341+
(f) => f.path === scriptBasename
342+
);
343+
if (byPath) {
344+
this.addCommand(new UserScript(value, byPath.path));
345+
input.setValue("");
346+
if (addButton) {
347+
addButton.buttonEl.style.display = "none";
348+
}
349+
return;
350+
}
351+
352+
const byName = this.javascriptFiles.filter(
353+
(f) => f.name === scriptBasename
354+
);
355+
if (byName.length === 1) {
356+
this.addCommand(new UserScript(value, byName[0].path));
357+
input.setValue("");
358+
if (addButton) {
359+
addButton.buttonEl.style.display = "none";
360+
}
361+
return;
362+
}
363+
364+
const byBasename = this.javascriptFiles.filter(
339365
(f) => f.basename === scriptBasename
340366
);
367+
if (byBasename.length !== 1) return;
368+
369+
const file = byBasename[0];
341370
if (!file) return;
342371

343372
this.addCommand(new UserScript(value, file.path));
@@ -359,7 +388,7 @@ export class CommandSequenceEditor {
359388
new GenericTextSuggester(
360389
this.app,
361390
textComponent.inputEl,
362-
this.javascriptFiles.map((f) => f.basename)
391+
this.javascriptFiles.map((f) => f.name)
363392
);
364393

365394
textComponent.inputEl.addEventListener(
@@ -378,7 +407,7 @@ export class CommandSequenceEditor {
378407
.onClick(async () => {
379408
const selected = await this.showScriptPicker();
380409
if (selected) {
381-
this.addCommand(new UserScript(selected.basename, selected.path));
410+
this.addCommand(new UserScript(selected.name, selected.path));
382411
}
383412
})
384413
)
@@ -494,20 +523,20 @@ export class CommandSequenceEditor {
494523
return null;
495524
}
496525

497-
const scriptNames = this.javascriptFiles.map((f) => f.basename);
526+
const scriptNames = this.javascriptFiles.map((f) => f.path);
498527
const selected = await InputSuggester.Suggest(
499528
this.app,
500529
scriptNames,
501530
scriptNames,
502531
{
503-
placeholder: "Select a JavaScript file",
504-
emptyStateText: "No .js files found in your vault",
532+
placeholder: "Select a script file",
533+
emptyStateText: "No .js or .md files found in your vault",
505534
}
506535
);
507536

508537
if (!selected) return null;
509538

510-
return this.javascriptFiles.find((f) => f.basename === selected) ?? null;
539+
return this.javascriptFiles.find((f) => f.path === selected) ?? null;
511540
}
512541

513542
private addCommand(command: ICommand) {

src/gui/MacroGUIs/ConditionalCommandSettingsModal.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import {
1111
getDefaultValueTypeForOperator,
1212
requiresExpectedValue,
1313
} from "../../utils/conditionalHelpers";
14-
import {
15-
JAVASCRIPT_FILE_EXTENSION_REGEX
16-
} from "../../constants";
14+
import { USER_SCRIPT_FILE_EXTENSION_REGEX } from "../../constants";
1715
import InputSuggester from "../InputSuggester/inputSuggester";
1816
import { showNoScriptsFoundNotice } from "./noScriptsFoundNotice";
1917

@@ -80,7 +78,7 @@ export class ConditionalCommandSettingsModal extends Modal {
8078
private loadJavascriptFiles() {
8179
this.javascriptFiles = this.app.vault
8280
.getFiles()
83-
.filter((file) => JAVASCRIPT_FILE_EXTENSION_REGEX.test(file.path));
81+
.filter((file) => USER_SCRIPT_FILE_EXTENSION_REGEX.test(file.path));
8482
}
8583

8684
private reload() {
@@ -244,7 +242,7 @@ export class ConditionalCommandSettingsModal extends Modal {
244242

245243
new Setting(this.contentEl)
246244
.setName("Script path")
247-
.setDesc("Vault-relative path to the JavaScript file.")
245+
.setDesc("Vault-relative path to the script file.")
248246
.addText((text) => {
249247
input = text;
250248
text
@@ -270,8 +268,8 @@ export class ConditionalCommandSettingsModal extends Modal {
270268
scriptNames,
271269
scriptNames,
272270
{
273-
placeholder: "Select a JavaScript file",
274-
emptyStateText: "No .js files found in your vault",
271+
placeholder: "Select a script file",
272+
emptyStateText: "No .js or .md files found in your vault",
275273
}
276274
);
277275

src/gui/MacroGUIs/noScriptsFoundNotice.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import { Notice } from "obsidian";
22

33
/**
4-
* Shows a notice to the user when no JavaScript files are found in their vault.
4+
* Shows a notice to the user when no script files are found in their vault.
55
* Provides helpful guidance on where to place scripts and links to documentation.
66
*/
77
export function showNoScriptsFoundNotice(): void {
88
const notice = new Notice("", 10000);
99
const messageEl = notice.messageEl ?? notice.containerEl ?? notice.noticeEl;
1010
messageEl.empty();
1111
messageEl.createEl("div", {
12-
text: "No JavaScript files found",
12+
text: "No script files found",
1313
cls: "quickadd-notice-title",
1414
});
1515

1616
const content = messageEl.createDiv();
1717
content.createEl("div", {
18-
text: "QuickAdd cannot find any .js files in your vault.",
18+
text: "QuickAdd cannot find any .js or .md files in your vault.",
1919
});
2020
content.createEl("br");
2121
content.createEl("div", { text: "Please make sure your scripts are:" });
@@ -25,7 +25,7 @@ export function showNoScriptsFoundNotice(): void {
2525
content.createEl("div", {
2626
text: "✓ Not in hidden folders (starting with a dot)",
2727
});
28-
content.createEl("div", { text: "✓ Have a .js extension" });
28+
content.createEl("div", { text: "✓ Have a .js or .md extension" });
2929
content.createEl("br");
3030

3131
const link = content.createEl("a", {

src/utilityObsidian.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import { __test } from "./utilityObsidian";
33

4-
const { convertLinkToEmbed, extractMarkdownLinkTarget } = __test;
4+
const {
5+
convertLinkToEmbed,
6+
extractMarkdownLinkTarget,
7+
extractSingleJavascriptCodeBlock,
8+
} = __test;
59

610
describe("convertLinkToEmbed", () => {
711
it("converts wiki links to embeds", () => {
@@ -70,4 +74,61 @@ describe("extractMarkdownLinkTarget", () => {
7074
it("returns null for empty targets", () => {
7175
expect(extractMarkdownLinkTarget("[Label]()")).toBeNull();
7276
});
73-
});
77+
});
78+
79+
describe("extractSingleJavascriptCodeBlock", () => {
80+
it("returns code when exactly one js block is present", () => {
81+
const note = [
82+
"# Script Note",
83+
"",
84+
"```js",
85+
"module.exports = async () => {};",
86+
"```",
87+
].join("\n");
88+
89+
expect(extractSingleJavascriptCodeBlock(note)).toEqual({
90+
code: "module.exports = async () => {};",
91+
});
92+
});
93+
94+
it("ignores non-js fences when one js block is present", () => {
95+
const note = [
96+
"```json",
97+
"{ \"ok\": true }",
98+
"```",
99+
"",
100+
"```javascript",
101+
"module.exports = async () => {};",
102+
"```",
103+
].join("\n");
104+
105+
expect(extractSingleJavascriptCodeBlock(note)?.code).toBe(
106+
"module.exports = async () => {};"
107+
);
108+
});
109+
110+
it("returns null when there are multiple js blocks", () => {
111+
const note = [
112+
"```js",
113+
"const a = 1;",
114+
"```",
115+
"```javascript",
116+
"const b = 2;",
117+
"```",
118+
].join("\n");
119+
120+
expect(extractSingleJavascriptCodeBlock(note)).toBeNull();
121+
});
122+
123+
it("returns null when no js blocks exist", () => {
124+
const note = [
125+
"# Script Note",
126+
"",
127+
"```json",
128+
"{ \"ok\": true }",
129+
"```",
130+
].join("\n");
131+
132+
expect(extractSingleJavascriptCodeBlock(note)).toBeNull();
133+
});
134+
});

0 commit comments

Comments
 (0)