Skip to content

Commit f2daeed

Browse files
authored
Merge pull request #1122 from chhoumann/1121-feature-request-allow-creating-and-templating-base-base-files
feat: support .base templates and guard capture targets
2 parents e7cbbf2 + 1ca1085 commit f2daeed

18 files changed

+416
-36
lines changed

docs/docs/Choices/CaptureChoice.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ Allows to quickly capture your input and save it from anywhere in Obsidian, with
1616
_Capture To_ is the name of the file you are capturing to.
1717
You can choose to either enable _Capture to active file_, or you can enter a file name in the _File Name_ input field.
1818

19+
QuickAdd treats file names as basename-first by default:
20+
- If you do **not** provide an extension, QuickAdd creates/targets a Markdown file (`.md`).
21+
- If you provide an explicit supported extension (for example `.md` or `.canvas`), QuickAdd keeps that extension.
22+
- Capture to `.base` files is not supported. Use a Template choice for `.base` workflows.
23+
1924
This field also supports the [format syntax](/FormatSyntax.md), which allows you to use dynamic file names.
2025
I have one for my daily journal with the name `bins/daily/{{DATE:gggg-MM-DD - ddd MMM D}}.md`.
2126
This automatically finds the file for the day, and whatever I enter will be captured to it.
@@ -140,6 +145,8 @@ If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will inse
140145

141146
You can use [format syntax](/FormatSyntax.md) here, which allows you to use dynamic values in your capture format.
142147

148+
If you want to insert `.base` content into your current note, keep **Capture to active file** enabled and use a `.base` template token in the capture format. See [Capture: Insert a Related Notes Base into an MOC Note](/Examples/Capture_InsertBaseTemplateIntoActiveFile.md).
149+
143150
If your capture format includes an inline `js quickadd` block and you need to
144151
transform user input, prefer reading input in script code through
145152
`this.quickAddApi.inputPrompt(...)` and/or assigning script variables on

