Skip to content

Commit ac08ec5

Browse files
committed
feat: insert template into active note
1 parent f1e2a9f commit ac08ec5

File tree

6 files changed

+531
-18
lines changed

6 files changed

+531
-18
lines changed

src/engine/TemplateChoiceEngine.ts

Lines changed: 279 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { App } from "obsidian";
2-
import { TFile } from "obsidian";
1+
import { MarkdownView, TFile, type App } from "obsidian";
32
import invariant from "src/utils/invariant";
43
import {
54
fileExistsAppendToBottom,
@@ -11,26 +10,41 @@ import {
1110
VALUE_SYNTAX,
1211
} from "../constants";
1312
import GenericSuggester from "../gui/GenericSuggester/genericSuggester";
13+
import InputSuggester from "src/gui/InputSuggester/inputSuggester";
1414
import type { IChoiceExecutor } from "../IChoiceExecutor";
1515
import { log } from "../logger/logManager";
1616
import type QuickAdd from "../main";
1717
import type ITemplateChoice from "../types/choices/ITemplateChoice";
18-
import { normalizeAppendLinkOptions } from "../types/linkPlacement";
1918
import {
19+
normalizeTemplateInsertionConfig,
20+
type TemplateInsertionConfig,
21+
type TemplateInsertionPlacement,
22+
} from "../types/choices/ITemplateChoice";
23+
import { normalizeAppendLinkOptions, type LinkPlacement } from "../types/linkPlacement";
24+
import {
25+
appendToCurrentLine,
2026
getAllFolderPathsInVault,
27+
insertLinkWithPlacement,
2128
insertFileLinkToActiveView,
29+
insertOnNewLineAbove,
30+
insertOnNewLineBelow,
2231
jumpToNextTemplaterCursorIfPossible,
2332
openExistingFileTab,
2433
openFile,
34+
templaterParseTemplate,
2535
} from "../utilityObsidian";
2636
import { isCancellationError, reportError } from "../utils/errorUtils";
37+
import { flattenChoices } from "../utils/choiceUtils";
38+
import { findYamlFrontMatterRange } from "../utils/yamlContext";
2739
import { TemplateEngine } from "./TemplateEngine";
2840
import { MacroAbortError } from "../errors/MacroAbortError";
2941
import { handleMacroAbort } from "../utils/macroAbortHandler";
3042

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

3549
constructor(
3650
app: App,
@@ -45,6 +59,12 @@ export class TemplateChoiceEngine extends TemplateEngine {
4559

4660
public async run(): Promise<void> {
4761
try {
62+
const insertion = normalizeTemplateInsertionConfig(this.choice.insertion);
63+
if (insertion.enabled) {
64+
await this.runInsertion(insertion);
65+
return;
66+
}
67+
4868
invariant(this.choice.templatePath, () => {
4969
return `Invalid template path for ${this.choice.name}. ${this.choice.templatePath.length === 0
5070
? "Template path is empty."
@@ -203,6 +223,262 @@ export class TemplateChoiceEngine extends TemplateEngine {
203223
}
204224
}
205225

226+
private async runInsertion(insertion: TemplateInsertionConfig): Promise<void> {
227+
const activeFile = this.app.workspace.getActiveFile();
228+
if (!activeFile) {
229+
throw new MacroAbortError("No active file to insert into.");
230+
}
231+
if (activeFile.extension !== "md") {
232+
throw new MacroAbortError("Active file is not a Markdown note.");
233+
}
234+
235+
const templatePath = await this.resolveInsertionTemplatePath(insertion);
236+
invariant(templatePath, () => {
237+
return `Invalid template path for ${this.choice.name}. ${
238+
templatePath?.length === 0 ? "Template path is empty." : ""
239+
}`;
240+
});
241+
242+
const { content: formattedTemplate, templateVars } =
243+
await this.formatTemplateForFile(templatePath, activeFile);
244+
const templaterContent = await templaterParseTemplate(
245+
this.app,
246+
formattedTemplate,
247+
activeFile,
248+
);
249+
250+
const { frontmatter, body } = this.splitFrontmatter(templaterContent);
251+
const hasBody = body.trim().length > 0;
252+
const hasFrontmatter = frontmatter?.trim().length;
253+
254+
if (insertion.placement === "top" || insertion.placement === "bottom") {
255+
const fileContent = await this.app.vault.read(activeFile);
256+
let nextContent = this.applyFrontmatterInsertion(
257+
fileContent,
258+
frontmatter,
259+
);
260+
if (hasBody) {
261+
nextContent =
262+
insertion.placement === "top"
263+
? this.insertBodyAtTop(nextContent, body)
264+
: this.insertBodyAtBottom(nextContent, body);
265+
}
266+
if (nextContent !== fileContent) {
267+
await this.app.vault.modify(activeFile, nextContent);
268+
}
269+
} else {
270+
if (hasFrontmatter) {
271+
const fileContent = await this.app.vault.read(activeFile);
272+
const nextContent = this.applyFrontmatterInsertion(
273+
fileContent,
274+
frontmatter,
275+
);
276+
if (nextContent !== fileContent) {
277+
await this.app.vault.modify(activeFile, nextContent);
278+
}
279+
}
280+
281+
if (hasBody) {
282+
this.insertBodyIntoEditor(body, insertion.placement);
283+
}
284+
}
285+
286+
if (this.shouldPostProcessFrontMatter(activeFile, templateVars)) {
287+
await this.postProcessFrontMatter(activeFile, templateVars);
288+
}
289+
}
290+
291+
private async resolveInsertionTemplatePath(
292+
insertion: TemplateInsertionConfig,
293+
): Promise<string> {
294+
switch (insertion.templateSource.type) {
295+
case "prompt":
296+
return await this.promptForTemplatePath();
297+
case "choice":
298+
return await this.resolveTemplatePathFromChoice(
299+
insertion.templateSource.value,
300+
);
301+
case "path":
302+
default:
303+
return insertion.templateSource.value ?? this.choice.templatePath;
304+
}
305+
}
306+
307+
private async promptForTemplatePath(): Promise<string> {
308+
const templates = this.plugin.getTemplateFiles().map((file) => file.path);
309+
try {
310+
return await InputSuggester.Suggest(this.app, templates, templates, {
311+
placeholder: "Template path",
312+
});
313+
} catch (error) {
314+
if (isCancellationError(error)) {
315+
throw new MacroAbortError("Input cancelled by user");
316+
}
317+
throw error;
318+
}
319+
}
320+
321+
private async resolveTemplatePathFromChoice(
322+
choiceIdOrName?: string,
323+
): Promise<string> {
324+
const templateChoices = flattenChoices(this.plugin.settings.choices).filter(
325+
(choice) => choice.type === "Template",
326+
) as ITemplateChoice[];
327+
328+
invariant(
329+
templateChoices.length > 0,
330+
"No Template choices available to select from.",
331+
);
332+
333+
let selectedChoice: ITemplateChoice | undefined;
334+
if (choiceIdOrName) {
335+
selectedChoice = templateChoices.find(
336+
(choice) =>
337+
choice.id === choiceIdOrName || choice.name === choiceIdOrName,
338+
);
339+
}
340+
341+
if (!selectedChoice) {
342+
const displayItems = templateChoices.map((choice) =>
343+
choice.templatePath
344+
? `${choice.name} (${choice.templatePath})`
345+
: choice.name,
346+
);
347+
try {
348+
selectedChoice = await GenericSuggester.Suggest(
349+
this.app,
350+
displayItems,
351+
templateChoices,
352+
"Select Template choice",
353+
);
354+
} catch (error) {
355+
if (isCancellationError(error)) {
356+
throw new MacroAbortError("Input cancelled by user");
357+
}
358+
throw error;
359+
}
360+
}
361+
362+
invariant(
363+
selectedChoice?.templatePath,
364+
`Template choice "${selectedChoice?.name ?? "Unknown"}" has no template path.`,
365+
);
366+
367+
return selectedChoice.templatePath;
368+
}
369+
370+
private splitFrontmatter(content: string): {
371+
frontmatter: string | null;
372+
body: string;
373+
} {
374+
const match = TemplateChoiceEngine.FRONTMATTER_REGEX.exec(content);
375+
if (!match) {
376+
return { frontmatter: null, body: content };
377+
}
378+
379+
return {
380+
frontmatter: match[2],
381+
body: content.slice(match[0].length),
382+
};
383+
}
384+
385+
private applyFrontmatterInsertion(
386+
content: string,
387+
frontmatter: string | null,
388+
): string {
389+
if (!frontmatter || frontmatter.trim().length === 0) {
390+
return content;
391+
}
392+
393+
const trimmedInsert = frontmatter.trimEnd();
394+
const match = TemplateChoiceEngine.FRONTMATTER_REGEX.exec(content);
395+
if (!match) {
396+
return `---\n${trimmedInsert}\n---\n${content}`;
397+
}
398+
399+
const existing = match[2];
400+
const merged =
401+
existing.trim().length === 0
402+
? trimmedInsert
403+
: `${existing.trimEnd()}\n${trimmedInsert}`;
404+
405+
return `${match[1]}${merged}${match[3]}${content.slice(match[0].length)}`;
406+
}
407+
408+
private insertBodyAtTop(content: string, body: string): string {
409+
if (!body || body.trim().length === 0) {
410+
return content;
411+
}
412+
413+
const yamlRange = findYamlFrontMatterRange(content);
414+
const insertIndex = yamlRange ? yamlRange[1] : 0;
415+
const prefix = content.slice(0, insertIndex);
416+
const suffix = content.slice(insertIndex);
417+
418+
return this.joinWithNewlines(prefix, body, suffix);
419+
}
420+
421+
private insertBodyAtBottom(content: string, body: string): string {
422+
if (!body || body.trim().length === 0) {
423+
return content;
424+
}
425+
426+
return this.joinWithNewlines(content, body, "");
427+
}
428+
429+
private insertBodyIntoEditor(
430+
body: string,
431+
placement: TemplateInsertionPlacement,
432+
): void {
433+
if (!body || body.trim().length === 0) {
434+
return;
435+
}
436+
437+
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
438+
if (!view) {
439+
throw new MacroAbortError("No active Markdown view.");
440+
}
441+
442+
switch (placement) {
443+
case "currentLine":
444+
appendToCurrentLine(body, this.app);
445+
break;
446+
case "newLineAbove":
447+
insertOnNewLineAbove(body, this.app);
448+
break;
449+
case "newLineBelow":
450+
insertOnNewLineBelow(body, this.app);
451+
break;
452+
case "replaceSelection":
453+
case "afterSelection":
454+
case "endOfLine":
455+
insertLinkWithPlacement(this.app, body, placement as LinkPlacement);
456+
break;
457+
default:
458+
throw new Error(`Unknown insertion placement: ${placement}`);
459+
}
460+
}
461+
462+
private joinWithNewlines(prefix: string, insert: string, suffix: string): string {
463+
if (!insert || insert.length === 0) {
464+
return `${prefix}${suffix}`;
465+
}
466+
467+
let output = prefix;
468+
if (output && !output.endsWith("\n") && !insert.startsWith("\n")) {
469+
output += "\n";
470+
}
471+
472+
output += insert;
473+
474+
if (suffix && !output.endsWith("\n") && !suffix.startsWith("\n")) {
475+
output += "\n";
476+
}
477+
478+
output += suffix;
479+
return output;
480+
}
481+
206482
/**
207483
* Resolve an existing file by path with a case-insensitive fallback.
208484
*

src/engine/TemplateEngine.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,26 @@ export abstract class TemplateEngine extends QuickAddEngine {
543543
}
544544
}
545545

546+
protected async formatTemplateForFile(
547+
templatePath: string,
548+
targetFile: TFile,
549+
): Promise<{ content: string; templateVars: Map<string, unknown> }> {
550+
const templateContent: string = await this.getTemplateContent(templatePath);
551+
552+
// Use the target file's basename as the title
553+
this.formatter.setTitle(targetFile.basename);
554+
555+
const formattedTemplateContent: string =
556+
await this.formatter.formatFileContent(templateContent);
557+
558+
const templateVars = this.formatter.getAndClearTemplatePropertyVars();
559+
560+
return {
561+
content: formattedTemplateContent,
562+
templateVars,
563+
};
564+
}
565+
546566
public setLinkToCurrentFileBehavior(behavior: LinkToCurrentFileBehavior) {
547567
this.formatter.setLinkToCurrentFileBehavior(behavior);
548568
}

0 commit comments

Comments
 (0)