Skip to content
Closed
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
106 changes: 103 additions & 3 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<void> {
Expand Down Expand Up @@ -263,6 +293,76 @@ export class TemplateChoiceEngine extends TemplateEngine {
}
}

private async getOrCreateFolder(
folders: string[],
options: FolderChoiceOptions = {},
): Promise<string> {
return await this.folderSelectionService.getOrCreateFolder(
folders,
options,
);
}

private setLinkToCurrentFileBehavior(
behavior: Parameters<CompleteFormatter["setLinkToCurrentFileBehavior"]>[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<TFile | null> {
const templateContent = await this.getTemplateContent(templatePath);
return await this.templateFileService.createFileWithTemplateContent(
filePath,
templateContent,
this.templateEvaluator,
);
}

private async overwriteFileWithTemplate(
file: TFile,
templatePath: string,
): Promise<TFile | null> {
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<TFile | null> {
const templateContent = await this.getTemplateContent(templatePath);
return await this.templateFileService.appendToFileWithTemplateContent(
file,
templateContent,
section,
this.templateEvaluator,
);
}

private async getTemplateContent(templatePath: string): Promise<string> {
return await this.templateFileService.readTemplateContent(templatePath);
}

/**
* Resolve an existing file by path with a case-insensitive fallback.
*
Expand Down
154 changes: 154 additions & 0 deletions src/services/FolderSelectionService.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>()) {
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",
]);
});
});
31 changes: 26 additions & 5 deletions src/services/FolderSelectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type FolderSelection = {
raw: string;
normalized: string;
resolved: string;
validationPath: string;
exists: boolean;
isAllowed: boolean;
isEmpty: boolean;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -152,6 +171,7 @@ export class FolderSelectionService {
context: FolderSelectionContext,
): Promise<FolderSelection> {
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;
Expand All @@ -170,6 +190,7 @@ export class FolderSelectionService {
raw,
normalized,
resolved,
validationPath: canonical ?? validationPath,
exists,
isAllowed,
isEmpty,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
Loading