From afe98b4667db1959ca5a9b65d62532f64a2f6118 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 14 May 2026 19:44:29 +0200 Subject: [PATCH] refactor: extract vault frontmatter services --- .../CaptureChoiceEngine.selection.test.ts | 12 +- src/engine/CaptureChoiceEngine.ts | 22 +- src/engine/QuickAddEngine.test.ts | 33 +- src/engine/QuickAddEngine.ts | 317 +----------------- src/engine/QuickAddEngine.validation.test.ts | 72 ++-- src/engine/TemplateChoiceEngine.ts | 6 +- src/engine/TemplateEngine.ts | 20 +- .../FrontmatterPropertyService.test.ts | 124 +++++++ src/services/FrontmatterPropertyService.ts | 203 +++++++++++ src/services/VaultFileService.test.ts | 120 +++++++ src/services/VaultFileService.ts | 81 +++++ 11 files changed, 607 insertions(+), 403 deletions(-) create mode 100644 src/services/FrontmatterPropertyService.test.ts create mode 100644 src/services/FrontmatterPropertyService.ts create mode 100644 src/services/VaultFileService.test.ts create mode 100644 src/services/VaultFileService.ts diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index 06f9b837..66a58825 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -199,7 +199,7 @@ describe("CaptureChoiceEngine selection-as-value resolution", () => { (engine as any).getFormattedPathToCaptureTo = vi .fn() .mockResolvedValue("Test.md"); - (engine as any).fileExists = vi.fn().mockResolvedValue(true); + (engine as any).vaultFileService.fileExists = vi.fn().mockResolvedValue(true); (engine as any).onFileExists = vi.fn().mockResolvedValue({ file, newFileContent: "content", @@ -355,7 +355,7 @@ describe("CaptureChoiceEngine capture target resolution", () => { createExecutor(), ); - (engine as any).createFileWithInput = vi.fn(async (path: string) => ({ + (engine as any).vaultFileService.createFileWithInput = vi.fn(async (path: string) => ({ path, basename: path.split("/").pop()?.replace(/\.(base|canvas)$/i, "") ?? "", extension: path.endsWith(".base") ? "base" : "canvas", @@ -414,7 +414,7 @@ describe("CaptureChoiceEngine capture target resolution", () => { newFileContent: "updated", captureContent: "capture", })); - (engine as any).fileExists = fileExistsMock; + (engine as any).vaultFileService.fileExists = fileExistsMock; (engine as any).onFileExists = onFileExistsMock; await engine.run(); @@ -473,7 +473,7 @@ describe("CaptureChoiceEngine capture target resolution", () => { createExecutor(), ); - (engine as any).fileExists = vi.fn(async () => true); + (engine as any).vaultFileService.fileExists = vi.fn(async () => true); (engine as any).onFileExists = vi.fn(async () => ({ file: linkedFile, newFileContent: "updated", @@ -588,7 +588,7 @@ describe("CaptureChoiceEngine capture target resolution", () => { }), ); - (engine as any).fileExists = fileExistsMock; + (engine as any).vaultFileService.fileExists = fileExistsMock; (engine as any).onCreateFileIfItDoesntExist = onCreateFileIfItDoesntExistMock; @@ -636,7 +636,7 @@ describe("CaptureChoiceEngine capture target resolution", () => { const fileExistsMock = vi.fn(); const onFileExistsMock = vi.fn(); - (engine as any).fileExists = fileExistsMock; + (engine as any).vaultFileService.fileExists = fileExistsMock; (engine as any).onFileExists = onFileExistsMock; await engine.run(); diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index e72a272e..844931a8 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -241,7 +241,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { linkOptions?: AppendLinkOptions, ) => Promise<{ file: TFile; newFileContent: string; captureContent: string }>; let getFileAndAddContentFn: GetFileAndAddContentFn; - const fileAlreadyExists = await this.fileExists(filePath); + const fileAlreadyExists = await this.vaultFileService.fileExists(filePath); if (fileAlreadyExists) { getFileAndAddContentFn = @@ -504,7 +504,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // 3) trailing "/" => folder picker (explicit) // 4) known file extension => file // 5) ambiguous => folder if it exists and no same-name file exists; else file - const normalizedCaptureTo = this.stripLeadingSlash( + const normalizedCaptureTo = this.vaultFileService.stripLeadingSlash( formattedCaptureTo.trim(), ); @@ -540,7 +540,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { } // Guard against ambiguity where a folder and file share the same name. - const fileCandidatePath = this.normalizeMarkdownFilePath("", folderPath); + const fileCandidatePath = this.vaultFileService.normalizeMarkdownFilePath("", folderPath); const fileCandidate = this.app.vault.getAbstractFileByPath( fileCandidatePath, ); @@ -631,7 +631,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { newFileContent: string; captureContent: string; }> { - const file: TFile = this.getFileByPath(filePath); + const file: TFile = this.vaultFileService.getFileByPath(filePath); if (!file) throw new Error("File not found"); // Set the title to the existing file's basename @@ -736,7 +736,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { } // Create the new file with the (optional) template content - const file: TFile = await this.createFileWithInput(filePath, fileContent, { + const file: TFile = await this.vaultFileService.createFileWithInput(filePath, fileContent, { suppressTemplaterOnCreate: this.choice.createFileIfItDoesntExist.createWithTemplate, }); @@ -744,8 +744,8 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // Post-process front matter for template property types if we used a template if (this.choice.createFileIfItDoesntExist.createWithTemplate && this.templatePropertyVars && - this.shouldPostProcessFrontMatter(file, this.templatePropertyVars)) { - await this.postProcessFrontMatter(file, this.templatePropertyVars); + this.frontmatterPropertyService.shouldPostProcessFrontMatter(file, this.templatePropertyVars)) { + await this.frontmatterPropertyService.postProcessFrontMatter(file, this.templatePropertyVars); } // Process Templater commands in the template if a template was used @@ -786,7 +786,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { } private normalizeCaptureFilePath(path: string): string { - const normalizedPath = this.stripLeadingSlash(path); + const normalizedPath = this.vaultFileService.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.`, @@ -799,7 +799,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { return normalizedPath; } - return this.normalizeMarkdownFilePath("", normalizedPath); + return this.vaultFileService.normalizeMarkdownFilePath("", normalizedPath); } private mergeCapturePropertyVars(vars: Map): void { @@ -821,7 +821,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { return; } - if (!this.shouldPostProcessFrontMatter(file, this.capturePropertyVars)) { + if (!this.frontmatterPropertyService.shouldPostProcessFrontMatter(file, this.capturePropertyVars)) { this.capturePropertyVars.clear(); return; } @@ -829,7 +829,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { log.logMessage( `CaptureChoiceEngine: Post-processing front matter with ${this.capturePropertyVars.size} capture variables` ); - await this.postProcessFrontMatter(file, this.capturePropertyVars); + await this.frontmatterPropertyService.postProcessFrontMatter(file, this.capturePropertyVars); this.capturePropertyVars.clear(); } } diff --git a/src/engine/QuickAddEngine.test.ts b/src/engine/QuickAddEngine.test.ts index 757a8f8e..c8aab76b 100644 --- a/src/engine/QuickAddEngine.test.ts +++ b/src/engine/QuickAddEngine.test.ts @@ -1,26 +1,25 @@ import { describe, expect, it } from "vitest"; -import { QuickAddEngine } from "./QuickAddEngine"; +import { VaultFileService } from "../services/VaultFileService"; -class TestEngine extends QuickAddEngine { - public constructor() { - super({} as any); - } - - public normalize(folderPath: string, fileName: string): string { - return this.normalizeMarkdownFilePath(folderPath, fileName); - } - - public run(): void {} -} - -describe("QuickAddEngine path normalization", () => { - const engine = new TestEngine(); +describe("VaultFileService path normalization", () => { + const service = new VaultFileService({} as any); it("strips leading slashes from folder and file", () => { - expect(engine.normalize("/daily", "/note")).toBe("daily/note.md"); + expect(service.normalizeMarkdownFilePath("/daily", "/note")).toBe( + "daily/note.md", + ); }); it("strips leading slashes from file-only paths", () => { - expect(engine.normalize("", "/review/daily")).toBe("review/daily.md"); + expect(service.normalizeMarkdownFilePath("", "/review/daily")).toBe( + "review/daily.md", + ); + }); + + it("omits empty folder prefixes and de-duplicates markdown suffixes", () => { + expect(service.normalizeMarkdownFilePath("", "note.md")).toBe("note.md"); + expect(service.normalizeMarkdownFilePath("/folder", "note.md")).toBe( + "folder/note.md", + ); }); }); diff --git a/src/engine/QuickAddEngine.ts b/src/engine/QuickAddEngine.ts index 14cd53d8..81cab682 100644 --- a/src/engine/QuickAddEngine.ts +++ b/src/engine/QuickAddEngine.ts @@ -1,322 +1,17 @@ import type { App } from "obsidian"; -import { TFile, TFolder } from "obsidian"; -import { MARKDOWN_FILE_EXTENSION_REGEX } from "../constants"; -import { log } from "../logger/logManager"; -import { withTemplaterFileCreationSuppressed } from "../utilityObsidian"; -import { coerceYamlValue } from "../utils/yamlValues"; -import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector"; - -/** - * Configuration for structured variable validation - */ -const VALIDATION_LIMITS = { - /** Maximum nesting depth for objects and arrays to prevent stack overflow */ - MAX_NESTING_DEPTH: 10, -} as const; - -/** - * Result of validating a structured variable - */ -interface ValidationResult { - isValid: boolean; - warnings: string[]; - errors: string[]; -} +import { FrontmatterPropertyService } from "../services/FrontmatterPropertyService"; +import { VaultFileService } from "../services/VaultFileService"; export abstract class QuickAddEngine { public app: App; - - /** - * File extensions that support YAML front matter post-processing. - * Currently only Markdown files are supported (Canvas files use JSON, not YAML). - */ - private static readonly YAML_FRONTMATTER_EXTENSIONS = ['md']; + protected readonly vaultFileService: VaultFileService; + protected readonly frontmatterPropertyService: FrontmatterPropertyService; protected constructor(app: App) { this.app = app; + this.vaultFileService = new VaultFileService(app); + this.frontmatterPropertyService = new FrontmatterPropertyService(app); } public abstract run(): void | Promise; - - /** - * Validates structured variables to ensure they can be safely processed. - * Checks for: - * - Circular references - * - Maximum nesting depth - * - Invalid types (functions, symbols, etc.) - * - * @param templatePropertyVars - Map of variables to validate - * @returns ValidationResult with any warnings or errors found - */ - protected validateStructuredVariables(templatePropertyVars: Map): ValidationResult { - const warnings: string[] = []; - const errors: string[] = []; - - for (const [key, value] of templatePropertyVars) { - const issues = this.validateValue(key, value, new Set(), 0); - warnings.push(...issues.warnings); - errors.push(...issues.errors); - } - - return { - isValid: errors.length === 0, - warnings, - errors, - }; - } - - /** - * Recursively validates a value for circular references, depth limits, and invalid types. - * - * @param key - The key/path being validated (for error messages) - * @param value - The value to validate - * @param seen - Set of objects already seen (for circular reference detection) - * @param depth - Current nesting depth - * @returns Object containing arrays of warnings and errors - */ - private validateValue( - key: string, - value: unknown, - seen: Set, - depth: number - ): { warnings: string[]; errors: string[] } { - const warnings: string[] = []; - const errors: string[] = []; - - // Check for invalid types - if (typeof value === "function") { - errors.push(`Variable "${key}" contains a function, which cannot be serialized to YAML`); - return { warnings, errors }; - } - - if (typeof value === "symbol") { - errors.push(`Variable "${key}" contains a symbol, which cannot be serialized to YAML`); - return { warnings, errors }; - } - - if (typeof value === "bigint") { - warnings.push(`Variable "${key}" contains a BigInt, which will be converted to a string`); - return { warnings, errors }; - } - - // Handle null, undefined, primitives - if (value === null || value === undefined) { - return { warnings, errors }; - } - - if (typeof value !== "object") { - return { warnings, errors }; - } - - // Check for circular references - if (seen.has(value)) { - errors.push(`Variable "${key}" contains a circular reference`); - return { warnings, errors }; - } - - // Check nesting depth - if (depth >= VALIDATION_LIMITS.MAX_NESTING_DEPTH) { - errors.push( - `Variable "${key}" exceeds maximum nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}` - ); - return { warnings, errors }; - } - - // Add to seen set for circular reference detection - seen.add(value); - - try { - // Recursively validate arrays - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - const childResult = this.validateValue( - `${key}[${i}]`, - value[i], - seen, - depth + 1 - ); - warnings.push(...childResult.warnings); - errors.push(...childResult.errors); - } - } - // Recursively validate objects - else { - for (const [childKey, childValue] of Object.entries(value)) { - const childResult = this.validateValue( - `${key}.${childKey}`, - childValue, - seen, - depth + 1 - ); - warnings.push(...childResult.warnings); - errors.push(...childResult.errors); - } - } - } finally { - // Remove from seen set after processing - seen.delete(value); - } - - return { warnings, errors }; - } - - protected async createFolder(folder: string): Promise { - const folderExists = await this.app.vault.adapter.exists(folder); - - if (!folderExists) { - await this.app.vault.createFolder(folder); - } - } - - protected stripLeadingSlash(path: string): string { - return path.replace(/^\/+/, ""); - } - - protected normalizeMarkdownFilePath( - folderPath: string, - fileName: string - ): string { - const safeFolderPath = this.stripLeadingSlash(folderPath); - const actualFolderPath: string = safeFolderPath ? `${safeFolderPath}/` : ""; - const formattedFileName: string = this.stripLeadingSlash(fileName).replace( - MARKDOWN_FILE_EXTENSION_REGEX, - "" - ); - return `${actualFolderPath}${formattedFileName}.md`; - } - - protected async fileExists(filePath: string): Promise { - return await this.app.vault.adapter.exists(filePath); - } - - protected getFileByPath(filePath: string): TFile { - const file = this.app.vault.getAbstractFileByPath(filePath); - - if (!file) { - log.logError(`${filePath} not found`); - throw new Error(`${filePath} not found`); - } - - if (file instanceof TFolder) { - log.logError(`${filePath} found but it's a folder`); - throw new Error(`${filePath} found but it's a folder`); - } - - if (!(file instanceof TFile)) - throw new Error(`${filePath} is not a file`); - - return file; - } - - protected async createFileWithInput( - filePath: string, - fileContent: string, - opts: { suppressTemplaterOnCreate?: boolean } = {}, - ): Promise { - const dirMatch = filePath.match(/(.*)[/\\]/); - let dirName = ""; - if (dirMatch) dirName = dirMatch[1]; - - const dir = this.app.vault.getAbstractFileByPath(dirName); - - if (!dir || !(dir instanceof TFolder)) { - await this.createFolder(dirName); - - } - - const createFile = () => this.app.vault.create(filePath, fileContent); - const shouldSuppress = - opts.suppressTemplaterOnCreate && - filePath.toLowerCase().endsWith(".md"); - - return shouldSuppress - ? await withTemplaterFileCreationSuppressed(this.app, filePath, createFile) - : await createFile(); - } - - /** - * Determines if a file's front matter should be post-processed for template property types. - * Only processes files with supported extensions (Markdown) when template variables are present. - * - * @param file - The file to check - * @param templateVars - The map of template variables to be processed - * @returns true if the file should be post-processed, false otherwise - */ - protected shouldPostProcessFrontMatter(file: TFile, templateVars: Map): boolean { - return QuickAddEngine.YAML_FRONTMATTER_EXTENSIONS.includes(file.extension) && - templateVars.size > 0; - } - - /** - * Post-processes the front matter of a newly created file to properly format - * template property variables (arrays, objects, etc.) using Obsidian's YAML processor. - * - * This method handles special internal conventions: - * - @date:ISO strings are automatically converted to Date objects for proper YAML formatting - * (see coerceYamlValue in utils/yamlValues.ts for implementation details) - */ - protected async postProcessFrontMatter(file: TFile, templatePropertyVars: Map): Promise { - // Validate structured variables before processing - const validation = this.validateStructuredVariables(templatePropertyVars); - - // Log any validation warnings - if (validation.warnings.length > 0) { - for (const warning of validation.warnings) { - log.logWarning(`Structured variable validation warning: ${warning}`); - } - } - - // If validation found errors, log them and skip post-processing - if (!validation.isValid) { - const errorSummary = validation.errors.join("; "); - log.logError( - `Cannot post-process front matter for file ${file.path} due to validation errors: ${errorSummary}. ` + - `The file was created successfully, but some structured variables may not be properly formatted. ` + - `Please check the variable values and ensure they don't contain circular references, ` + - `exceed nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}, or contain unsupported types (functions, symbols).` - ); - return; - } - - try { - log.logMessage(`Post-processing front matter for ${file.path} with ${templatePropertyVars.size} structured variables`); - log.logMessage(`Variable types: ${Array.from(templatePropertyVars.entries()) - .map(([k, v]) => `${k}:${typeof v}`).join(', ')}`); - - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - for (const [key, value] of templatePropertyVars) { - const pathSegments = key.includes(TemplatePropertyCollector.PATH_SEPARATOR) - ? key.split(TemplatePropertyCollector.PATH_SEPARATOR) - : [key]; - const coerced = coerceYamlValue(value); - this.assignFrontmatterValue(frontmatter, pathSegments, coerced); - } - }); - - log.logMessage(`Successfully post-processed front matter for ${file.path}`); - } catch (err) { - // Improved error message with actionable information - log.logError( - `Failed to post-process front matter for file ${file.path}: ${err}. ` + - `The file was created successfully, but structured variables may not be properly formatted. ` + - `This usually happens when variable values contain unexpected types or when Obsidian's YAML processor encounters an issue. ` + - `Check the console for more details about which variables caused the problem.` - ); - // Don't throw - the file was still created successfully - } - } - - private assignFrontmatterValue(frontmatter: Record, path: string[], value: unknown): void { - if (path.length === 0) return; - let target = frontmatter; - for (let i = 0; i < path.length - 1; i++) { - const segment = path[i]; - const existing = target[segment]; - if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) { - target[segment] = {}; - } - target = target[segment] as Record; - } - target[path[path.length - 1]] = value; - } } diff --git a/src/engine/QuickAddEngine.validation.test.ts b/src/engine/QuickAddEngine.validation.test.ts index 157dcd95..ee0344ba 100644 --- a/src/engine/QuickAddEngine.validation.test.ts +++ b/src/engine/QuickAddEngine.validation.test.ts @@ -1,32 +1,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; import type { App } from 'obsidian'; -import { QuickAddEngine } from './QuickAddEngine'; - -/** - * Test implementation of QuickAddEngine for validation testing - */ -class TestQuickAddEngine extends QuickAddEngine { - constructor(app: App) { - super(app); - } - - public run(): void { - // Not needed for validation tests - } - - // Expose protected method for testing - public testValidateStructuredVariables(vars: Map) { - return this.validateStructuredVariables(vars); - } -} - -describe('QuickAddEngine - Structured Variable Validation', () => { +import { FrontmatterPropertyService } from '../services/FrontmatterPropertyService'; + +describe('FrontmatterPropertyService - Structured Variable Validation', () => { let mockApp: App; - let engine: TestQuickAddEngine; + let service: FrontmatterPropertyService; beforeEach(() => { mockApp = {} as App; - engine = new TestQuickAddEngine(mockApp); + service = new FrontmatterPropertyService(mockApp); }); describe('Valid variables', () => { @@ -38,7 +20,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['null', null], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -52,7 +34,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['mixedArray', [1, 'two', true, null]], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -64,7 +46,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['nestedObject', { user: { name: 'test', active: true } }], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -75,7 +57,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['dateString', '@date:2024-01-15T10:30:00.000Z'], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -88,7 +70,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['func', () => {}], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); @@ -101,7 +83,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['sym', Symbol('test')], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); @@ -114,7 +96,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['bigNum', BigInt(123)], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); // Warning, not error expect(result.warnings).toHaveLength(1); @@ -127,7 +109,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['obj', { data: 'test', callback: () => {} }], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); @@ -149,7 +131,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['obj', obj], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -172,7 +154,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['obj', obj], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -186,7 +168,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['circular', circular], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); @@ -203,7 +185,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['nested', obj1], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); @@ -218,7 +200,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['arrayCircular', arr], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(1); @@ -245,7 +227,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['deepObj', deepObj], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -262,7 +244,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['tooDeep', deepObj], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); @@ -281,7 +263,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['deepArray', deepArr], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); @@ -300,7 +282,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['sym', Symbol('test')], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThanOrEqual(3); @@ -317,7 +299,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['problematic', problematic], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThanOrEqual(2); // function and symbol @@ -329,7 +311,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { it('should handle empty Map', () => { const vars = new Map(); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -342,7 +324,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['nullVal', null], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -353,7 +335,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['date', new Date('2024-01-15')], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); @@ -364,7 +346,7 @@ describe('QuickAddEngine - Structured Variable Validation', () => { ['event', { name: 'Meeting', date: new Date('2024-01-15'), attendees: ['Alice', 'Bob'] }], ]); - const result = engine.testValidateStructuredVariables(vars); + const result = service.validateStructuredVariables(vars); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index fb79c614..dbe45dac 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -371,8 +371,8 @@ export class TemplateChoiceEngine extends TemplateEngine { fileName: string, folderPath: string, ): { fileName: string; strippedPrefix: boolean } { - const normalizedFolder = this.stripLeadingSlash(folderPath); - const normalizedFileName = this.stripLeadingSlash(fileName); + const normalizedFolder = this.vaultFileService.stripLeadingSlash(folderPath); + const normalizedFileName = this.vaultFileService.stripLeadingSlash(fileName); if (!normalizedFolder) { return { fileName: normalizedFileName, strippedPrefix: false }; @@ -400,7 +400,7 @@ export class TemplateChoiceEngine extends TemplateEngine { if (normalizedFileName.startsWith("/")) return true; - const [firstSegment] = this.stripLeadingSlash(normalizedFileName).split("/"); + const [firstSegment] = this.vaultFileService.stripLeadingSlash(normalizedFileName).split("/"); if (!firstSegment) return false; const rootEntry = this.app.vault.getAbstractFileByPath(firstSegment); diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 7453487a..c9152e6f 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -251,7 +251,7 @@ export abstract class TemplateEngine extends QuickAddEngine { private async ensureFolderExists(selection: FolderSelection): Promise { if (selection.isEmpty || selection.exists) return; - await this.createFolder(selection.resolved); + await this.vaultFileService.createFolder(selection.resolved); } private async handleSingleSelection( @@ -438,7 +438,7 @@ export abstract class TemplateEngine extends QuickAddEngine { format, promptHeader ); - return this.normalizeMarkdownFilePath(folderPath, formattedName); + return this.vaultFileService.normalizeMarkdownFilePath(folderPath, formattedName); } protected getTemplateExtension(templatePath: string): string { @@ -456,10 +456,10 @@ export abstract class TemplateEngine extends QuickAddEngine { fileName: string, templatePath: string ): string { - const safeFolderPath = this.stripLeadingSlash(folderPath); + const safeFolderPath = this.vaultFileService.stripLeadingSlash(folderPath); const actualFolderPath: string = safeFolderPath ? `${safeFolderPath}/` : ""; const extension = this.getTemplateExtension(templatePath); - const formattedFileName: string = this.stripLeadingSlash(fileName) + const formattedFileName: string = this.vaultFileService.stripLeadingSlash(fileName) .replace(MARKDOWN_FILE_EXTENSION_REGEX, "") .replace(CANVAS_FILE_EXTENSION_REGEX, "") .replace(BASE_FILE_EXTENSION_REGEX, ""); @@ -495,15 +495,15 @@ export abstract class TemplateEngine extends QuickAddEngine { const suppressTemplaterOnCreate = filePath .toLowerCase() .endsWith(".md"); - const createdFile: TFile = await this.createFileWithInput( + const createdFile: TFile = await this.vaultFileService.createFileWithInput( filePath, formattedTemplateContent, { suppressTemplaterOnCreate }, ); // Post-process front matter for template property types BEFORE Templater - if (this.shouldPostProcessFrontMatter(createdFile, templateVars)) { - await this.postProcessFrontMatter(createdFile, templateVars); + if (this.frontmatterPropertyService.shouldPostProcessFrontMatter(createdFile, templateVars)) { + await this.frontmatterPropertyService.postProcessFrontMatter(createdFile, templateVars); } // Process Templater commands for template choices @@ -554,8 +554,8 @@ export abstract class TemplateEngine extends QuickAddEngine { await this.app.vault.modify(file, formattedTemplateContent); // Post-process front matter for template property types BEFORE Templater - if (this.shouldPostProcessFrontMatter(file, templateVars)) { - await this.postProcessFrontMatter(file, templateVars); + if (this.frontmatterPropertyService.shouldPostProcessFrontMatter(file, templateVars)) { + await this.frontmatterPropertyService.postProcessFrontMatter(file, templateVars); } // Process Templater commands @@ -612,7 +612,7 @@ export abstract class TemplateEngine extends QuickAddEngine { } protected async getTemplateContent(templatePath: string): Promise { - let correctTemplatePath: string = this.stripLeadingSlash(templatePath); + let correctTemplatePath: string = this.vaultFileService.stripLeadingSlash(templatePath); if (!MARKDOWN_FILE_EXTENSION_REGEX.test(templatePath) && !CANVAS_FILE_EXTENSION_REGEX.test(templatePath) && !BASE_FILE_EXTENSION_REGEX.test(templatePath)) diff --git a/src/services/FrontmatterPropertyService.test.ts b/src/services/FrontmatterPropertyService.test.ts new file mode 100644 index 00000000..d10f5cde --- /dev/null +++ b/src/services/FrontmatterPropertyService.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { TFile } from "obsidian"; +import { log } from "../logger/logManager"; +import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector"; +import { FrontmatterPropertyService } from "./FrontmatterPropertyService"; + +function file(path: string, extension = "md"): TFile { + const result = new TFile(); + result.path = path; + result.extension = extension; + return result; +} + +describe("FrontmatterPropertyService", () => { + let app: App; + let service: FrontmatterPropertyService; + let frontmatter: Record; + + beforeEach(() => { + frontmatter = {}; + app = { + fileManager: { + processFrontMatter: vi.fn(async (_file: TFile, callback) => { + callback(frontmatter); + }), + }, + } as unknown as App; + service = new FrontmatterPropertyService(app); + vi.spyOn(log, "logError").mockImplementation(() => {}); + vi.spyOn(log, "logWarning").mockImplementation(() => {}); + vi.spyOn(log, "logMessage").mockImplementation(() => {}); + }); + + it("gates post-processing to markdown files with collected variables", () => { + expect( + service.shouldPostProcessFrontMatter( + file("note.md", "md"), + new Map([["key", "value"]]), + ), + ).toBe(true); + expect( + service.shouldPostProcessFrontMatter(file("note.md", "md"), new Map()), + ).toBe(false); + expect( + service.shouldPostProcessFrontMatter( + file("board.canvas", "canvas"), + new Map([["key", "value"]]), + ), + ).toBe(false); + expect( + service.shouldPostProcessFrontMatter( + file("data.base", "base"), + new Map([["key", "value"]]), + ), + ).toBe(false); + }); + + it("assigns nested frontmatter paths through object containers", async () => { + frontmatter.project = null; + frontmatter.replaceArray = []; + const separator = TemplatePropertyCollector.PATH_SEPARATOR; + + await service.postProcessFrontMatter( + file("note.md"), + new Map([ + [`project${separator}sources`, ["[[A]]", "[[B]]"]], + [`replaceArray${separator}child`, true], + ]), + ); + + expect(frontmatter).toEqual({ + project: { sources: ["[[A]]", "[[B]]"] }, + replaceArray: { child: true }, + }); + }); + + it("coerces valid @date values and passes invalid dates through", async () => { + await service.postProcessFrontMatter( + file("note.md"), + new Map([ + ["valid", "@date:2024-01-15T10:30:00.000Z"], + ["invalid", "@date:not-a-date"], + ]), + ); + + expect(frontmatter.valid).toBeInstanceOf(Date); + expect((frontmatter.valid as Date).toISOString()).toBe( + "2024-01-15T10:30:00.000Z", + ); + expect(frontmatter.invalid).toBe("@date:not-a-date"); + }); + + it("does not throw or process frontmatter for invalid structured variables", async () => { + await expect( + service.postProcessFrontMatter( + file("note.md"), + new Map([["callback", () => {}]]), + ), + ).resolves.toBeUndefined(); + + expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); + expect(log.logError).toHaveBeenCalledWith( + expect.stringContaining("Cannot post-process front matter for file note.md"), + ); + }); + + it("logs processFrontMatter failures without rethrowing", async () => { + vi.mocked(app.fileManager.processFrontMatter).mockRejectedValueOnce( + new Error("yaml broke"), + ); + + await expect( + service.postProcessFrontMatter( + file("note.md"), + new Map([["key", "value"]]), + ), + ).resolves.toBeUndefined(); + + expect(log.logError).toHaveBeenCalledWith( + expect.stringContaining("Failed to post-process front matter for file note.md"), + ); + }); +}); diff --git a/src/services/FrontmatterPropertyService.ts b/src/services/FrontmatterPropertyService.ts new file mode 100644 index 00000000..dba94031 --- /dev/null +++ b/src/services/FrontmatterPropertyService.ts @@ -0,0 +1,203 @@ +import type { App, TFile } from "obsidian"; +import { log } from "../logger/logManager"; +import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector"; +import { coerceYamlValue } from "../utils/yamlValues"; + +const VALIDATION_LIMITS = { + MAX_NESTING_DEPTH: 10, +} as const; + +export interface ValidationResult { + isValid: boolean; + warnings: string[]; + errors: string[]; +} + +export class FrontmatterPropertyService { + private static readonly YAML_FRONTMATTER_EXTENSIONS = ["md"]; + + public constructor(private readonly app: App) {} + + public shouldPostProcessFrontMatter( + file: TFile, + templateVars: Map, + ): boolean { + return ( + FrontmatterPropertyService.YAML_FRONTMATTER_EXTENSIONS.includes( + file.extension, + ) && templateVars.size > 0 + ); + } + + public validateStructuredVariables( + templatePropertyVars: Map, + ): ValidationResult { + const warnings: string[] = []; + const errors: string[] = []; + + for (const [key, value] of templatePropertyVars) { + const issues = this.validateValue(key, value, new Set(), 0); + warnings.push(...issues.warnings); + errors.push(...issues.errors); + } + + return { isValid: errors.length === 0, warnings, errors }; + } + + private validateValue( + key: string, + value: unknown, + seen: Set, + depth: number, + ): { warnings: string[]; errors: string[] } { + const warnings: string[] = []; + const errors: string[] = []; + + if (typeof value === "function") { + errors.push( + `Variable "${key}" contains a function, which cannot be serialized to YAML`, + ); + return { warnings, errors }; + } + + if (typeof value === "symbol") { + errors.push( + `Variable "${key}" contains a symbol, which cannot be serialized to YAML`, + ); + return { warnings, errors }; + } + + if (typeof value === "bigint") { + warnings.push( + `Variable "${key}" contains a BigInt, which will be converted to a string`, + ); + return { warnings, errors }; + } + + if (value === null || value === undefined) return { warnings, errors }; + if (typeof value !== "object") return { warnings, errors }; + + if (seen.has(value)) { + errors.push(`Variable "${key}" contains a circular reference`); + return { warnings, errors }; + } + + if (depth >= VALIDATION_LIMITS.MAX_NESTING_DEPTH) { + errors.push( + `Variable "${key}" exceeds maximum nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}`, + ); + return { warnings, errors }; + } + + seen.add(value); + + try { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const childResult = this.validateValue( + `${key}[${i}]`, + value[i], + seen, + depth + 1, + ); + warnings.push(...childResult.warnings); + errors.push(...childResult.errors); + } + } else { + for (const [childKey, childValue] of Object.entries(value)) { + const childResult = this.validateValue( + `${key}.${childKey}`, + childValue, + seen, + depth + 1, + ); + warnings.push(...childResult.warnings); + errors.push(...childResult.errors); + } + } + } finally { + seen.delete(value); + } + + return { warnings, errors }; + } + + public async postProcessFrontMatter( + file: TFile, + templatePropertyVars: Map, + ): Promise { + const validation = this.validateStructuredVariables(templatePropertyVars); + + for (const warning of validation.warnings) { + log.logWarning(`Structured variable validation warning: ${warning}`); + } + + if (!validation.isValid) { + const errorSummary = validation.errors.join("; "); + log.logError( + `Cannot post-process front matter for file ${file.path} due to validation errors: ${errorSummary}. ` + + `The file was created successfully, but some structured variables may not be properly formatted. ` + + `Please check the variable values and ensure they don't contain circular references, ` + + `exceed nesting depth of ${VALIDATION_LIMITS.MAX_NESTING_DEPTH}, or contain unsupported types (functions, symbols).`, + ); + return; + } + + try { + log.logMessage( + `Post-processing front matter for ${file.path} with ${templatePropertyVars.size} structured variables`, + ); + log.logMessage( + `Variable types: ${Array.from(templatePropertyVars.entries()) + .map(([key, value]) => `${key}:${typeof value}`) + .join(", ")}`, + ); + + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + for (const [key, value] of templatePropertyVars) { + const pathSegments = key.includes( + TemplatePropertyCollector.PATH_SEPARATOR, + ) + ? key.split(TemplatePropertyCollector.PATH_SEPARATOR) + : [key]; + this.assignFrontmatterValue( + frontmatter, + pathSegments, + coerceYamlValue(value), + ); + } + }); + + log.logMessage(`Successfully post-processed front matter for ${file.path}`); + } catch (err) { + log.logError( + `Failed to post-process front matter for file ${file.path}: ${err}. ` + + `The file was created successfully, but structured variables may not be properly formatted. ` + + `This usually happens when variable values contain unexpected types or when Obsidian's YAML processor encounters an issue. ` + + `Check the console for more details about which variables caused the problem.`, + ); + } + } + + private assignFrontmatterValue( + frontmatter: Record, + path: string[], + value: unknown, + ): void { + if (path.length === 0) return; + let target = frontmatter; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const existing = target[segment]; + if ( + typeof existing !== "object" || + existing === null || + Array.isArray(existing) + ) { + target[segment] = {}; + } + target = target[segment] as Record; + } + target[path[path.length - 1]] = value; + } +} diff --git a/src/services/VaultFileService.test.ts b/src/services/VaultFileService.test.ts new file mode 100644 index 00000000..5b9d738a --- /dev/null +++ b/src/services/VaultFileService.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { TFile, TFolder } from "obsidian"; +import { VaultFileService } from "./VaultFileService"; +import { withTemplaterFileCreationSuppressed } from "../utilityObsidian"; +import type * as UtilityObsidian from "../utilityObsidian"; + +vi.mock("../utilityObsidian", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withTemplaterFileCreationSuppressed: vi.fn( + async (_app, _path, createFile: () => Promise) => createFile(), + ), + }; +}); + +function file(path: string, extension = "md"): TFile { + const result = new TFile(); + result.path = path; + result.extension = extension; + return result; +} + +function folder(path: string): TFolder { + const result = new TFolder(); + result.path = path; + return result; +} + +describe("VaultFileService", () => { + let app: App; + let service: VaultFileService; + let abstractFiles: Map; + + beforeEach(() => { + abstractFiles = new Map(); + app = { + vault: { + adapter: { exists: vi.fn() }, + getAbstractFileByPath: vi.fn((path: string) => abstractFiles.get(path)), + createFolder: vi.fn(), + create: vi.fn(async (path: string) => file(path)), + }, + } as unknown as App; + service = new VaultFileService(app); + vi.mocked(withTemplaterFileCreationSuppressed).mockClear(); + }); + + it("returns files by path and rejects missing paths and folders", () => { + const note = file("notes/a.md"); + abstractFiles.set("notes/a.md", note); + abstractFiles.set("notes", folder("notes")); + + expect(service.getFileByPath("notes/a.md")).toBe(note); + expect(() => service.getFileByPath("missing.md")).toThrow( + "missing.md not found", + ); + expect(() => service.getFileByPath("notes")).toThrow( + "notes found but it's a folder", + ); + }); + + it("delegates file existence to the adapter", async () => { + vi.mocked(app.vault.adapter.exists).mockResolvedValueOnce(true); + await expect(service.fileExists("a.md")).resolves.toBe(true); + expect(app.vault.adapter.exists).toHaveBeenCalledWith("a.md"); + + vi.mocked(app.vault.adapter.exists).mockResolvedValueOnce(false); + await expect(service.fileExists("b.md")).resolves.toBe(false); + expect(app.vault.adapter.exists).toHaveBeenCalledWith("b.md"); + }); + + it("creates folders idempotently", async () => { + vi.mocked(app.vault.adapter.exists).mockResolvedValueOnce(false); + await service.createFolder("notes"); + expect(app.vault.createFolder).toHaveBeenCalledWith("notes"); + + vi.mocked(app.vault.createFolder).mockClear(); + vi.mocked(app.vault.adapter.exists).mockResolvedValueOnce(true); + await service.createFolder("notes"); + expect(app.vault.createFolder).not.toHaveBeenCalled(); + }); + + it("creates missing parent folders before files and skips root parents", async () => { + vi.mocked(app.vault.adapter.exists).mockResolvedValue(false); + + await service.createFileWithInput("notes/new.md", "body"); + expect(app.vault.createFolder).toHaveBeenCalledWith("notes"); + expect(app.vault.create).toHaveBeenCalledWith("notes/new.md", "body"); + expect( + vi.mocked(app.vault.createFolder).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(app.vault.create).mock.invocationCallOrder[0]); + + vi.mocked(app.vault.createFolder).mockClear(); + vi.mocked(app.vault.create).mockClear(); + await service.createFileWithInput("root.md", "body"); + expect(app.vault.createFolder).not.toHaveBeenCalled(); + expect(app.vault.create).toHaveBeenCalledWith("root.md", "body"); + }); + + it("suppresses templater on-create only for markdown files when requested", async () => { + await service.createFileWithInput("note.md", "body", { + suppressTemplaterOnCreate: true, + }); + expect(withTemplaterFileCreationSuppressed).toHaveBeenCalledTimes(1); + expect(withTemplaterFileCreationSuppressed).toHaveBeenCalledWith( + app, + "note.md", + expect.any(Function), + ); + + vi.mocked(withTemplaterFileCreationSuppressed).mockClear(); + await service.createFileWithInput("board.canvas", "{}", { + suppressTemplaterOnCreate: true, + }); + await service.createFileWithInput("plain.md", "body"); + expect(withTemplaterFileCreationSuppressed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/VaultFileService.ts b/src/services/VaultFileService.ts new file mode 100644 index 00000000..769f85f4 --- /dev/null +++ b/src/services/VaultFileService.ts @@ -0,0 +1,81 @@ +import type { App } from "obsidian"; +import { TFile, TFolder } from "obsidian"; +import { MARKDOWN_FILE_EXTENSION_REGEX } from "../constants"; +import { log } from "../logger/logManager"; +import { withTemplaterFileCreationSuppressed } from "../utilityObsidian"; + +export class VaultFileService { + public constructor(private readonly app: App) {} + + public stripLeadingSlash(path: string): string { + return path.replace(/^\/+/, ""); + } + + public normalizeMarkdownFilePath( + folderPath: string, + fileName: string, + ): string { + const safeFolderPath = this.stripLeadingSlash(folderPath); + const actualFolderPath = safeFolderPath ? `${safeFolderPath}/` : ""; + const formattedFileName = this.stripLeadingSlash(fileName).replace( + MARKDOWN_FILE_EXTENSION_REGEX, + "", + ); + return `${actualFolderPath}${formattedFileName}.md`; + } + + public async fileExists(filePath: string): Promise { + return await this.app.vault.adapter.exists(filePath); + } + + public getFileByPath(filePath: string): TFile { + const file = this.app.vault.getAbstractFileByPath(filePath); + + if (!file) { + log.logError(`${filePath} not found`); + throw new Error(`${filePath} not found`); + } + + if (file instanceof TFolder) { + log.logError(`${filePath} found but it's a folder`); + throw new Error(`${filePath} found but it's a folder`); + } + + if (!(file instanceof TFile)) throw new Error(`${filePath} is not a file`); + + return file; + } + + public async createFolder(folder: string): Promise { + const folderExists = await this.app.vault.adapter.exists(folder); + + if (!folderExists) { + await this.app.vault.createFolder(folder); + } + } + + public async createFileWithInput( + filePath: string, + fileContent: string, + opts: { suppressTemplaterOnCreate?: boolean } = {}, + ): Promise { + const dirMatch = filePath.match(/(.*)[/\\]/); + const dirName = dirMatch ? dirMatch[1] : ""; + + if (dirName) { + const dir = this.app.vault.getAbstractFileByPath(dirName); + + if (!dir || !(dir instanceof TFolder)) { + await this.createFolder(dirName); + } + } + + const createFile = () => this.app.vault.create(filePath, fileContent); + const shouldSuppress = + opts.suppressTemplaterOnCreate && filePath.toLowerCase().endsWith(".md"); + + return shouldSuppress + ? await withTemplaterFileCreationSuppressed(this.app, filePath, createFile) + : await createFile(); + } +}