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
6 changes: 3 additions & 3 deletions docs/src/content/docs/docs/Advanced/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,9 @@ obsidian vault=dev quickadd:run choice="Inbox" value-value=" " verify=true

That run is working exactly as designed: the capture's payload was empty, so
QuickAdd deliberately left `Inbox.md` alone rather than writing a blank line, and
said so in a notice. A Template set to **Do nothing** when the file already exists
reports the same. If you are counting captures, writing an idempotency marker, or
deciding whether to retry, key off `effect`, not `ok`.
said so in a notice. A Template set to **Keep existing file** when the file
already exists reports the same `effect`. If you are counting captures, writing an
idempotency marker, or deciding whether to retry, key off `effect`, not `ok`.

`effect` is present on every **success** payload (`ok:true`), and `unknown` is stated
rather than omitted - a missing key reads as `false` in both `jq` and JavaScript, which
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/docs/Choices/TemplateChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ With **Search existing notes before creating** enabled, this setting is called
existing file**, **Create another file**, or **Keep existing file**.
- **Update action** - shown only when you choose **Update existing file**.
- **New file naming** - shown only when you choose **Create another file**.
- **Keep action** - shown only when you choose **Keep existing file**.

### Let QuickAdd ask each time {#ask-every-time}

Expand All @@ -442,7 +443,7 @@ already exists:
- **Overwrite file**
- **Increment trailing number**
- **Append duplicate suffix**
- **Do nothing**
- **Open existing file**

### Update the existing note {#update-existing-file}

Expand Down Expand Up @@ -479,8 +480,7 @@ These options keep the existing file untouched and create a new file instead:

### Keep the existing note {#keep-existing-file}

Selecting **Keep existing file** applies the same result as choosing **Do
nothing** from the prompt:
These options leave the existing file unchanged:

