Last active
January 7, 2025 21:09
-
-
Save ljavuras/d47247a1399cf3b34a6ceb2e06796626 to your computer and use it in GitHub Desktop.
Issue tracker with unresolved CustomJS dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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(); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 | |
); | |
}); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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