Skip to content
4 changes: 4 additions & 0 deletions docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ 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 extension (for example `Project.base`), QuickAdd keeps that extension.

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
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
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
73 changes: 71 additions & 2 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { isFolder, openFile } from "../utilityObsidian";

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

vi.mock("../formatters/captureChoiceFormatter", () => ({
Expand All @@ -15,7 +16,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 +133,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 +218,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 +269,68 @@ describe("CaptureChoiceEngine capture target resolution", () => {

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

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

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

expect(result).toBe("Boards/Kanban.base");
});

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 .base/.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/Kanban.base",
"capture",
);
await (engine as any).onCreateFileIfItDoesntExist(
"Boards/Map.canvas",
"capture",
);

expect(setTitleMock).toHaveBeenCalledWith("Kanban");
expect(setTitleMock).toHaveBeenCalledWith("Map");
});
});
51 changes: 36 additions & 15 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 @@ -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 @@ -311,7 +315,11 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
return { kind: "folder", folder: folderPath };
}

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

Expand Down Expand Up @@ -462,8 +470,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 +557,20 @@ 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 (
MARKDOWN_FILE_EXTENSION_REGEX.test(normalizedPath) ||
CANVAS_FILE_EXTENSION_REGEX.test(normalizedPath) ||
BASE_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
26 changes: 20 additions & 6 deletions src/engine/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
} from "../utilityObsidian";
import GenericSuggester from "../gui/GenericSuggester/genericSuggester";
import InputSuggester from "../gui/InputSuggester/inputSuggester";
import { MARKDOWN_FILE_EXTENSION_REGEX, CANVAS_FILE_EXTENSION_REGEX } from "../constants";
import {
BASE_FILE_EXTENSION_REGEX,
CANVAS_FILE_EXTENSION_REGEX,
MARKDOWN_FILE_EXTENSION_REGEX,
} from "../constants";
import { reportError } from "../utils/errorUtils";
import { basenameWithoutMdOrCanvas } from "../utils/pathUtils";
import {
Expand Down Expand Up @@ -441,6 +445,9 @@ export abstract class TemplateEngine extends QuickAddEngine {
if (CANVAS_FILE_EXTENSION_REGEX.test(templatePath)) {
return ".canvas";
}
if (BASE_FILE_EXTENSION_REGEX.test(templatePath)) {
return ".base";
}
return ".md";
}

Expand All @@ -454,7 +461,8 @@ export abstract class TemplateEngine extends QuickAddEngine {
const extension = this.getTemplateExtension(templatePath);
const formattedFileName: string = this.stripLeadingSlash(fileName)
.replace(MARKDOWN_FILE_EXTENSION_REGEX, "")
.replace(CANVAS_FILE_EXTENSION_REGEX, "");
.replace(CANVAS_FILE_EXTENSION_REGEX, "")
.replace(BASE_FILE_EXTENSION_REGEX, "");
return `${actualFolderPath}${formattedFileName}${extension}`;
}

Expand All @@ -463,7 +471,12 @@ export abstract class TemplateEngine extends QuickAddEngine {
let newFileName = fileName;

// Determine the extension from the filename and construct a matching regex
const extension = CANVAS_FILE_EXTENSION_REGEX.test(fileName) ? ".canvas" : ".md";
let extension = ".md";
if (CANVAS_FILE_EXTENSION_REGEX.test(fileName)) {
extension = ".canvas";
} else if (BASE_FILE_EXTENSION_REGEX.test(fileName)) {
extension = ".base";
}
const extPattern = extension.replace(/\./g, "\\.");
const numberWithExtRegex = new RegExp(`(\\d*)${extPattern}$`);
const exec = numberWithExtRegex.exec(fileName);
Expand Down Expand Up @@ -501,8 +514,8 @@ export abstract class TemplateEngine extends QuickAddEngine {
templatePath
);

// Extract filename without extension from the full path (supports .md and .canvas)
const fileBasename = basenameWithoutMdOrCanvas(filePath);
// Extract filename without extension from the full path.
const fileBasename = basenameWithoutMdOrCanvas(filePath);
this.formatter.setTitle(fileBasename);

const formattedTemplateContent: string =
Expand Down Expand Up @@ -636,7 +649,8 @@ export abstract class TemplateEngine extends QuickAddEngine {
protected async getTemplateContent(templatePath: string): Promise<string> {
let correctTemplatePath: string = this.stripLeadingSlash(templatePath);
if (!MARKDOWN_FILE_EXTENSION_REGEX.test(templatePath) &&
!CANVAS_FILE_EXTENSION_REGEX.test(templatePath))
!CANVAS_FILE_EXTENSION_REGEX.test(templatePath) &&
!BASE_FILE_EXTENSION_REGEX.test(templatePath))
correctTemplatePath += ".md";

const templateFile =
Expand Down
Loading