- **Do nothing** - leaves the existing file unchanged and opens it
automatically. This does not require the separate **Open** setting.
- **Open existing file** - opens the existing file, even when **Open** is off.
- **Do nothing** - opens the existing file only when **Open** is on.
32 changes: 32 additions & 0 deletions src/engine/TemplateChoiceEngine.collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ vi.mock("../formatters/completeFormatter", () => {
vi.mock("../utils/templaterIntegration", () => ({
getTemplater: vi.fn(() => ({})),
overwriteTemplaterOnce: vi.fn(),
jumpToNextTemplaterCursorIfPossible: vi.fn(async () => true),
}));
vi.mock("../utils/errorUtils", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/errorUtils")>()),
reportError: vi.fn(),
}));
vi.mock("../utils/vaultQueries", () => ({
getAllFolderPathsInVault: vi.fn(() => []),
Expand Down Expand Up @@ -125,6 +130,9 @@ vi.mock("./TemplateInsertEngine", () => {

import { TFile, TFolder, type App } from "obsidian";
import GenericSuggester from "../gui/GenericSuggester/genericSuggester";
import { Notice } from "../../tests/obsidian-stub";
import { openFile as openFileInWorkspace } from "../utils/fileOpening";
import { reportError } from "../utils/errorUtils";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import { settingsStore } from "../settingsStore";
import { getPromptModes } from "../template/fileExistsPolicy";
Expand Down Expand Up @@ -739,4 +747,28 @@ describe("TemplateChoiceEngine collision behavior", () => {
expect(GenericSuggester.Suggest).toHaveBeenCalledTimes(1);
expect(engine.choice.fileExistsBehavior).toEqual({ kind: "prompt" });
});

it.each([
{ mode: "doNothing", openFile: false, opens: true, notice: [] },
{ mode: "leaveUnchanged", openFile: false, opens: false, notice: ["'Test Template' already exists."] },
{ mode: "leaveUnchanged", openFile: true, opens: true, notice: [] },
] as const)("$mode with openFile=$openFile opens the existing note: $opens", async ({ mode, openFile, opens, notice }) => {
const { app, engine } = createEngine();
const existingFile = createExistingFile("Test Template.md");
engine.choice.openFile = openFile;
engine.choice.fileExistsBehavior = { kind: "apply", mode };
(app.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(app.vault.getAbstractFileByPath as ReturnType<typeof vi.fn>).mockReturnValue(existingFile);
vi.mocked(openFileInWorkspace).mockClear();
vi.mocked(reportError).mockClear();
const noticesBefore = Notice.instances.length;

await engine.run();

expect(vi.mocked(openFileInWorkspace).mock.calls.map(([, file]) => file)).toEqual(
opens ? [existingFile] : [],
);
expect(Notice.instances.slice(noticesBefore).map(({ message }) => message)).toEqual(notice);
expect(reportError).not.toHaveBeenCalled();
});
});
21 changes: 12 additions & 9 deletions src/engine/TemplateChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ export class TemplateChoiceEngine extends TemplateEngine {
// What this run did to its target note (#1615). Derived from the file-exists
// resolution the engine actually performed rather than from a byte compare,
// which is exact for the two answers an automation acts on: "createNew"
// always writes a new note, and "reuseExisting" — the shipped "Do nothing"
// mode — writes nothing at all. Only "modifyExisting" (append/overwrite) is
// always writes a new note, and "reuseExisting" — the Keep existing file
// modes — writes nothing at all. Only "modifyExisting" (append/overwrite) is
// inferred: it always writes, so `changed` can in principle over-report a
// write whose bytes happened to match, which is the harmless direction.
let effect: ChoiceEffect = "created";
Expand Down Expand Up @@ -290,14 +290,18 @@ export class TemplateChoiceEngine extends TemplateEngine {
this.placeCursor(createdFile, cursorBeforeLink);
}
} else if (
createdNew &&
(createdNew || effect === "unchanged") &&
!linkOptions.enabled &&
!this.choice.copyLinkToClipboard
) {
// The note was created but nothing else surfaces it (not opened, no
// link appended, not copied to clipboard). Confirm the creation so
// the run isn't silent — mirroring Capture's success notice.
new Notice(`Created '${createdFile.basename}'.`);
// Nothing else surfaces the note (not opened, no link appended, not
// copied to clipboard). Say what happened so the run isn't silent -
// mirroring Capture's success notice.
new Notice(
createdNew
? `Created '${createdFile.basename}'.`
: `'${createdFile.basename}' already exists.`,
);
}
} catch (err) {
if (
Expand Down Expand Up @@ -515,10 +519,9 @@ export class TemplateChoiceEngine extends TemplateEngine {
};
}
case "reuseExisting":
log.logMessage(`Opening existing file: ${existingFile!.path}`);
return {
createdFile: existingFile,
shouldAutoOpen: true,
shouldAutoOpen: mode.opensExistingFile,
};
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/engine/choiceOutcomeRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class ChoiceOutcomeRecorder {
*
* `effect` is required rather than defaulted: every call site has to state its claim,
* so a new one cannot inherit a positive "something landed" by omission. The four
* existing sites split evenly — two of them (Template's "Do nothing" mode and its
* existing sites split evenly — two of them (Template's Keep existing file modes and its
* open-an-existing-note discovery path) commit nothing at all.
*/
success(file: TFile | undefined, effect: ChoiceEffect): void {
Expand Down
26 changes: 10 additions & 16 deletions src/gui/ChoiceBuilder/TemplateChoiceForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
} from "../../template/fileExistsPolicy";
import {
fileExistsBehaviorCategoryOptions,
getBehaviorCategory,
getDefaultBehaviorForCategory,
getFileExistsMode,
getModesForCategory,
Expand Down Expand Up @@ -178,18 +177,13 @@ commitPending = addPendingFolder;
onDestroy(addPendingFolder);

// --- File already exists -------------------------------------------------
const behaviorCategory = $derived(getBehaviorCategory(choice.fileExistsBehavior));
const showModeRow = $derived(
behaviorCategory !== "prompt" && behaviorCategory !== "keep",
);
const modeOptions = $derived(
getModesForCategory(behaviorCategory === "update" ? "update" : "create"),
);
const modeRowName = { update: "Update action", create: "New file naming", keep: "Keep action" };
const selectedMode = $derived(
choice.fileExistsBehavior.kind === "apply"
? choice.fileExistsBehavior.mode
: modeOptions[0].id,
? getFileExistsMode(choice.fileExistsBehavior.mode)
: null,
);
const behaviorCategory = $derived(selectedMode?.category ?? "prompt");

function onCategoryChange(value: string) {
choice.fileExistsBehavior = getDefaultBehaviorForCategory(
Expand Down Expand Up @@ -375,15 +369,15 @@ function onModeChange(value: string) {
{/snippet}
</SettingItem>

{#if showModeRow}
{#if selectedMode}
<SettingItem
name={behaviorCategory === "update" ? "Update action" : "New file naming"}
desc={getFileExistsMode(selectedMode).description}
name={modeRowName[selectedMode.category]}
desc={selectedMode.description}
>
{#snippet control()}
<Dropdown
value={selectedMode}
options={modeOptions.map((mode) => ({
value={selectedMode.id}
options={getModesForCategory(selectedMode.category).map((mode) => ({
value: mode.id,
label: mode.label,
}))}
Expand All @@ -393,7 +387,7 @@ function onModeChange(value: string) {
</SettingItem>
{/if}

<OpenFileSetting bind:openFile={choice.openFile} description="Open the created file." />
<OpenFileSetting bind:openFile={choice.openFile} description="Open the file." />
{#if choice.openFile}
<FileOpeningSetting bind:fileOpening={choice.fileOpening} contextLabel="created" />
{/if}
Expand Down
23 changes: 17 additions & 6 deletions src/gui/ChoiceBuilder/TemplateChoiceForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,27 @@ describe("TemplateChoiceForm", () => {
expect(settingNames(container)).toContain("File opening location");
});

it("shows the file-exists mode row only for update/create categories", () => {
it("shows the file-exists mode row for every category except Ask every time", () => {
const { container, props } = mountForm();
expect(settingNames(container)).not.toContain("New file naming");
const modeRows = ["Update action", "New file naming", "Keep action"];
expect(settingNames(container).filter((name) => modeRows.includes(name))).toEqual([]);

props.choice.fileExistsBehavior = { kind: "apply", mode: "increment" };
flushSync();
const names = settingNames(container);
expect(
names.includes("New file naming") || names.includes("Update action"),
).toBe(true);
expect(settingNames(container).filter((name) => modeRows.includes(name))).toEqual(["New file naming"]);
});

it("keeps a saved Keep existing file choice on Open existing file and offers Do nothing", async () => {
const { container, props } = mountForm();
props.choice.fileExistsBehavior = { kind: "apply", mode: "doNothing" };
flushSync();
const select = settingItem(container, "Keep action").querySelector("select")!;
expect(select.value).toBe("doNothing");
expect(Array.from(select.options, (option) => option.text)).toEqual(["Open existing file", "Do nothing"]);

await fireEvent.change(select, { target: { value: "leaveUnchanged" } });
flushSync();
expect(props.choice.fileExistsBehavior).toEqual({ kind: "apply", mode: "leaveUnchanged" });
});

it("persists the copy-link-to-clipboard toggle", async () => {
Expand Down
15 changes: 1 addition & 14 deletions src/template/fileExistsPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
fileExistsBehaviorCategoryOptions,
getBehaviorCategory,
getDefaultBehaviorForCategory,
getFileExistsMode,
getExistingNoteAction,
Expand Down Expand Up @@ -47,22 +46,10 @@ describe("fileExistsPolicy registry", () => {
]);
expect(getModesForCategory("keep").map((mode) => mode.id)).toEqual([
"doNothing",
"leaveUnchanged",
]);
});

it("derives behavior category from behavior state", () => {
expect(getBehaviorCategory({ kind: "prompt" })).toBe("prompt");
expect(
getBehaviorCategory({ kind: "apply", mode: "appendBottom" }),
).toBe("update");
expect(getBehaviorCategory({ kind: "apply", mode: "increment" })).toBe(
"create",
);
expect(getBehaviorCategory({ kind: "apply", mode: "doNothing" })).toBe(
"keep",
);
});

it("returns category defaults and preserves modes already in-category", () => {
expect(getDefaultBehaviorForCategory("prompt")).toEqual({ kind: "prompt" });
expect(getDefaultBehaviorForCategory("update")).toEqual({
Expand Down
30 changes: 18 additions & 12 deletions src/template/fileExistsPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,24 @@ export const fileExistsModes = [
resolutionKind: "createNew",
},
{
// Saved choices store this id from when it was the only keep mode, so it
// keeps its id and its behavior: it opens the note even with Open off.
id: "doNothing",
category: "keep",
label: "Open existing file",
description: "Leaves the existing file unchanged and opens it, even when Open is off.",
requiresExistingFile: true,
resolutionKind: "reuseExisting",
opensExistingFile: true,
},
{
id: "leaveUnchanged",
category: "keep",
label: "Do nothing",
description: "Leaves the file unchanged and opens the existing file.",
description: "Leaves the existing file unchanged. It opens only when Open is on.",
requiresExistingFile: true,
resolutionKind: "reuseExisting",
opensExistingFile: false,
},
] as const;

Expand Down Expand Up @@ -133,6 +145,7 @@ const legacyFileExistsModeMap: Record<string, FileExistsModeId> = {
increment: "increment",
duplicateSuffix: "duplicateSuffix",
doNothing: "doNothing",
leaveUnchanged: "leaveUnchanged",
};

const createNewPathResolvers: Record<
Expand Down Expand Up @@ -163,18 +176,11 @@ function getCategoryForMode(
return getFileExistsMode(modeId).category;
}

// The prompt's Do nothing item used to open the note, so its slot now reads Open
// existing file with the same behavior. Leaving the note alone from the prompt is
// what Escape already does.
export function getPromptModes(): FileExistsModeDefinition[] {
return [...fileExistsModes];
}

export function getBehaviorCategory(
behavior: TemplateFileExistsBehavior,
): FileExistsBehaviorCategoryId {
if (behavior.kind === "prompt") {
return "prompt";
}

return getCategoryForMode(behavior.mode);
return fileExistsModes.filter((mode) => mode.id !== "leaveUnchanged");
}

export function getDefaultBehaviorForCategory(
Expand Down
2 changes: 1 addition & 1 deletion src/types/ChoiceOutcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { TFile } from "obsidian";
* automation asks — it asks "did anything land?". Those came apart in two shipped
* configurations that need no interactivity at all: a Capture whose formatted payload
* is empty deliberately leaves the file untouched (inserting would add a blank line
* or, on `currentLine`, DELETE the selection), and a Template set to "Do nothing" when
* or, on `currentLine`, DELETE the selection), and a Template set to "Keep existing file" when
* the file exists is, by name, a no-op. Both reported an unqualified success, so a
* caller counting captures or writing an idempotency marker recorded work that never
* happened (#1615).
Expand Down
Loading
Loading