Skip to content

Instantly share code, notes, and snippets.

@kherge
Last active April 13, 2026 16:21
Show Gist options
  • Select an option

  • Save kherge/ea5e1f38c24c001ece409fba6305a7e2 to your computer and use it in GitHub Desktop.

Select an option

Save kherge/ea5e1f38c24c001ece409fba6305a7e2 to your computer and use it in GitHub Desktop.
A collection of utilities as a custom user script for the Templater plugin in Obsidian.
/**
* The base class for the find utility classes.
*
* When a tag is provided for inclusion or exclusion, the tag expression
* is converted into a regular expression. The tag expression supports the
* wildcard character `*`, which matches anything after the string. For
* example, the tag expression `project/*` would match any tag that starts
* with `#project/`, such as `#project/alpha` or `#project/beta`.
*/
class FindBase {
/**
* The tags to exclude.
*/
_excludedTags = [];
/**
* The tags to include.
*/
_includedTags = [];
/**
* Excludes a tag from the search.
*
* @param {string} tag The tag expression.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
excludeTag(tag) {
this._excludedTags.push(this.#toRegExp(tag));
return this;
}
/**
* Exclude multiple tags from the search.
*
* @param {string[]} tags The tags to exclude.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
excludeTags(...tags) {
tags.flat().forEach(tag => this.excludeTag(tag));
return this;
}
/**
* Includes a tag from the search.
*
* @param {string} tag The tag expression.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
includeTag(tag) {
this._includedTags.push(this.#toRegExp(tag));
return this;
}
/**
* Include multiple tags from the search.
*
* @param {string[]} tags The tags to include.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
includeTags(...tags) {
tags.flat().forEach(tag => this.includeTag(tag));
return this;
}
/**
* Checks if one or more tags have been excluded.
*
* @param {string[]} tags The tags to check.
*
* @return {boolean} If the file is excluded.
*/
_isExcludedByTag(...tags) {
if (this._excludedTags.length > 0) {
return tags
.some(tag => this
._excludedTags
.some(expression => expression.test(tag)));
}
return false;
}
/**
* Checks if one or more tags have been included.
*
* @param {string[]} tags The tags to check.
*
* @return {boolean} If the file is included.
*/
_isIncludedByTag(...tags) {
if (this._includedTags.length > 0) {
return tags
.some(tag => this
._includedTags
.some(expression => expression.test(tag)));
}
return true;
}
/**
* Converts a tag expression into a regular expression.
*
* @param {string} tag The tag expression to convert.
*
* @returns {RegExp} The converted regular expression.
*/
#toRegExp(tag) {
const expression = RegExp
.escape(tag)
.replace(/^\x23/, '')
.replace(/\\\*/, '.*');
return new RegExp(`^${expression}$`);
}
}
/**
* A utility class for finding Markdown files.
*/
class FindMarkdownFiles extends FindBase {
/**
* The folders to exclude.
*/
#excludedFolders = [];
/**
* The folders to include.
*/
#includedFolders = [];
/**
* Excludes a folder from the search.
*
* Any file found will be excluded if its folder matches the given value.
* This is not a prefix check, this is using the complete folder path for
* comparison (TFile#parent.path).
*
* @param {string} folder The folder to exclude.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
excludeFolder(folder) {
this.#excludedFolders.push(folder);
return this;
}
/**
* Searches the vault for Markdown files based on the given criteria.
*
* @returns {TFile[]} The found files.
*/
getFiles() {
const files = [];
for (const file of app.vault.getMarkdownFiles()) {
if (this.#isExcludedByFolder(file) || !this.#isIncludedByFolder(file)) {
continue;
}
if (this._includedTags.length > 0 || this._excludedTags.length > 0) {
const tags = this.#getTags(file);
if (this._isExcludedByTag(...tags) || !this._isIncludedByTag(...tags)) {
continue;
}
}
files.push(file);
}
return files;
}
/**
* Includes a folder in the search.
*
* Any file found will be included if its folder matches the given value.
* This is not a prefix check, this is using the complete folder path for
* comparison (TFile#parent.path).
*
* @param {string} folder The folder to include.
*
* @returns {FindMarkdownFiles} For method chaining.
*/
includeFolder(folder) {
this.#includedFolders.push(folder);
return this;
}
/**
* Returns all tags for a file.
*
* This will return the tags from both the front matter and the contents
* of the file. For consistency, the tags from the contents will have the
* `#` prefix removed to match the format used in the front matter.
*
* @param {TFile} file The file with tags.
*
* @return {string[]} The tags for the file.
*/
#getTags(file) {
const cache = app.metadataCache.getFileCache(file);
const tags = new Set();
cache
?.tags
?.map(tag => tags.add(tag.tag.replace(/^\x23/, '')));
cache
?.frontmatter
?.tags
?.map(tag => tags.add(tag));
return Array.from(tags);
}
/**
* Checks if a file has been excluded based on its folder.
*
* @param {TFile} file The file to check.
*
* @return {boolean} If the file is excluded.
*/
#isExcludedByFolder(file) {
if (this.#excludedFolders.length > 0) {
return this.#excludedFolders.includes(file.parent.path);
}
return false;
}
/**
* Checks if a file has been included based on its folder.
*
* @param {TFile} file The file to check.
*
* @return {boolean} If the file is included.
*/
#isIncludedByFolder(file) {
if (this.#includedFolders.length > 0) {
return this.#includedFolders.includes(file.parent.path);
}
return true;
}
}
/**
* A utility class for prompting for one or more Markdown files.
*/
class ChooseMarkdownFiles extends FindMarkdownFiles {
/**
* The properties to use for display text.
*/
#displayProperties = [];
/**
* The maximum number of files allowed.
*/
#max = Infinity;
/**
* The minimum number of files required.
*/
#min = 0;
/**
* The prompt text to use for the file selection.
*/
#prompt = "Choose a file";
/**
* The Templater instance.
*/
#templater;
/**
* Initializes the builder.
*
* @param {Templater} templater The Templater instance.
*/
constructor(templater) {
super();
this.#templater = templater;
}
/**
* Adds a property to use for the display text of the file options.
*
* The property is retrieved from the file's front matter and used as the
* text to display in the file selection modal. If multiple properties are
* added, the first property with a value will be used. If no properties
* are added or if none of the properties have a value, the file name will
* be used.
*
* @param {string} property The property name.
*
* @returns {ChooseMarkdownFiles} For method chaining.
*/
displayProperty(property) {
this.#displayProperties.push(property);
return this;
}
/**
* Prompts the user to select one or more files.
*
* @return {Promise<TFile[]>} The selected files.
*/
async getFiles() {
const chosen = [];
let files = this.#getFilesWithDisplayText();
for (let i = 0; i < this.#max && files.length > 0; i++) {
const file = await this.#promptForFile(files, chosen.length);
if (file) {
chosen.push(file);
files.splice(files.indexOf(file), 1);
} else {
break;
}
}
return chosen;
}
/**
* Sets the maximum number of files that can be selected.
*
* @param {number} count The maximum number of files.
*
* @returns {ChooseMarkdownFiles} For method chaining.
*/
max(count) {
this.#max = count;
return this;
}
/**
* Sets the minimum number of files that must be selected.
*
* @param {number} count The minimum number of files.
*
* @returns {ChooseMarkdownFiles} For method chaining.
*/
min(count) {
this.#min = count;
return this;
}
/**
* Sets the text for the prompt.
*
* @param {string} prompt The prompt text.
*
* @returns {ChooseMarkdownFiles} For method chaining.
*/
prompt(prompt) {
this.#prompt = prompt;
return this;
}
/**
* Searches the vault for Markdown files based on the given criteria.
*
* This function also sets the display text in the prompt for each
* file based on the display properties that have been configured.
*
* @returns {TFile[]} The found files.
*/
#getFilesWithDisplayText() {
return super
.getFiles()
.map(file => {
this.#setDisplayText(file);
return file;
});
}
/**
* Generates the prompt text to display.
*
* @param {number} count The number of files selected.
*
* @returns {string} The prompt to display.
*/
#getPrompt(count) {
let prompt = this.#prompt;
if (this.#max !== Infinity && count < this.#max) {
prompt = `[${count + 1}/${this.#max}] ${prompt}`;
}
if (this.#min === 0 || count >= this.#min) {
prompt += " (Press ESC to finish)";
}
return prompt;
}
/**
* Prompts the user to select a file.
*
* @param {TFile[]} files The files to choose from.
* @param {number} count The number of files selected.
*
* @returns {Promise<TFile|null>} The selected file, if any.
*/
async #promptForFile(files, count) {
return await this.#templater.system.suggester(
file => file.displayText ?? file.path,
files,
!(count >= this.#min),
this.#getPrompt(count)
);
}
/**
* Sets the display text for a file in a prompt.
*
* @param {TFile} file The file to set.
*/
#setDisplayText(file) {
const cache = app.metadataCache.getFileCache(file);
for (const property of this.#displayProperties) {
const value = cache?.frontmatter?.[property];
if (Array.isArray(value)) {
file.displayText = value[0];
break
} else if (typeof value === 'string') {
file.displayText = value;
break;
}
}
}
}
/**
* A utility class for finding tags.
*/
class FindTags extends FindBase {
/**
* Searches the vault for tags based on the given criteria.
*
* @returns {string[]} The found tags.
*/
getTags() {
return Object
.keys(app.metadataCache.getTags())
.map(tag => tag.replace(/^\x23/, ''))
.filter(tag => !this._isExcludedByTag(tag))
.filter(tag => this._isIncludedByTag(tag));
}
}
/**
* A utility class for prompting for one or more tags.
*/
class ChooseTags extends FindTags {
/**
* The maximum number of tags allowed.
*/
#max = Infinity;
/**
* The minimum number of tags required.
*/
#min = 0;
/**
* The prompt text to use for the tag selection.
*/
#prompt = "Choose a tag";
/**
* The Templater instance.
*/
#templater;
/**
* Initializes the builder.
*
* @param {Templater} templater The Templater instance.
*/
constructor(templater) {
super();
this.#templater = templater;
}
/**
* Prompts the user to select one or more tags.
*
* @return {Promise<string[]>} The selected tags.
*/
async getTags() {
const chosen = [];
let tags = super.getTags();
tags.sort();
for (let i = 0; i < this.#max && tags.length > 0; i++) {
const tag = await this.#promptForTag(tags, chosen.length);
if (tag) {
chosen.push(tag);
tags.splice(tags.indexOf(tag), 1);
} else {
break;
}
}
return chosen;
}
/**
* Sets the maximum number of tags that can be selected.
*
* @param {number} count The maximum number of tags.
*
* @returns {ChooseTags} For method chaining.
*/
max(count) {
this.#max = count;
return this;
}
/**
* Sets the minimum number of tags that must be selected.
*
* @param {number} count The minimum number of tags.
*
* @returns {ChooseTags} For method chaining.
*/
min(count) {
this.#min = count;
return this;
}
/**
* Sets the text for the prompt.
*
* @param {string} prompt The prompt text.
*
* @returns {ChooseTags} For method chaining.
*/
prompt(prompt) {
this.#prompt = prompt;
return this;
}
/**
* Generates the prompt text to display.
*
* @param {number} count The number of files selected.
*
* @returns {string} The prompt to display.
*/
#getPrompt(count) {
let prompt = this.#prompt;
if (this.#max !== Infinity && count < this.#max) {
prompt = `[${count + 1}/${this.#max}] ${prompt}`;
}
if (this.#min === 0 || count >= this.#min) {
prompt += " (Press ESC to finish)";
}
return prompt;
}
/**
* Prompts the user to select a tag.
*
* @param {string[]} tags The tags to choose from.
* @param {number} count The number of tags selected.
*
* @returns {Promise<string|null>} The selected tag, if any.
*/
async #promptForTag(tags, count) {
return await this.#templater.system.suggester(
tag => tag,
tags,
!(count >= this.#min),
this.#getPrompt(count)
);
}
}
module.exports = {
/**
* Renders a file as a Wikilink.
*
* @param {TFile} file The file to render.
*
* @returns {string} The rendered Wikilink.
*/
asLink(file) {
if (Array.isArray(file)) {
file = file[0];
}
if (!file) {
return '';
}
const link = ['[[', file.basename];
if (file.displayText) {
link.push('|', file.displayText);
}
link.push(']]');
return link.join('');
},
/**
* Prompts the user to select one or more Markdown files.
*
* A new builder instance is returned that will allow a search for vault
* Markdown files to be performed based on various criteria. The search
* is not performed until the getFiles() method is called on the builder.
*
* @param {Templater} templater The Templater instance.
*
* @returns {ChooseMarkdownFiles} The new builder.
*/
chooseMarkdownFiles(templater) {
return new ChooseMarkdownFiles(templater);
},
/**
* Prompts the user to select one or more tags.
*
* A new builder instance is returned that will allow a search for vault
* tags to be performed based on various criteria. The search is not
* performed until the getTags() method is called on the builder.
*
* @param {Templater} templater The Templater instance.
*
* @returns {ChooseTags} The new builder.
*/
chooseTags(templater) {
return new ChooseTags(templater);
},
/**
* Finds Markdown files based on the given criteria.
*
* A new builder instance is returned that will allow a search for vault
* Markdown files to be performed based on various criteria. The search
* is not performed until the getFiles() method is called on the builder.
*
* @returns {FindMarkdownFiles} The new builder.
*/
findMarkdownFiles() {
return new FindMarkdownFiles();
},
/**
* Finds tags based on the given criteria.
*
* A new builder instance is returned that will allow a search for vault
* tags to be performed based on various criteria. The search is not
* performed until the getTags() method is called on the builder.
*
* @returns {FindTags} The new builder.
*/
findTags() {
return new FindTags();
},
/**
* Flattens multiple values into a single array, removing duplicates.
*
* @param {...(any[]|any)} values The values to flatten.
*
* @returns {any[]} The flattened array.
*/
flatArray(...values) {
const merged = new Set();
for (const value of values) {
if (Array.isArray(value)) {
value.flat().forEach(item => merged.add(item));
} else {
merged.add(value);
}
}
return Array.from(merged);
},
/**
* Requires the user to respond to at least the first prompt.
*
* @param {Templater} tp The Templater instance.
* @param {string} required The required prompt to display.
* @param {...string} optional The optional prompts to display.
*
* @returns {string[]} The responses.
*/
async requireFirstInput(tp, required, ...optional) {
const responses = [await this.requireInput(tp, required)];
for (const prompt of optional) {
const response = (await tp.system.prompt(prompt))?.trim();
if (response) {
responses.push(response);
}
}
return responses;
},
/**
* Requires the user to respond to a prompt.
*
* @param {Templater} tp The Templater instance.
* @param {string} prompt The prompt to display.
*
* @returns {string} The response.
*/
async requireInput(tp, prompt) {
const response = await tp.system.prompt(prompt, null, true);
return response?.trim();
},
/**
* Requires the user to respond to multiple prompts.
*
* @param {Templater} tp The Templater instance.
* @param {...string} prompts The prompts to display.
*
* @returns {string[]} The responses.
*/
async requireInputs(tp, ...prompts) {
const responses = [];
for (const prompt of prompts) {
responses.push(await this.requireInput(tp, prompt));
}
return responses;
},
/**
* Serializes the given JavaScript value into a YAML string.
*
* If the value is undefined, an empty string is returned. This allows for
* optional values to be easily handled in templates without rendering the
* string "undefined". If a prefix is provided, it will be prepended to the
* YAML string.
*
* @param {Templater} tp The Templater instance.
* @param {any} value The value to serialize.
* @param {string} prefix The string to prepend.
*
* @returns {string} The serialized YAML string.
*/
toYaml(tp, value, prefix = "") {
if (value === "" || value === undefined) {
return '';
}
return prefix + tp.obsidian.stringifyYaml(value).trim();
},
/**
* Validates the given value using a regular expression.
*
* @param {string} value The value to validate.
* @param {RegExp|string} regex The validation expression.
* @param {string} [message] The error message.
*
* @return {string} The value that was validated.
*/
validate(tp, value, regex, message = "The value is not valid.") {
if (!value.match(regex)) {
new tp.obsidian.Notice(message);
throw new Error(message);
}
return value;
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment