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
10 changes: 10 additions & 0 deletions docs/src/content/docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,16 @@ For a list property, each line of the Capture format is one item, the same way
Enter works in Obsidian's List property editor. Lines are trimmed, blank lines
are ignored, and a comma inside a line stays part of that item.

**Sort list** sets the order of the list QuickAdd writes. **Keep order** is the
default and keeps the order described above. **Ascending** and **Descending**
sort the whole list after new items are added and duplicates are removed. This
applies to both actions and to formats with `{{PROPERTY}}`.

Items sort in natural text order, so `YYYY-MM-DD` dates sort chronologically
and `item 2` comes before `item 10`. For example, with **Ascending**, adding
`2026-10-11` to `[2026-10-10, 2026-10-12]` gives
`[2026-10-10, 2026-10-11, 2026-10-12]`. Adding it again changes nothing.

#### Keep the current value with `{{PROPERTY}}`

In the format, `{{PROPERTY}}` is the property's current value. Put it where
Expand Down
4 changes: 2 additions & 2 deletions src/cli/currentNoteUse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,11 @@ describe("currentNoteUse", () => {

it("reads a named property's name, which cannot be empty", () => {
expect(use(capture((c) => {
c.propertyCapture = { property: { kind: "named", format: "{{FILENAMECURRENT}}" }, action: "set", createIfMissing: true };
c.propertyCapture = { property: { kind: "named", format: "{{FILENAMECURRENT}}" }, action: "set", createIfMissing: true, sort: "none" };
c.appendLink = { ...optionalLink };
}))).toBe("required");
expect(use(capture((c) => {
c.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: true };
c.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: true, sort: "none" };
}))).toBe("none");
});

