1- import type { App } from "obsidian" ;
2- import { TFile } from "obsidian" ;
1+ import { MarkdownView , TFile , type App } from "obsidian" ;
32import invariant from "src/utils/invariant" ;
43import {
54 fileExistsAppendToBottom ,
@@ -11,26 +10,41 @@ import {
1110 VALUE_SYNTAX ,
1211} from "../constants" ;
1312import GenericSuggester from "../gui/GenericSuggester/genericSuggester" ;
13+ import InputSuggester from "src/gui/InputSuggester/inputSuggester" ;
1414import type { IChoiceExecutor } from "../IChoiceExecutor" ;
1515import { log } from "../logger/logManager" ;
1616import type QuickAdd from "../main" ;
1717import type ITemplateChoice from "../types/choices/ITemplateChoice" ;
18- import { normalizeAppendLinkOptions } from "../types/linkPlacement" ;
1918import {
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" ;
2636import { isCancellationError , reportError } from "../utils/errorUtils" ;
37+ import { flattenChoices } from "../utils/choiceUtils" ;
38+ import { findYamlFrontMatterRange } from "../utils/yamlContext" ;
2739import { TemplateEngine } from "./TemplateEngine" ;
2840import { MacroAbortError } from "../errors/MacroAbortError" ;
2941import { handleMacroAbort } from "../utils/macroAbortHandler" ;
3042
3143export 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 *
0 commit comments