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
109 changes: 55 additions & 54 deletions src/metaController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type MetaEdit from "./main";
import GenericPrompt from "./Modals/GenericPrompt/GenericPrompt";
import {EditMode} from "./Types/editMode";
import GenericSuggester from "./Modals/GenericSuggester/GenericSuggester";
import type {MetaEditSettings} from "./Settings/metaEditSettings";
import {ADD_FIRST_ELEMENT, ADD_TO_BEGINNING, ADD_TO_END} from "./constants";
import type {ProgressProperty} from "./Types/progressProperty";
import {ProgressPropertyOptions} from "./Types/progressPropertyOptions";
Expand All @@ -14,6 +13,7 @@ import {log} from "./logger/logManager";
import AutoPropertyValueModal from "./Modals/AutoPropertyValueModal/AutoPropertyValueModal";
import type {AutoProperty} from "./Types/autoProperty";
import {findAutoProperty, isMultiAutoProperty, toValueArray, withChoiceAdded} from "./autoProperties";
import {applyMultiValueEdit, isMultiValueYamlProperty, shouldUseMultiValueEditor, type MultiValueEdit} from "./multiValue";

const fileWriteQueues: Map<string, Promise<unknown>> = new Map();

Expand Down Expand Up @@ -87,8 +87,6 @@ export default class MetaController {
}