docs/docs/Choices/TemplateChoice.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The template choice type is not meant to be a replacement for [Templater](https:
77
## Mandatory
88
**Template Path**. This is a path to the template you wish to insert. Paths are vault-relative; a leading `/` is ignored.
99

10-
QuickAdd supports both markdown (`.md`) and canvas (`.canvas`) templates. When using a canvas template, the created file will also be a canvas file with the same extension.
10+
QuickAdd supports markdown (`.md`), canvas (`.canvas`), and base (`.base`) templates. The created file uses the same extension as the template.
1111

1212
## Optional
1313
**File Name Format**. You can specify a format for the file name, which is based on the format syntax - which you can see further down this page.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
title: "Capture: Insert a Related Notes Base into an MOC Note"
3+
---
4+
5+
Use this pattern when you want QuickAdd to insert a live "related notes" Base
6+
view into a map-of-content (MOC) note.
7+
8+
## Why this pattern
9+
10+
Capture does not write directly to `.base` files, but it can still pull content
11+
from a `.base` template and insert that content into the active markdown note.
12+
This is useful for MOCs where you want a note-local index of backlinks.
13+
14+
## Setup
15+
16+
1. Create a `.base` template file, for example
17+
`Templates/MOC Related Notes.base`:
18+
19+
```yaml
20+
filters:
21+
and:
22+
- 'file.ext == "md"'
23+
- "file.hasLink(this.file)"
24+
- "file.path != this.file.path"
25+
views:
26+
- type: table
27+
name: Related notes
28+
```
29+
30+
2. Create a Capture choice.
31+
3. Enable **Capture to active file**.
32+
4. Set **Active file write position** to **Top**.
33+
5. In **Capture format**, reference your `.base` template with an explicit file
34+
extension:
35+
36+
Example:
37+
38+
````markdown
39+
## Related Notes
40+
41+
```base
42+
{{TEMPLATE:Templates/MOC Related Notes.base}}
43+
```
44+
45+
Context: {{VALUE}}
46+
````
47+
48+
6. Run the Capture choice while your MOC note is active
49+
(for example `MOCs/Alpha Project.md`).
50+
51+
QuickAdd resolves the `.base` template and inserts it into the active note.
52+
Because the base view is embedded in that note, `this.file` points at the MOC,
53+
so the table shows notes that link to that specific MOC.
54+
55+
![MOC related notes capture demo](../Images/capture_moc_related_notes_demo.png)
43.7 KB
Loading

docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ const sidebars = {
109109
"Examples/Capture_AddJournalEntry",
110110
"Examples/Capture_AddTaskToKanbanBoard",
111111
"Examples/Capture_FetchTasksFromTodoist",
112+
"Examples/Capture_InsertBaseTemplateIntoActiveFile",
112113
],
113114
},
114115
{

src/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ export const LINK_TO_CURRENT_FILE_REGEX = new RegExp(/{{LINKCURRENT}}/i);
9898
export const FILE_NAME_OF_CURRENT_FILE_REGEX = new RegExp(/{{FILENAMECURRENT}}/i);
9999
export const MARKDOWN_FILE_EXTENSION_REGEX = new RegExp(/\.md$/);
100100
export const CANVAS_FILE_EXTENSION_REGEX = new RegExp(/\.canvas$/);
101+
export const BASE_FILE_EXTENSION_REGEX = new RegExp(/\.base$/);
101102
export const JAVASCRIPT_FILE_EXTENSION_REGEX = new RegExp(/\.js$/);
102103
export const MACRO_REGEX = new RegExp(/{{MACRO:([^\n\r}]*)}}/i);
103-
export const TEMPLATE_REGEX = new RegExp(/{{TEMPLATE:([^\n\r}]*.md)}}/i);
104+
export const TEMPLATE_REGEX = new RegExp(
105+
/{{TEMPLATE:([^\n\r}]*\.(?:md|canvas|base))}}/i,
106+
);
104107
export const GLOBAL_VAR_REGEX = new RegExp(/{{GLOBAL_VAR:([^\n\r}]*)}}/i);
105108
export const INLINE_JAVASCRIPT_REGEX = new RegExp(
106109
/`{3,}js quickadd([\s\S]*?)`{3,}/,

src/engine/CaptureChoiceEngine.selection.test.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
44
import type ICaptureChoice from "../types/choices/ICaptureChoice";
55
import type { IChoiceExecutor } from "../IChoiceExecutor";
66
import { isFolder, openFile } from "../utilityObsidian";
7+
import { QA_INTERNAL_CAPTURE_TARGET_FILE_PATH } from "../constants";
8+
import { ChoiceAbortError } from "../errors/ChoiceAbortError";
79

8-
const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({
10+
const { setUseSelectionAsCaptureValueMock, setTitleMock } = vi.hoisted(() => ({
911
setUseSelectionAsCaptureValueMock: vi.fn(),
12+
setTitleMock: vi.fn(),
1013
}));
1114

1215
vi.mock("../formatters/captureChoiceFormatter", () => ({
@@ -15,7 +18,9 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({
1518
setUseSelectionAsCaptureValue(value: boolean) {
1619
setUseSelectionAsCaptureValueMock(value);
1720
}
18-
setTitle() {}
21+
setTitle(value: string) {
22+
setTitleMock(value);
23+
}
1924
setDestinationFile() {}
2025
setDestinationSourcePath() {}
2126
async formatContentOnly(content: string) {
@@ -130,6 +135,7 @@ const createExecutor = (): IChoiceExecutor => ({
130135
describe("CaptureChoiceEngine selection-as-value resolution", () => {
131136
beforeEach(() => {
132137
setUseSelectionAsCaptureValueMock.mockClear();
138+
setTitleMock.mockClear();
133139
vi.mocked(openFile).mockClear();
134140
});
135141

@@ -214,6 +220,7 @@ describe("CaptureChoiceEngine selection-as-value resolution", () => {
214220
describe("CaptureChoiceEngine capture target resolution", () => {
215221
beforeEach(() => {
216222
vi.mocked(isFolder).mockReset();
223+
setTitleMock.mockClear();
217224
});
218225

219226
it("treats folder path without trailing slash as folder when folder exists", () => {
@@ -264,4 +271,82 @@ describe("CaptureChoiceEngine capture target resolution", () => {
264271

265272
expect(result).toEqual({ kind: "file", path: "journals" });
266273
});
274+
275+
it("rejects explicit .base capture target paths", () => {
276+
const app = createApp();
277+
const engine = new CaptureChoiceEngine(
278+
app,
279+
{ settings: { useSelectionAsCaptureValue: false } } as any,
280+
createChoice({ captureTo: "Boards/Kanban.base" }),
281+
createExecutor(),
282+
);
283+
284+
expect(() =>
285+
(engine as any).resolveCaptureTarget("Boards/Kanban.base"),
286+
).toThrow(ChoiceAbortError);
287+
});
288+
289+
it("rejects preselected .base capture target paths", async () => {
290+
const app = createApp();
291+
const executor = createExecutor();
292+
executor.variables.set(
293+
QA_INTERNAL_CAPTURE_TARGET_FILE_PATH,
294+
"Boards/Kanban.base",
295+
);
296+
const engine = new CaptureChoiceEngine(
297+
app,
298+
{ settings: { useSelectionAsCaptureValue: false } } as any,
299+
createChoice(),
300+
executor,
301+
);
302+
303+
await expect(
304+
(engine as any).getFormattedPathToCaptureTo(false),
305+
).rejects.toBeInstanceOf(ChoiceAbortError);
306+
});
307+
308+
it("preserves explicit .canvas capture target paths", async () => {
309+
const app = createApp();
310+
const engine = new CaptureChoiceEngine(
311+
app,
312+
{ settings: { useSelectionAsCaptureValue: false } } as any,
313+
createChoice({ captureTo: "Boards/Map.canvas" }),
314+
createExecutor(),
315+
);
316+
317+
const result = await (engine as any).getFormattedPathToCaptureTo(false);
318+
319+
expect(result).toBe("Boards/Map.canvas");
320+
});
321+
322+
it("uses extensionless title for created .canvas capture files", async () => {
323+
const app = createApp() as any;
324+
app.vault.read = vi.fn(async () => "");
325+
326+
const engine = new CaptureChoiceEngine(
327+
app,
328+
{ settings: { useSelectionAsCaptureValue: false } } as any,
329+
createChoice({
330+
createFileIfItDoesntExist: {
331+
enabled: true,
332+
createWithTemplate: false,
333+
template: "",
334+
},
335+
}),
336+
createExecutor(),
337+
);
338+
339+
(engine as any).createFileWithInput = vi.fn(async (path: string) => ({
340+
path,
341+
basename: path.split("/").pop()?.replace(/\.(base|canvas)$/i, "") ?? "",
342+
extension: path.endsWith(".base") ? "base" : "canvas",
343+
}));
344+
345+
await (engine as any).onCreateFileIfItDoesntExist(
346+
"Boards/Map.canvas",
347+
"capture",
348+
);
349+
350+
expect(setTitleMock).toHaveBeenCalledWith("Map");
351+
});
267352
});

src/engine/CaptureChoiceEngine.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import invariant from "src/utils/invariant";
44
import merge from "three-way-merge";
55
import type { IChoiceExecutor } from "../IChoiceExecutor";
66
import {
7+
BASE_FILE_EXTENSION_REGEX,
8+
CANVAS_FILE_EXTENSION_REGEX,
9+
MARKDOWN_FILE_EXTENSION_REGEX,
710
QA_INTERNAL_CAPTURE_TARGET_FILE_PATH,
811
VALUE_SYNTAX,
912
} from "../constants";
@@ -30,6 +33,7 @@ import {
3033
} from "../utilityObsidian";
3134
import { isCancellationError, reportError } from "../utils/errorUtils";
3235
import { normalizeFileOpening } from "../utils/fileOpeningDefaults";
36+
import { basenameWithoutMdOrCanvas } from "../utils/pathUtils";
3337
import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine";
3438
import { ChoiceAbortError } from "../errors/ChoiceAbortError";
3539
import { MacroAbortError } from "../errors/MacroAbortError";
@@ -247,7 +251,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
247251
typeof preselected === "string" &&
248252
preselected.length > 0
249253
) {
250-
return preselected;
254+
return this.normalizeCaptureFilePath(preselected);
251255
}
252256

253257
if (shouldCaptureToActiveFile) {
@@ -264,17 +268,17 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
264268
);
265269
const resolution = this.resolveCaptureTarget(formattedCaptureTo);
266270

267-
switch (resolution.kind) {
268-
case "vault":
269-
return this.selectFileInFolder("", true);
270-
case "tag":
271-
return this.selectFileWithTag(resolution.tag);
272-
case "folder":
273-
return this.selectFileInFolder(resolution.folder, false);
274-
case "file":
275-
return this.normalizeMarkdownFilePath("", resolution.path);
271+
switch (resolution.kind) {
272+
case "vault":
273+
return this.selectFileInFolder("", true);
274+
case "tag":
275+
return this.selectFileWithTag(resolution.tag);
276+
case "folder":
277+
return this.selectFileInFolder(resolution.folder, false);
278+
case "file":
279+
return this.normalizeCaptureFilePath(resolution.path);
280+
}
276281
}
277-
}
278282

279283
private resolveCaptureTarget(
280284
formattedCaptureTo: string,
@@ -287,7 +291,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
287291
// 1) empty => vault picker
288292
// 2) #tag => tag picker
289293
// 3) trailing "/" => folder picker (explicit)
290-
// 4) ".md" => file
294+
// 4) known file extension => file
291295
// 5) ambiguous => folder if it exists and no same-name file exists; else file
292296
const normalizedCaptureTo = this.stripLeadingSlash(
293297
formattedCaptureTo.trim(),
@@ -304,14 +308,23 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
304308
};
305309
}
306310

311+
if (BASE_FILE_EXTENSION_REGEX.test(normalizedCaptureTo)) {
312+
throw new ChoiceAbortError(
313+
`Capture to '.base' files is not supported (${normalizedCaptureTo}). Use a Template choice instead.`,
314+
);
315+
}
316+
307317
const endsWithSlash = normalizedCaptureTo.endsWith("/");
308318
const folderPath = normalizedCaptureTo.replace(/\/+$/, "");
309319

310320
if (endsWithSlash) {
311321
return { kind: "folder", folder: folderPath };
312322
}
313323

314-
if (normalizedCaptureTo.endsWith(".md")) {
324+
if (
325+
MARKDOWN_FILE_EXTENSION_REGEX.test(normalizedCaptureTo) ||
326+
CANVAS_FILE_EXTENSION_REGEX.test(normalizedCaptureTo)
327+
) {
315328
return { kind: "file", path: normalizedCaptureTo };
316329
}
317330

@@ -462,8 +475,8 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
462475
newFileContent: string;
463476
captureContent: string;
464477
}> {
465-
// Extract filename without extension from the full path
466-
const fileBasename = filePath.split("/").pop()?.replace(/\.md$/, "") || "";
478+
// Extract filename without extension from the full path.
479+
const fileBasename = basenameWithoutMdOrCanvas(filePath);
467480
this.formatter.setTitle(fileBasename);
468481

469482
// Set the destination path so formatters can generate proper relative links
@@ -549,7 +562,24 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
549562
this.choice.name,
550563
);
551564

552-
return this.normalizeMarkdownFilePath("", formattedCaptureTo);
565+
return this.normalizeCaptureFilePath(formattedCaptureTo);
566+
}
567+
568+
private normalizeCaptureFilePath(path: string): string {
569+
const normalizedPath = this.stripLeadingSlash(path);
570+
if (BASE_FILE_EXTENSION_REGEX.test(normalizedPath)) {
571+
throw new ChoiceAbortError(
572+
`Capture to '.base' files is not supported (${normalizedPath}). Use a Template choice instead.`,
573+
);
574+
}
575+
if (
576+
MARKDOWN_FILE_EXTENSION_REGEX.test(normalizedPath) ||
577+
CANVAS_FILE_EXTENSION_REGEX.test(normalizedPath)
578+
) {
579+
return normalizedPath;
580+
}
581+
582+
return this.normalizeMarkdownFilePath("", normalizedPath);
553583
}
554584

555585
private mergeCapturePropertyVars(vars: Map<string, unknown>): void {

0 commit comments

Comments
 (0)