Skip to content

Instantly share code, notes, and snippets.

@ljavuras
Last active January 7, 2025 21:09
Show Gist options
  • Save ljavuras/d47247a1399cf3b34a6ceb2e06796626 to your computer and use it in GitHub Desktop.
Save ljavuras/d47247a1399cf3b34a6ceb2e06796626 to your computer and use it in GitHub Desktop.
Issue tracker with unresolved CustomJS dependencies
/**
* Wrapper for Obsidian API
*
* @author Ljavuras <[email protected]>
*/
class Obsidian {
file = {
getTags(file) {
return customJS.obsidian.getAllTags(
app.metadataCache.getFileCache(file)
);
}
};
/**
* Vault related Obsidian API
*/
vault = {
// Special characters that aren't allowed for filename in Obsidian:
// *"\/<>:|?#^[]
specialCharSet : "*\"\\/<>:|?#^[]",
specialCharSetRegex : /[*"\\/<>:|?#^[\]]/,
/**
* Checks if a string is a valid filename in Obsidian
* @param {string} filename - Name of a potential new file
* @returns {boolean}
*/
isValidFilename(filename) {
return !this.specialCharSetRegex.test(filename);
},
/**
* Checks if a file at a given path exists
* @param {String} filePath - Path of a file
* @returns {Boolean} Returns true if file exists
*/
existsFile(filePath) {
filePath = obsidian.normalizePath(filePath);
return app.vault.getAbstractFileByPath(filePath) instanceof obsidian.TFile;
},
/**
* Returns a TFile given a file path
* @param {string} filePath - Path of the file
* @returns {TFile}
*
* @todo Handle file extension (w/ or w/o ".md")
*/
getFile(filePath) {
filePath = obsidian.normalizePath(filePath);
let file = app.vault.getAbstractFileByPath(filePath);
if (!file instanceof obsidian.TFile) {
throw new customJS.VaultError(`${filePath} is not a file.`)
}
return file;
},
/**
* Gets a best matching TFile by file name
* @param {string} fileName - Name of file, w/ or w/o extension
* @returns {TFile} Obsidian TFile object
*/
getFileByName(fileName) {
// TODO: handle error
return app.metadataCache.getFirstLinkpathDest(fileName, "");
},
/**
* Get contents of a file
* @param {string | TFile} file - Path or object of a file
* @param {Boolean} stripYAML - Strip frontmatter if true
* @returns {string} Contents of the file
*/
async getFileContent(fileId, stripYAML = true, readOnly = true) {
let tfile;
// fileId is a path
if (typeof fileId == 'string') {
tfile = this.getFile(fileId);
// fileId is TFile
} else if (fileId instanceof obsidian.TFile) {
tfile = fileId;
// Handle error
} else {
throw new customJS.VaultError(
"Cannot get file content.\n" +
"Invalid parameter passed to Obsidian.getFileContent"
)
}
let fileContent = readOnly
? await app.vault.cachedRead(tfile)
: await app.vault.read(tfile);
if (stripYAML) {
fileContent = fileContent.replace(/^---\v.*?\v---\v/s, "");
}
return fileContent;
},
existsFolder(folderPath) {
folderPath = obsidian.normalizePath(folderPath);
return app.vault.getAbstractFileByPath(folderPath) instanceof obsidian.TFolder;
},
/**
* Creates a folder if the folder doesn't exist
* @param {string} folderPath - Path of the folder
*/
async createFolder(folderPath) {
if (!this.existsFolder(folderPath)) {
await app.vault.createFolder(folderPath);
}
},
/**
* Opens a file in editor, creates the file if it doesn't exist
* @param {TFile|string} file - File or path to be opened in editor
* @param {string} mode - Open mode, see options in `modeMap`
*/
async openFile(file, mode = "current") {
let modeMap = {
"current": [false],
"new-tab": [true],
"split-right": ["split"],
"split-down": ["split", "horizontal"],
"new-window": ["window"],
};
// file type check
if (!(file instanceof obsidian.TFile) &&
typeof file != "string") {
throw new customJS.VaultError(
"file should either be type TFile or string, but it is type " +
typeof file + "."
);
}
// Get TFile from path
if (typeof file == "string") {
if (!this.existsFile(file)) {
file = await app.vault.create(file, '');
} else {
file = app.vault.getAbstractFileByPath(file);
}
}
// Open file with appropriate mode
let activeLeaf = app.workspace.getLeaf(...modeMap[mode]);
await activeLeaf.openFile(file);
},
/**
* Gets a list of files under a folder
* @param {string} folderPath - Path to the folder
* @param {string} options
* @returns Files and folders under the path
*/
async list(folderPath, options = "all") {
let filenames = await app.vault.adapter.list(folderPath);
let folders = filenames.folders.map((path) => {
return app.vault.getAbstractFileByPath(path);
});
let files = filenames.files.map((path) => {
return app.vault.getAbstractFileByPath(path);
});
switch (options) {
case "all":
return {folders: folders, files: files}
case "folder":
return folders;
case "file":
return files;
default:
throw new customJS.VaultError(
"Invalid option passed to Obsidian.list\n" +
"options can only be: all, folder, file"
);
}
},
};
/**
* Workspace related Obsidian API
*/
workspace = {
/**
* Returns most recently active file
* @returns {TFile}
*/
getActiveFile() {
return app.workspace.getActiveFile();
},
/**
* Returns previous in the history of active view
* @returns {TFile}
*/
getPreviousFile() {
return app.vault.getAbstractFileByPath(
app.workspace.getLastOpenFiles()[0]
);
},
/**
* Returns the workspace leaf of a file in workspace
* @param {string} filePath - Path of the file
* @returns {WorkspaceLeaf}
*/
getLeafByFilePath(filePath) {
// Iterate through layout tree and extract the leaves
function extractLeafs(layoutTree) {
if (layoutTree.type === "leaf") {
return layoutTree;
}
return layoutTree.children.reduce((acc, child) => {
return acc.concat(extractLeafs(child));
}, []);
}
// Get all views from left, main and right of Obsidian workspace
let leaves = Object.entries(app.workspace.getLayout())
.reduce((leaves, entry) => {
if (['left', 'main', 'right'].includes(entry[0])) {
leaves = leaves.concat(extractLeafs(entry[1]));
}
return leaves;
}, []);
let leafId = leaves?.find(
leaf => leaf.state.state.file.localeCompare(filePath) === 0
)?.id;
if (leafId) {
return app.workspace.getLeafById(leafId);
}
}
};
frontmatter = {
/**
* Set frontmatter of a file
*
* @param {TFile} file - Target file
* @param {Object.<string, string>} properties - Dictionary of properties
*
* @example
* // Add delay or hook to prevent race condition between Templater and
* // Obsidian API.
* tp.hooks.on_all_templates_executed(async () => {
* customJS.Obsidian.frontmatter.set(tp.config.target_file, {
* 'property1': 'value1',
* 'property2': 'value2',
* });
* });
*
* @todo handle exceptions
*/
set(file, properties) {
app.fileManager.processFrontMatter(
file,
(frontmatter) => {
for (const [property, value] of Object.entries(properties)) {
frontmatter[property] = value;
}
}
);
},
get(file) {
return app.metadataCache.getFileCache(file)?.frontmatter;
},
/**
* Rename a property of target file
* @param {TFile} file - Target file
* @param {String} property - Property to rename
* @param {String} newProperty - New property name
*/
rename(file, property, newProperty) {
app.fileManager.processFrontMatter(
file,
(frontmatter) => {
Object.defineProperty(frontmatter, newProperty,
Object.getOwnPropertyDescriptor(frontmatter, property)
);
delete frontmatter[property];
}
)
},
/**
* Push an array of values into a property, if the property is a string,
* it will turn it into an array.
*
* Takes care of nested structure, e.g., tags, avoids duplicated values
* @param {TFile} file - Target file
* @param {String} property - Property name to push into
* @param {Array.<string>} values - Values to push into property
* @param {Boolean} isNested - Has nested structure like tags
*/
addList(file, property, values, isNested = false) {
app.fileManager.processFrontMatter(
file,
(frontmatter) => {
if (!frontmatter[property]) {
frontmatter[property] = values;
return;
} else if (typeof frontmatter[property] !== 'string'
&& !Array.isArray(frontmatter[property])) {
// TODO: Handle type error
}
if (typeof frontmatter[property] === 'string') {
frontmatter[property] = [frontmatter[property]];
}
// Concat two arrays and eliminate duplicates
if (isNested) {
// E.g., ['note/issue', 'note'] => ['note/issue']
values = values.filter((new_value) => {
return !frontmatter[property].some((present_value) => {
return present_value.startsWith(new_value);
});
});
frontmatter[property] = frontmatter[property]
.filter((present_value) => {
return !values.some((new_value) => {
return new_value.startsWith(present_value);
})
});
frontmatter[property] = frontmatter[property].concat(values);
} else {
// Create unique array
frontmatter[property] = [...new Set(
[...frontmatter[property], ...values]
)];
}
}
);
},
/**
* Add tags to frontmatter of a file
* @param {TFile} file - Target file
* @param {Array.<string>} tags - Tags to add in frontmatter
*
* @example
* // Add delay or hook to prevent race condition between Templater and
* // Obsidian API.
* tp.hooks.on_all_templates_executed(async () => {
* customJS.Obsidian.frontmatter.add(
* tp.config.target_file,
* ['tag1', 'tag2']
* );
* });
* @todo Accept single tag as a string
* @todo Remove '#' if provided
*/
addTags(file, tags) {
this.addList(file, "tags", tags, true);
},
/**
* Gets a link object from a frontmatter link
* @param {TFile} file - Target file
* @param {String} property - Property name
* @returns {Obsidian.Link}
*/
getLink(file, property) {
let linkRef = app.metadataCache.getFileCache(file)
?.frontmatterLinks
.find(fl => fl.key == property);
if (linkRef) {
return new customJS.Obsidian.Link().fromReference(linkRef)
}
},
};
/**
* Renders markdown to a container
* @param {String} markdown - Markdown content
* @param {HTMLElement} containerEl - Container of the rendered markdown
* @param {String} sourcePath - Path used to resolve relative internal links
* @param {obsidian.Component} component - Parent component to manage the
* lifecycle of the rendered child components.
* @param {Boolean} inline - Remove margin if rendered inline
*/
renderMarkdown(markdown, containerEl, sourcePath, component, inline = true) {
if (!containerEl) return;
containerEl.innerHTML = "";
obsidian.MarkdownRenderer.renderMarkdown(markdown, containerEl, sourcePath, component)
.then(() => {
if (!containerEl || !inline) return;
// Unwrap any created paragraph elements if we are inline.
let paragraph = containerEl.querySelector("p");
while (paragraph) {
let children = paragraph.childNodes;
paragraph.replaceWith(...Array.from(children));
paragraph = containerEl.querySelector("p");
}
});
}
/**
* @todo Support markdown link & external link
*/
Link = class {
/**
* @param {TFile} file - File the link links to
* @param {String} sourcePath - Path where the link links from
* @param {String} displayText - Alternative display text
*/
constructor(file, sourcePath = "", displayText) {
this.file = file;
this.sourcePath = sourcePath;
this.displayText = displayText;
}
/**
* Constructs a link from path, even if the target path doesn't exist.
* Use this method for unresolved links.
* @param {String} path - Path the link links to
* @param {String} sourcePath - Path the link links from
* @param {String} displayText - Alternative display text
* @returns {Obsidian.Link}
*/
fromPath(path, sourcePath = "", displayText) {
this.path = path;
this.file = app.metadataCache.getFirstLinkpathDest(path, sourcePath);
this.sourcePath = sourcePath;
this.displayText = displayText;
return this;
}
/**
* Construct a Link from link string
* @param {String} string - Link string, e.g., "[[file name]]"
* @param {String} sourcePath - Path the link links from
* @returns {Obsidian.Link}
*/
fromString(string, sourcePath = "") {
const { link, display } =
/!?\[\[(?<link>[^|\]]*)(\|(?<display>[^\]]*))?\]\]/
.exec(string).groups;
this.file = app.metadataCache.getFirstLinkpathDest(
link,
this.sourcePath
);
if (!this.file) {
this.path = link; // Unresolved link
}
this.sourcePath = sourcePath;
this.displayText = display;
this.original = string;
return this;
}
/**
* Constructs a Link from reference object
* @param {Object} reference - Obsidian Reference object,
* https://docs.obsidian.md/Reference/TypeScript+API/Reference
* @param {String} reference.displayText
* @param {String} reference.link
* @param {String} reference.original
* @param {String} sourcePath - Path the link links from
* @returns {Obsidian.Link}
*/
fromReference(reference, sourcePath = "") {
this.file = app.metadataCache.getFirstLinkpathDest(
reference.link,
this.sourcePath
);
if (!this.file) {
this.path = reference.link; // Unresolved link
}
this.sourcePath = sourcePath;
this.displayText = reference.link != reference.displayText?
reference.displayText : undefined;
this.original = reference.original;
return this;
}
/**
* Set sourcePath and returns itself for chaining
* @param {String} sourcePath - Path where the link displays from
* @returns {Obsidian.Link}
*/
setSourcePath(sourcePath) {
this.sourcePath = sourcePath;
return this;
}
/**
* Set displayText and returns itself for chaining
* @param {String} displayText - Alternative text display
* @returns {Obsidian.Link}
*/
setDisplayText(displayText) {
this.displayText = displayText;
return this;
}
/**
* Returns Obsidian flavored markdown wikilink
* @returns {String}
*/
toString() {
let linkText = app.metadataCache.fileToLinktext(
this.file,
this.sourcePath
);
let displayText =
(this.displayText == linkText) ? undefined:
this.displayText ? this.displayText:
(this.file.basename != linkText) ? this.file.basename:
undefined;
return "[["
+ linkText
+ (displayText? `|${displayText}` : '')
+ "]]";
}
/**
* Returns an <a> element
* @param {Boolean} removeContent - Removes text if true
* @returns {HTMLAnchorElement}
*/
toAnchor(removeContent = false) {
let linkText = this.file?
app.metadataCache.fileToLinktext(this.file, this.sourcePath):
this.path; // Unresolved link
let displayText = this.displayText ?? linkText;
return createFragment().createEl("a", {
attr: {
...(this.displayText && { 'data-tooltip-position': "top" }),
...(this.displayText && { 'aria-label': linkText }),
'data-href': linkText,
target: "_blank",
rel: "noopener"
},
href: linkText,
cls: "internal-link" + (!this.file? " is-unresolved" : ""),
text: removeContent? null : displayText,
});
}
/**
* Returns true if two links points to the same file
* @param {Obsidian.Link} link - The link to compare with this link
* @returns {Boolean}
*/
equals(link) {
if (this.file) {
return this.file.path == link.file?.path;
} else {
// Unresolved link
return this.path
&& this.path == link.path;
}
}
}
/**
* Creates InputPromptModal that receives user input
*/
InputPromptModal = class extends obsidian.Modal {
/**
* Generates HTMLElemnts, and opens the modal
* @param {string} submitPurpose - Description of submission purpose
* @param {string} placeholder - Placeholder text
* @param {string} prefill - Prefill input with text
*/
constructor(
submitPurpose = "enter",
placeholder = "Input text",
prefill = ""
) {
super(app);
// Generate HTMLElements for InputPromptModal
this.inputEl = document.createElement("input");
this.inputEl.type = "text";
this.inputEl.className = "prompt-input";
this.inputEl.placeholder = placeholder;
this.inputEl.value = prefill;
this.instructionsEl = document.createElement("div");
this.instructionsEl.className = "prompt-instructions";
[
{command: "↵", description: `to ${submitPurpose}`},
{command: "esc", description: "to dismiss"}
]
.forEach((instruction) => {
const instructionEl = document.createElement("div");
instructionEl.className = "prompt-instruciton";
const commandEl = document.createElement("span");
commandEl.className = "prompt-instruction-command";
commandEl.innerText = instruction.command;
instructionEl.appendChild(commandEl);
const descriptionEl = document.createElement("span");
descriptionEl.innerText = instruction.description;
instructionEl.appendChild(descriptionEl);
this.instructionsEl.appendChild(instructionEl);
});
// modalEl creation
this.modalEl.className = "prompt";
this.modalEl.innerHTML = "";
this.modalEl.appendChild(this.inputEl);
this.modalEl.appendChild(this.instructionsEl);
// Open the modal
this.open();
}
onOpen() {
this.inputEl.focus();
this.inputEl.addEventListener("keydown", this.inputListener);
}
onClose() {
this.inputEl.removeEventListener("keydown", this.inputListener);
}
/**
* Listen for keydown event, submit input if enter is pressed
*/
inputListener = function (event) {
if (event.key == "Enter") {
// Intercept event
event.preventDefault();
// Emit inputPromptSubmit event
this.inputEl.dispatchEvent(new Event("inputPromptSubmit"));
this.close();
}
}.bind(this);
/**
* Returns user input after user submits an input
* @returns {string} User input from InputPromptModal
*/
async getInput() {
// A promise that will resolve after user submits an input
return new Promise((resolve, reject) => {
// Calls resolve() on inputPromptSubmit event
const submitListener = function () {
this.inputEl.removeEventListener(
"inputPromptSubmit", submitListener);
resolve(this.inputEl.value);
}.bind(this);
// Listen for inputPromptSubmit event
this.inputEl.addEventListener(
"inputPromptSubmit", submitListener);
});
}
}
/**
* A tooltip that shows under parentEl
*
* Imitates Obsidian API obsidian.setTooltip(), with additional isError
* option.
*
* isError should be available somewhere in the API, where is it?
*/
Tooltip = class {
/**
* Attaches a tooltip to a HTMLElement
* @param {HTMLElement} parentEl - The element that the tooltip attaches to
* @param {boolean} isError - If the tooltip notifies an error
*/
constructor(parentEl, isError) {
this.parentEl = parentEl;
this.isError = isError;
this.el = createEl('div', { cls: "tooltip" });
this.el.textNode = this.el.appendChild(new Text(""));
this.el.createEl('div', { cls: "tooltip-arrow" });
}
show(message) {
this.el.textNode.nodeValue = message;
if (this.isError) { this.el.addClass("mod-error") }
let parentRect = this.parentEl.getBoundingClientRect();
this.el.setCssProps({
"top": `${parentRect.bottom + 8}px`,
"left": `${parentRect.left + (parentRect.width/2)}px`,
});
this.mount();
}
hide() {
this.unmount();
}
mount() {
clearTimeout(this.timeoutID);
this.timeoutID = setTimeout(() => { this.unmount() }, 2500);
document.body.appendChild(this.el);
}
unmount() {
this.el.parentElement?.removeChild(this.el);
}
}
/**
* Identical to Obsidian's Notice, but allows html element as message.
*/
Notice = class extends Notice {
constructor(messageEl, duration) {
super("", duration);
this.noticeEl.innerHTML = messageEl;
}
}
PopoverSuggest = class extends obsidian.PopoverSuggest {
constructor(
app,
scope,
attachToEl,
getSuggestions,
renderSuggestion,
suggestHandler,
) {
super(app, scope);
this.attachToEl = attachToEl;
this.getSuggestions = getSuggestions;
this.renderSuggestion = renderSuggestion;
this.suggestHandler = suggestHandler;
}
open() {
this.suggestions.setSuggestions(this.getSuggestions());
this.suggestEl.style.left = `${this.attachToEl.getClientRects()[0].left}px`;
this.suggestEl.style.top = `${this.attachToEl.getClientRects()[0].bottom}px`;
super.open();
}
toggle() {
this.isOpen? this.close() : this.open();
}
selectSuggestion(value, evt) {
this.suggestHandler(value);
this.close();
}
}
}
/**
* A facade of Templater API
*
* @author Ljavuras <[email protected]>
*/
class Templater {
plugin = app.plugins.plugins["templater-obsidian"];
settings = {
// Folder where templates are stored
folder: app.plugins.plugins["templater-obsidian"]
.settings.templates_folder,
};
// Templater enum RunMode definition, extracted from:
// https://github.com/SilentVoid13/Templater/blob/2abce98863bfad10c3f9ee6440f808f9ff9dbd10/src/core/Templater.ts#L25
RunMode = Object.freeze({
CreateNewFromTemplate: 0,
AppendActiveFile : 1,
OverwriteFile : 2,
OverwriteActiveFile : 3,
DynamicProcessor : 4,
StartupTemplate : 5,
});
/**
* Gets an Obsidian file object of a template
* @param {string} template - Basename or wikilink to a template
* @returns {TFile}
*/
getFile(template) {
let templateName = template.match(/^\[\[(.*)\]\]$/)?.[1] ?? template;
return app.vault
.getAbstractFileByPath(`${this.settings.folder}/${templateName}.md`);
}
getInfo(templateName) {
return customJS.Script.get(
this.getFile(templateName)
);
}
exists(templateName) {
return customJS.Obsidian.vault.existsFile(
`${this.settings.folder}/${templateName}.md`
);
}
/**
* Returns a template based on current state
* @returns {string} - Template name
*/
async resolveTemplate() {
let state = {
targetFile: customJS.Obsidian.workspace.getActiveFile(),
previousFile: customJS.Obsidian.workspace.getPreviousFile(),
};
// Each resolver tries to resolve a template, returns undefined if
// they cannot find a template
let resolvers = [
/** Unique Note Creator */
(state) => {
if (moment(
state.targetFile.basename,
customJS.UniqueNoteCreator.settings.format,
true
).isValid()) {
return "note.fleeting";
}
},
/** Periodic Notes */
(state) => {
let periodicType = customJS.Periodic.getType(
state.targetFile.basename
);
if (periodicType) {
return periodicType.template;
}
},
/** Project Notes */
async (state) => {
let templates = {
notes: "note.project.notes",
meeting: "note.project.notes.meeting",
};
let project = customJS.Projects.getProjectByFile(
state.previousFile
);
if (project) {
await customJS.Obsidian.vault.createFolder(project.notePath);
return (moment(state.targetFile.basename,
"[meeting.]YYYY-MM-DD",
true
).isValid())? templates.meeting : templates.notes;
}
},
/** Default template */
async (state) => {
return "note.default";
}
];
// Go through every resolver, stops when a resolver succeeds
for (const resolver of resolvers) {
let template = await resolver(state);
if (template) { return template; }
}
}
/**
* Creates a new file from template
* @param {string} filePath - Path of the new file
* @param {string} templateName - Name of the template
* @returns {TFile} The created file
*/
async createNewFileFromTemplate(filePath, templateName) {
const { folder, fileName } = filePath
.match(/^((?<folder>.*)\/)?(?<fileName>.*?)(\.md)?$/).groups;
return await this.plugin.templater.create_new_note_from_template(
this.getFile(templateName),
folder,
fileName,
false // processFrontMatter will be ignored if set to true
);
}
/**
* Insert template into active file
* @param {string} templateName - Name of the template
*/
async insertTemplateToActiveNote(templateName) {
// Sanitize template path
if (!templateName.endsWith(".md")) {
templateName += ".md";
}
let templatePath = obsidian.normalizePath(
`${this.settings.folder}/${templateName}`
);
let templateTFile = app.vault.getAbstractFileByPath(templatePath);
// TODO: handle file non-existence exception
// Insert template
await this.plugin.templater.append_template_to_active_file(templateTFile);
}
/**
* Overwrite target file with template
* @param {String} filePath - Path of file to apply template upon
* @param {String} templateName - Name of template
*/
async apply(filePath, templateName) {
// Sanitize template path
if (!templateName.endsWith(".md")) {
templateName += ".md";
}
let templateTFile = customJS.Obsidian.vault.getFile(
`${this.settings.folder}/${templateName}`
);
let targetTFile = customJS.Obsidian.vault.getFile(filePath);
await this.plugin.templater.write_template_to_file(
templateTFile,
targetTFile
);
}
/**
* Checks if a note can be updated
* @param {TFile} file - Target note to be updated
* @returns {String} status of updater availability
*/
async tryUpdate(file) {
const {name, version} = customJS.Script.template.getInfo(file);
// No template assigned
if (!name) {
return "missing-template";
}
// Missing version number
if (!version) {
return "missing-version";
}
// Note version is latest or next
if (version == "next" ||
version >= await this.getInfo(name).getVersion()) {
return "latest";
}
// Template updater doesn't exist
const updateTemplate = `${name}.update.${version}`;
if (!this.exists(updateTemplate)) {
return "missing-updater";
}
return "update-available";
}
/**
* Update a note layout to match latest template design
* @param {TFile} file - Target note to update
*/
async update(file) {
await this.apply(file.path, "system.update");
}
/**
* Wraps the tp object and provides additional functionality. All properties
* and methods of tp can be accessed as usual.
*
* @example
* tp = new Templater.API(tp);
* tR += tp.date.now() // Unaffected, tp object can be used as usual
*
* @constructor
* @param {Record<string, unknown>} tp - Templater current_function_object
* @returns {VioletTemplaterInlineAPI}
*/
API = function VioletTemplaterInlineAPI(tp) {
// tp is already wrapped, don't wrap again
if ('include' in tp) {
return tp;
}
// Wrap tp
Object.setPrototypeOf(this, Object.setPrototypeOf({}, tp));
this.file = Object.setPrototypeOf({}, tp.file);
// Hijack this.file.include
this.__proto__.include = this.file.include = include.bind(this);
/**
* Parses a template with correct template_file set, returns parsed
* content.
*
* `tp.file.include()` won't update `tp.config.template_file`, accessing
* `tp.config.template_file` within an included template will only get
* the template at the top of include chain.
*
* @param {string} templateLink - Template wiki link, e.g., "[[templateName]]"
* @param {boolean} notify - Notify include messages
* @returns {string} Parsed template content
*/
async function include(templateLink, notify = true) {
const { Obsidian, VaultError, Templater } = await cJS();
let templateName = templateLink.match(/^\[\[(.*)\]\]$/)?.[1];
// let tp = this.plugin.templater.current_functions_object;
// Invalid templateLink
if (!templateName) {
new Obsidian.Notice(
`<strong>${this.config.target_file.name}</strong>: Invalid file format, provide an obsidian link between quotes`
);
throw new VaultError(
`${this.config.target_file.name}: Invalid file format, provide an obsidian link between quotes`
);
}
// Templates doesn't exist
if (!Templater.exists(templateName)) {
new Obsidian.Notice(
`<strong>${this.config.target_file.name}</strong>: Template <em>${templateLink}</em> doesn't exist`
);
throw new VaultError(
`${this.config.target_file.name}: Template ${templateLink} doesn't exist`
);
}
let currentTemplateFile = this.config.template_file;
// Notify the user of the template inclusion
if (notify) {
new Obsidian.Notice(
`<strong>${this.config.target_file.name}</strong>: Template <em>[[${currentTemplateFile.basename}]]</em> included <em>${templateLink}</em>`
);
console.log(
`%c${this.config.target_file.name}%c: Template %c[[${currentTemplateFile.basename}]]%c included %c${templateLink}`,
"font-weight:bold", "font-weight:initial", "font-style:italic", "font-style:initial", "font-style:italic"
);
}
// Save and restore current template, so the code within the
// template can know exactly what template they are in.
this.config.template_file = Templater.getFile(templateName);
let content = await this.__proto__.file.include(`[[${templateName}]]`);
this.config.template_file = currentTemplateFile;
return content;
}
/**
* Set frontmatter of target file
*
* WARNING
* This operation will erase existing frontmatter
*
* @param {Object<string, string>} properties - Dictionary of properties
*/
this.__proto__.setFrontMatter = function(properties) {
// Prevent race condition between Templater and Obsidian
this.hooks.on_all_templates_executed(() => {
customJS.Obsidian.frontmatter.set(
this.config.target_file,
properties
);
});
}
/**
* Add tags to target file's frontmatter tags list
* @param {Array.<string>} tags - Tags to add in frontmatter
*/
this.__proto__.addTags = function(tags) {
// Prevent race condition between Templater and Obsidian
this.hooks.on_all_templates_executed(() => {
customJS.Obsidian.frontmatter.addTags(
this.config.target_file,
tags
);
});
}
}
}
/**
* Dataview issue tracker
*
* @author ljavuras <[email protected]>
* ======================================== */
const { Obsidian, Templater } = await cJS();
/** Container HTMLElement of Issue Tracker */
const containerEl = dv.container;
/** User options passed through dv.view("path/to/IssueTracker", options) */
const user_options = input;
/** Expose Obsidian API */
const obsidian = input.obsidian || (await cJS()).obsidian;
/**
* Options
*/
const default_options = {
/** Fallback for project name, use project note, or folder name if not supplied */
project_name: user_options?.project_note? user_options.project_note :
getCurrentFolderName(),
/** Fallback for project note, use project name, or folder name if not supplied */
project_note: user_options?.project_name? user_options.project_name :
getCurrentFolderName(),
/** Sub-folder where issue notes should go */
issue_folder: "issues/",
/** Template name, a template that exists in templater folder, must include '.md' */
issue_template: "note.project.issue",
/** Default query that shows up in the search bar */
default_query: "is:open",
locale: "en-US",
};
const options = {
...default_options,
...(user_options || {}),
};
/**
* Utilities
*/
function getCurrentFolderPath() {
return dv.current().file.folder;
}
function getCurrentFolderName() {
// Return the string after the last '/', if no '/' is found, return full path
return getCurrentFolderPath()?.match(/\/([^\/]+)$/)?.[1]
|| getCurrentFolderPath();
}
function resolvePath(basepath, relative_path) {
return obsidian.normalizePath(
`${basepath}/${relative_path}`
);
}
/** Returns a SVGElement */
const getIcon = {
open: () => {
let icon = obsidian.getIcon('circle-dot');
icon.addClass("issue-open");
return icon;
},
closed: () => {
let icon = obsidian.getIcon('check-circle');
icon.addClass("issue-closed");
return icon;
},
}
class IssueTracker {
constructor(options) {
this.config = {
issueTracker: {
default_query: options.default_query,
/** A dataview link: [[path/to/issueTracker.md|Issues]] */
link: dv.current().file.link.withDisplay("Issues"),
},
project: {
name: options.project_name,
/** A dataview link: [[path/to/ProjectNote.md|project_name]] */
/** Fallbacks to options.project_name */
link: Obsidian.vault.getFileByName(options.project_note)?
dv.page(Obsidian.vault.getFileByName(options.project_note)?.path
)?.file.link
.withDisplay(options.project_name) :
options.project_name,
},
issues: {
folder_path: resolvePath(getCurrentFolderPath(), options.issue_folder),
template_name: options.issue_template,
},
};
this.issueList = new this.IssueList(this);
this.filter = new this.Filter(this);
this.searchBar = new this.SearchBar(this);
this.el = createDiv({ cls: "issueTracker" });
this.searchBar.render(this.el);
}
async render(containerEl) {
containerEl.appendChild(this.el);
await this.issueList.render(this.el);
}
Filter = class {
constructor(issueTracker) {
this.issueTracker = issueTracker;
this.sortBy = "Newest";
}
async getStatusCount(status) {
let query_without_status = structuredClone(this.query);
delete query_without_status.status;
return (await this.queryIssues(
this.issueTracker.issueList.issues,
query_without_status
))
.filter((issue) => issue.status == status )
.length;
}
submitQuery(query) {
this.query = query;
this.issueTracker.issueList.refresh();
}
/**
* Filter issues with a query object
* @param {Issue[]} issues
* @param {Object} query - Query object
* @returns {Issue[]} Filtered issues
*/
async queryIssues(issues, query) {
async function includeIssue(issue, query) {
if (query?.status) {
if (issue.status != query.status) { return false; }
}
for (let label of query.labels) {
if (!issue.labels
.some((issue_label) => issue_label.includes(label))) {
return false;
}
}
for (let title of query.title) {
if (!issue.name.toLowerCase().includes(title.toLowerCase())) {
return false;
}
}
for (let content of query.content) {
if (!issue.name.toLowerCase().includes(content.toLowerCase()) &&
!(await issue.getContent()).toLowerCase().includes(content.toLowerCase())) {
return false;
}
}
return true;
}
return (await Promise.all(
issues.map(async (issue) => ({
issue: issue,
include: await includeIssue(issue, query)
}))))
.filter(data => data.include)
.map(data => data.issue);
}
/**
* Filter and sort issues with this.query
* @param {Issue[]} issues
* @returns {Issue[]} Filtered and sorted issues
*/
async filterIssues(issues) {
return (await this.queryIssues(issues, this.query))
.sort(issue => issue, "asc", (issue1, issue2) => {
switch (this.sortBy) {
case "Newest":
return issue2.created - issue1.created;
case "Oldest":
return issue1.created - issue2.created;
case "Recently updated":
return issue2.modified.ts - issue1.modified.ts;
case "Least recently updated":
return issue1.modified.ts - issue2.modified.ts;
}
});
}
}
SearchBar = class {
constructor(issueTracker) {
this.issueTracker = issueTracker;
this.el = createDiv({ cls: "issues__searchBar" });
this.searchQuery = new this.SearchQuery(issueTracker, this.el);
}
render(containerEl) {
this.newIssueBtn = this.el.createEl("button", { text: "New Issue" });
this.newIssueBtn.onclick = () => {
new this.issueTracker
.CreateIssueModal(this.issueTracker)
.open();
}
this.newIssueBtn.updateState = () => {
// Disable the button if issue folder doesn't exist
if (Obsidian.vault.existsFolder(
this.issueTracker.config.issues.folder_path)) {
this.newIssueBtn.disabled = false;
this.newIssueBtn.removeClass("mod-disabled");
} else {
this.newIssueBtn.disabled = true;
this.newIssueBtn.addClass("mod-disabled");
}
}
this.newIssueBtn.updateState();
containerEl.appendChild(this.el);
}
SearchQuery = class {
constructor(issueTracker, containerEl) {
this.issueTracker = issueTracker;
this.containerEl = containerEl;
this.input = createEl("input", {
attr: { placeholder: "Search issues" }
});
this.input.onkeydown = (event) => {
if (event.key == "Enter") {
this.setValue(this.input.value);
}
}
containerEl.appendChild(this.input);
this.setValue(issueTracker.config.issueTracker.default_query);
}
_parse(query_string) {
let status_query_exist = false;
// Split queries by space, respect enclosed quotation marks
this.query_tokens = query_string
.match(/((\S+)?"[^"]+")|[\S]+/g)
?.map((token) => {
return new this.Token().fromString(token);
})
// Allow only one status query
.filter((token) => {
if (token.type == "status" || token.type == "is") {
if (status_query_exist) {
return false;
} else {
status_query_exist = true;
return true;
}
}
return true;
})
|| [];
}
_render() {
this.input.value = this.query_tokens
.reduce((query_string, token, index) => {
return query_string + (index? ' ' : '') + token.toString();
}, "")
}
_submit() {
let query = {
status : undefined,
labels : [],
title : [],
content: [],
};
for (const token of this.query_tokens) {
switch (token.type) {
case "status":
case "is":
query.status = token.content;
break;
case "label":
query.labels.push(token.content);
break;
case "title":
query.title.push(token.content);
break;
case "content":
query.content.push(token.content);
}
}
this._render();
this.issueTracker.filter.submitQuery(query);
}
setValue(query_string) {
this._parse(query_string);
this._submit();
}
toggleStatus(status) {
let status_token = this.query_tokens
.find(token => token.type.match(/^is|status$/));
if (!status_token) {
this.query_tokens.push(new this.Token("is", status));
} else if (status_token.content == status) {
this.query_tokens = this.query_tokens.filter((token) => {
return !token.type.match(/^is|status$/);
});
} else {
status_token.content = status;
}
this._submit();
}
toggleQuery(type, content) {
let query_token = this.query_tokens.find(token =>
token.type == type && token.content == content
);
if (query_token) {
this.query_tokens = this.query_tokens.filter((token) => {
return token.type != type || token.content != content
});
} else {
this.query_tokens.push(
new this.Token(type, content)
);
};
this._submit();
}
Token = class {
constructor(type, content) {
this._type = type;
this._content = content;
}
get type() { return this._type ?? "content"; }
set type(string) { this._type = string; }
get content() { return this._content.replaceAll('"', ''); }
set content(string) {
this._content = string.indexOf(' ') != -1? `"${string}"` : string;
}
fromString(string) {
let { type, content } = string
.match(/^((?<type>status|is|title|label):)?(?<content>.+)$/)
.groups;
this._type = type;
this._content = content;
return this;
}
toString() {
return (this._type? `${this._type}:` : '')
+ this._content;
}
}
}
}
IssueList = class {
constructor(issueTracker) {
this.issueTracker = issueTracker;
this.issueFolder = issueTracker.config.issues.folder_path;
this.issues = dv.pages(`"${this.issueFolder}"`)
.map(page => new this.Issue(issueTracker, page))
.sort(issue => issue.issueNo, "desc");
this.toolbar = new this.ToolBar(issueTracker);
this.el = createDiv({ cls: "issueList" });
this.issuesEl = createDiv();
}
async render(containerEl) {
containerEl.appendChild(this.el);
this.toolbar.render(this.el);
this.el.appendChild(this.issuesEl);
}
async refresh() {
this.toolbar.refresh();
this.issuesEl.empty();
if (this.issues.length == 0) {
if (!Obsidian.vault.existsFolder(this.issueFolder)) {
this.noIssuesMessage.noFolder(this.issuesEl);
} else {
this.noIssuesMessage.noIssues(this.issuesEl);
}
}
for (const issue of await this.issueTracker.filter.filterIssues(this.issues)) {
issue.render(this.issuesEl);
}
if (this.issuesEl.children.length == 0) {
this.noIssuesMessage.noMatch(this.issuesEl);
}
}
// Messages to display when no issues are available for display
noIssuesMessage = {
noFolder: (containerEl) => {
let contentEl = containerEl.createDiv({ cls: "no-issues-message" });
contentEl.appendChild(getIcon.open());
contentEl.appendChild(createEl('h3', { text: "Getting started" }));
let msgEl = contentEl.createDiv();
let createFolderBtn = msgEl
.createEl('button', { text: "Create Folder" });
msgEl.appendChild(new Text(" at "));
msgEl.createEl('code', { text: this.issueFolder });
msgEl.appendChild(new Text(
", or specify your preferred subfolder for issue notes:"
));
msgEl.createEl('pre').createEl('code', {
text: "dv.view(\"path/to/IssueTracker\", {\n"
+ " obsidian: obsidian,\n"
+ " issue_folder: \"your/subfolder/\",\n"
+ "});"
});
// Create folder, enable "New Issue" button, refresh issueList
createFolderBtn.onclick = async () => {
await Obsidian.vault.createFolder(this.issueFolder);
this.refresh();
this.issueTracker.searchBar.newIssueBtn.updateState();
};
},
noIssues: (containerEl) => {
let contentEl = containerEl.createDiv({ cls: "no-issues-message" });
contentEl.appendChild(getIcon.closed());
contentEl.appendChild(createEl('h3', { text: "A fresh start!" }));
let msgEl = contentEl.createDiv();
msgEl.appendChild(new Text("There are currently no issues."));
},
noMatch: (containerEl) => {
let contentEl = containerEl.createDiv({ cls: "no-issues-message" });
contentEl.appendChild(createEl('h3', { text: "No matches found" }));
},
}
ToolBar = class {
constructor(issueTracker) {
this.issueTracker = issueTracker;
this.statusOpenBtn
= new this.StatusBtn(this.issueTracker, "open", getIcon.open());
this.statusClosedBtn
= new this.StatusBtn(this.issueTracker, "closed", getIcon.closed());
}
render(containerEl) {
this.el = containerEl.createDiv({ cls: "issues__issueList__toolbar" });
this.el.appendChild(this.statusOpenBtn.el);
this.el.appendChild(this.statusClosedBtn.el);
}
refresh() {
this.statusOpenBtn.refresh();
this.statusClosedBtn.refresh();
}
StatusBtn = class {
constructor(issueTracker, status, icon) {
this.issueTracker = issueTracker;
this.status = status;
// Capitalize first letter
let label = status.charAt(0).toUpperCase() + status.slice(1);
this.el = createSpan({ cls: "status-toggle" });
this.el.appendChild(icon);
this.el.appendChild(new Text(" "));
this.countText = this.el.appendChild(new Text(""));
this.el.appendChild(new Text(` ${label}`));
this.el.onclick = () => {
this.issueTracker.searchBar.searchQuery.toggleStatus(status);
}
}
async refresh() {
this.countText.nodeValue = await this.issueTracker.filter
.getStatusCount(this.status);
}
}
}
Issue = class {
constructor(issueTracker, dataview_page) {
this.issueTracker = issueTracker;
this.dv_file = dataview_page.file;
this.file = Obsidian.vault
.getFile(this.dv_file.path);
this.issueNo = dataview_page['issue/no'];
this.status = dataview_page['issue/status'];
this.name = dataview_page.file.name;
this.labels = dataview_page['issue/labels'] || [];
this.created = dataview_page.created;
this.modified = dataview_page.file.mtime;
this._contentCache = '';
}
/**
* Update issue content getter if issue template has changed.
*/
async getContent() {
if (this._contentCache) { return this._contentCache; }
let issueMetadata = app.metadataCache.getFileCache(this.file);
let sectionIndex = issueMetadata.sections.findIndex((section) => {
return section.type == "code";
})
if (issueMetadata.sections.length == sectionIndex + 1) {
return "";
}
let contentStartOffset = issueMetadata.sections[sectionIndex + 1]
.position.start.offset;
this._contentCache = (await app.vault.cachedRead(this.file))
.slice(contentStartOffset);
return this._contentCache;
}
render(containerEl) {
this.el = containerEl.createDiv({ cls: "issue" });
let issusStatusEl = this.el.createDiv({
cls: "issue-status",
});
issusStatusEl.appendChild(
this.status == "open" ? getIcon.open() :
this.status == "closed" ? getIcon.closed() :
createDiv({ text: "?" })
);
let issueBodyEl = this.el.createDiv({ cls: "issue-body" });
issueBodyEl.createEl("a", {
cls: "internal-link",
attr: { href: this.name, target: "_blank", rel: "noopener" },
text: this.name,
});
for (const label of this.labels) {
issueBodyEl.appendChild(new Text(" "));
let labelChipEl = issueBodyEl.createSpan({
cls: "label-chip",
attr: {label: label},
text: label,
})
labelChipEl.onclick = () => {
this.issueTracker.searchBar.searchQuery
.toggleQuery("label", label);
}
}
issueBodyEl.createDiv({
cls: "issue-desc",
text: `#${this.issueNo} opened on ${this.created
.setLocale(options.locale)
.toLocaleString(DataviewAPI.luxon.DateTime.DATETIME_SHORT)}`,
})
}
}
}
CreateIssueModal = class extends obsidian.Modal {
constructor(issueTracker) {
super(app);
this.issueTracker = issueTracker;
this.containerEl.addClass("issueTracker__createIssue");
}
onOpen() {
this.contentEl.createEl('h4', {
text: `Issue: ${this.issueTracker.config.project.name}`
});
this.titleInput = this.contentEl.createEl('input', {
cls: "title-setting",
attr: { type: "text", placeholder: "Title" }
});
this.titleInput.tooltip = new Obsidian.Tooltip(this.titleInput, true);
new obsidian.Setting(this.contentEl)
.setClass("label-setting")
.setName("Add labels")
.addText((labelInput) => {
this.labelInput = labelInput.inputEl;
labelInput.inputEl.placeholder
= "Space delimitered labels, use \" \" to preserve space \"like this\"";
})
new obsidian.Setting(this.contentEl)
.addButton((submitBtn) => {
submitBtn
.setButtonText("Create Issue")
.setCta()
.onClick(() => { this.submit() })
})
}
onClose() {
this.titleInput.tooltip.hide();
this.contentEl.empty();
}
async submit() {
let newIssueInfo = {
issueNo: this.issueTracker.issueList.issues.length?
this.issueTracker.issueList.issues.issueNo.max() + 1 : 1,
title: this.titleInput.value,
path: `${this.issueTracker.config.issues.folder_path}/${this.titleInput.value}.md`,
// Split labels by space, respects enclosed quotations marks
labels: this.labelInput.value?.match(/"[^"]+"|([^"\s]+)/g)
?.map(label => label.replaceAll('"', '')),
issueTrackerConfig: this.issueTracker.config,
}
// Handle invalid issue titles
if (!newIssueInfo.title) {
this.titleInput.tooltip.show(
"Issue title cannot be empty"
);
return;
} else if (!Obsidian.vault
.isValidFilename(newIssueInfo.title)) {
this.titleInput.tooltip.show(
"Issue title cannot contain any of the following characters:\n"
+ Obsidian.vault.specialCharSet
);
return;
} else if (this.issueTracker.issueList.issues.name
.includes(newIssueInfo.title)) {
this.titleInput.tooltip.show(
`Issue already exists`
);
return;
}
// Export new issue into to global for templater to pick up
new IssueInfoExporter(newIssueInfo);
// Create issue from template
let issueFile = await Templater
.createNewFileFromTemplate(
newIssueInfo.path,
this.issueTracker.config.issues.template_name
)
Obsidian.vault.openFile(issueFile, "new-tab");
this.close();
}
}
}
/**
* Export info of new issue onto global for templater to pickup
*/
class IssueInfoExporter {
constructor(newIssueInfo) {
this.issueNo = newIssueInfo.issueNo;
this.title = newIssueInfo.title;
this.labels = newIssueInfo.labels || [];
this.issueTrackerConfig = newIssueInfo.issueTrackerConfig;
this.created = dv.luxon.DateTime.now();
// Mount itself to window
if (window.newIssueInfo) { clearTimeout(window.newIssueInfo.timeoutID); }
window.newIssueInfo = this;
// Remove itself after templater probably has finished parsing
this.timeoutID = setTimeout(() => {
delete window.newIssueInfo;
}, 2000);
}
/**
* Both `issueTrackerLink()` and `projectNoteLink()` returns a Dataview Link
* object, which offers various methods to mutate the link, see:
*
* https://github.com/blacksmithgu/obsidian-dataview/blob/master/src/data-model/value.ts#L416
*/
get issueTrackerLink() { return this.issueTrackerConfig.issueTracker.link; }
get projectNoteLink() { return this.issueTrackerConfig.project.link; }
}
await dv.view("System/script/Dataview/project/Navigation");
new IssueTracker(options).render(containerEl);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment