diff --git a/docs/src/content/docs/docs/Choices/CaptureChoice.md b/docs/src/content/docs/docs/Choices/CaptureChoice.md index 7c2d25969..b7bf6273c 100644 --- a/docs/src/content/docs/docs/Choices/CaptureChoice.md +++ b/docs/src/content/docs/docs/Choices/CaptureChoice.md @@ -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 diff --git a/src/cli/currentNoteUse.test.ts b/src/cli/currentNoteUse.test.ts index e6c8f6b1a..88938eb97 100644 --- a/src/cli/currentNoteUse.test.ts +++ b/src/cli/currentNoteUse.test.ts @@ -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"); }); diff --git a/src/cli/registerQuickAddCliHandlers.test.ts b/src/cli/registerQuickAddCliHandlers.test.ts index 80443ef8b..9e10b95c5 100644 --- a/src/cli/registerQuickAddCliHandlers.test.ts +++ b/src/cli/registerQuickAddCliHandlers.test.ts @@ -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; diff --git a/src/engine/CaptureChoiceEngine.property.test.ts b/src/engine/CaptureChoiceEngine.property.test.ts index f51f472ce..eded257a8 100644 --- a/src/engine/CaptureChoiceEngine.property.test.ts +++ b/src/engine/CaptureChoiceEngine.property.test.ts @@ -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; @@ -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"); @@ -207,7 +207,7 @@ 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(); @@ -215,7 +215,7 @@ describe("Capture property writes", () => { 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(); @@ -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(); diff --git a/src/engine/captureAction.test.ts b/src/engine/captureAction.test.ts index f74e0a9ab..73edb9740 100644 --- a/src/engine/captureAction.test.ts +++ b/src/engine/captureAction.test.ts @@ -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); }); diff --git a/src/engine/captureProperty.test.ts b/src/engine/captureProperty.test.ts index 32842b4c3..57e4e36d3 100644 --- a/src/engine/captureProperty.test.ts +++ b/src/engine/captureProperty.test.ts @@ -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([ @@ -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); @@ -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"); }); diff --git a/src/engine/captureProperty.ts b/src/engine/captureProperty.ts index ebf977ed0..5269cfdcc 100644 --- a/src/engine/captureProperty.ts +++ b/src/engine/captureProperty.ts @@ -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[]; @@ -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; key: string; value: unknown; - config: Pick; + config: Pick; registeredType: string | null; /** * When the Capture format contained `{{PROPERTY}}`, the formatted value is @@ -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.`); @@ -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 { diff --git a/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts b/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts index aaeb91f69..2ed57ab13 100644 --- a/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts +++ b/src/gui/ChoiceBuilder/CaptureChoiceForm.test.ts @@ -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" }); diff --git a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts index bf82fba75..9ca7f17cb 100644 --- a/src/gui/ChoiceBuilder/captureChoiceBuilder.ts +++ b/src/gui/ChoiceBuilder/captureChoiceBuilder.ts @@ -54,5 +54,8 @@ export class CaptureChoiceBuilder extends ChoiceBuilder { if (this.choice.openFile) { this.choice.fileOpening = normalizeFileOpening(this.choice.fileOpening); } + if (this.choice.propertyCapture) { + this.choice.propertyCapture.sort ??= "none"; + } } } diff --git a/src/gui/ChoiceBuilder/choiceForms-1497-legacy-missing-fields.test.ts b/src/gui/ChoiceBuilder/choiceForms-1497-legacy-missing-fields.test.ts index 2251a5bec..2a9a9bb28 100644 --- a/src/gui/ChoiceBuilder/choiceForms-1497-legacy-missing-fields.test.ts +++ b/src/gui/ChoiceBuilder/choiceForms-1497-legacy-missing-fields.test.ts @@ -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. */ @@ -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, { diff --git a/src/gui/ChoiceBuilder/components/PropertyCaptureFields.svelte b/src/gui/ChoiceBuilder/components/PropertyCaptureFields.svelte index 5d277c519..a9dbf2d68 100644 --- a/src/gui/ChoiceBuilder/components/PropertyCaptureFields.svelte +++ b/src/gui/ChoiceBuilder/components/PropertyCaptureFields.svelte @@ -37,7 +37,9 @@ let lastNamedFormat = ""; {#snippet control()} + + {#snippet control()} + { config.sort = value === "asc" || value === "desc" ? value : "none"; }} /> + {/snippet} + + {#snippet control()} diff --git a/src/gui/ChoiceBuilder/components/WritePositionSetting.svelte b/src/gui/ChoiceBuilder/components/WritePositionSetting.svelte index 931ee7a74..585876808 100644 --- a/src/gui/ChoiceBuilder/components/WritePositionSetting.svelte +++ b/src/gui/ChoiceBuilder/components/WritePositionSetting.svelte @@ -52,6 +52,7 @@ function onWritePositionChange(value: string) { property: { kind: "named", format: "" }, action: "set", createIfMissing: true, + sort: "none", }; return; } diff --git a/src/preflight/collectChoiceRequirements.test.ts b/src/preflight/collectChoiceRequirements.test.ts index fbab5de55..ca68677a2 100644 --- a/src/preflight/collectChoiceRequirements.test.ts +++ b/src/preflight/collectChoiceRequirements.test.ts @@ -2048,7 +2048,7 @@ describe("collectChoiceRequirements - macro form roster", () => { describe("property capture requirements", () => { it.each(["number", "checkbox"])("uses a known property's %s widget in the one-page form", async (type) => { const choice = createCaptureChoice("Inbox.md"); - choice.propertyCapture = { property: { kind: "named", format: "rating" }, action: "set", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "rating" }, action: "set", createIfMissing: true, sort: "none" }; choice.format = { enabled: true, format: "{{VALUE:ratingInput}}" }; const requirements = await collectChoiceRequirements({ vault: { getAbstractFileByPath: () => null }, @@ -2060,7 +2060,7 @@ describe("property capture requirements", () => { it("defers only the value whose native property type depends on the runtime selection", async () => { const choice = createCaptureChoice("Inbox.md"); - choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:propertyName}}" }, action: "set", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:propertyName}}" }, action: "set", createIfMissing: true, sort: "none" }; choice.format = { enabled: true, format: "{{VALUE:propertyInput}}" }; const requirements = await collectChoiceRequirements({ vault: { getAbstractFileByPath: () => null } } as unknown as App, createPreflightPlugin(false), createChoiceExecutor(), choice); @@ -2070,7 +2070,7 @@ describe("property capture requirements", () => { it("collects property-name and value formats while ignoring dormant body-position fields", async () => { const choice = createCaptureChoice("Inbox.md"); - choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:property}}" }, action: "set", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:property}}" }, action: "set", createIfMissing: true, sort: "none" }; choice.format = { enabled: true, format: "{{VALUE:amount|type:number}}" }; choice.insertAfter.enabled = true; choice.insertAfter.after = "{{VALUE:dormantAfter}}"; diff --git a/src/services/packagePreview.test.ts b/src/services/packagePreview.test.ts index 025bab217..22b358001 100644 --- a/src/services/packagePreview.test.ts +++ b/src/services/packagePreview.test.ts @@ -984,7 +984,7 @@ describe("buildPackagePreview - inline JavaScript in a choice's own settings", ( format: { enabled: true, format: `- ${inline}` }, insertAfter: { enabled: true, after: `## ${inline}` }, insertBefore: { enabled: false, before: inline }, - propertyCapture: { property: { kind: "named", format: inline }, action: "set", createIfMissing: true }, + propertyCapture: { property: { kind: "named", format: inline }, action: "set", createIfMissing: true, sort: "none" }, } as unknown as ICaptureChoice; const template = { id: "t1", diff --git a/src/types/choices/ICaptureChoice.ts b/src/types/choices/ICaptureChoice.ts index bb3e7c067..08554dc4f 100644 --- a/src/types/choices/ICaptureChoice.ts +++ b/src/types/choices/ICaptureChoice.ts @@ -4,10 +4,13 @@ import type { OpenLocation, FileViewMode2 } from "../fileOpening"; export type BlankLineAfterMatchMode = "auto" | "skip" | "none"; +export type PropertyListSort = "none" | "asc" | "desc"; + export interface PropertyCapture { property: { kind: "named"; format: string } | { kind: "prompt" }; action: "set" | "addToList"; createIfMissing: boolean; + sort: PropertyListSort; } /** How to derive a sort key from a section heading's text (issue #481). */ @@ -111,21 +114,24 @@ export default interface ICaptureChoice extends IChoice { } export function parsePropertyCapture(input: unknown): PropertyCapture { + const sort = input && typeof input === "object" && "sort" in input ? input.sort : "none"; if ( !input || typeof input !== "object" || !("property" in input) || !input.property || typeof input.property !== "object" || !("kind" in input.property) || !("action" in input) || (input.action !== "set" && input.action !== "addToList") || - !("createIfMissing" in input) || typeof input.createIfMissing !== "boolean" + !("createIfMissing" in input) || typeof input.createIfMissing !== "boolean" || + (sort !== "none" && sort !== "asc" && sort !== "desc") ) { throw new Error("Invalid property capture settings. Choose a property and action in the Capture settings."); } const property = input.property; + const options: Omit = { action: input.action, createIfMissing: input.createIfMissing, sort }; if (property.kind === "prompt") { - return { property: { kind: "prompt" }, action: input.action, createIfMissing: input.createIfMissing }; + return { property: { kind: "prompt" }, ...options }; } if (property.kind === "named" && "format" in property && typeof property.format === "string") { - return { property: { kind: "named", format: property.format }, action: input.action, createIfMissing: input.createIfMissing }; + return { property: { kind: "named", format: property.format }, ...options }; } throw new Error("Invalid property capture selector."); } diff --git a/tests/e2e/capture-cursor.test.ts b/tests/e2e/capture-cursor.test.ts index b977cea53..4d2a6b722 100644 --- a/tests/e2e/capture-cursor.test.ts +++ b/tests/e2e/capture-cursor.test.ts @@ -372,7 +372,7 @@ describe("Capture cursor markers in native Obsidian", () => { it("strips property markers without changing the note body or cursor", async () => { const { choice, path } = await setup("---\nstatus: old\n---\nBody"); - choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: false }; + choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: false, sort: "none" }; choice.format.format = "before{{CURSOR}}after"; await saveAndOpen(choice, path); await getContext().obsidian.dev.evalJson("(() => { app.workspace.activeLeaf.view.editor.setCursor({line:3,ch:2}); return true; })()"); diff --git a/tests/e2e/capture-run-characterization.test.ts b/tests/e2e/capture-run-characterization.test.ts index f166eee76..beb5ba4ec 100644 --- a/tests/e2e/capture-run-characterization.test.ts +++ b/tests/e2e/capture-run-characterization.test.ts @@ -864,7 +864,7 @@ const CASES: Case[] = [ // Property capture. ["setting a property", async (a) => { await note(a); - a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true }; + a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" }; a.choice.format.format = "{{VALUE}}"; a.vars.value = "done"; }, { @@ -880,7 +880,7 @@ const CASES: Case[] = [ }], ["adding to a list property", async (a) => { a.choice.captureTo = await a.file("Target.md", "---\ntags:\n - a\n---\nbody\n"); - a.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true }; + a.choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" }; a.choice.format.format = "b"; }, { result: { ok: true, verified: true, effect: "changed", file: "Target.md" }, @@ -896,7 +896,7 @@ const CASES: Case[] = [ ["a property on a new note", async (a) => { a.choice.captureTo = a.path("Created.md"); a.choice.createFileIfItDoesntExist.enabled = true; - a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true }; + a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" }; a.choice.format.format = "open"; }, { result: { ok: true, verified: true, effect: "created", file: "Created.md" }, @@ -911,7 +911,7 @@ const CASES: Case[] = [ }], ["a property on a missing note without creation", async (a) => { a.choice.captureTo = a.path("Missing.md"); - a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true }; + a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" }; a.choice.format.format = "open"; }, { result: { ok: false, aborted: true, error: "Target file missing: Missing.md. Enable \"Create file if it doesn't exist\" or choose an existing file." }, @@ -927,7 +927,7 @@ const CASES: Case[] = [ ["a property on a Canvas text card", async (a) => { a.choice.captureTo = await a.file("Board.canvas", canvasJson([{ id: "card", type: "text", text: CARD }])); a.choice.captureToCanvasNodeId = "card"; - a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true }; + a.choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: true, sort: "none" }; a.choice.format.format = "open"; }, { result: { ok: false, aborted: true, error: "Property capture requires a Markdown note. Canvas text cards do not have note properties." }, diff --git a/tests/e2e/property-capture-ui.test.ts b/tests/e2e/property-capture-ui.test.ts index d0540d51e..518302915 100644 --- a/tests/e2e/property-capture-ui.test.ts +++ b/tests/e2e/property-capture-ui.test.ts @@ -43,7 +43,7 @@ function choiceFor(name: string, path: string, property: string) { choice.onePageInput = "never"; choice.useSelectionAsCaptureValue = false; choice.propertyCapture = { - property: { kind: "named", format: property }, action: "set", createIfMissing: true, + property: { kind: "named", format: property }, action: "set", createIfMissing: true, sort: "none", }; return choice; } @@ -127,7 +127,7 @@ describe("property capture through native keyboard prompts", () => { const path = await seedVaultFile(obsidian, sandbox, "runtime-list.md", `---\ntags: [original]\nstatus: active\n---\n${BODY}`); const choice = choiceFor("Choose a property then capture", path, "unused"); choice.onePageInput = "always"; - choice.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: false }; + choice.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: false, sort: "none" }; await saveChoice(choice, true); await obsidian.exec("command", { id: `quickadd:choice:${choice.id}` }); await waitForElement(obsidian, PROPERTY_INPUT); @@ -206,7 +206,7 @@ describe("property capture through native keyboard prompts", () => { capture.useSelectionAsCaptureValue = false; capture.format = { enabled: true, format: "{{VALUE:state}}" }; capture.propertyCapture = { - property: { kind: "named", format: "status" }, action: "set", createIfMissing: false, + property: { kind: "named", format: "status" }, action: "set", createIfMissing: false, sort: "none", }; const macro = new MacroChoice("Append project owner and update status"); macro.command = true; diff --git a/tests/e2e/property-capture.test.ts b/tests/e2e/property-capture.test.ts index a95bd5e6e..c9abf4fb6 100644 --- a/tests/e2e/property-capture.test.ts +++ b/tests/e2e/property-capture.test.ts @@ -17,7 +17,7 @@ function choiceFor(path: string) { choice.captureTo = path; choice.onePageInput = "never"; choice.format = { enabled: true, format: "{{VALUE:input}}" }; - choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:property}}" }, action: "set", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "{{VALUE:property}}" }, action: "set", createIfMissing: true, sort: "none" }; return choice; } @@ -48,7 +48,7 @@ describe("property capture in native Obsidian", () => { const { obsidian, sandbox } = getContext(); const path = await seedVaultFile(obsidian, sandbox, "list.md", `---\nqa_capture_sources: [old]\n---\n${BODY}`); const choice = choiceFor(path); - choice.propertyCapture = { property: { kind: "named", format: "qa_capture_sources" }, action: "addToList", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "qa_capture_sources" }, action: "addToList", createIfMissing: true, sort: "none" }; await saveChoice(choice); await obsidian.dev.evalJsonAsync(`(async () => { await app.plugins.plugins.quickadd.api.executeChoice(${JSON.stringify(choice.name)}, { input: ["old", "a,b", "new", "new"] }); @@ -59,12 +59,26 @@ describe("property capture in native Obsidian", () => { expect(outcome).toMatchObject({ ok: true, effect: "unchanged" }); }); + it("sorts the list after adding a date, and adding it again leaves the note unchanged", async () => { + const { obsidian, sandbox } = getContext(); + const path = await seedVaultFile(obsidian, sandbox, "sorted.md", `---\ncomplete_instances:\n - 2026-10-10\n - 2026-10-12\n---\n${BODY}`); + const choice = choiceFor(path); + choice.propertyCapture = { property: { kind: "named", format: "complete_instances" }, action: "addToList", createIfMissing: true, sort: "asc" }; + await saveChoice(choice); + for (const effect of ["changed", "unchanged"]) { + const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ input: "2026-10-11" }) }); + expect(outcome).toMatchObject({ ok: true, effect }); + } + await expect.poll(() => readNote(path), { timeout: 10000, interval: 100 }) + .toEqual({ properties: { complete_instances: ["2026-10-10", "2026-10-11", "2026-10-12"] }, body: BODY }); + }); + it("adds one item per line of a literal format plus the typed value, keeping commas inside a line", async () => { const { obsidian, sandbox } = getContext(); const path = await seedVaultFile(obsidian, sandbox, "lines.md", `---\ntags: [old]\n---\n${BODY}`); const choice = choiceFor(path); choice.format = { enabled: true, format: "{{VALUE:input}}\nwork\n\nSmith, John\nold\n" }; - choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" }; await saveChoice(choice); const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ input: "personal" }) }); expect(outcome).toMatchObject({ ok: true, verified: true, effect: "changed" }); @@ -77,7 +91,7 @@ describe("property capture in native Obsidian", () => { const path = await seedVaultFile(obsidian, sandbox, "multi-picks.md", `---\ntags: [old]\n---\n${BODY}`); const choice = choiceFor(path); choice.format = { enabled: true, format: "{{VALUE:work,home,urgent|multi|format:markdown}}\ninbox" }; - choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" }; await saveChoice(choice); const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ "work,home,urgent": ["work", "urgent"] }) }); expect(outcome).toMatchObject({ ok: true, verified: true, effect: "changed" }); @@ -91,7 +105,7 @@ describe("property capture in native Obsidian", () => { const path = await seedVaultFile(obsidian, sandbox, "included-picks.md", `---\ntags: [clippings]\n---\n${BODY}`); const choice = choiceFor(path); choice.format = { enabled: true, format: `{{TEMPLATE:${template}}}` }; - choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" }; await saveChoice(choice); const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ "business,pricing,marketing": ["business", "pricing"] }) }); expect(outcome).toMatchObject({ ok: true, verified: true, effect: "changed" }); @@ -139,7 +153,7 @@ describe("property capture in native Obsidian", () => { const { obsidian, sandbox } = getContext(); const path = sandbox.path("must-not-exist.md"); const choice = choiceFor(path); - choice.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "prompt" }, action: "set", createIfMissing: true, sort: "none" }; choice.createFileIfItDoesntExist.enabled = true; await saveChoice(choice); const outcome = await obsidian.execJson<{ ok: boolean; error?: string }>("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ input: "done" }) }); @@ -151,7 +165,7 @@ describe("property capture in native Obsidian", () => { const { obsidian, sandbox } = getContext(); const path = await seedVaultFile(obsidian, sandbox, "casing.md", `---\nStatus: active\n---\n${BODY}`); const choice = choiceFor(path); - choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: false }; + choice.propertyCapture = { property: { kind: "named", format: "status" }, action: "set", createIfMissing: false, sort: "none" }; await saveChoice(choice); const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ input: "done" }) }); expect(outcome).toMatchObject({ ok: true, verified: true, effect: "changed" }); @@ -163,7 +177,7 @@ describe("property capture in native Obsidian", () => { const { obsidian, sandbox } = getContext(); const path = sandbox.path("empty-add/absent.md"); const choice = choiceFor(path); - choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true }; + choice.propertyCapture = { property: { kind: "named", format: "tags" }, action: "addToList", createIfMissing: true, sort: "none" }; choice.createFileIfItDoesntExist.enabled = true; await saveChoice(choice); const outcome = await obsidian.execJson("quickadd:run", { id: choice.id, verify: true, vars: JSON.stringify({ input }) });