Skip to content

Commit 3297d54

Browse files
committed
fix: make template path resolution deterministic
1 parent 7bfd41b commit 3297d54

2 files changed

Lines changed: 64 additions & 23 deletions

File tree

src/engine/TemplateChoiceEngine.notice.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,41 @@ describe("TemplateChoiceEngine destination path resolution", () => {
382382
);
383383
});
384384

385+
it("does not drop default-folder prefix after duplicate-prefix stripping", async () => {
386+
const { engine, app } = createEngine("ignored", {
387+
throwDuringFileName: false,
388+
stubTemplateContent: true,
389+
});
390+
const createdFile = new TFile();
391+
const createSpy = vi
392+
.spyOn(
393+
engine as unknown as {
394+
createFileWithTemplate: (
395+
filePath: string,
396+
templatePath: string,
397+
) => Promise<TFile | null>;
398+
},
399+
"createFileWithTemplate",
400+
)
401+
.mockResolvedValue(createdFile);
402+
403+
engine.choice.folder.enabled = false;
404+
engine.choice.fileNameFormat.enabled = true;
405+
engine.choice.fileNameFormat.format = "{{VALUE:path}}";
406+
407+
formatFileNameMock.mockResolvedValueOnce("projects/docs/readme");
408+
(app.fileManager.getNewFileParent as ReturnType<typeof vi.fn>).mockReturnValue({
409+
path: "projects",
410+
});
411+
412+
await engine.run();
413+
414+
expect(createSpy).toHaveBeenCalledWith(
415+
"projects/docs/readme.md",
416+
engine.choice.templatePath,
417+
);
418+
});
419+
385420
it("keeps Obsidian default location behavior for plain file names", async () => {
386421
const { engine, app } = createEngine("ignored", {
387422
throwDuringFileName: false,

src/engine/TemplateChoiceEngine.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,23 @@ export class TemplateChoiceEngine extends TemplateEngine {
7575
const format = this.choice.fileNameFormat.enabled
7676
? this.choice.fileNameFormat.format
7777
: VALUE_SYNTAX;
78-
const formattedName = this.stripDuplicateFolderPrefix(
79-
await this.formatter.formatFileName(
80-
format,
81-
this.choice.name,
82-
),
78+
const formattedName = await this.formatter.formatFileName(
79+
format,
80+
this.choice.name,
81+
);
82+
const { fileName, strippedPrefix } = this.stripDuplicateFolderPrefix(
83+
formattedName,
8384
folderPath,
8485
);
8586
const treatAsVaultRelativePath =
8687
await this.shouldTreatFormattedNameAsVaultRelativePath(
8788
formattedName,
88-
folderPath,
89+
strippedPrefix,
8990
);
9091

9192
let filePath = this.normalizeTemplateFilePath(
9293
treatAsVaultRelativePath ? "" : folderPath,
93-
formattedName,
94+
fileName,
9495
this.choice.templatePath,
9596
);
9697

@@ -311,37 +312,42 @@ export class TemplateChoiceEngine extends TemplateEngine {
311312
});
312313
}
313314

314-
private stripDuplicateFolderPrefix(fileName: string, folderPath: string): string {
315+
private stripDuplicateFolderPrefix(
316+
fileName: string,
317+
folderPath: string,
318+
): { fileName: string; strippedPrefix: boolean } {
315319
const normalizedFolder = this.stripLeadingSlash(folderPath);
316320
const normalizedFileName = this.stripLeadingSlash(fileName);
317321

318-
if (!normalizedFolder) return normalizedFileName;
322+
if (!normalizedFolder) {
323+
return { fileName: normalizedFileName, strippedPrefix: false };
324+
}
319325
if (!normalizedFileName.startsWith(`${normalizedFolder}/`)) {
320-
return normalizedFileName;
326+
return { fileName: normalizedFileName, strippedPrefix: false };
321327
}
322328

323-
return normalizedFileName.slice(normalizedFolder.length + 1);
329+
return {
330+
fileName: normalizedFileName.slice(normalizedFolder.length + 1),
331+
strippedPrefix: true,
332+
};
324333
}
325334

326335
private async shouldTreatFormattedNameAsVaultRelativePath(
327336
formattedName: string,
328-
folderPath: string,
337+
strippedPrefix: boolean,
329338
): Promise<boolean> {
330339
if (this.choice.folder.enabled) return false;
340+
if (strippedPrefix) return false;
331341

332-
const normalizedFileName = this.stripLeadingSlash(formattedName);
342+
const normalizedFileName = formattedName.trim();
333343
if (!normalizedFileName.includes("/")) return false;
344+
if (normalizedFileName.startsWith("./")) return false;
345+
if (normalizedFileName.startsWith("/")) return true;
334346

335-
const normalizedFolder = this.stripLeadingSlash(folderPath);
336-
if (!normalizedFolder) return true;
337-
338-
const [firstSegment] = normalizedFileName.split("/");
339-
if (!firstSegment) return false;
340-
341-
// Preserve legacy "relative subpath under default note location" behavior.
342-
// Only skip default folder prefixing when the first path segment exists at
343-
// vault root, which indicates an explicit vault-relative destination.
344-
return await this.app.vault.adapter.exists(firstSegment);
347+
const slashCount = normalizedFileName.split("/").length - 1;
348+
// Keep one-level subpaths (e.g. "tasks/note") relative to Obsidian's
349+
// default folder, while treating deeper paths as vault-relative.
350+
return slashCount >= 2;
345351
}
346352

347353
private getCurrentFolderSuggestion():

0 commit comments

Comments
 (0)