diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index dbe45dac..6edbee7c 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -3,10 +3,21 @@ import { TFile } from "obsidian"; import { TFolder } from "obsidian"; import invariant from "src/utils/invariant"; import { VALUE_SYNTAX } from "../constants"; +import type { CompleteFormatter } from "../formatters/completeFormatter"; import GenericSuggester from "../gui/GenericSuggester/genericSuggester"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { log } from "../logger/logManager"; import type QuickAdd from "../main"; +import { + FolderSelectionService, + type FolderChoiceOptions, +} from "../services/FolderSelectionService"; +import { FormatterFactory } from "../services/FormatterFactory"; +import { + TemplateEvaluator, + TemplateFileService, +} from "../services/TemplateFileService"; +import { VaultFileService } from "../services/VaultFileService"; import { getFileExistsMode, getPromptModes, @@ -28,13 +39,18 @@ import { sortFolderPathsByTree, } from "../utils/folder-sorting"; import { normalizeFileOpening } from "../utils/fileOpeningDefaults"; -import { TemplateEngine } from "./TemplateEngine"; import { MacroAbortError } from "../errors/MacroAbortError"; import { handleMacroAbort } from "../utils/macroAbortHandler"; -export class TemplateChoiceEngine extends TemplateEngine { +export class TemplateChoiceEngine { public choice: ITemplateChoice; + public app: App; + private readonly formatter: CompleteFormatter; private readonly choiceExecutor: IChoiceExecutor; + private readonly vaultFileService: VaultFileService; + private readonly folderSelectionService: FolderSelectionService; + private readonly templateFileService: TemplateFileService; + private readonly templateEvaluator: TemplateEvaluator; constructor( app: App, @@ -43,9 +59,23 @@ export class TemplateChoiceEngine extends TemplateEngine { choiceExecutor: IChoiceExecutor, private readonly originLeaf: WorkspaceLeaf | null = null, ) { - super(app, plugin, choiceExecutor); + this.app = app; this.choiceExecutor = choiceExecutor; this.choice = choice; + this.vaultFileService = new VaultFileService(app); + this.formatter = new FormatterFactory( + app, + plugin, + ).createCompleteFormatter(choiceExecutor); + this.folderSelectionService = new FolderSelectionService( + app, + this.vaultFileService, + ); + this.templateFileService = new TemplateFileService( + app, + this.vaultFileService, + ); + this.templateEvaluator = new TemplateEvaluator(this.formatter); } public async run(): Promise { @@ -263,6 +293,76 @@ export class TemplateChoiceEngine extends TemplateEngine { } } + private async getOrCreateFolder( + folders: string[], + options: FolderChoiceOptions = {}, + ): Promise { + return await this.folderSelectionService.getOrCreateFolder( + folders, + options, + ); + } + + private setLinkToCurrentFileBehavior( + behavior: Parameters[0], + ): void { + this.formatter.setLinkToCurrentFileBehavior(behavior); + } + + private normalizeTemplateFilePath( + folderPath: string, + fileName: string, + templatePath: string, + ): string { + return this.templateFileService.normalizeTemplateFilePath( + folderPath, + fileName, + templatePath, + ); + } + + private async createFileWithTemplate( + filePath: string, + templatePath: string, + ): Promise { + const templateContent = await this.getTemplateContent(templatePath); + return await this.templateFileService.createFileWithTemplateContent( + filePath, + templateContent, + this.templateEvaluator, + ); + } + + private async overwriteFileWithTemplate( + file: TFile, + templatePath: string, + ): Promise { + const templateContent = await this.getTemplateContent(templatePath); + return await this.templateFileService.overwriteFileWithTemplateContent( + file, + templateContent, + this.templateEvaluator, + ); + } + + private async appendToFileWithTemplate( + file: TFile, + templatePath: string, + section: "top" | "bottom", + ): Promise { + const templateContent = await this.getTemplateContent(templatePath); + return await this.templateFileService.appendToFileWithTemplateContent( + file, + templateContent, + section, + this.templateEvaluator, + ); + } + + private async getTemplateContent(templatePath: string): Promise { + return await this.templateFileService.readTemplateContent(templatePath); + } + /** * Resolve an existing file by path with a case-insensitive fallback. * diff --git a/src/services/FolderSelectionService.test.ts b/src/services/FolderSelectionService.test.ts new file mode 100644 index 00000000..6143382b --- /dev/null +++ b/src/services/FolderSelectionService.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { genericSuggestMock, inputSuggestMock } = vi.hoisted(() => ({ + genericSuggestMock: vi.fn(), + inputSuggestMock: vi.fn(), +})); + +vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ + default: { + Suggest: genericSuggestMock, + }, +})); + +vi.mock("../gui/InputSuggester/inputSuggester", () => ({ + default: { + Suggest: inputSuggestMock, + }, +})); + +import type { App } from "obsidian"; +import { Notice } from "obsidian"; +import { MacroAbortError } from "../errors/MacroAbortError"; +import { FolderSelectionService } from "./FolderSelectionService"; + +type NoticeTestClass = typeof Notice & { + instances: Array<{ message: string; timeout?: number }>; +}; + +const noticeClass = Notice as unknown as NoticeTestClass; + +function createApp(existingPaths = new Set()) { + const createFolder = vi.fn(async (path: string) => { + existingPaths.add(path); + }); + const exists = vi.fn(async (path: string) => existingPaths.has(path)); + + const app = { + vault: { + adapter: { exists }, + createFolder, + }, + } as unknown as App; + + return { app, createFolder, exists }; +} + +describe("FolderSelectionService", () => { + beforeEach(() => { + genericSuggestMock.mockReset(); + inputSuggestMock.mockReset(); + noticeClass.instances.length = 0; + }); + + it("returns an existing valid single folder without prompting or recreating it", async () => { + const { app, createFolder } = createApp(new Set(["Projects"])); + const service = new FolderSelectionService(app); + + await expect(service.getOrCreateFolder(["Projects"])).resolves.toBe( + "Projects", + ); + + expect(genericSuggestMock).not.toHaveBeenCalled(); + expect(inputSuggestMock).not.toHaveBeenCalled(); + expect(createFolder).not.toHaveBeenCalled(); + }); + + it("throws when a non-prompted single selection is outside allowed roots", async () => { + const { app, createFolder } = createApp(new Set(["Archive"])); + const service = new FolderSelectionService(app); + + await expect( + service.getOrCreateFolder(["Archive"], { + allowedRoots: ["Projects"], + }), + ).rejects.toThrow(new MacroAbortError("Selected folder not allowed.")); + + expect(createFolder).not.toHaveBeenCalled(); + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([ + "Folder must be under: Projects", + ]); + }); + + it("rejects invalid non-prompted single selections before returning or creating", async () => { + const invalidFolders = [ + "Projects/TrailingSpace ", + "Projects/TrailingPeriod.", + "Projects/CON", + "Projects/..", + "Projects/Bad\u0001Name", + 'Projects/Bad"Name', + ]; + + for (const folder of invalidFolders) { + noticeClass.instances.length = 0; + const { app, createFolder } = createApp(); + const service = new FolderSelectionService(app); + + await expect( + service.getOrCreateFolder([folder], { + allowedRoots: ["Projects"], + }), + ).resolves.toBe(""); + + expect(createFolder).not.toHaveBeenCalled(); + expect(noticeClass.instances).toHaveLength(1); + } + }); + + it("notices and reprompts invalid typed folders before creating a valid one", async () => { + const { app, createFolder } = createApp(new Set(["Projects"])); + const service = new FolderSelectionService(app); + + inputSuggestMock + .mockResolvedValueOnce("Projects/Invalid ") + .mockResolvedValueOnce("Projects/Valid"); + + await expect( + service.getOrCreateFolder([], { + allowCreate: true, + allowedRoots: ["Projects"], + }), + ).resolves.toBe("Projects/Valid"); + + expect(inputSuggestMock).toHaveBeenCalledTimes(2); + expect(createFolder).toHaveBeenCalledTimes(1); + expect(createFolder).toHaveBeenCalledWith("Projects/Valid"); + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([ + "Folder name cannot end with a space or a period.", + ]); + }); + + it("notices and reprompts typed folders outside allowed roots", async () => { + const { app, createFolder } = createApp(new Set(["Projects"])); + const service = new FolderSelectionService(app); + + inputSuggestMock + .mockResolvedValueOnce("Archive/New") + .mockResolvedValueOnce("Projects/New"); + + await expect( + service.getOrCreateFolder([], { + allowCreate: true, + allowedRoots: ["Projects"], + }), + ).resolves.toBe("Projects/New"); + + expect(inputSuggestMock).toHaveBeenCalledTimes(2); + expect(createFolder).toHaveBeenCalledTimes(1); + expect(createFolder).toHaveBeenCalledWith("Projects/New"); + expect(noticeClass.instances.map((notice) => notice.message)).toEqual([ + "Folder must be under: Projects", + ]); + }); +}); diff --git a/src/services/FolderSelectionService.ts b/src/services/FolderSelectionService.ts index ec37a2e0..475e4894 100644 --- a/src/services/FolderSelectionService.ts +++ b/src/services/FolderSelectionService.ts @@ -35,6 +35,7 @@ type FolderSelection = { raw: string; normalized: string; resolved: string; + validationPath: string; exists: boolean; isAllowed: boolean; isEmpty: boolean; @@ -71,6 +72,10 @@ export class FolderSelectionService { return path.trim().replace(/^\/+/, "").replace(/\/+$/, ""); } + private normalizeFolderPathForValidation(path: string): string { + return path.replace(/^\/+/, "").replace(/\/+$/, ""); + } + private buildFolderSelectionContext( folders: string[], options: FolderChoiceOptions, @@ -91,6 +96,20 @@ export class FolderSelectionService { allowedRoots.length > 0 ? allowedRoots : undefined, ); + if ( + items.length === 0 && + folders.length === 1 && + !allowCreate && + allowedRoots.length > 0 + ) { + const folder = folders[0] ?? ""; + const normalized = this.normalizeFolderPath(folder); + items.push(folder); + displayItems.push(folder); + normalizedItems.push(normalized); + canonicalByNormalized.set(normalized, folder); + } + return { items, displayItems, @@ -152,6 +171,7 @@ export class FolderSelectionService { context: FolderSelectionContext, ): Promise { const normalized = this.normalizeFolderPath(raw); + const validationPath = this.normalizeFolderPathForValidation(raw); const isEmpty = normalized.length === 0; const canonical = context.canonicalByNormalized.get(normalized); const resolved = canonical ?? normalized; @@ -170,6 +190,7 @@ export class FolderSelectionService { raw, normalized, resolved, + validationPath: canonical ?? validationPath, exists, isAllowed, isEmpty, @@ -197,7 +218,7 @@ export class FolderSelectionService { } try { - this.validateFolderPath(selection.resolved); + this.validateFolderPath(selection.validationPath); } catch (error) { if (error instanceof InvalidFolderPathError) { new Notice(error.message); @@ -230,7 +251,7 @@ export class FolderSelectionService { if (selection.resolved) { try { - this.validateFolderPath(selection.resolved); + this.validateFolderPath(selection.validationPath); } catch (error) { if (error instanceof InvalidFolderPathError) { new Notice(error.message); @@ -245,10 +266,10 @@ export class FolderSelectionService { } private validateFolderPath(path: string): void { - const trimmed = path.trim(); - if (!trimmed) return; + const normalized = this.normalizeFolderPathForValidation(path); + if (!normalized.trim()) return; - const segments = trimmed.split("/"); + const segments = normalized.split("/"); for (const segment of segments) { this.validateFolderSegment(segment); }