Properties: Support multi-level YAML (nested attributes)

I would also love to see support for nested yaml metadata.

I created a kind of electronic lab notebook for my students using obsidian, dataview and the templates plugin, which heavily relies on nested metadata structures to store information about projects, processes, devices, samples, analysis, etc.

I don’t really understand why this is not supported. It’s not that difficult to recursively parse a hierarchical data structure and display it as a tree view. It’s basically the same as a file browser.

6 Likes

+1 from me! I would love to see this functionality added as well.

I created a dataview dv.view script that can display nested yaml properties. Like this the yaml part of the document can at least be displayed but unfortunately not edited. The script and css still needs some refinement but I thought to share my first version with you and well come any suggestions to improve the code.

As a small appetizer here is how the rendered yaml currently looks like:

4 Likes

And that’s the code for it:

const rootKey = "__root__";

if (input && dv) {
  const properties = dv.el("div", "", { cls: "note-properties", attr: { id: "properties-container" } });
  // Set up a tree-like dict of all the directory {path : ul element} mappings
  let listTree = {};
  let header = "Properties";
  let obj = {};
  const frontmatter = dv.current().file.frontmatter
  if (Object.keys(input).length === 0 && input.constructor === Object && frontmatter.hasOwnProperty("note type")) {
    const note_type = frontmatter["note type"];
    switch (note_type) {
      case "electrochemical cell":
        header = "Cell Properties";
        obj = frontmatter["cell"];
        break;
      case "device":
        header = "Device Properties";
        obj = frontmatter["device"];
        break;
      case "instrument":
        header = "Instrument Properties";
        obj = frontmatter["instrument"];
        break;
      case "chemical":
        header = "Chemical Properties";
        obj = frontmatter["chemical"];
        break;
      case "electrode":
        header = "Electrode Properties";
        obj = frontmatter["electrode"];
        break;
      case "reference electrode":
        header = "Reference Electrode Properties";
        obj = frontmatter["electrode"];
        break;
      case "process":
        header = "Process Properties";
        obj = frontmatter["process"];
        break;
      case "sample":
        header = "Sample Properties";
        obj = frontmatter["sample"];
        break;
      case "analysis":
        header = "Analysis Properties";
        obj = frontmatter["analysis"];
        break;
      case "lab":
        header = "Lab Properties";
        obj = frontmatter["lab"];
        break;
      default:
        obj = frontmatter;
    }
  }
  else {
    let key = "";
    // check if input has property "key"
    if (input.hasOwnProperty("key")) {
      obj = frontmatter[input.key];
      key = input.key;
    }
    else {
      obj = frontmatter;
    }
    if (input.hasOwnProperty("header")) {
      header = input.header;
    }
    else {
      // if key is not empty, capitalize the first letter
      if (key !== "") {
        key = key.charAt(0).toUpperCase() + key.slice(1);
      }
      header = `${key} Properties`;
    }
  }
  dv.header(2, header, { container: properties });
  listTree[rootKey] = dv.el("ul", "", { container: properties });
  yaml_object_to_list(obj, listTree, 0, "");
}

