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
44 changes: 44 additions & 0 deletions src/engine/CaptureChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,48 @@ describe("CaptureChoiceEngine cancellation notices", () => {
"Capture execution aborted: Target file missing",
);
});

it("shows a notice when the target file is missing and create is disabled", async () => {
settingsStore.setState({
...settingsStore.getState(),
showInputCancellationNotification: false,
});

const app = {
vault: {
adapter: {
exists: vi.fn(async () => false),
},
getAbstractFileByPath: vi.fn(),
modify: vi.fn(),
create: vi.fn(),
},
workspace: {
getActiveFile: vi.fn(() => null),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
},
} as unknown as App;

const plugin = { settings: settingsStore.getState() } as any;
const choiceExecutor: IChoiceExecutor = {
execute: vi.fn(),
variables: new Map<string, unknown>(),
};

const engine = new CaptureChoiceEngine(
app,
plugin,
createCaptureChoice(),
choiceExecutor,
);

await engine.run();

expect(noticeClass.instances).toHaveLength(1);
expect(noticeClass.instances[0]?.message).toContain(
"Capture execution aborted: Target file missing",
);
});
});
58 changes: 57 additions & 1 deletion src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { App } from "obsidian";
import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { openFile } from "../utilityObsidian";
import { isFolder, openFile } from "../utilityObsidian";

const { setUseSelectionAsCaptureValueMock } = vi.hoisted(() => ({
setUseSelectionAsCaptureValueMock: vi.fn(),
Expand Down Expand Up @@ -74,6 +74,7 @@ const createApp = () =>
adapter: {
exists: vi.fn(async () => false),
},
getAbstractFileByPath: vi.fn(() => null),
},
workspace: {
getActiveFile: vi.fn(() => null),
Expand Down Expand Up @@ -209,3 +210,58 @@ describe("CaptureChoiceEngine selection-as-value resolution", () => {
);
});
});

describe("CaptureChoiceEngine capture target resolution", () => {
beforeEach(() => {
vi.mocked(isFolder).mockReset();
});

it("treats folder path without trailing slash as folder when folder exists", () => {
const app = createApp();
vi.mocked(isFolder).mockReturnValue(true);

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "journals" }),
createExecutor(),
);

const result = (engine as any).resolveCaptureTarget("journals");

expect(result).toEqual({ kind: "folder", folder: "journals" });
});

it("treats trailing slash as folder even when folder does not exist", () => {
const app = createApp();
vi.mocked(isFolder).mockReturnValue(false);

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "journals/" }),
createExecutor(),
);

const result = (engine as any).resolveCaptureTarget("journals/");

expect(result).toEqual({ kind: "folder", folder: "journals" });
});

it("treats folder path as file when a file exists", () => {
const app = createApp();
vi.mocked(isFolder).mockReturnValue(true);
vi.mocked(app.vault.getAbstractFileByPath).mockReturnValue({} as any);

const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "journals" }),
createExecutor(),
);

const result = (engine as any).resolveCaptureTarget("journals");

expect(result).toEqual({ kind: "file", path: "journals" });
});
});
94 changes: 71 additions & 23 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { isCancellationError, reportError } from "../utils/errorUtils";
import { normalizeFileOpening } from "../utils/fileOpeningDefaults";
import { QuickAddChoiceEngine } from "./QuickAddChoiceEngine";
import { ChoiceAbortError } from "../errors/ChoiceAbortError";
import { MacroAbortError } from "../errors/MacroAbortError";
import { SingleTemplateEngine } from "./SingleTemplateEngine";
import { getCaptureAction, type CaptureAction } from "./captureAction";
Expand Down Expand Up @@ -130,12 +131,11 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
getFileAndAddContentFn = ((path, capture, _options) =>
this.onCreateFileIfItDoesntExist(path, capture, linkOptions)
) as typeof this.onCreateFileIfItDoesntExist;
} else {
log.logWarning(
`The file ${filePath} does not exist and "Create file if it doesn't exist" is disabled.`,
);
return;
}
} else {
throw new ChoiceAbortError(
`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`,
);
}

const { file, newFileContent, captureContent } =
await getFileAndAddContentFn(filePath, content);
Expand Down Expand Up @@ -258,27 +258,75 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
}

const captureTo = this.choice.captureTo;
const formattedCaptureTo = await this.formatFilePath(captureTo);

// Removing the trailing slash from the capture to path because otherwise isFolder will fail
// to get the folder.
const folderPath = formattedCaptureTo.replace(/^\/$|\/\.md$|^\.md$/, "");
// Empty string means we suggest to capture anywhere in the vault.
const captureAnywhereInVault = folderPath === "";
const shouldCaptureToFolder =
captureAnywhereInVault || isFolder(this.app, folderPath);
const shouldCaptureWithTag = formattedCaptureTo.startsWith("#");

