From 0bd3b1e4cc7b12f469a9c445a8b6eb55ebc1f7eb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 21:26:17 +0200 Subject: [PATCH 1/2] feat(capture): sort list properties written by property Capture Property Capture gets a "Sort list" setting: Keep order (default), Ascending, or Descending. Sorting applies to every list the Capture writes (Add to list, Set value on a list, and {{PROPERTY}} formats), after new items are merged and duplicates removed. Items sort in natural text order, so YYYY-MM-DD dates sort chronologically and "item 2" comes before "item 10". Settings saved without a sort load as Keep order, so existing Captures behave as before. Refs #2236 --- .../docs/docs/Choices/CaptureChoice.md | 10 ++++ src/cli/currentNoteUse.test.ts | 4 +- src/cli/registerQuickAddCliHandlers.test.ts | 2 +- .../CaptureChoiceEngine.property.test.ts | 10 ++-- src/engine/captureAction.test.ts | 2 +- src/engine/captureProperty.test.ts | 51 ++++++++++++++++++- src/engine/captureProperty.ts | 22 +++++--- .../ChoiceBuilder/CaptureChoiceForm.test.ts | 6 ++- .../components/PropertyCaptureFields.svelte | 12 ++++- .../components/WritePositionSetting.svelte | 1 + .../collectChoiceRequirements.test.ts | 6 +-- src/services/packagePreview.test.ts | 2 +- src/types/choices/ICaptureChoice.ts | 12 +++-- tests/e2e/capture-cursor.test.ts | 2 +- .../e2e/capture-run-characterization.test.ts | 10 ++-- tests/e2e/property-capture-ui.test.ts | 6 +-- tests/e2e/property-capture.test.ts | 30 ++++++++--- 17 files changed, 145 insertions(+), 43 deletions(-) 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/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 }) }); From 4965654c287fc3be8ba2754760abd7a3ce2f13f8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 22:51:28 +0200 Subject: [PATCH 2/2] fix(capture): show Keep order for property Captures saved before Sort list Saved choices reach the builder without passing through parsePropertyCapture, so a property Capture saved before this setting existed had no sort. The Sort list dropdown rendered blank and the Add to list description dropped its ordering note. The builder now fills in Keep order when it opens, like its other legacy defaults. --- src/gui/ChoiceBuilder/captureChoiceBuilder.ts | 3 +++ ...ceForms-1497-legacy-missing-fields.test.ts | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) 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, {