function yaml_object_to_list(obj, listTree, level, parent) {

  if (parent === "") {
    parent = rootKey;
  }

  const objkeys = Object.keys(obj);

  objkeys.forEach(okey => {
    if (obj[okey] instanceof Object) {
      if (obj[okey] instanceof Array) {
        const parentEl = listTree[parent];
        // check if the array contains an object 
        if (obj[okey].length > 0 && obj[okey].some((e) => e instanceof Object) ) {
          const listEl = dv.el("li", "", { container: parentEl });
          dv.el("div", okey, { container: listEl, cls: "property-object" });
          listTree[okey] = dv.el("ul", "", { container: parentEl });
          obj[okey].forEach(entry => {
            if (entry instanceof Object) {
              const listEl = dv.el("li", "", { container: parentEl });
              dv.el("div", okey, { container: listEl, cls: "property-object" });
              listTree[okey] = dv.el("ul", "", { container: parentEl })
              yaml_object_to_list(entry, listTree, level + 1, okey)
            }
            else {
              const parentEl = listTree[parent];
              dv.el("li", `${entry}`, { container: parentEl });
            }
          })
        }
        else {
          const data_type = "list"
          const listEl = dv.el("li", "", { container: parentEl });
          dv.el("div", okey, { container: listEl, cls: "property-key", attr: { "data-type": data_type } });
          obj[okey].forEach(element => {
            const data_type = get_data_type(okey, obj[okey]);
            dv.el("div", element, { container: listEl, cls: "property-array-value", attr: { "data-type": data_type } });
          });
        }     
        // console.log(JSON.stringify(obj[okey]))
      } else {
        const parentEl = listTree[parent];
        const listEl = dv.el("li", "", { container: parentEl });
        dv.el("div", okey, { container: listEl, cls: "property-object" });
        listTree[okey] = dv.el("ul", "", { container: parentEl });
        yaml_object_to_list(obj[okey], listTree, level + 1, okey)
      }
    } else {
      // determine data type of obj[okey]
      const data_type = get_data_type(okey, obj[okey]);
      const parentEl = listTree[parent];
      const listEl = dv.el("li", "", { container: parentEl });
      dv.el("div", okey, { container: listEl, cls: "property-key", attr: { "data-type": data_type } });
      dv.el("div", obj[okey], { container: listEl, cls: "property-value", attr: { "data-type": data_type } });
    }
  });
}

function get_data_type(key, value) {
  let data_type = "string";
  if (typeof value === "number") {
    data_type = "number";
  }
  else if (typeof value === "boolean") {
    data_type = "boolean";
  }
  else if (typeof value === "object") {
    data_type = "object";
  }
  else {
    switch (key.toLowerCase()) {
      case "date":
        data_type = "date";
        break;
      case "time":
        data_type = "time";
        break;
      case "link":
        data_type = "link";
        break;
      default:
        data_type = "string";
    }
  }
  return data_type;
}
1 Like

And the CSS to format it:

/* ========================================================
 * PROPERTIES
 * ======================================================== */

.note-properties {
    margin-top: 1rem;
    margin-bottom: 1rem;
}

.note-properties ul {
    list-style-type: none;
    padding: 0;
}

.note-properties ul ul {
    padding-left: 22px;
}

.note-properties li {
    display: flex;
}

.note-properties h2 {
    margin-top: 0;
    margin-bottom: 1rem;
    font-size: 1.2rem;
    font-weight: var(--bold-weight);
    /* color: var(--text-muted); */
}

.property-object {
    font-size: 0.9rem;
    font-weight: var(--bold-weight);
    color: var(--text-muted);
    margin-bottom: 0.5rem;
}

div.property-key:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="%23797567" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-text"><path d="M17 6.1H3"></path><path d="M21 12.1H3"></path><path d="M15.1 18H3"></path></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

div.property-key[data-type="number"]:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="%23797567" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-binary"><rect x="14" y="14" width="4" height="6" rx="2"></rect><rect x="6" y="4" width="4" height="6" rx="2"></rect><path d="M6 20h4"></path><path d="M14 10h4"></path><path d="M6 14h2v6"></path><path d="M14 4h2v6"></path></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

div.property-key[data-type="date"]:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="%23797567" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-calendar"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

div.property-key[data-type="time"]:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23797567" stroke-width="2"><circle cx="12" cy="12" r="11"/><line x1="12" y1="12" x2="12" y2="4"></line><line x1="12" y1="12" x2="18" y2="12"></line></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

div.property-key[data-type="link"]:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="%23797567" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

div.property-key[data-type="list"]:before {
    content: "";
    display: block;
    background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="%23797567" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>') no-repeat;
    width: 18px;
    height: 18px;
    float: left;
    margin: 0 6px 0 0;
}

.property-key {
    font-size: 0.9rem;
    /* font-weight: var(--bold-weight); */
    color: var(--text-muted);
    min-width: 200px;
}

div.property-key>span {
    padding-bottom: 3px;
}

.property-value {
    font-size: 0.9rem;
    color: var(--metadata-input-text-color);
}

.property-array-value {
    font-size: 0.9rem;
    color: var(--metadata-input-text-color);
    background-color: var(--background-secondary);
    padding: 0.1rem 0.6rem;
    margin-right: 0.1rem;
    border-radius: 15px;
}
1 Like

