Skip to content
7 changes: 7 additions & 0 deletions docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Allows to quickly capture your input and save it from anywhere in Obsidian, with
_Capture To_ is the name of the file you are capturing to.
You can choose to either enable _Capture to active file_, or you can enter a file name in the _File Name_ input field.

QuickAdd treats file names as basename-first by default:
- If you do **not** provide an extension, QuickAdd creates/targets a Markdown file (`.md`).
- If you provide an explicit supported extension (for example `.md` or `.canvas`), QuickAdd keeps that extension.
- Capture to `.base` files is not supported. Use a Template choice for `.base` workflows.

This field also supports the [format syntax](/FormatSyntax.md), which allows you to use dynamic file names.
I have one for my daily journal with the name `bins/daily/{{DATE:gggg-MM-DD - ddd MMM D}}.md`.
This automatically finds the file for the day, and whatever I enter will be captured to it.
Expand Down Expand Up @@ -140,6 +145,8 @@ If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will inse

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

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 Base Template into the Active File](/Examples/Capture_InsertBaseTemplateIntoActiveFile.md).

If your capture format includes an inline `js quickadd` block and you need to
transform user input, prefer reading input in script code through
`this.quickAddApi.inputPrompt(...)` and/or assigning script variables on
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/Choices/TemplateChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The template choice type is not meant to be a replacement for [Templater](https:
## Mandatory
**Template Path**. This is a path to the template you wish to insert. Paths are vault-relative; a leading `/` is ignored.

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.
QuickAdd supports markdown (`.md`), canvas (`.canvas`), and base (`.base`) templates. The created file uses the same extension as the template.

## Optional
**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.
Expand Down
34 changes: 34 additions & 0 deletions docs/docs/Examples/Capture_InsertBaseTemplateIntoActiveFile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
title: "Capture: Insert a Base Template into the Active File"
---

Use this pattern when you want QuickAdd to insert `.base` syntax into your
current note.

## Why this pattern

Capture does not write directly to `.base` files, but it can still pull content
from a `.base` template and insert that content into the active markdown note.

## Setup

1. Create a Capture choice.
2. Enable **Capture to active file**.
3. In **Capture format**, reference your `.base` template with an explicit file
extension.

Example:

````markdown
## New Board Snippet

```base
{{TEMPLATE:Templates/Kanban Board.base}}
```

Note: {{VALUE}}
````

4. Run the Capture choice while the destination note is active.

QuickAdd will resolve the `.base` template and insert it into the active note.
5 changes: 4 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,12 @@ export const LINK_TO_CURRENT_FILE_REGEX = new RegExp(/{{LINKCURRENT}}/i);
export const FILE_NAME_OF_CURRENT_FILE_REGEX = new RegExp(/{{FILENAMECURRENT}}/i);
export const MARKDOWN_FILE_EXTENSION_REGEX = new RegExp(/\.md$/);
export const CANVAS_FILE_EXTENSION_REGEX = new RegExp(/\.canvas$/);
export const BASE_FILE_EXTENSION_REGEX = new RegExp(/\.base$/);
export const JAVASCRIPT_FILE_EXTENSION_REGEX = new RegExp(/\.js$/);
export const MACRO_REGEX = new RegExp(/{{MACRO:([^\n\r}]*)}}/i);
export const TEMPLATE_REGEX = new RegExp(/{{TEMPLATE:([^\n\r}]*.md)}}/i);
export const TEMPLATE_REGEX = new RegExp(
/{{TEMPLATE:([^\n\r}]*\.(?:md|canvas|base))}}/i,
);
export const GLOBAL_VAR_REGEX = new RegExp(/{{GLOBAL_VAR:([^\n\r}]*)}}/i);
export const INLINE_JAVASCRIPT_REGEX = new RegExp(
/`{3,}js quickadd([\s\S]*?)`{3,}/,
Expand Down
89 changes: 87 additions & 2 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { isFolder, openFile } from "../utilityObsidian";
import { QA_INTERNAL_CAPTURE_TARGET_FILE_PATH } from "../constants";
import { ChoiceAbortError } from "../errors/ChoiceAbortError";

const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({
const { setUseSelectionAsCaptureValueMock, setTitleMock } = vi.hoisted(() => ({
setUseSelectionAsCaptureValueMock: vi.fn(),
setTitleMock: vi.fn(),
}));

vi.mock("../formatters/captureChoiceFormatter", () => ({
Expand All @@ -15,7 +18,9 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({
setUseSelectionAsCaptureValue(value: boolean) {
setUseSelectionAsCaptureValueMock(value);
}
setTitle() {}
setTitle(value: string) {
setTitleMock(value);
}
setDestinationFile() {}
setDestinationSourcePath() {}
async formatContentOnly(content: string) {
Expand Down Expand Up @@ -130,6 +135,7 @@ const createExecutor = (): IChoiceExecutor => ({
describe("CaptureChoiceEngine selection-as-value resolution", () => {
beforeEach(() => {
setUseSelectionAsCaptureValueMock.mockClear();
setTitleMock.mockClear();
vi.mocked(openFile).mockClear();
});

Expand Down Expand Up @@ -214,6 +220,7 @@ describe("CaptureChoiceEngine selection-as-value resolution", () => {
describe("CaptureChoiceEngine capture target resolution", () => {
beforeEach(() => {
vi.mocked(isFolder).mockReset();
setTitleMock.mockClear();
});

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

expect(result).toEqual({ kind: "file", path: "journals" });
});

it("rejects explicit .base capture target paths", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "Boards/Kanban.base" }),
createExecutor(),
);

expect(() =>
(engine as any).resolveCaptureTarget("Boards/Kanban.base"),
).toThrow(ChoiceAbortError);
});

it("rejects preselected .base capture target paths", async () => {
const app = createApp();
const executor = createExecutor();
executor.variables.set(
QA_INTERNAL_CAPTURE_TARGET_FILE_PATH,
"Boards/Kanban.base",
);
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice(),
executor,
);

await expect(
(engine as any).getFormattedPathToCaptureTo(false),
).rejects.toBeInstanceOf(ChoiceAbortError);
});

it("preserves explicit .canvas capture target paths", async () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "Boards/Map.canvas" }),
createExecutor(),
);

const result = await (engine as any).getFormattedPathToCaptureTo(false);

expect(result).toBe("Boards/Map.canvas");
});

it("uses extensionless title for created .canvas capture files", async () => {
const app = createApp() as any;
app.vault.read = vi.fn(async () => "");

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({
createFileIfItDoesntExist: {
enabled: true,
createWithTemplate: false,
template: "",
},
}),
createExecutor(),
);

(engine as any).createFileWithInput = vi.fn(async (path: string) => ({
path,
basename: path.split("/").pop()?.replace(/\.(base|canvas)$/i, "") ?? "",
extension: path.endsWith(".base") ? "base" : "canvas",
}));

await (engine as any).onCreateFileIfItDoesntExist(
"Boards/Map.canvas",
"capture",
);

expect(setTitleMock).toHaveBeenCalledWith("Map");
});
});
62 changes: 46 additions & 16 deletions src/engine/CaptureChoiceEngine.ts
Comment thread
chhoumann marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import invariant from "src/utils/invariant";
import merge from "three-way-merge";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import {
BASE_FILE_EXTENSION_REGEX,
CANVAS_FILE_EXTENSION_REGEX,
MARKDOWN_FILE_EXTENSION_REGEX,
QA_INTERNAL_CAPTURE_TARGET_FILE_PATH,
VALUE_SYNTAX,
} from "../constants";
Expand All @@ -30,6 +33,7 @@ import {
} from "../utilityObsidian";
import { isCancellationError, reportError } from "../utils/errorUtils";
import { normalizeFileOpening } from "../utils/fileOpeningDefaults";
import { basenameWithoutMdOrCanvas } from "../utils/pathUtils";
import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine";
import { ChoiceAbortError } from "../errors/ChoiceAbortError";
import { MacroAbortError } from "../errors/MacroAbortError";
Expand Down Expand Up @@ -247,7 +251,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
typeof preselected === "string" &&
preselected.length > 0
) {
return preselected;
return this.normalizeCaptureFilePath(preselected);
}

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

switch (resolution.kind) {
case "vault":
return this.selectFileInFolder("", true);
case "tag":
return this.selectFileWithTag(resolution.tag);
case "folder":
return this.selectFileInFolder(resolution.folder, false);
case "file":
return this.normalizeMarkdownFilePath("", resolution.path);
switch (resolution.kind) {
case "vault":
return this.selectFileInFolder("", true);
case "tag":
return this.selectFileWithTag(resolution.tag);
case "folder":
return this.selectFileInFolder(resolution.folder, false);
case "file":
return this.normalizeCaptureFilePath(resolution.path);
}
}
}

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

if (BASE_FILE_EXTENSION_REGEX.test(normalizedCaptureTo)) {
throw new ChoiceAbortError(
`Capture to '.base' files is not supported (${normalizedCaptureTo}). Use a Template choice instead.`,
);
}

const endsWithSlash = normalizedCaptureTo.endsWith("/");
const folderPath = normalizedCaptureTo.replace(/\/+$/, "");

if (endsWithSlash) {
return { kind: "folder", folder: folderPath };
}

if (normalizedCaptureTo.endsWith(".md")) {
if (
MARKDOWN_FILE_EXTENSION_REGEX.test(normalizedCaptureTo) ||
CANVAS_FILE_EXTENSION_REGEX.test(normalizedCaptureTo)
) {
Comment thread
chhoumann marked this conversation as resolved.
return { kind: "file", path: normalizedCaptureTo };
}

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

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

return this.normalizeMarkdownFilePath("", formattedCaptureTo);
return this.normalizeCaptureFilePath(formattedCaptureTo);
}

private normalizeCaptureFilePath(path: string): string {
const normalizedPath = this.stripLeadingSlash(path);
if (BASE_FILE_EXTENSION_REGEX.test(normalizedPath)) {
throw new ChoiceAbortError(
`Capture to '.base' files is not supported (${normalizedPath}). Use a Template choice instead.`,
);
}
if (
MARKDOWN_FILE_EXTENSION_REGEX.test(normalizedPath) ||
CANVAS_FILE_EXTENSION_REGEX.test(normalizedPath)
) {
Comment thread
chhoumann marked this conversation as resolved.
return normalizedPath;
}

return this.normalizeMarkdownFilePath("", normalizedPath);
}

private mergeCapturePropertyVars(vars: Map<string, unknown>): void {
Expand Down
44 changes: 44 additions & 0 deletions src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,50 @@ describe("TemplateChoiceEngine file casing resolution", () => {
engine.choice.templatePath,
);
});

it("supports existing .base files for overwrite mode", async () => {
const { engine, app } = createEngine("ignored", {
throwDuringFileName: false,
stubTemplateContent: true,
});

const existingFile = new TFile();
existingFile.path = "Board.base";
existingFile.name = "Board.base";
existingFile.extension = "base";
existingFile.basename = "Board";

engine.choice.templatePath = "Templates/Board.base";
engine.choice.fileExistsMode = fileExistsOverwriteFile;
engine.choice.setFileExistsBehavior = true;
formatFileNameMock.mockResolvedValueOnce("Board");

(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(
true,
);
(app.vault.getAbstractFileByPath as ReturnType<typeof vi.fn>).mockReturnValue(
existingFile,
);

const overwriteSpy = vi
.spyOn(
engine as unknown as {
overwriteFileWithTemplate: (
file: TFile,
templatePath: string,
) => Promise<TFile | null>;
},
"overwriteFileWithTemplate",
)
.mockResolvedValue(existingFile);

await engine.run();

expect(overwriteSpy).toHaveBeenCalledWith(
existingFile,
"Templates/Board.base",
);
});
});

describe("TemplateChoiceEngine destination path resolution", () => {
Expand Down
6 changes: 4 additions & 2 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ export class TemplateChoiceEngine extends TemplateEngine {
const file = this.findExistingFile(filePath);
if (
!(file instanceof TFile) ||
(file.extension !== "md" && file.extension !== "canvas")
(file.extension !== "md" &&
file.extension !== "canvas" &&
file.extension !== "base")
) {
log.logError(
`'${filePath}' already exists but could not be resolved as a markdown or canvas file.`,
`'${filePath}' already exists but could not be resolved as a markdown, canvas, or base file.`,
);
return;
}
Expand Down
Loading