if (shouldCaptureToFolder) {
return this.selectFileInFolder(folderPath, captureAnywhereInVault);
const formattedCaptureTo = await this.formatter.formatFileName(
captureTo,
this.choice.name,
);
const resolution = this.resolveCaptureTarget(formattedCaptureTo);

switch (resolution.kind) {
case "vault":
return this.selectFileInFolder("", true);
case "tag":
return this.selectFileWithTag(resolution.tag);
case "folder":
return this.selectFileInFolder(resolution.folder, false);
case "file":
return this.normalizeMarkdownFilePath("", resolution.path);
}
}

private resolveCaptureTarget(
formattedCaptureTo: string,
):
| { kind: "vault" }
| { kind: "tag"; tag: string }
| { kind: "folder"; folder: string }
| { kind: "file"; path: string } {
// Resolution order:
// 1) empty => vault picker
// 2) #tag => tag picker
// 3) trailing "/" => folder picker (explicit)
// 4) ".md" => file
// 5) ambiguous => folder if it exists and no same-name file exists; else file
const normalizedCaptureTo = this.stripLeadingSlash(
formattedCaptureTo.trim(),
);

if (normalizedCaptureTo === "") {
return { kind: "vault" };
}

if (normalizedCaptureTo.startsWith("#")) {
return {
kind: "tag",
tag: normalizedCaptureTo.replace(/\.md$/, ""),
};
}

if (shouldCaptureWithTag) {
const tag = formattedCaptureTo.replace(/\.md$/, "");
return this.selectFileWithTag(tag);
const endsWithSlash = normalizedCaptureTo.endsWith("/");
const folderPath = normalizedCaptureTo.replace(/\/+$/, "");

if (endsWithSlash) {
return { kind: "folder", folder: folderPath };
}

if (normalizedCaptureTo.endsWith(".md")) {
return { kind: "file", path: normalizedCaptureTo };
}

// Guard against ambiguity where a folder and file share the same name.
const fileCandidatePath = this.normalizeMarkdownFilePath("", folderPath);
const fileCandidate = this.app.vault.getAbstractFileByPath(
fileCandidatePath,
);
const fileExists = !!fileCandidate;

if (isFolder(this.app, folderPath) && !fileExists) {
return { kind: "folder", folder: folderPath };
}

return formattedCaptureTo;
return { kind: "file", path: normalizedCaptureTo };
}

private async selectFileInFolder(
Expand Down
3 changes: 3 additions & 0 deletions src/errors/ChoiceAbortError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { MacroAbortError } from "./MacroAbortError";

export class ChoiceAbortError extends MacroAbortError {}
24 changes: 18 additions & 6 deletions src/gui/ChoiceBuilder/captureChoiceBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
normalizeAppendLinkOptions,
placementSupportsEmbed,
} from "../../types/linkPlacement";
import { getAllFolderPathsInVault } from "../../utilityObsidian";
import { createValidatedInput } from "../components/validatedInput";
import { FormatSyntaxSuggester } from "../suggesters/formatSyntaxSuggester";
import { ChoiceBuilder } from "./choiceBuilder";
Expand Down Expand Up @@ -125,7 +126,9 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
private addCapturedToSetting() {
new Setting(this.contentEl)
.setName("Capture to")
.setDesc("Target file path. Supports format syntax.");
.setDesc(
"Vault-relative path. Supports format syntax (use trailing '/' for folders).",
);

const captureToContainer: HTMLDivElement =
this.contentEl.createDiv("captureToContainer");
Expand Down Expand Up @@ -157,7 +160,9 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {

new Setting(captureToFileContainer)
.setName("File path / format")
.setDesc("Choose a file or use format syntax (e.g., {{DATE}})");
.setDesc(
"Choose a file, folder, or format syntax (e.g., {{DATE}})",
);

const displayFormatter: FileNameDisplayFormatter =
new FileNameDisplayFormatter(this.app, this.plugin);
Expand All @@ -168,10 +173,17 @@ export class CaptureChoiceBuilder extends ChoiceBuilder {
formatDisplay.setAttr("aria-live", "polite");
formatDisplay.textContent = "Loading preview…";

const markdownFilesAndFormatSyntax = [
...this.app.vault.getMarkdownFiles().map((f) => f.path),
...FILE_NAME_FORMAT_SYNTAX,
];
const folderPaths = getAllFolderPathsInVault(this.app)
.filter((path) => path.length > 0)
.map((path) => (path.endsWith("/") ? path : `${path}/`));

const markdownFilesAndFormatSyntax = Array.from(
new Set([
...folderPaths,
...this.app.vault.getMarkdownFiles().map((f) => f.path),
...FILE_NAME_FORMAT_SYNTAX,
]),
);

createValidatedInput({
app: this.app,
Expand Down