It looks good. I hope you can make it editable as well.
Could you make it an official plugin? I can’t seem to add it manually.

Unfortunately, I had no time to learn how to create plugins for Obsidian yet. Don’t think I will find time for it any time soon. So don’t expect an official plugin from me. But maybe someone is picking up the ball.

Besides: To use the code you have to save the javascript code as a javascript file in your fault.
Let’s assume you named the file properties.js and saved it to a folder dvviews in your vault. Then you can display the properties anywhere in a note by adding

await dv.view("/dvviews/properties", {});

to it. If do not want to display all metadata of the note but only a part you can use

await dv.view("/dvviews/properties", {key: "key_name"});

The CSS you will have to add to your css snippet folder to properly format the output of the dv.view

It looks like it’s not on the roadmap (anymore)? maybe I’m confused.

Indeed, it no longer seems to be on the roadmap. The closest thing now is this:

Dynamic views

Create dynamic tables using data stored in note properties.

Database was renamed dynamic views for clarity.

3 Likes

Yep, the roadmap seems to have changed since I last looked at it.

+1 for me too. Any news about this?

1 Like

+1 - this is one of the main things im missing in obsidian, a good way to handle multilevel YAML properties. and not just input but also display them in a neat way.

did anyone look into meta-bind for this?

a build in solution would be best, but workarounds are welcome. meta data menu is sadly really buggy for the object list - and the display of the data in properties is unusable

how are you guys using this right now?

2 Likes

Add, edit, and render functionality added via MetaData Menu API

const { namedFileFields, fieldModifier: f } = MetadataMenu.api;

(async () => {
    const filePath = dv.current().file.path;
    const tFile = await app.vault.getAbstractFileByPath(filePath); 
    const fileCache = await app.metadataCache.getFileCache(tFile); 
    const namedFileFieldsData = await namedFileFields(filePath);

    if (!namedFileFieldsData) {
        console.error('Error fetching named file fields');
        return;
    }

    const properties = dv.el("div", "", { cls: "note-properties", attr: { id: "properties-container" } });
    const header = "Field Properties";
    dv.header(2, header, { container: properties });
    const ulElement = dv.el("ul", "", { container: properties });

    // Define a function to recursively calculate indentation level
    const calculateIndentation = (path) => {
        const child = path.split('.')[1]; // Get the first child
        return child ? 1 + calculateIndentation(child) : 0;
    };

    // Modify indexed paths and values
    Object.entries(namedFileFieldsData).forEach(([indexedPath, fieldInfo]) => {
        // Replace "____" with "."
        const modifiedIndexedPath = indexedPath.replace(/____/g, ".");
        const value = fieldInfo.value;
        const type = fieldInfo.type; // Store the value of Type in a variable
        const currentFilePath = dv.current().file.path;
        const modifiedField = f(dv, dv.page(currentFilePath), modifiedIndexedPath, { options: { alwaysOn: true, showAddField: true, inFrontmatter: true }});

        // Calculate the indentation level recursively
        const indentationLevel = calculateIndentation(modifiedIndexedPath);

        // Check if type is 'Object'
        const isObject = type === 'Object';

        // Create list item for the field
        const liElement = dv.el("li", "", { container: ulElement });
        
        // Calculate the total indentation based on the level
        const totalIndent = indentationLevel * 50;

        // Apply indentation
        liElement.style.paddingLeft = `${totalIndent}px`; 

        // Display child if exists, otherwise display full path
        const displayText = modifiedIndexedPath.split('.').pop(); 
        dv.el("div", `${displayText}:`, { container: liElement, cls: "property-key" }); 

        // Create a wrapper div to hold both value and modifiedField inline
        const inlineWrapper = dv.el("div", "", { container: liElement, cls: "inline-wrapper", attr: { style: "display: flex; align-items: center;" } });
        
        // Add value and modifiedField to the inline wrapper
        dv.el("span", `${value}`, { container: inlineWrapper, cls: "property-value", attr: { style: isObject ? "margin-right: 10px; font-size: 0;" : "margin-right: 10px;" } });
        dv.el("span", modifiedField, { container: inlineWrapper, cls: "property-modified-field", attr: { style: "font-size: 0;" } });
    });
})();
1 Like

