Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const createEngine = (
},
getAbstractFileByPath: vi.fn(),
getFiles: vi.fn(() => []),
createFolder: vi.fn(),
create: vi.fn(),
modify: vi.fn(),
},
Expand Down Expand Up @@ -332,3 +333,161 @@ describe("TemplateChoiceEngine file casing resolution", () => {
);
});
});

describe("TemplateChoiceEngine destination path resolution", () => {
beforeEach(() => {
settingsStore.setState(structuredClone(defaultSettingsState));
formatFileNameMock.mockReset();
formatFileContentMock.mockReset();
formatFileContentMock.mockResolvedValue("");
});

it("treats slash-separated filename formats as vault-relative paths when the first segment exists", 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<TFile | null>;
},
"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<typeof vi.fn>).mockReturnValue({
path: "03_Aufgabenmanagement/ToDos/W-Tanso",
});
(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockImplementation(
async (path: string) => path === "03_Aufgabenmanagement",
);

await engine.run();

expect(createSpy).toHaveBeenCalledWith(
"03_Aufgabenmanagement/ToDos/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<TFile | null>;
},
"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<typeof vi.fn>).mockReturnValue({
path: "projects",
});

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<TFile | null>;
},
"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<typeof vi.fn>).mockReturnValue({
path: "03_Aufgabenmanagement/ToDos/W-Tanso",
});
(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(false);

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<TFile | null>;
},
"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<typeof vi.fn>).mockReturnValue({
path: "03_Aufgabenmanagement/ToDos/W-Tanso",
});
(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockImplementation(
async () => false,
);

await engine.run();

expect(createSpy).toHaveBeenCalledWith(
"03_Aufgabenmanagement/ToDos/W-Tanso/tasks/Issue1116.md",
engine.choice.templatePath,
);
});
});
66 changes: 56 additions & 10 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,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
Expand All @@ -79,11 +79,19 @@ export class TemplateChoiceEngine extends TemplateEngine {
format,
this.choice.name,
);

const { fileName, strippedPrefix } = this.stripDuplicateFolderPrefix(
formattedName,
folderPath,
);
const treatAsVaultRelativePath =
await this.shouldTreatFormattedNameAsVaultRelativePath(
formattedName,
strippedPrefix,
);
Comment thread
chhoumann marked this conversation as resolved.

let filePath = this.normalizeTemplateFilePath(
folderPath,
formattedName,
treatAsVaultRelativePath ? "" : folderPath,
fileName,
this.choice.templatePath,
);

Expand Down Expand Up @@ -304,6 +312,44 @@ 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 async shouldTreatFormattedNameAsVaultRelativePath(
formattedName: string,
strippedPrefix: boolean,
): Promise<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 slashCount = normalizedFileName.split("/").length - 1;
// Keep one-level subpaths (e.g. "tasks/note") relative to Obsidian's
// default folder, while treating deeper paths as vault-relative.
return slashCount >= 2;
Comment thread
chhoumann marked this conversation as resolved.
Outdated
}

private getCurrentFolderSuggestion():
| { path: string; label: string }
| null {
Expand Down