SilentVoid13 / Templater

A template plugin for obsidian
https://silentvoid13.github.io/Templater
GNU Affero General Public License v3.0
3k stars 175 forks source link

Set Frontmatter #109

Open calpa opened 3 years ago

calpa commented 3 years ago

For now, there is only getter of frontmatter, setter is also needed.

tp.frontmatter.set('a', 'b')

=>

---
a = b
---
luckman212 commented 3 years ago

In the meantime, anyone know of a commandline tool that we could wire up to a user script to fiddle with the YAML frontmatter?

I found a handful of libraries in NPM and PyPI, but nothing stood out to me as the "right" choice. I am inclined to stick with good old sed and awk.

CovetingEpiphany2152 commented 3 years ago

For now, there is only getter of frontmatter, setter is also needed.

tp.frontmatter.set('a', 'b')

=>

---
a = b
---

On top of just setting the values, since YAML supports lists and dictionaries, will also need to be able to work with them. For example, removing/appending a tag from existing list of tags. Or checking if a key exists in a dictionary, etc.

AB1908 commented 2 years ago

This would be great, of course. For anyone else looking, you can regex match and replace using Obsidian API or use MetaEdit's API.

TfTHacker commented 1 year ago

I wanted to add my voice into this. Until now I have used the metaedit plugin, which has an API for modifying frontmatter. However the plugin hasn't been updated (generates console errors). Also the implementation is a bit wonky, seems not to be tested in all scenarios.

Anyhow, reading and writing frontmatter would be a natural fit for Templater.

Tnx for all the great work on this plugin.

AB1908 commented 1 year ago

I wonder if the methods for parseYaml and stringifyYaml help with this.

mayurankv commented 1 year ago

+1 for this!

FynnFreyer commented 1 year ago

Because Templater exposes the Obsidian-API, writing/replacing frontmatter can be done relatively easily already.

I'm using this script to completely replace the current frontmatter with frontmatterobject. This can of course be used in conjunction with tp.frontmatter to just update parts of it.

module.exports = async (tp, frontmatterObject = {}) => {
    if (typeof frontmatterObject !== "object") {
        throw new Error("frontmatterObject must be an object")
    }

    // get proper file object from vault path
    const currentNotePath = tp.file.path(true)
    let currentNoteFile = this.app.vault.getAbstractFileByPath(currentNotePath)

    try {
        // Obsidian-API method to work with frontmatter
        await this.app.fileManager.processFrontMatter(currentNoteFile, (frontmatter) => {
            // clear the object
            for (let member in frontmatter) delete frontmatter[member]
            // copy over the content I'm interested in
            Object.assign(frontmatter, frontmatterObject)
        })
    } catch (e) {
        console.log(e)
    }
}

The actual template I use to replace the current frontmatter with a computed default looks like this

<%*
let default_frontmatter = await tp.user.default_frontmatter(tp)
await tp.user.replace_frontmatter(tp, tp.user.default_frontmatter(tp, true))
%>

Hope this helps!

Credit where it's due:
I cobbled this together from code posted on the forum, and I also use (a modified version of) some of these very good snippets.

FynnFreyer commented 1 year ago

Having an easy integrated way to set frontmatter through Templater would still be nice though ... but in the meantime you can use this I guess.

AB1908 commented 1 year ago

Obsidian now exposes an API to edit frontmatter directly. See https://github.com/obsidianmd/obsidian-api/blob/master/CHANGELOG.md#new-metadata-api

stracker-phil commented 5 months ago

Thanks for the info, @AB1908 !

I successfully create/update a frontmatter attribute using Templater like this:

<%*
// Generate a list of all notes in my "People" folder.
const fileList = app.vault.getMarkdownFiles().filter(file =>
    file.path.startsWith(baseDir) 
    && 'md' === file.extension
    && file.basename
);

// Get the `TFile` of a contact-note.
const contactFile = await tp.system.suggester(file => file.basename, fileList);

// For testing purposes, set the "contact date" to today.
const contactDate = moment().format('YYYY-MM-DD');