Expand Down
2 changes: 1 addition & 1 deletion src/cli/registerQuickAddCliHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ describe("registerQuickAddCliHandlers", () => {
underHeading.insertAfter.promptHeading = true;
const status = new CaptureChoice("Status");
status.captureToActiveFile = true;
status.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true };
status.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" };
// The builder hides Task and One entry per line on a property capture; neither applies.
status.task = true;
status.eachLine = true;
Expand Down
10 changes: 5 additions & 5 deletions src/engine/CaptureChoiceEngine.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ vi.mock("../utils/templaterIntegration", () => ({
function fixture(content?: string) {
const choice = new CaptureChoice("Set status");
choice.captureTo = "Inbox.md";
choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true };
choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" };
const executor = { ...createChoiceExecutor(), interactive: false, recordExecutionResult: vi.fn(), signalAbort: vi.fn() };
const file = Object.assign(new TFile(), { path: "Inbox.md", basename: "Inbox", name: "Inbox.md", extension: "md" });
let stored = content;
Expand Down Expand Up @@ -190,7 +190,7 @@ describe("Capture property writes", () => {

it("seeds property variables and compose-writes when the format contains {{PROPERTY}}", async () => {
const test = fixture("---\ntags: [old, keep]\n---\nBody\n");
test.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true };
test.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" };
test.choice.format = { enabled: true, format: "work\n{{PROPERTY}}" };
mocks.value.mockImplementation(async () => {
expect(test.executor.variables.get("propertyKey")).toBe("tags");
Expand All @@ -207,15 +207,15 @@ describe("Capture property writes", () => {

it("restores seeds so a second property Capture on the same executor can run", async () => {
const first = fixture("---\ntags: [old]\n---\nBody\n");
first.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true };
first.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" };
first.choice.format = { enabled: true, format: "work\n{{PROPERTY}}" };
mocks.value.mockResolvedValueOnce("work\nold");
await first.run();
expect(readCaptureFrontmatter(first.read() ?? "")).toEqual({ tags: ["work", "old"] });
expect(first.executor.variables.has("list")).toBe(false);

first.overwrite("---\ntags: [work, old]\nstatus: Draft\n---\nBody\n");
first.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true };
first.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" };
first.choice.format = { enabled: true, format: "{{PROPERTY}} → Ready" };
mocks.value.mockResolvedValueOnce("Draft → Ready");
await first.run();
Expand All @@ -225,7 +225,7 @@ describe("Capture property writes", () => {

it("still appends when {{PROPERTY}} is absent from the format", async () => {
const test = fixture("---\ntags: [old, keep]\n---\nBody\n");
test.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true };
test.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" };
test.choice.format = { enabled: true, format: "work" };
mocks.value.mockResolvedValue("work");
await test.run();
Expand Down
2 changes: 1 addition & 1 deletion src/engine/captureAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe("getWritePosition", () => {
["legacy prepend on the active file", { captureToActiveFile: true, prepend: true }, "bottom"],
["active file top", { captureToActiveFile: true, activeFileWritePosition: "top" as const }, "activeTop"],
["new line above on the active file", { captureToActiveFile: true, newLineCapture: { enabled: true, direction: "above" as const } }, "newLineAbove"],
["property capture over everything", { prepend: true, propertyCapture: { property: { kind: "prompt" as const }, action: "set" as const, createIfMissing: true } }, "property"],
["property capture over everything", { prepend: true, propertyCapture: { property: { kind: "prompt" as const }, action: "set" as const, createIfMissing: true, sort: "none" as const } }, "property"],
])("resolves %s", (_label, overrides, expected) => {
expect(getWritePosition(capture(overrides))).toBe(expected);
});
Expand Down
51 changes: 49 additions & 2 deletions src/engine/captureProperty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
} from "./captureProperty";
import { parsePropertyCapture } from "../types/choices/ICaptureChoice";

const set = { action: "set", createIfMissing: true } as const;
const add = { action: "addToList", createIfMissing: true } as const;
const set = { action: "set", createIfMissing: true, sort: "none" } as const;
const add = { action: "addToList", createIfMissing: true, sort: "none" } as const;

describe("captures list items", () => {
it.each([
Expand Down Expand Up @@ -205,6 +205,44 @@ describe("{{PROPERTY}} compose writes", () => {
});
});

describe("sorted list properties", () => {
const ascending = { ...add, sort: "asc" } as const;
const dates = { complete_instances: ["2026-10-10", "2026-10-12"] };

it("adds a date between existing dates, and adding it again changes nothing", () => {
const once = planPropertyUpdate({ frontmatter: dates, key: "complete_instances", value: "2026-10-11", config: ascending, registeredType: null });
expect(once).toEqual(["2026-10-10", "2026-10-11", "2026-10-12"]);
expect(planPropertyUpdate({ frontmatter: { complete_instances: once }, key: "complete_instances", value: "2026-10-11", config: ascending, registeredType: null }))
.toEqual(["2026-10-10", "2026-10-11", "2026-10-12"]);
});
it("sorts descending", () => {
expect(planPropertyUpdate({ frontmatter: dates, key: "complete_instances", value: "2026-10-11", config: { ...add, sort: "desc" }, registeredType: null }))
.toEqual(["2026-10-12", "2026-10-11", "2026-10-10"]);
});
it("keeps insertion order with Keep order", () => {
expect(planPropertyUpdate({ frontmatter: dates, key: "complete_instances", value: "2026-10-11", config: add, registeredType: null }))
.toEqual(["2026-10-10", "2026-10-12", "2026-10-11"]);
});
it("orders numbers inside items by value", () => {
expect(planPropertyUpdate({ frontmatter: { items: ["item 10", "item 1"] }, key: "items", value: "item 2", config: ascending, registeredType: null }))
.toEqual(["item 1", "item 2", "item 10"]);
});
it("sorts a list written by Set value but not a multi-line text value", () => {
const config = { ...set, sort: "asc" } as const;
expect(planPropertyUpdate({ frontmatter: { tags: ["old"] }, key: "tags", value: "work\nhome\nwork", config, registeredType: "tags" }))
.toEqual(["home", "work"]);
expect(planPropertyUpdate({ frontmatter: { notes: "old" }, key: "notes", value: "b\na", config, registeredType: "text" })).toBe("b\na");
});
it("sorts a list composed with {{PROPERTY}}", () => {
expect(planPropertyUpdate({ frontmatter: { tags: ["b", "d"] }, key: "tags", value: "c\na\nb\nd", config: ascending, registeredType: null, compose: true }))
.toEqual(["a", "b", "c", "d"]);
});
it("leaves the list as it is when nothing is added", () => {
expect(planPropertyUpdate({ frontmatter: { items: ["b", "a"] }, key: "items", value: "", config: ascending, registeredType: null }))
.toEqual(["b", "a"]);
});
});

describe("property capture values", () => {
it.each(["001", "false", "2026-09-07", "[a, b]", "a,b", "", "a\nb"])("keeps plain text %j as text", (value) => {
expect(planPropertyUpdate({ frontmatter: { status: "active" }, key: "status", value, config: set, registeredType: "text" })).toBe(value);
Expand Down Expand Up @@ -265,6 +303,15 @@ describe("property capture values", () => {
expect(() => parsePropertyCapture({ property: { kind: "named", format: "status" }, action: "append" })).toThrow("Invalid property");
expect(() => parsePropertyCapture(null)).toThrow("Invalid property");
});
it("reads the list sort and loads settings saved without one as Keep order", () => {
const saved = { property: { kind: "named", format: "complete_instances" }, action: "addToList", createIfMissing: true };
expect(parsePropertyCapture(saved)).toEqual({ ...saved, sort: "none" });
expect(parsePropertyCapture({ ...saved, sort: "desc" })).toEqual({ ...saved, sort: "desc" });
expect(parsePropertyCapture({ property: { kind: "prompt" }, action: "set", createIfMissing: false, sort: "asc" }))
.toEqual({ property: { kind: "prompt" }, action: "set", createIfMissing: false, sort: "asc" });
expect(() => parsePropertyCapture({ ...saved, sort: "alphabetical" })).toThrow("Invalid property");
expect(() => parsePropertyCapture({ ...saved, sort: null })).toThrow("Invalid property");
});
it.each(["", " ", "a\nb", "__proto__"])("rejects unsafe property name %j", (name) => {
expect(() => validatePropertyName(name)).toThrow("Property name");
});
Expand Down
22 changes: 16 additions & 6 deletions src/engine/captureProperty.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getFrontMatterInfo, parseYaml, stringifyYaml } from "obsidian";
import { PROPERTY_REGEX } from "../constants";
import type { PropertyCapture } from "../types/choices/ICaptureChoice";
import type { PropertyCapture, PropertyListSort } from "../types/choices/ICaptureChoice";

type CapturePropertyValue = string | number | boolean | string[];

Expand Down Expand Up @@ -193,11 +193,21 @@ function validateType(value: CapturePropertyValue, type: PropertyType, key: stri
throw new Error(`Property '${key}' requires ${type}; the capture is ${actual}. Use a matching typed VALUE token or value.`);
}

const naturalOrder = new Intl.Collator(undefined, { numeric: true });

/** Drops duplicates (first occurrence wins), then orders the list by the Capture's sort. */
function dedupeAndSort(items: readonly string[], sort: PropertyListSort): string[] {
const unique = [...new Set(items)];
if (sort === "none") return unique;
const direction = sort === "asc" ? 1 : -1;
return unique.sort((a, b) => direction * naturalOrder.compare(a, b));
}

export function planPropertyUpdate(args: {
frontmatter: Record<string, unknown>;
key: string;
value: unknown;
config: Pick<PropertyCapture, "action" | "createIfMissing">;
config: Pick<PropertyCapture, "action" | "createIfMissing" | "sort">;
registeredType: string | null;
/**
* When the Capture format contained `{{PROPERTY}}`, the formatted value is
Expand Down Expand Up @@ -227,9 +237,9 @@ export function planPropertyUpdate(args: {
const items = lines ?? captured;
if (!Array.isArray(items)) throw new Error(`Property '${key}' requires text or a list of text to add.`);
if (items.length === 0) return current ?? [];
// Token present ⇒ write the composed list as-is (first occurrence wins).
if (args.compose) return [...new Set(items)];
return [...new Set([...(current ?? []), ...items])];
// Token present ⇒ the composed list replaces the existing one (first occurrence wins).
if (args.compose) return dedupeAndSort(items, config.sort);
return dedupeAndSort([...(current ?? []), ...items], config.sort);
}
if (type === "list" && current !== null && !Array.isArray(current)) {
throw new Error(`Property '${key}' contains ${inferType(current)}. Set a list only after correcting the existing property to a list.`);
Expand All @@ -242,7 +252,7 @@ export function planPropertyUpdate(args: {
}
const next = type === "list" ? lines ?? captured : captured;
if (type !== null) validateType(next, type, key);
return Array.isArray(next) ? [...new Set(next)] : next;
return Array.isArray(next) ? dedupeAndSort(next, config.sort) : next;
}

export function readCaptureFrontmatter(content: string): Record<string, unknown> {
Expand Down
6 changes: 5 additions & 1 deletion src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ describe("CaptureChoiceForm", () => {
await fireEvent.input(getByLabelText("Property"), { target: { value: "{{VALUE:property}}" } });
await fireEvent.change(selectUnderSetting(container, "Action"), { target: { value: "addToList" } });
flushSync();
expect(props.choice.propertyCapture).toEqual({ property: { kind: "named", format: "{{VALUE:property}}" }, action: "addToList", createIfMissing: true });
expect(props.choice.propertyCapture).toEqual({ property: { kind: "named", format: "{{VALUE:property}}" }, action: "addToList", createIfMissing: true, sort: "none" });
await fireEvent.change(selectUnderSetting(container, "Sort list"), { target: { value: "asc" } });
flushSync();
expect(props.choice.propertyCapture?.sort).toBe("asc");
expect(settingItem(container, "Action").querySelector(".setting-item-description")?.textContent).toBe("Each line is one item.");
await fireEvent.change(selectUnderSetting(container, "Property"), { target: { value: "prompt" } });
flushSync();
expect(props.choice.propertyCapture?.property).toEqual({ kind: "prompt" });
Expand Down
3 changes: 3 additions & 0 deletions src/gui/ChoiceBuilder/captureChoiceBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,8 @@ export class CaptureChoiceBuilder extends ChoiceBuilder<ICaptureChoice> {
if (this.choice.openFile) {
this.choice.fileOpening = normalizeFileOpening(this.choice.fileOpening);
}
if (this.choice.propertyCapture) {
this.choice.propertyCapture.sort ??= "none";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Toggle from "../components/Toggle.svelte";
import { createCaptureChoiceFormProps } from "./captureChoiceFormProps.svelte";
import { createTemplateChoiceFormProps } from "./templateChoiceFormProps.svelte";
import { TemplateChoiceBuilder } from "./templateChoiceBuilder";
import { CaptureChoiceBuilder } from "./captureChoiceBuilder";
import type IChoice from "../../types/choices/IChoice";

/** Open the builder page and leave it untouched; returns what it saved. */
Expand Down Expand Up @@ -228,6 +229,28 @@ describe("choice edit forms tolerate legacy choices missing newer fields (#1497)
expect(resolved.fileNameFormat).toEqual({ enabled: true, format: "" });
});

it("CaptureChoiceBuilder shows and saves Keep order for a property Capture saved before Sort list existed", () => {
const choice = legacyCaptureChoice();
choice.propertyCapture = {
property: { kind: "named", format: "complete_instances" },
action: "addToList",
createIfMissing: true,
} as ICaptureChoice["propertyCapture"];
let saved: IChoice | undefined;
const builder = new CaptureChoiceBuilder(new App(), choice, plugin, (edited) => {
saved = edited;
});
builder.display();
const items = Array.from(builder.containerEl.querySelectorAll(".setting-item"));
const item = (name: string) => items.find((el) => el.querySelector(".setting-item-name")?.textContent === name);
expect(item("Sort list")?.querySelector("select")?.value).toBe("none");
expect(item("Action")?.querySelector(".setting-item-description")?.textContent).toBe(
"Each line is one item. New items go below the existing ones. Put {{PROPERTY}} on its own line to choose where the existing items go.",
);
builder.hide();
expect((saved as ICaptureChoice | undefined)?.propertyCapture?.sort).toBe("none");
});

it("Toggle accepts an undefined binding and writes a boolean on flip", async () => {
const model = { flag: undefined as boolean | undefined };
const { container } = render(Toggle, {
Expand Down
12 changes: 11 additions & 1 deletion src/gui/ChoiceBuilder/components/PropertyCaptureFields.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ let lastNamedFormat = "";
</LabeledField>

<SettingItem name="Action" desc={config.action === "addToList"
? "Each line is one item. New items go below the existing ones. Put {{PROPERTY}} on its own line to choose where the existing items go."
? config.sort === "none"
? "Each line is one item. New items go below the existing ones. Put {{PROPERTY}} on its own line to choose where the existing items go."
: "Each line is one item."
: "Replaces the value. For a list, each line is one item. Put {{PROPERTY}} where the current value should stay. A Text property keeps all lines as one value. A property with no type yet rejects several lines. Use Add to list, or set the type to Text in Obsidian first."}>
{#snippet control()}
<Dropdown value={config.action}
Expand All @@ -46,6 +48,14 @@ let lastNamedFormat = "";
{/snippet}
</SettingItem>

<SettingItem name="Sort list">
{#snippet control()}
<Dropdown value={config.sort}
options={[{ value: "none", label: "Keep order" }, { value: "asc", label: "Ascending" }, { value: "desc", label: "Descending" }]}
onchange={(value) => { config.sort = value === "asc" || value === "desc" ? value : "none"; }} />
{/snippet}
</SettingItem>

<SettingItem name="Create property if missing">
{#snippet control()}
<Toggle bind:checked={config.createIfMissing} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function onWritePositionChange(value: string) {
property: { kind: "named", format: "" },
action: "set",
createIfMissing: true,
sort: "none",
};
return;
}
Expand Down
Loading
Loading