diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 35788b53..1def1a46 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -100,7 +100,7 @@ vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn(), })); -import { TFile, type App } from "obsidian"; +import { TFile, TFolder, type App } from "obsidian"; import { Notice } from "obsidian"; import { TemplateChoiceEngine } from "./TemplateChoiceEngine"; import type { IChoiceExecutor } from "../IChoiceExecutor"; @@ -161,6 +161,7 @@ const createEngine = ( }, getAbstractFileByPath: vi.fn(), getFiles: vi.fn(() => []), + createFolder: vi.fn(), create: vi.fn(), modify: vi.fn(), }, @@ -332,3 +333,335 @@ describe("TemplateChoiceEngine file casing resolution", () => { ); }); }); + +describe("TemplateChoiceEngine destination path resolution", () => { + beforeEach(() => { + settingsStore.setState(structuredClone(defaultSettingsState)); + formatFileNameMock.mockReset(); + formatFileContentMock.mockReset(); + formatFileContentMock.mockResolvedValue(""); + }); + + it("treats deep slash-separated filename formats as vault-relative paths", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce( + "03_Aufgabenmanagement/ToDos/Issue1116", + ); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "03_Aufgabenmanagement/ToDos/W-Tanso", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockImplementation( + (path: string) => { + if (path === "03_Aufgabenmanagement") { + const folder = new TFolder(); + folder.path = "03_Aufgabenmanagement"; + folder.name = "03_Aufgabenmanagement"; + return folder; + } + return null; + }, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "03_Aufgabenmanagement/ToDos/Issue1116.md", + engine.choice.templatePath, + ); + }); + + it("treats leading-slash filename formats as vault-relative paths", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce("/Projects/Issue1116"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "03_Aufgabenmanagement/ToDos/W-Tanso", + }); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "Projects/Issue1116.md", + engine.choice.templatePath, + ); + }); + + it("does not drop default-folder prefix after duplicate-prefix stripping", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce("projects/docs/readme"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "projects", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + null, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "projects/docs/readme.md", + engine.choice.templatePath, + ); + }); + + it("keeps Obsidian default location behavior for plain file names", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:name}}"; + + formatFileNameMock.mockResolvedValueOnce("Issue1116"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "03_Aufgabenmanagement/ToDos/W-Tanso", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + null, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "03_Aufgabenmanagement/ToDos/W-Tanso/Issue1116.md", + engine.choice.templatePath, + ); + }); + + it("keeps relative subpaths under the default location when the first segment does not exist at vault root", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce("tasks/Issue1116"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "03_Aufgabenmanagement/ToDos/W-Tanso", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockImplementation( + (path: string) => { + if (path === "tasks") return null; + return null; + }, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "03_Aufgabenmanagement/ToDos/W-Tanso/tasks/Issue1116.md", + engine.choice.templatePath, + ); + }); + + it("keeps deep relative subpaths under the default location when root segment is missing", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce("sub/tasks/Issue1116"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "03_Aufgabenmanagement/ToDos/W-Tanso", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockReturnValue( + null, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "03_Aufgabenmanagement/ToDos/W-Tanso/sub/tasks/Issue1116.md", + engine.choice.templatePath, + ); + }); + + it("does not treat root-level files as folder roots for vault-relative detection", async () => { + const { engine, app } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + + engine.choice.folder.enabled = false; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + + formatFileNameMock.mockResolvedValueOnce("notes/Session"); + (app.fileManager.getNewFileParent as ReturnType).mockReturnValue({ + path: "DailyNotes", + }); + (app.vault.getAbstractFileByPath as ReturnType).mockImplementation( + (path: string) => { + if (path === "notes") { + const file = new TFile(); + file.path = "notes"; + file.name = "notes"; + file.basename = "notes"; + file.extension = ""; + return file; + } + return null; + }, + ); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "DailyNotes/notes/Session.md", + engine.choice.templatePath, + ); + }); + + it("never treats filename formats as vault-relative when create in folder is enabled", async () => { + const { engine } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + const createdFile = new TFile(); + const createSpy = vi + .spyOn( + engine as unknown as { + createFileWithTemplate: ( + filePath: string, + templatePath: string, + ) => Promise; + }, + "createFileWithTemplate", + ) + .mockResolvedValue(createdFile); + vi.spyOn( + engine as unknown as { + getFolderPath: () => Promise; + }, + "getFolderPath", + ).mockResolvedValue("ConfiguredFolder"); + + engine.choice.folder.enabled = true; + engine.choice.fileNameFormat.enabled = true; + engine.choice.fileNameFormat.format = "{{VALUE:path}}"; + formatFileNameMock.mockResolvedValueOnce("RootLike/Path/Issue1116"); + + await engine.run(); + + expect(createSpy).toHaveBeenCalledWith( + "ConfiguredFolder/RootLike/Path/Issue1116.md", + engine.choice.templatePath, + ); + }); +}); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 9f5fd7b4..3af34dab 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -1,5 +1,6 @@ import type { App } from "obsidian"; import { TFile } from "obsidian"; +import { TFolder } from "obsidian"; import invariant from "src/utils/invariant"; import { fileExistsAppendToBottom, @@ -63,14 +64,14 @@ export class TemplateChoiceEngine extends TemplateEngine { let folderPath = ""; if (this.choice.folder.enabled) { - folderPath = await this.getFolderPath(); + folderPath = await this.getFolderPath(); } else { - // Respect Obsidian's "Default location for new notes" setting - const parent = this.app.fileManager.getNewFileParent( - this.app.workspace.getActiveFile()?.path ?? "" - ); - folderPath = parent === this.app.vault.getRoot() ? "" : parent.path; - } + // Respect Obsidian's "Default location for new notes" setting + const parent = this.app.fileManager.getNewFileParent( + this.app.workspace.getActiveFile()?.path ?? "", + ); + folderPath = parent === this.app.vault.getRoot() ? "" : parent.path; + } const format = this.choice.fileNameFormat.enabled ? this.choice.fileNameFormat.format @@ -79,11 +80,19 @@ export class TemplateChoiceEngine extends TemplateEngine { format, this.choice.name, ); - + const { fileName, strippedPrefix } = this.stripDuplicateFolderPrefix( + formattedName, + folderPath, + ); + const treatAsVaultRelativePath = + this.shouldTreatFormattedNameAsVaultRelativePath( + formattedName, + strippedPrefix, + ); let filePath = this.normalizeTemplateFilePath( - folderPath, - formattedName, + treatAsVaultRelativePath ? "" : folderPath, + fileName, this.choice.templatePath, ); @@ -304,6 +313,46 @@ export class TemplateChoiceEngine extends TemplateEngine { }); } + private stripDuplicateFolderPrefix( + fileName: string, + folderPath: string, + ): { fileName: string; strippedPrefix: boolean } { + const normalizedFolder = this.stripLeadingSlash(folderPath); + const normalizedFileName = this.stripLeadingSlash(fileName); + + if (!normalizedFolder) { + return { fileName: normalizedFileName, strippedPrefix: false }; + } + if (!normalizedFileName.startsWith(`${normalizedFolder}/`)) { + return { fileName: normalizedFileName, strippedPrefix: false }; + } + + return { + fileName: normalizedFileName.slice(normalizedFolder.length + 1), + strippedPrefix: true, + }; + } + + private shouldTreatFormattedNameAsVaultRelativePath( + formattedName: string, + strippedPrefix: boolean, + ): boolean { + if (this.choice.folder.enabled) return false; + if (strippedPrefix) return false; + + const normalizedFileName = formattedName.trim(); + if (!normalizedFileName.includes("/")) return false; + if (normalizedFileName.startsWith("./")) return false; + + if (normalizedFileName.startsWith("/")) return true; + + const [firstSegment] = this.stripLeadingSlash(normalizedFileName).split("/"); + if (!firstSegment) return false; + + const rootEntry = this.app.vault.getAbstractFileByPath(firstSegment); + return rootEntry instanceof TFolder; + } + private getCurrentFolderSuggestion(): | { path: string; label: string } | null {