// Create or update the frontmatter property "last_contact".
app.fileManager.processFrontMatter(contactFile, (frontmatter) => {
    if (!frontmatter.last_contact || frontmatter.last_contact < contactDate) {
        frontmatter.last_contact = contactDate;
    }
});
%>
luckman212 commented 5 months ago

Is there a way to have the property formatted as

tags: [ foo, bar, baz ]

I use the template below to collect stray tags throughout the file, sort and deduplicate them, and place them all in the frontmatter. But, it formats it as

tags: foo, bar, baz

I always thought the first style was "more correct"?

<%*
  function getUnique(value, index, self) {
    return self.indexOf(value) === index;
  }
  const f = app.workspace.getActiveFile();
  var tArr = tp.file.tags.filter(getUnique);
  if (tArr.length) {
    tArr.sort();
    var tStr = tArr.join(', ').replace(/[#\[\]]/g,'');
    app.fileManager.processFrontMatter(f, (fm) => {
      fm.tags = tStr;
    });
  }
%>
stracker-phil commented 5 months ago

did you try assigning the array to the tags property, without converting it to a string?

...
    app.fileManager.processFrontMatter(f, (fm) => {
      fm.tags = tArr;
    });
...
luckman212 commented 5 months ago

Yes I did. Unfortunately, that results in the multiline YAML format which I absolutely despise because it wastes so much vertical space:

tags:
  - foo
  - bar
  - baz
Zachatoo commented 5 months ago

Using the app.fileManager.processFrontMatter API will always output arrays in the multiline format. You'd have to parse the frontmatter yourself if you don't like that format, though I'm sure many plugins use this API and will modify the frontmatter to that format for you if you have plugins that modify frontmatter.

I'd recommend either looking into integrating MetaEdit into your Templater script, or using the new getFrontmatterInfo function to find the frontmatter lines and using JS string manipulation to modify it.

luckman212 commented 5 months ago

For now I settled on just allowing it to use the unbracketed version. Obsidian still recognizes the tags. Hopefully in the future the API might be updated to support a multiline=false parameter...

luckman212 commented 5 months ago

I hacked away a bit and got a weirdly working function. The first time you invoke it, it will produce the desired format

tags: [ foo, bar, baz ]

Then, if you run it a 2nd time, it seems to work like a toggle, the format becomes

tags: foo, bar, baz

Run it a 3rd time, and it again becomes

tags: [ foo, bar, baz ]

It's very strange, I banged my keyboard and my head for a few hours before giving up. Seems to be some kind of cache issue or bug in Editor.replaceRange() ... @SilentVoid13 do you have any idea?

<%*
  const f = app.workspace.getActiveFile();
  const e = app.workspace.activeLeaf.view.editor;
  var tArr = tp.file.tags;
  if (tArr.length) {
    tArr.sort();
    var tStr = tArr.join(', ').replace(/#/g,'');
    var tbStr = `tags: [ ${tStr} ]`;
    await app.fileManager.processFrontMatter(f, (fm) => {
      fm.tags = tStr;
    });
    const nfm = await tp.obsidian.getFrontMatterInfo(e.getValue());
    if (nfm.exists) {
      const bfm = nfm.frontmatter.replace(/\btags: [^\n]*/, tbStr);
      await e.replaceRange(bfm, {line: 0, ch: nfm.from}, {line:0, ch: nfm.to});
    }
  }
%>
luckman212 commented 5 months ago

I added a small 100ms delay and that "solves" the problem. Still would like to know if I'm going about this completely the wrong way...

<%*
  function getUnique(value, index, self) {
    return self.indexOf(value) === index;
  }
  const f = app.workspace.getActiveFile();
  const e = app.workspace.activeLeaf.view.editor;
  var exclude_tags = [
    '#any_tags',
    '#you_dont_want',
    '#added_to_frontmatter'
  ];
  var tUnique = tp.file.tags.filter(getUnique);
  var tArr = tUnique.filter(t => exclude_tags.includes(t) === false); 
  if (tArr.length) {
    tArr.sort();
    var tStr = tArr.join(', ').replace(/#/g,'');
    var tbStr = `tags: [ ${tStr} ]`;
    await app.fileManager.processFrontMatter(f, (fm) => {
      //fm.tags = tArr;
      fm.tags = tStr;
    });
    setTimeout(async () => {
      const nfm = await tp.obsidian.getFrontMatterInfo(e.getValue());
      const bfm = await nfm.frontmatter.replace(/\btags: [^\n]*/, tbStr);
      await e.replaceRange(bfm, {line: 0, ch: nfm.from}, {line:0, ch: nfm.to});
    }, 100)
  }
%>
luckman212 commented 2 months ago

Updated version for completeness (currently broken in 2.3.2)

<%*
  const f = app.workspace.getActiveFile();
  const e = app.workspace.activeLeaf.view.editor;
  var tStr = null;
  var kStr = null;
  var tArr = null;
  const excludeTags = new Set([
    '#foo',
    '#bar',
    '#baz'
  ]);
  if (tp.file.tags.length) {
    const tUnique = Array.from(new Set(tp.file.tags));
    tArr = tUnique.filter(t => !excludeTags.has(t));
    tArr.sort();
    tStr = `tags: [ ${tArr.join(', ').replace(/#/g,'')} ]`;
  }
  const processFrontMatterResult = await app.fileManager.processFrontMatter(f, (fm) => {
    var kArr = fm['keywords'];
    if (kArr) {
      kArr = Array.from(new Set(kArr));
      kArr.sort();
      kStr = `keywords: [ ${kArr.join(', ')} ]`;
    }
    delete fm['tags'];
    delete fm['keywords'];
    if (kArr || tArr) {
      fm.placeholder = null;
    } else {
      return false;
    }
  });
  if (processFrontMatterResult === false) {
    return;
  }
  const newFmStr = [tStr, kStr].filter(k => k !== null).join('\n');
  tp.hooks.on_all_templates_executed(async () => {
    const nfm = await tp.obsidian.getFrontMatterInfo(e.getValue());
    const bfm = await nfm.frontmatter.replace(/\bplaceholder: [^\n]*/, newFmStr);
    await e.replaceRange(bfm, {line: 0, ch: nfm.from}, {line: 0, ch: nfm.to});
  })
%>
luckman212 commented 2 months ago

I don't know exactly why but I monkeyed around with this for about 5 hours today. Here's what I ended up with, works with 2.3.2 even despite #1380

<%*

  const e = app.workspace.activeLeaf?.view.editor;
  if (!e) {
    console.error('No editor leaf found');
    return;
  }
  let tStr = null;
  let kStr = null;
  let tArr = null;
  let kArr = null;
  const excludeTags = new Set([
    '#foo',
    '#bar',
    '#baz',
  ]);

  if (tp.file.tags.length) {
    const tUnique = Array.from(new Set(tp.file.tags));
    tArr = tUnique.filter(t => !excludeTags.has(t));
    tArr.sort();
    tStr = `tags: [ ${tArr.join(', ').replace(/#/g,'')} ]`;
  }
  let k = tp.frontmatter.keywords;
  if (typeof k === 'object' && k.length) {
    kStr = `keywords: [ ${tp.frontmatter.keywords.sort().join(', ') ?? ''} ]`;
  }

  tp.hooks.on_all_templates_executed(async () => {
    const f = tp.config.active_file;
    const processFrontMatterResult = await app.fileManager.processFrontMatter(f, (fm) => {
      delete fm['tags'];
      delete fm['keywords'];
      if (tStr || kStr) {
        fm.processing = 'please wait';
      } else {
        return false;
      }
    });
    if (processFrontMatterResult === false) {
      console.error('Error processing frontmatter');
      return;
    }
    setTimeout(async () => {
      const filtered = [tStr, kStr].filter(s => s && s.length).join('\n');
      const existingFm = await tp.obsidian.getFrontMatterInfo(e.getValue());
      const replacementFm = await existingFm.frontmatter.replace(/\bprocessing: please wait\b/, filtered);
      await e.replaceRange(replacementFm,
        {line: 0, ch: existingFm.from},
        {line: 0, ch: existingFm.to});
    }, 200)
  });

-%>