public async editMetaElement(property: Property, meta: Property[], file: TFile): Promise<void> {
const mode: EditMode = this.plugin.settings.EditMode.mode;

if (property.type === MetaType.Tag) {
await this.editTag(property, file);
return;
Expand All @@ -101,7 +99,11 @@ export default class MetaController {
return;
}

if (mode === EditMode.AllMulti || mode === EditMode.SomeMulti)
// A real YAML list is inherently multi-value, so it always uses the
// element-aware list editor - editing it as a single text line (the
// `standardMode` path) would flatten the list and shred elements that
// contain commas or `[[wikilinks]]`.
if (shouldUseMultiValueEditor(property, this.plugin.settings.EditMode))
await this.multiValueMode(property, file);
else
await this.standardMode(property, file);
Expand Down Expand Up @@ -224,70 +226,69 @@ export default class MetaController {
}

private async multiValueMode(property: Property, file: TFile): Promise<boolean> {
const settings: MetaEditSettings = this.plugin.settings;
let newValue: string | string[];


if (settings.EditMode.mode == EditMode.SomeMulti && !settings.EditMode.properties.includes(property.key)) {
await this.standardMode(property, file);
return false;
}

let selectedOption: string, tempValue: string, splitValues: string[];
splitValues = this.splitMultiValue(property);

if (splitValues.length == 0 || (splitValues.length == 1 && splitValues[0] == "")) {
// A YAML list is edited element-by-element off its ORIGINAL typed array,
// so every element the user does not touch keeps its exact type, order,
// and spelling (commas and `[[wikilinks]]` included). An inline field (or
// a YAML value stored as a comma string) has no real array, so it is
// split on commas and re-joined.
const editsArray = isMultiValueYamlProperty(property);
const writeBase: unknown[] = editsArray
? (property.content as unknown[])
: this.splitMultiValue(property);
// The selectable view is always strings, kept 1:1 with `writeBase` so a
// selection maps back to the correct element.
const displayValues: string[] = editsArray
? writeBase.map(value => (value ?? "").toString())
: (writeBase as string[]);

let selectedOption: string;
if (displayValues.length == 0 || (displayValues.length == 1 && displayValues[0] == "")) {
const options = ["Add new value"];
selectedOption = await GenericSuggester.Suggest(this.app, options, [ADD_FIRST_ELEMENT]);
}
else if (splitValues.length == 1) {
const options = [splitValues[0], "Add to end", "Add to beginning"];
selectedOption = await GenericSuggester.Suggest(this.app, options, [splitValues[0], ADD_TO_END, ADD_TO_BEGINNING]);
else if (displayValues.length == 1) {
const options = [displayValues[0], "Add to end", "Add to beginning"];
selectedOption = await GenericSuggester.Suggest(this.app, options, [displayValues[0], ADD_TO_END, ADD_TO_BEGINNING]);
} else {
const options = ["Add to end", ...splitValues, "Add to beginning"];
selectedOption = await GenericSuggester.Suggest(this.app, options, [ADD_TO_END, ...splitValues, ADD_TO_BEGINNING]);
const options = ["Add to end", ...displayValues, "Add to beginning"];
selectedOption = await GenericSuggester.Suggest(this.app, options, [ADD_TO_END, ...displayValues, ADD_TO_BEGINNING]);
}

if (!selectedOption) return;
let selectedIndex;

// Auto Properties are intercepted in editMetaElement; this path is free-text.
if (selectedOption.includes("cmd")) {
if (!selectedOption) return false;

let tempValue: string;
let selectedIndex = -1;
// Match the add/insert sentinels EXACTLY. A substring `includes("cmd")`
// check would misread a real list element that merely contains "cmd"
// (e.g. `cmd:build`) as a command and collapse the whole list.
// (Auto Properties are intercepted in editMetaElement; this path is free-text.)
const isAddCommand =
selectedOption === ADD_FIRST_ELEMENT ||
selectedOption === ADD_TO_BEGINNING ||
selectedOption === ADD_TO_END;
Comment on lines +265 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating literal command sentinels as edits

When a YAML array is now routed into this list editor from All Single mode, an existing element whose literal value is exactly cmd:addfirst, cmd:beg, or cmd:end collides with the internal add sentinels returned by GenericSuggester. Selecting that real element is interpreted as an add command instead of a replacement, and cmd:addfirst in a non-empty list will replace the whole list with the prompted value, dropping the other elements; track the selected row/index or use non-colliding item identities rather than reserving user-visible strings.

Useful? React with 👍 / 👎.

if (isAddCommand) {
tempValue = await GenericPrompt.Prompt(this.app, "Enter a new value");
} else {
selectedIndex = splitValues.findIndex(el => el == selectedOption);
selectedIndex = displayValues.findIndex(el => el == selectedOption);
tempValue = await GenericPrompt.Prompt(this.app, `Change ${selectedOption} to`, selectedOption);
}

if (!tempValue) return;
switch(selectedOption) {
case ADD_FIRST_ELEMENT:
newValue = `${tempValue}`;
break;
case ADD_TO_BEGINNING:
newValue = `${[tempValue, ...splitValues].join(", ")}`;
break;
case ADD_TO_END:
newValue = `${[...splitValues, tempValue].join(", ")}`;
break;
default:
if (selectedIndex !== -1)
splitValues[selectedIndex] = tempValue;
else
splitValues = [tempValue];
newValue = splitValues.join(", ");
break;
}
if (!tempValue) return false;

if (property.type === MetaType.YAML)
newValue = this.splitMultiValue({...property, content: newValue});
const edit: MultiValueEdit =
selectedOption === ADD_FIRST_ELEMENT ? {kind: "addFirst", value: tempValue} :
selectedOption === ADD_TO_BEGINNING ? {kind: "prepend", value: tempValue} :
selectedOption === ADD_TO_END ? {kind: "append", value: tempValue} :
{kind: "replace", index: selectedIndex, value: tempValue};

if (newValue) {
await this.updatePropertyInFile(property, newValue, file);
return true;
}
const newList = applyMultiValueEdit(writeBase, edit);

return false;
// YAML persists a real list (round-trips as a native YAML array); an
// inline/tag field stores the comma-joined string per the inline convention.
const newValue: unknown = property.type === MetaType.YAML ? newList : newList.join(", ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect condition for determining output format. A YAML scalar edited in AllMulti mode will be written as an array instead of a string.

What breaks: When a YAML scalar property (e.g., status: open) is edited via multiValueMode in AllMulti or SomeMulti mode, the condition checks property.type === MetaType.YAML and writes newList (an array) directly, converting the scalar to an array ["new-value"].

Root cause: The condition uses property.type instead of editsArray. A YAML scalar reaches multiValueMode when EditMode is AllMulti/SomeMulti, where editsArray is false (scalar was split on commas), but property.type is still MetaType.YAML.

Fix:

const newValue: unknown = editsArray ? newList : newList.join(", ");

This ensures only properties that were originally arrays (editsArray === true) are written back as arrays, while scalars (even YAML ones) are joined back to strings.

Suggested change
const newValue: unknown = property.type === MetaType.YAML ? newList : newList.join(", ");
const newValue: unknown = editsArray ? newList : newList.join(", ");

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


await this.updatePropertyInFile(property, newValue, file);
return true;
}

private getActiveAutoProperty(propertyName: string): AutoProperty | undefined {
Expand Down
87 changes: 87 additions & 0 deletions src/multiValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {describe, expect, it} from "vitest";
import {MetaType} from "./Types/metaType";
import {EditMode} from "./Types/editMode";
import {
applyMultiValueEdit,
isMultiValueYamlProperty,
shouldUseMultiValueEditor,
} from "./multiValue";

describe("isMultiValueYamlProperty", () => {
it("is true only for a YAML property whose value is a real array", () => {
expect(isMultiValueYamlProperty({type: MetaType.YAML, content: ["a", "b"]})).toBe(true);
expect(isMultiValueYamlProperty({type: MetaType.YAML, content: []})).toBe(true);
});

it("is false for YAML scalars, non-YAML types, and empty content", () => {
expect(isMultiValueYamlProperty({type: MetaType.YAML, content: "a, b"})).toBe(false);
expect(isMultiValueYamlProperty({type: MetaType.YAML, content: null})).toBe(false);
expect(isMultiValueYamlProperty({type: MetaType.YAML, content: 5})).toBe(false);
expect(isMultiValueYamlProperty({type: MetaType.Dataview, content: ["a", "b"]})).toBe(false);
expect(isMultiValueYamlProperty({type: MetaType.Tag, content: "#a"})).toBe(false);
});
});

describe("shouldUseMultiValueEditor", () => {
const allSingle = {mode: EditMode.AllSingle, properties: [] as string[]};
const allMulti = {mode: EditMode.AllMulti, properties: [] as string[]};

it("always routes a real YAML list to the list editor, even in AllSingle (#94)", () => {
expect(shouldUseMultiValueEditor({key: "tags", type: MetaType.YAML, content: ["a", "b"]}, allSingle)).toBe(true);
});

it("keeps a YAML scalar on the single-value path in AllSingle", () => {
expect(shouldUseMultiValueEditor({key: "status", type: MetaType.YAML, content: "open"}, allSingle)).toBe(false);
});

it("honours AllMulti and SomeMulti for non-array values", () => {
expect(shouldUseMultiValueEditor({key: "status", type: MetaType.Dataview, content: "a"}, allMulti)).toBe(true);

const someMulti = {mode: EditMode.SomeMulti, properties: ["tags"]};
expect(shouldUseMultiValueEditor({key: "tags", type: MetaType.Dataview, content: "a"}, someMulti)).toBe(true);
expect(shouldUseMultiValueEditor({key: "status", type: MetaType.Dataview, content: "a"}, someMulti)).toBe(false);
});
});

describe("applyMultiValueEdit", () => {
it("adds the first element to an empty list", () => {
expect(applyMultiValueEdit([], {kind: "addFirst", value: "a"})).toEqual(["a"]);
});

it("prepends and appends without disturbing existing elements", () => {
expect(applyMultiValueEdit(["b", "c"], {kind: "prepend", value: "a"})).toEqual(["a", "b", "c"]);
expect(applyMultiValueEdit(["a", "b"], {kind: "append", value: "c"})).toEqual(["a", "b", "c"]);
});

it("replaces only the targeted element", () => {
expect(applyMultiValueEdit(["a", "b", "c"], {kind: "replace", index: 1, value: "B"})).toEqual(["a", "B", "c"]);
});

it("replaces the whole list when no element matched (index -1)", () => {
expect(applyMultiValueEdit(["a", "b"], {kind: "replace", index: -1, value: "x"})).toEqual(["x"]);
});

// The core regression: editing one element must NOT shred the others.
it("preserves elements that contain commas (#94)", () => {
expect(applyMultiValueEdit(["Smith, John", "Doe, Jane"], {kind: "replace", index: 1, value: "Roe, Jane"}))
.toEqual(["Smith, John", "Roe, Jane"]);
});

it("preserves bracketed values and wikilinks", () => {
expect(applyMultiValueEdit(["[[Home]]"], {kind: "append", value: "[[Away]]"}))
.toEqual(["[[Home]]", "[[Away]]"]);
expect(applyMultiValueEdit(["[draft]", "[final]"], {kind: "replace", index: 0, value: "[wip]"}))
.toEqual(["[wip]", "[final]"]);
});

it("keeps the type of every untouched element (numbers, booleans, null)", () => {
expect(applyMultiValueEdit([1, true, null, 3], {kind: "replace", index: 1, value: "maybe"}))
.toEqual([1, "maybe", null, 3]);
});

it("does not mutate the input list", () => {
const base = ["a", "b"];
applyMultiValueEdit(base, {kind: "replace", index: 0, value: "X"});
expect(base).toEqual(["a", "b"]);
});
});
79 changes: 79 additions & 0 deletions src/multiValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {MetaType} from "./Types/metaType";
import {EditMode} from "./Types/editMode";

/**
* Pure, Obsidian-free helpers for editing multi-value (list) properties. Kept
* separate from `metaController` so the list-mutation logic - where array
* corruption bugs live - can be unit-tested in the jsdom-free `node` env.
*/

export interface EditModeSettings {
mode: EditMode;
properties: string[];
}

interface PropertyLike {
key?: string;
type?: MetaType;
content?: unknown;
}

/**
* Whether a property's stored value is a real YAML list. Such a value is
* inherently multi-value and must be edited as a list regardless of the global
* EditMode - editing it through a single-line text field would flatten the list
* and destroy element boundaries (commas, `[[wikilinks]]`, types).
*/
export function isMultiValueYamlProperty(property: PropertyLike): boolean {
return property.type === MetaType.YAML && Array.isArray(property.content);
}

/**
* Decide whether to open the element-aware list editor for a property.
*
* A real YAML list always uses the list editor; otherwise the global EditMode
* decides (AllMulti, or SomeMulti when the property is opted in).
*/
export function shouldUseMultiValueEditor(property: PropertyLike, editMode: EditModeSettings): boolean {
if (isMultiValueYamlProperty(property)) return true;
if (editMode.mode === EditMode.AllMulti) return true;
if (editMode.mode === EditMode.SomeMulti && !!property.key && editMode.properties.includes(property.key)) {
return true;
}
return false;
}

export type MultiValueEdit =
| {kind: "addFirst"; value: string}
| {kind: "prepend"; value: string}
| {kind: "append"; value: string}
| {kind: "replace"; index: number; value: string};

/**
* Apply a single add/replace edit to a list, returning a NEW list.
*
* `base` is the list being edited: the original (typed) array for a YAML list,
* or the comma-split string elements for an inline field. Untouched elements are
* carried over by reference, so a YAML list keeps the exact type, ordering, and
* spelling of every element the user did not touch - numbers stay numbers, null
* stays null, and a value containing a comma or `[[wikilink]]` is never
* re-split. Only the one element the user actually edited becomes their typed
* string. A `replace` whose index is out of range (no element matched) replaces
* the whole list with the single new value, mirroring the prior behaviour.
*/
export function applyMultiValueEdit(base: readonly unknown[], edit: MultiValueEdit): unknown[] {
switch (edit.kind) {
case "addFirst":
return [edit.value];
case "prepend":
return [edit.value, ...base];
case "append":
return [...base, edit.value];
case "replace": {
if (edit.index < 0 || edit.index >= base.length) return [edit.value];
const next = [...base];
next[edit.index] = edit.value;
return next;
}
}
}
23 changes: 23 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ describe("MetaEditParser frontmatter parsing", () => {
]);
});

// #94/#31: a YAML list must read back as a real array (not a joined string),
// so the edit/write path can keep it a native list. Exercises the position +
// parseYaml read path (the cache-fallback path is covered below).
it("reads a YAML array via the parseYaml position path as a real array", async () => {
const file = new TFile("tags-list.md");
const parser = createParser(
{
frontmatter: {tags: ["state/inprogress", "course/x"]},
frontmatterPosition: {
start: {line: 0, col: 0, offset: 0},
end: {line: 2, col: 3, offset: 0},
},
},
"---\ntags: [state/inprogress, course/x]\n---\nbody\n",
);

const props = await parser.parseFrontmatter(file);
expect(props).toEqual([
{key: "tags", content: ["state/inprogress", "course/x"], type: MetaType.YAML},
]);
expect(Array.isArray(props[0].content)).toBe(true);
});

it("falls back to cached frontmatter entries when no position metadata exists", async () => {
const file = new TFile("cache-only.md");
const parser = createParser(
Expand Down
Loading
Loading