Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
62 changes: 59 additions & 3 deletions src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ vi.mock("obsidian-dataview", () => ({
getAPI: vi.fn(),
}));

import type { App } from "obsidian";
import { TFile, type App } from "obsidian";
import { Notice } from "obsidian";
import { TemplateChoiceEngine } from "./TemplateChoiceEngine";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import type ITemplateChoice from "../types/choices/ITemplateChoice";
import { MacroAbortError } from "../errors/MacroAbortError";
import { settingsStore } from "../settingsStore";
import { fileExistsAppendToBottom } from "../constants";
import { fileExistsAppendToBottom, fileExistsOverwriteFile } from "../constants";

const defaultSettingsState = structuredClone(settingsStore.getState());

Expand Down Expand Up @@ -158,6 +158,7 @@ const createEngine = (
exists: vi.fn(async () => false),
},
getAbstractFileByPath: vi.fn(),
getFiles: vi.fn(() => []),
create: vi.fn(),
modify: vi.fn(),
},
Expand Down Expand Up @@ -194,7 +195,7 @@ const createEngine = (
formatFileNameMock.mockResolvedValue("Test Template");
}

return { engine, choiceExecutor };
return { engine, choiceExecutor, app };
};

describe("TemplateChoiceEngine cancellation notices", () => {
Expand Down Expand Up @@ -274,3 +275,58 @@ describe("TemplateChoiceEngine cancellation notices", () => {
expect(choiceExecutor.signalAbort).toHaveBeenCalledTimes(1);
});
});

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

it("overwrites existing files when the path casing differs", async () => {
const { engine, app } = createEngine("ignored", {
throwDuringFileName: false,
stubTemplateContent: true,
});

const existingFile = new TFile();
existingFile.path = "Bug report.md";
existingFile.name = "Bug report.md";
existingFile.extension = "md";
existingFile.basename = "Bug report";

engine.choice.fileExistsMode = fileExistsOverwriteFile;
engine.choice.setFileExistsBehavior = true;
formatFileNameMock.mockResolvedValueOnce("Bug Report");

(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(
true,
);
(app.vault.getAbstractFileByPath as ReturnType<typeof vi.fn>).mockReturnValue(
null,
);
(app.vault.getFiles as ReturnType<typeof vi.fn>).mockReturnValue([
existingFile,
]);

const overwriteSpy = vi
.spyOn(
engine as unknown as {
overwriteFileWithTemplate: (
file: TFile,
templatePath: string,
) => Promise<TFile | null>;
},
"overwriteFileWithTemplate",
)
.mockResolvedValue(existingFile);

await engine.run();

expect(overwriteSpy).toHaveBeenCalledWith(
existingFile,
engine.choice.templatePath,
);
});
});
35 changes: 33 additions & 2 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ export class TemplateChoiceEngine extends TemplateEngine {
let createdFile: TFile | null;
let shouldAutoOpen = false;
if (await this.app.vault.adapter.exists(filePath)) {
const file = this.app.vault.getAbstractFileByPath(filePath);
const file = this.findExistingFile(filePath);
if (
!(file instanceof TFile) ||
(file.extension !== "md" && file.extension !== "canvas")
) {
log.logError(
`'${filePath}' already exists and is not a valid markdown or canvas file.`,
`'${filePath}' already exists but could not be resolved as a markdown or canvas file.`,
);
return;
}
Expand Down Expand Up @@ -203,6 +203,37 @@ export class TemplateChoiceEngine extends TemplateEngine {
}
}

/**
* Resolve an existing file by path with a case-insensitive fallback.
*
* Obsidian's in-memory file index is case-sensitive, but on
* case-insensitive filesystems adapter.exists can still return true.
* If a direct lookup fails, scan the vault for a single case-insensitive
* match. Multiple matches are treated as ambiguous and return null.
*/
private findExistingFile(filePath: string): TFile | null {
const direct = this.app.vault.getAbstractFileByPath(filePath);
if (direct instanceof TFile) return direct;
if (direct) return null;

// On case-insensitive filesystems, adapter.exists can return true even when
// Obsidian's case-sensitive path index can't resolve the file.
const lowerPath = filePath.toLowerCase();
const matches = this.app.vault
.getFiles()
.filter((file) => file.path.toLowerCase() === lowerPath);

if (matches.length === 1) return matches[0];
if (matches.length > 1) {
const matchList = matches.map((match) => match.path).join(", ");
log.logError(
`Multiple files match '${filePath}' when ignoring case: ${matchList}`,
);
}

return null;
}

private async formatFolderPaths(folders: string[]) {
const folderPaths = await Promise.all(
folders.map(async (folder) => {
Expand Down