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
3 changes: 2 additions & 1 deletion docs/src/content/docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,8 @@ choice.
_Run Templater on entire destination file after capture_ is an advanced,
legacy option: it executes any `<% %>` anywhere in the destination file,
including inside code blocks. Leave it off unless you specifically need that
whole-file pass.
whole-file pass. When that pass changes the note, QuickAdd skips placing the
cursor at `{{CURSOR}}`, since the text under the marker may have moved.

### Templater and newly created notes {#templater-and-newly-created-files}

Expand Down
3 changes: 2 additions & 1 deletion src/engine/CaptureChoiceEngine.audit-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,13 @@ const createRunApp = (captureFile: TFile, fileContent = "existing body") =>
path === captureFile.path ? captureFile : null,
),
read: vi.fn(async () => fileContent),
modify: vi.fn(async () => {}),
process: vi.fn(async (_file: TFile, fn: (content: string) => string) => fn(fileContent)),
create: vi.fn(),
},
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: { getNewFileParent: vi.fn(() => ({ path: "" })) },
}) as unknown as App;
Expand Down
3 changes: 2 additions & 1 deletion src/engine/CaptureChoiceEngine.effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function harness({ exists, existing }: { exists: boolean; existing: string }) {
adapter: { exists: vi.fn(async () => exists) },
getAbstractFileByPath: vi.fn(() => (exists ? captureFile : null)),
read: vi.fn(async () => existing),
modify: vi.fn(),
process: vi.fn(async (_file: TFile, fn: (content: string) => string) => fn(existing)),
create: vi.fn(async (path: string, content: string) => {
created.push({ path, content });
return captureFile;
Expand All @@ -142,6 +142,7 @@ function harness({ exists, existing }: { exists: boolean; existing: string }) {
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: { getNewFileParent: vi.fn(() => ({ path: "" })) },
} as unknown as App;
Expand Down
1 change: 1 addition & 0 deletions src/engine/CaptureChoiceEngine.heading-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const createApp = (content = HEADING_NOTE) =>
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: { getNewFileParent: vi.fn(() => ({ path: "" })) },
}) as unknown as App;
Expand Down
115 changes: 68 additions & 47 deletions src/engine/CaptureChoiceEngine.merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createChoiceExecutor } from "../../tests/helpers/createChoiceExecutor";
import type * as ChoiceFileActions from "./choiceFileActions";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { formatContentWithFileMock, getCaptureInsertionEndOffsetMock } = vi.hoisted(() => ({
Expand Down Expand Up @@ -74,11 +75,17 @@ vi.mock("obsidian-dataview", () => ({
getAPI: vi.fn(),
}));

vi.mock("./choiceFileActions", async (importOriginal) => ({
...(await importOriginal<typeof ChoiceFileActions>()),
openChoiceFile: vi.fn(async () => true),
}));

import type { App } from "obsidian";
import { TFile } from "obsidian";
import { CaptureChoiceEngine } from "./CaptureChoiceEngine";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import type ICaptureChoice from "../types/choices/ICaptureChoice";
import { setMarkdownCursorAtOffset } from "../utilityObsidian";

const createCaptureChoice = (): ICaptureChoice => ({
name: "Test Capture Choice",
Expand Down Expand Up @@ -127,110 +134,124 @@ const createFile = (path: string) => {
};

const createEngine = ({
firstRead,
secondRead,
read,
concurrent,
formattedFileContent,
}: {
firstRead: string;
secondRead: string;
read: string;
/** The note's text when the write lands, after edits made while the capture was formatted. */
concurrent: string;
formattedFileContent: string;
}) => {
const filePath = "Daily/Test.md";
const file = createFile(filePath);
const disk = { content: read };
const app = {
vault: {
adapter: {
exists: vi.fn(async () => true),
},
getAbstractFileByPath: vi.fn(() => file),
read: vi
.fn()
.mockResolvedValueOnce(firstRead)
.mockResolvedValueOnce(secondRead),
modify: vi.fn(),
read: vi.fn(async () => disk.content),
process: vi.fn(async (_file: TFile, fn: (content: string) => string) => {
disk.content = fn(concurrent);
return disk.content;
}),
create: vi.fn(),
},
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
},
} as unknown as App;

const plugin = { settings: { showCaptureNotification: true } } as any;
const plugin = { settings: { showCaptureNotification: false } } as any;
const choiceExecutor: IChoiceExecutor = {
...createChoiceExecutor(),
execute: vi.fn(),
recordExecutionResult: vi.fn(),
variables: new Map<string, unknown>(),
};
const engine = new CaptureChoiceEngine(
app,
plugin,
createCaptureChoice(),
{ ...createCaptureChoice(), openFile: true },
choiceExecutor,
);

formatContentWithFileMock.mockResolvedValue(formattedFileContent);
getCaptureInsertionEndOffsetMock.mockReturnValue(formattedFileContent.length);

return { engine, filePath };
return { engine, disk, file, choiceExecutor };
};

describe("CaptureChoiceEngine concurrent-edit merge", () => {
beforeEach(() => {
formatContentWithFileMock.mockReset();
getCaptureInsertionEndOffsetMock.mockReset();
vi.mocked(setMarkdownCursorAtOffset).mockClear();
});

it("proceeds when concurrent edits can be merged cleanly", async () => {
const firstRead = "alpha\nbeta\ngamma\n";
const secondRead = "alpha changed by sync\nbeta\ngamma\n";
const formattedFileContent = "alpha\nbeta\ngamma\ncaptured ours\n";
const { engine, filePath } = createEngine({
firstRead,
secondRead,
formattedFileContent,
it("merges edits made while the capture was formatted and skips cursor placement", async () => {
const { engine, disk, choiceExecutor } = createEngine({
read: "alpha\nbeta\ngamma\n",
concurrent: "alpha changed by sync\nbeta\ngamma\n",
formattedFileContent: "alpha\nbeta\ngamma\ncaptured ours\n",
});

const result = await (engine as any).onFileExists(filePath, "captured ours");
await engine.run();

expect(result.newFileContent).toBe(
"alpha changed by sync\nbeta\ngamma\ncaptured ours\n",
expect(disk.content).toBe("alpha changed by sync\nbeta\ngamma\ncaptured ours\n");
expect(choiceExecutor.recordExecutionResult).toHaveBeenLastCalledWith(
expect.objectContaining({ status: "success", effect: "changed" }),
);
expect(result.captureContent).toBe("captured ours");
expect(result.cursorPlacementSafe).toBe(false);
expect(setMarkdownCursorAtOffset).not.toHaveBeenCalled();
});

it("aborts when concurrent edits conflict", async () => {
const firstRead = "alpha\nbeta\ngamma\n";
const secondRead = "alpha from sync\nbeta\ngamma\n";
const formattedFileContent = "alpha from capture\nbeta\ngamma\n";
const { engine, filePath } = createEngine({
firstRead,
secondRead,
formattedFileContent,
it("refuses to write when concurrent edits conflict", async () => {
const concurrent = "alpha from sync\nbeta\ngamma\n";
const { engine, disk, choiceExecutor } = createEngine({
read: "alpha\nbeta\ngamma\n",
concurrent,
formattedFileContent: "alpha from capture\nbeta\ngamma\n",
});

await expect(
(engine as any).onFileExists(filePath, "alpha from capture"),
).rejects.toThrow("has been modified since the last read");
await engine.run();

expect(disk.content).toBe("alpha\nbeta\ngamma\n");
expect(choiceExecutor.recordExecutionResult).toHaveBeenLastCalledWith(
expect.objectContaining({ status: "error" }),
);
expect(setMarkdownCursorAtOffset).not.toHaveBeenCalled();
});

it("reports unchanged when only a concurrent edit changed the note", async () => {
const read = "alpha\nbeta\ngamma\n";
const concurrent = "alpha changed by sync\nbeta\ngamma\n";
const { engine, disk, choiceExecutor } = createEngine({ read, concurrent, formattedFileContent: read });

await engine.run();

expect(disk.content).toBe(concurrent);
expect(choiceExecutor.recordExecutionResult).toHaveBeenLastCalledWith(
expect.objectContaining({ status: "success", effect: "unchanged" }),
);
});

it("uses formatted content directly when the file did not change between reads", async () => {
const firstRead = "alpha\nbeta\ngamma\n";
it("writes the formatted content and places the cursor when the note did not change", async () => {
const read = "alpha\nbeta\ngamma\n";
const formattedFileContent = "alpha\nbeta\ngamma\ncaptured ours\n";
const { engine, filePath } = createEngine({
firstRead,
secondRead: firstRead,
formattedFileContent,
});
const { engine, disk, file } = createEngine({ read, concurrent: read, formattedFileContent });

const result = await (engine as any).onFileExists(filePath, "captured ours");
await engine.run();

expect(result.newFileContent).toBe(formattedFileContent);
expect(result.cursorPlacementSafe).toBe(true);
expect(result.cursor).toEqual({ kind: "offset", source: "defaultEnd", value: formattedFileContent.length });
expect(disk.content).toBe(formattedFileContent);
expect(setMarkdownCursorAtOffset).toHaveBeenCalledWith(
expect.anything(), file, formattedFileContent.length, formattedFileContent,
);
});
});
25 changes: 16 additions & 9 deletions src/engine/CaptureChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ const createEngine = (abortError: Error) => {
},
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
Expand Down Expand Up @@ -275,6 +277,8 @@ describe("CaptureChoiceEngine cancellation notices", () => {
},
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
Expand Down Expand Up @@ -318,6 +322,7 @@ describe("CaptureChoiceEngine append-link destination", () => {
function createAppendLinkHarness() {
const captureFile = createTestFile("Daily/Test.md");
const destinationFile = createTestFile("Indexes/MOC.md");
const disk = { content: "existing" };
const app = {
vault: {
adapter: {
Expand All @@ -326,13 +331,14 @@ describe("CaptureChoiceEngine append-link destination", () => {
getAbstractFileByPath: vi.fn((path: string) =>
path === captureFile.path ? captureFile : null,
),
read: vi.fn(async () => "existing"),
modify: vi.fn(),
read: vi.fn(async () => disk.content),
process: vi.fn(async (_file: TFile, fn: (content: string) => string) => (disk.content = fn(disk.content))),
create: vi.fn(),
},
workspace: {
getActiveFile: vi.fn(() => null),
getActiveViewOfType: vi.fn(() => null),
getLeavesOfType: vi.fn(() => []),
},
fileManager: {
getNewFileParent: vi.fn(() => ({ path: "" })),
Expand Down Expand Up @@ -362,6 +368,7 @@ describe("CaptureChoiceEngine append-link destination", () => {

return {
app,
disk,
captureFile,
choice,
destinationFile,
Expand All @@ -371,14 +378,14 @@ describe("CaptureChoiceEngine append-link destination", () => {
}

it("copies the captured file link after committing without append-link insertion", async () => {
const { app, captureFile, choice, choiceExecutor, engine } =
const { disk, captureFile, choice, choiceExecutor, engine } =
createAppendLinkHarness();
choice.appendLink = false;
choice.copyLinkToClipboard = true;

await engine.run();

expect(app.vault.modify).toHaveBeenCalledWith(captureFile, "");
expect(disk.content).toBe("");
expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({
status: "success",
file: captureFile,
Expand All @@ -389,7 +396,7 @@ describe("CaptureChoiceEngine append-link destination", () => {
});

it("keeps capture execution successful when clipboard copying throws", async () => {
const { app, captureFile, choice, choiceExecutor, engine } =
const { disk, captureFile, choice, choiceExecutor, engine } =
createAppendLinkHarness();
choice.appendLink = false;
choice.copyLinkToClipboard = true;
Expand All @@ -399,7 +406,7 @@ describe("CaptureChoiceEngine append-link destination", () => {

await engine.run();

expect(app.vault.modify).toHaveBeenCalledWith(captureFile, "");
expect(disk.content).toBe("");
expect(copyFileLinkToClipboardMock).toHaveBeenCalledWith(captureFile);
expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({
status: "success",
Expand All @@ -409,13 +416,13 @@ describe("CaptureChoiceEngine append-link destination", () => {
});

it("appends the captured file link to a specified destination without an active editor", async () => {
const { app, captureFile, destinationFile, choiceExecutor, engine } =
const { app, disk, captureFile, destinationFile, choiceExecutor, engine } =
createAppendLinkHarness();
getAppendLinkDestinationFileMock.mockReturnValue(destinationFile);

await engine.run();

expect(app.vault.modify).toHaveBeenCalledWith(captureFile, "");
expect(disk.content).toBe("");
expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({
status: "success",
file: captureFile,
Expand All @@ -440,7 +447,7 @@ describe("CaptureChoiceEngine append-link destination", () => {
await engine.run();

expect(app.vault.adapter.exists).not.toHaveBeenCalled();
expect(app.vault.modify).not.toHaveBeenCalled();
expect(app.vault.process).not.toHaveBeenCalled();
// This exit used to record NOTHING, so `executeWithOutcome` produced a
// reason-less error and the CLI substituted its fixed sentence - #1603's exact
// symptom, reachable without any throw. It now carries the message the desktop
Expand Down
2 changes: 1 addition & 1 deletion src/engine/CaptureChoiceEngine.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function fixture(content?: string) {
read: async () => stored ?? "",
create, createFolder,
},
workspace: { getActiveFile: () => stored === undefined ? null : file, getActiveViewOfType: () => null },
workspace: { getActiveFile: () => stored === undefined ? null : file, getActiveViewOfType: () => null, getLeavesOfType: () => [] },
fileManager: { processFrontMatter },
};
const plugin = { settings: { useSelectionAsCaptureValue: false, showCaptureNotification: false } } as QuickAdd;
Expand Down
Loading
Loading