I keep updating this code.
I will continuously post improvements and updates here.
The most recent update ( Added conditional statements to ensure the code only runs if an active file exists in the vault.)

const { namedFileFields, fieldModifier: f } = MetadataMenu.api;

(async () => {
    // Check if there's an active file
    if (app.workspace.getActiveFile()) {
        // Get the currently active file
        const tFile = app.workspace.getActiveFile();
        const fileCache = await app.metadataCache.getFileCache(tFile);
        const filePath = tFile.path;
        const namedFileFieldsData = await namedFileFields(filePath);

        if (!namedFileFieldsData) {
            console.error('Error fetching named file fields');
            return;
        }

        const properties = dv.el("div", "", { cls: "note-properties", attr: { id: "properties-container" } });
        const header = "Field Properties";
        dv.header(2, header, { container: properties });
        const ulElement = dv.el("ul", "", { container: properties });

        // Define a function to recursively calculate indentation level
        const calculateIndentation = (path) => {
            const child = path.split('.')[1]; 
            return child ? 1 + calculateIndentation(child) : 0;
        };

        // Modify indexed paths and values
        Object.entries(namedFileFieldsData).forEach(([indexedPath, fieldInfo]) => {
            // Replace "____" with "."
            const modifiedIndexedPath = indexedPath.replace(/____/g, ".");
            const value = fieldInfo.value;
            const type = fieldInfo.type; 
            const currentFilePath = tFile.path;
            const modifiedField = f(dv, dv.page(currentFilePath), modifiedIndexedPath, { options: { alwaysOn: true, showAddField: true, inFrontmatter: true }});

            // Calculate the indentation level recursively
            const indentationLevel = calculateIndentation(modifiedIndexedPath);

            // Check if type is 'Object'
            const isObject = type === 'Object';

            // Create list item for the field
            const liElement = dv.el("li", "", { container: ulElement });

            // Calculate the total indentation based on the level
            const totalIndent = indentationLevel * 50;

            // Apply indentation
            liElement.style.paddingLeft = `${totalIndent}px`; 

            // Display child if exists, otherwise display full path
            const displayText = modifiedIndexedPath.split('.').pop(); 
            dv.el("div", `${displayText}:`, { container: liElement, cls: "property-key" }); 

            // Create a wrapper div to hold both value and modifiedField inline
            const inlineWrapper = dv.el("div", "", { container: liElement, cls: "inline-wrapper", attr: { style: "display: flex; align-items: center;" } });

            // Add value and modifiedField to the inline wrapper
            dv.el("span", `${value}`, { container: inlineWrapper, cls: "property-value", attr: { style: isObject ? "margin-right: 10px; font-size: 0;" : "margin-right: 10px;" } });
            dv.el("span", modifiedField, { container: inlineWrapper, cls: "property-modified-field", attr: { style: "font-size: 0;" } });
        });
    }
})();
1 Like

+1 for this feature

I got fcskit’s .js and .css to work to display the nested yaml, also set up yours following the link Alternate Display of Properties in Document for Easier Editing of Nested Values Outside of Source Mode · mdelobelle/metadatamenu · Discussion #687 · GitHub but only the label “Field Properties” was displayed and not the detailed properties. Not sure what went wrong.

Is there anything in the Metadata Menu has to be set first?

Also it would be great if this can be called without the dataviewjs code block.

1 Like

+1
I currently use Properties to organise my content in Obsidian, so that it correlates with the course material, while studying. I also use Folders, and Tags not so much. Nested Properties seems to make the most sense for a course that is structured in this way, at least while I am studying.

There are Units, each Unit has multiple Sections.

So I would like to be able to do e.g.

  • Unit 1
    • Section 1
    • Section 2
    • Section 3
  • Unit 2
    • Section 1
    • etc.

I’m very much enjoying Obsidian and Zotero for studying. Thanks :wink:

1 Like

i am building a plugin to display properties based on a setup per tag/folder.
it handles nested properties if they are just nested one level.

its early stage

3 Likes

Yeah, you have to use MetaData Menu correctly in order for this to work. I am uploading an example vault on the github link. You have to set a FileClass to the file, and add the properties to that fileClass settings.

1 Like