-
-
Notifications
You must be signed in to change notification settings - Fork 20
fix: edit YAML lists (incl. tags) as native lists instead of collapsing them to a string (#94) #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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"; | ||||||
|
|
@@ -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(); | ||||||
|
|
||||||
|
|
@@ -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; | ||||||
|
|
@@ -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); | ||||||
|
|
@@ -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; | ||||||
| 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(", "); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., Root cause: The condition uses Fix: const newValue: unknown = editsArray ? newList : newList.join(", ");This ensures only properties that were originally arrays (
Suggested change
Spotted by Graphite |
||||||
|
|
||||||
| await this.updatePropertyInFile(property, newValue, file); | ||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| private getActiveAutoProperty(propertyName: string): AutoProperty | undefined { | ||||||
|
|
||||||
| 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"]); | ||
| }); | ||
| }); |
| 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; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, orcmd:endcollides with the internal add sentinels returned byGenericSuggester. Selecting that real element is interpreted as an add command instead of a replacement, andcmd:addfirstin 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 👍 / 👎.