Skip to content
Open
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
29 changes: 28 additions & 1 deletion docs/docs/Choices/TemplateChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,38 @@ title: Template
The template choice type is not meant to be a replacement for [Templater](https://github.com/SilentVoid13/Templater/) plugin or core `Templates`. It's meant to augment them, to add more possibilities. You can use both QuickAdd format syntax in a Templater template - and both will work.

## Mandatory
**Template Path**. This is a path to the template you wish to insert. Paths are vault-relative; a leading `/` is ignored.
**Template Path**. This is a path to the template you wish to insert. Paths are vault-relative; a leading `/` is ignored. When **Insert into active note** is enabled, this path is only required if the template source is set to **Use template path**.

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.

## Optional
### Insert into active note
Enable **Insert into active note** to insert the template content into the currently active Markdown note instead of creating a new file.

When enabled, QuickAdd requires an active Markdown note. The following settings are ignored because no new file is created:
- File name format
- Create in folder
- Append link
- File already exists behavior
- Open file

**Insertion placement** controls where content is inserted:
- **Current line (cursor)**
- **Replace selection**
- **After selection**
- **End of line**
- **New line above**
- **New line below**
- **Top of note**
- **Bottom of note**

**Template source** controls how the template is chosen:
- **Use template path** – use the Template Path setting
- **Prompt for template at runtime** – choose a template file when running the choice
- **Use another Template choice** – select another Template choice and reuse its template path (only the template path is reused; other settings on that choice are ignored)

If the template contains YAML frontmatter, QuickAdd merges it into the active note's frontmatter (objects deep-merge, arrays concatenate, scalars are overwritten by the template). The rest of the template is inserted according to the placement you choose.

**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.
Basically, this allows you to have dynamic file names. If you wrote `£ {{DATE}} {{NAME}}`, it would translate to a file name like `£ 2021-06-12 Manually-Written-File-Name`, where `Manually-Written-File-Name` is a value you enter when invoking the template.

Expand Down
315 changes: 312 additions & 3 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { App } from "obsidian";
import { TFile } from "obsidian";
import { MarkdownView, TFile, type App, parseYaml } from "obsidian";
import invariant from "src/utils/invariant";
import {
fileExistsAppendToBottom,
Expand All @@ -11,26 +10,41 @@ import {
VALUE_SYNTAX,
} from "../constants";
import GenericSuggester from "../gui/GenericSuggester/genericSuggester";
import InputSuggester from "src/gui/InputSuggester/inputSuggester";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { log } from "../logger/logManager";
import type QuickAdd from "../main";
import type ITemplateChoice from "../types/choices/ITemplateChoice";
import { normalizeAppendLinkOptions } from "../types/linkPlacement";
import {
normalizeTemplateInsertionConfig,
type TemplateInsertionConfig,
type TemplateInsertionPlacement,
} from "../types/choices/ITemplateChoice";
import { normalizeAppendLinkOptions, type LinkPlacement } from "../types/linkPlacement";
import {
appendToCurrentLine,
getAllFolderPathsInVault,
insertLinkWithPlacement,
insertFileLinkToActiveView,
insertOnNewLineAbove,
insertOnNewLineBelow,
jumpToNextTemplaterCursorIfPossible,
openExistingFileTab,
openFile,
templaterParseTemplate,
} from "../utilityObsidian";
import { isCancellationError, reportError } from "../utils/errorUtils";
import { flattenChoices } from "../utils/choiceUtils";
import { findYamlFrontMatterRange } from "../utils/yamlContext";
import { TemplateEngine } from "./TemplateEngine";
import { MacroAbortError } from "../errors/MacroAbortError";
import { handleMacroAbort } from "../utils/macroAbortHandler";

export class TemplateChoiceEngine extends TemplateEngine {
public choice: ITemplateChoice;
private readonly choiceExecutor: IChoiceExecutor;
private static readonly FRONTMATTER_REGEX =
/^(\s*---\r?\n)([\s\S]*?)(\r?\n(?:---|\.\.\.)\s*(?:\r?\n|$))/;

constructor(
app: App,
Expand All @@ -45,6 +59,12 @@ export class TemplateChoiceEngine extends TemplateEngine {

public async run(): Promise<void> {
try {
const insertion = normalizeTemplateInsertionConfig(this.choice.insertion);
if (insertion.enabled) {
await this.runInsertion(insertion);
return;
}

invariant(this.choice.templatePath, () => {
return `Invalid template path for ${this.choice.name}. ${this.choice.templatePath.length === 0
? "Template path is empty."
Expand Down Expand Up @@ -203,6 +223,295 @@ export class TemplateChoiceEngine extends TemplateEngine {
}
}

private async runInsertion(insertion: TemplateInsertionConfig): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new MacroAbortError("No active file to insert into.");
}
if (activeFile.extension !== "md") {
throw new MacroAbortError("Active file is not a Markdown note.");
}

const templatePath = await this.resolveInsertionTemplatePath(insertion);
invariant(templatePath, () => {
return `Invalid template path for ${this.choice.name}. ${
templatePath?.length === 0 ? "Template path is empty." : ""
}`;
});

const { content: formattedTemplate, templateVars } =
await this.formatTemplateForFile(templatePath, activeFile);
const templaterContent = await templaterParseTemplate(
this.app,
formattedTemplate,
activeFile,
);

const { frontmatter, body } = this.splitFrontmatter(templaterContent);
const hasBody = body.trim().length > 0;

if (frontmatter) {
await this.applyFrontmatterProperties(activeFile, frontmatter);
}

if (insertion.placement === "top" || insertion.placement === "bottom") {
const fileContent = await this.app.vault.read(activeFile);
const nextContent = hasBody
? insertion.placement === "top"
? this.insertBodyAtTop(fileContent, body)
: this.insertBodyAtBottom(fileContent, body)
: fileContent;
if (nextContent !== fileContent) {
await this.app.vault.modify(activeFile, nextContent);
}
} else if (hasBody) {
this.insertBodyIntoEditor(body, insertion.placement);
}

if (this.shouldPostProcessFrontMatter(activeFile, templateVars)) {
await this.postProcessFrontMatter(activeFile, templateVars);
}
}

private async resolveInsertionTemplatePath(
insertion: TemplateInsertionConfig,
): Promise<string> {
switch (insertion.templateSource.type) {
case "prompt":
return await this.promptForTemplatePath();
case "choice":
return await this.resolveTemplatePathFromChoice(
insertion.templateSource.value,
);
case "path":
default:
return insertion.templateSource.value ?? this.choice.templatePath;
}
}

private async promptForTemplatePath(): Promise<string> {
const templates = this.plugin.getTemplateFiles().map((file) => file.path);
try {
return await InputSuggester.Suggest(this.app, templates, templates, {
placeholder: "Template path",
});
} catch (error) {
if (isCancellationError(error)) {
throw new MacroAbortError("Input cancelled by user");
}
throw error;
}
}

private async resolveTemplatePathFromChoice(
choiceIdOrName?: string,
): Promise<string> {
const templateChoices = flattenChoices(this.plugin.settings.choices).filter(
(choice) => choice.type === "Template",
) as ITemplateChoice[];

invariant(
templateChoices.length > 0,
"No Template choices available to select from.",
);

let selectedChoice: ITemplateChoice | undefined;
if (choiceIdOrName) {
selectedChoice = templateChoices.find(
(choice) =>
choice.id === choiceIdOrName || choice.name === choiceIdOrName,
);
}

if (!selectedChoice) {
const displayItems = templateChoices.map((choice) =>
choice.templatePath
? `${choice.name} (${choice.templatePath})`
: choice.name,
);
try {
selectedChoice = await GenericSuggester.Suggest(
this.app,
displayItems,
templateChoices,
"Select Template choice",
);
} catch (error) {
if (isCancellationError(error)) {
throw new MacroAbortError("Input cancelled by user");
}
throw error;
}
}

invariant(
selectedChoice?.templatePath,
`Template choice "${selectedChoice?.name ?? "Unknown"}" has no template path.`,
);

return selectedChoice.templatePath;
}

private splitFrontmatter(content: string): {
frontmatter: string | null;
body: string;
} {
const match = TemplateChoiceEngine.FRONTMATTER_REGEX.exec(content);
if (!match) {
return { frontmatter: null, body: content };
}

return {
frontmatter: match[2],
body: content.slice(match[0].length),
};
}

private async applyFrontmatterProperties(
file: TFile,
frontmatter: string,
): Promise<void> {
const trimmed = frontmatter.trim();
if (!trimmed) return;

let parsed: unknown;
try {
parsed = parseYaml(trimmed);
} catch (error) {
log.logWarning(
`Template insertion: failed to parse frontmatter for ${file.path}: ${String(
error,
)}`,
);
return;
}

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
log.logWarning(
`Template insertion: frontmatter did not parse to an object for ${file.path}.`,
);
return;
}

try {
await this.app.fileManager.processFrontMatter(file, (fm) => {
for (const [key, value] of Object.entries(
parsed as Record<string, unknown>,
)) {
fm[key] = this.mergeFrontmatterValue(fm[key], value);
}
});
} catch (error) {
log.logWarning(
`Template insertion: failed to apply frontmatter for ${file.path}: ${String(
error,
)}`,
);
}
}

private mergeFrontmatterValue(
existing: unknown,
incoming: unknown,
): unknown {
if (Array.isArray(existing) && Array.isArray(incoming)) {
return [...existing, ...incoming];
}

if (this.isPlainObject(existing) && this.isPlainObject(incoming)) {
const merged: Record<string, unknown> = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
merged[key] = this.mergeFrontmatterValue(merged[key], value);
}
return merged;
}

return incoming;
}

private isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
value.constructor === Object
);
}

private insertBodyAtTop(content: string, body: string): string {
if (!body || body.trim().length === 0) {
return content;
}

const yamlRange = findYamlFrontMatterRange(content);
const insertIndex = yamlRange ? yamlRange[1] : 0;
const prefix = content.slice(0, insertIndex);
const suffix = content.slice(insertIndex);

return this.joinWithNewlines(prefix, body, suffix);
}

private insertBodyAtBottom(content: string, body: string): string {
if (!body || body.trim().length === 0) {
return content;
}

return this.joinWithNewlines(content, body, "");
}

private insertBodyIntoEditor(
body: string,
placement: TemplateInsertionPlacement,
): void {
if (!body || body.trim().length === 0) {
return;
}

const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
throw new MacroAbortError("No active Markdown view.");
}

switch (placement) {
case "currentLine":
appendToCurrentLine(body, this.app);
break;
case "newLineAbove":
insertOnNewLineAbove(body, this.app);
break;
case "newLineBelow":
insertOnNewLineBelow(body, this.app);
break;
case "replaceSelection":
case "afterSelection":
case "endOfLine":
insertLinkWithPlacement(this.app, body, placement as LinkPlacement);
break;
default:
throw new Error(`Unknown insertion placement: ${placement}`);
}
}

private joinWithNewlines(prefix: string, insert: string, suffix: string): string {
if (!insert || insert.length === 0) {
return `${prefix}${suffix}`;
}

let output = prefix;
if (output && !output.endsWith("\n") && !insert.startsWith("\n")) {
output += "\n";
}

output += insert;

if (suffix && !output.endsWith("\n") && !suffix.startsWith("\n")) {
output += "\n";
}

output += suffix;
return output;
}

/**
* Resolve an existing file by path with a case-insensitive fallback.
*
Expand Down
Loading