Skip to content

Instantly share code, notes, and snippets.

@ljavuras
Last active June 28, 2024 17:04
Show Gist options
  • Save ljavuras/83da9459dffaf42c74ea4ec91aee5540 to your computer and use it in GitHub Desktop.
Save ljavuras/83da9459dffaf42c74ea4ec91aee5540 to your computer and use it in GitHub Desktop.
Home dashboard in Obsidian, built upon Dataview, Templater and CustomJS
/** Clock/view.css */
/**
* Clock widget built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
.widget__clock {
margin: var(--size-4-2);
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
}
.widget__clock__time {
font-size: 48px;
}
.widget__clock__date .internal-link,
.widget__clock__date .internal-link.is-unresolved {
color: var(--text-normal);
}
/** Clock/view.js */
/**
* Clock widget built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
const containerEl = input.containerEl.createDiv({ cls: "widget__clock" });
const timeEl = containerEl.createDiv({ cls: "widget__clock__time" });
function updateTime() {
timeEl.innerHTML = moment().format("HH:mm");
setTimeout(() => {
updateTime();
}, moment().endOf('minute').diff(moment()) + 1);
}
updateTime();
const dateEl = containerEl.createDiv({ cls: "widget__clock__date" });
function updateDate() {
customJS.Obsidian.renderMarkdown(
`[[${moment().format("YYYY-MM-DD"}|${moment().format("MMM Do, dddd")}]]`,
dateEl,
dv.currentFilePath,
dv.component
);
setTimeout(() => {
updateDate();
}, moment().endOf('day').diff(moment()) + 1);
}
updateDate();
/** Convo/view.css */
/**
* A social media like widget to display conversations
*
* @author Ljavuras <[email protected]>
*/
.convoWidget {
display: flex;
flex-direction: column;
row-gap: var(--size-4-2);
}
.newConvo {
padding: var(--size-4-2) var(--size-4-1) var(--size-4-1) var(--size-4-3);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
cursor: text;
}
.newConvo:has(:focus) {
border-color: var(--background-modifier-border-focus);
}
.newConvo__input {
margin-top: var(--size-4-1);
padding: unset;
width: 100%;
resize: none;
overflow: hidden;
background-color: var(--background-primary);
border: none;
}
.newConvo__input:focus {
box-shadow: none;
}
.newConvo__submit {
display: block;
height: unset;
line-height: 1;
padding: var(--size-4-2) var(--size-4-3);
margin: 0 var(--size-4-1) var(--size-4-1) auto;
border-radius: var(--radius-s);
}
.convo {
/* padding: var(--size-4-2) var(--size-4-3); */
cursor: default;
}
.convo.top-level {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
}
.convo.top-level:has(> .convo__comment-section.hide),
.convo__comment-section.last-comment-section {
padding-bottom: var(--size-4-2);
}
.convo__content {
padding-top: var(--size-4-2);
}
.convo__bottom {
display: flex;
justify-content: flex-end;
}
.convo__content, .convo__bottom {
padding-left: var(--size-4-3);
padding-right: var(--size-4-3);
}
.convo__time {
margin-right: auto;
display: inline-flex;
align-items: center;
font-size: var(--font-smaller);
color: var(--text-muted);
}
.convo__show-comment {
gap: var(--size-4-1);
}
.convo__link.internal-link {
color: unset;
}
.convo__link.internal-link:hover {
color: unset;
}
.convo__comment-section {
margin-left: var(--size-4-3);
border-left: 1px solid var(--background-modifier-border);
}
.convo__comment-section .newConvo {
padding-right: var(--size-4-2);
padding-bottom: 0;
border: none;
}
.convo__comment-section .newConvo__submit {
margin-right: 0;
margin-bottom: 0;
}
.icon-wrap {
display: flex;
}
.hide {
display: none;
}
/** Convo/view.js */
/**
* A social media like widget to display conversations
*
* @author Ljavuras <[email protected]>
*/
let obsidian;
try {
obsidian = require('obsidian'); // enabled by fix require modules plugin
} catch {
obsidian = input.obsidian // provide obsidian api through `dv.view()`
|| customJS.obsidian // get obsidian api from customjs plugin
// get obsidian api from templater plugin
|| app.plugins.plugins['templater-obsidian'].templater.current_functions_object.obsidian
;
}
if (!obsidian) { /* Handle error */ }
let widgetOptions = {
locale: input.locale ?? localStorage.getItem('language') ?? 'en',
};
const containerEl = (input.containerEl || dv.container).createEl('div', { cls: "convoWidget" });
let newConvoEl = containerEl.createEl('div', { cls:"newConvo" });
newConvoEl.onclick = (event) => {
newConvoInputEl.focus();
}
let newConvoInputEl = newConvoEl.createEl('textarea', {
cls: "newConvo__input",
attr: { placeholder: "Write something", rows: 1},
});
newConvoInputEl.oninput = (event) => {
newConvoInputEl.style.height = '1px';
newConvoInputEl.style.height = newConvoInputEl.scrollHeight + 'px';
}
let newConvoSubmitEl = newConvoEl.createEl('button', {
cls: "newConvo__submit",
text: "Post"
});
newConvoSubmitEl.onclick = async (event) => {
let convoFile = await customJS.Plugins.templater.createNewFileFromTemplate(
//TODO: handle filename exist error
`convo.${moment().format('YYYY-MMDD-HHss')}.md`,
"note.convo"
);
app.vault.append(convoFile, newConvoInputEl.value);
};
//TODO: Filter based on date, note count
let convos = dv.pages("#note/convo")
.filter(page => !page['convo-replies-to'])
.filter(page => {
return DataviewAPI.luxon.DateTime.now().diff(page.created, 'days').days < 5
})
.sort(page => page.created, 'desc');
for (const convo of convos) {
renderConvo(convo, containerEl, topLevel = true);
}
let shownNewCommentEl;
async function renderConvo(convo, containerEl, topLevel = false,
convoRenderOptions = {
expandAllComment: true,
},
lastCommentSectionList = [],
isLast = true) {
let commentConvos = dv.pages("#note/convo")
.filter((commentConvo) => {
return convo.file.link.equals(commentConvo['convo-replies-to']);
})
.sort(page => page.created, 'asc');
// Better solution, but doesn't work:
// dv.pages(`"${convo.file.link.markdown()}"`).filter(/* filter convo-replies-to */);
let convoEl = containerEl.createEl('div', {
cls: topLevel? "convo top-level" : "convo"
});
let convoContentEl = convoEl.createEl('div', { cls: "convo__content" });
customJS.Obsidian.renderMarkdown(
await customJS.Obsidian.vault.getFileContent(convo.file.path),
convoContentEl,
dv.current().file.path,
dv.component
);
let convoBottomEl = convoEl.createEl('div', { cls: "convo__bottom" });
let convoTimeEl = convoBottomEl.createEl('span', {
cls: "convo__time",
text: convo.created.setLocale(widgetOptions.locale).toRelative() + ', ' +
convo.created.setLocale(widgetOptions.locale).toFormat('EEE')
});
convoTimeEl.onmouseenter = (event) => {
convoTimeEl.textContent = convo.created.setLocale(widgetOptions.locale)
.toLocaleString(DataviewAPI.luxon.DateTime.DATETIME_SHORT);
};
convoTimeEl.onmouseleave = (event) => {
convoTimeEl.textContent = convo.created.setLocale(widgetOptions.locale).toRelative();
}
let convoShowNewCommentEl = convoBottomEl.createEl('button', {
cls: "clickable-icon",
});
obsidian.setIcon(convoShowNewCommentEl, 'reply');
obsidian.setTooltip(convoShowNewCommentEl, "Reply");
convoShowNewCommentEl.onclick = (event) => {
convoCommentSectionEl.show();
convoNewCommentEl.show();
}
let convoShowCommentEl = convoBottomEl.createEl('button', {
cls: "convo__show-comment clickable-icon",
text: (commentConvos.length)? commentConvos.length : "",
});
obsidian.setIcon(
convoShowCommentEl.createEl('div', { cls: "icon-wrap" }),
'message-circle'
);
obsidian.setTooltip(convoShowCommentEl, "Comments");
convoShowCommentEl.onclick = (event) => {
if (convoCommentSectionEl.hasClass("hide")) {
convoCommentSectionEl.show();
convoNewCommentEl.show();
} else {
convoCommentSectionEl.hide();
convoNewCommentEl.hide();
}
}
let convoCopyLinkEl = convoBottomEl.createEl('button', {
cls: "clickable-icon"
});
obsidian.setIcon(convoCopyLinkEl, 'copy');
obsidian.setTooltip(convoCopyLinkEl, "Copy link")
convoCopyLinkEl.onclick = (event) => {
navigator.clipboard.writeText(convo.file.link.markdown());
new Notice("Markdown link copied to clipboard.");
}
let convoLinkEl = convoBottomEl.createEl('a', {
cls: "convo__link internal-link clickable-icon",
attr: {
'data-tooltip-position': 'top',
'aria-label': convo.file.name,
'data-href': convo.file.name,
href: convo.file.name,
target: '_blank',
rel: 'noopener',
}
})
obsidian.setIcon(convoLinkEl, 'link');
let convoCommentSectionEl = convoEl.createEl('div', {
cls: "convo__comment-section",
});
if (!convoRenderOptions.expandAllComment) { convoCommentSectionEl.addClass("hide"); }
if (!commentConvos.length) { convoCommentSectionEl.addClass("hide"); }
convoCommentSectionEl.show = () => {
convoCommentSectionEl.removeClass("hide");
findLastCommentSection();
};
convoCommentSectionEl.hide = () => {
convoCommentSectionEl.addClass("hide");
findLastCommentSection();
};
function findLastCommentSection() {
const CLS_LAST_COMMENT_SECTION = "last-comment-section";
let found = false;
for (let i = 0; i < lastCommentSectionList.length; i++) {
let commentSection = lastCommentSectionList[i];
if (found) {
commentSection.removeClass(CLS_LAST_COMMENT_SECTION);
} else if (i == lastCommentSectionList.length - 1) {
commentSection.hasClass("hide")
? commentSection.removeClass(CLS_LAST_COMMENT_SECTION)
: commentSection.addClass(CLS_LAST_COMMENT_SECTION);
} else {
let nextCommentSection = lastCommentSectionList[i + 1];
if (!commentSection.hasClass("hide") && nextCommentSection.hasClass("hide")) {
commentSection.addClass(CLS_LAST_COMMENT_SECTION);
found = true;
} else {
commentSection.removeClass(CLS_LAST_COMMENT_SECTION);
}
}
}
}
let convoCommentsEl = convoCommentSectionEl.createEl('div', {
cls: "convo__comments"
});
if (isLast) { lastCommentSectionList.push(convoCommentSectionEl); }
commentConvos.forEach((commentConvo, i, commentConvos) => {
renderConvo(
commentConvo,
convoCommentsEl,
topLevel = false,
convoRenderOptions,
lastCommentSectionList = lastCommentSectionList,
isLast = (i === commentConvos.length - 1),
);
});
findLastCommentSection();
let convoNewCommentEl = convoCommentSectionEl.createEl('div', {
cls: "newConvo hide",
});
convoNewCommentEl.show = () => {
shownNewCommentEl?.hide();
convoCommentSectionEl.show();
convoNewCommentEl.removeClass("hide");
convoNewCommentInputEl.focus();
shownNewCommentEl = convoNewCommentEl;
};
convoNewCommentEl.hide = () => {
convoNewCommentInputEl.blur();
convoNewCommentEl.addClass("hide");
if (!commentConvos.length) {
convoCommentSectionEl.hide();
}
};
convoNewCommentEl.onclick = (event) => {
convoNewCommentInputEl.focus();
};
let convoNewCommentInputEl = convoNewCommentEl.createEl('textarea', {
cls: "newConvo__input",
attr: { placeholder: "Write a comment", rows: 1},
});
convoNewCommentInputEl.oninput = (event) => {
convoNewCommentInputEl.style.height = '1px';
convoNewCommentInputEl.style.height = convoNewCommentInputEl.scrollHeight + 'px';
}
let convoNewCommentSubmitEl = convoNewCommentEl.createEl('button', {
cls: "newConvo__submit",
text: "Reply"
});
convoNewCommentSubmitEl.onclick = async (event) => {
let convoFile = await customJS.Plugins.templater.createNewFileFromTemplate(
//TODO: handle filename exist error
`convo.${moment().format('YYYY-MMDD-HHss')}.md`,
"note.convo"
);
customJS.Obsidian.frontmatter.set(
convoFile,
{ 'convo-replies-to': convo.file.link.markdown() }
);
app.vault.append(convoFile, convoNewCommentInputEl.value);
};
}
/** Home/view.css */
/**
* Home dashboard in Obsidian, built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
#home__banner {
margin-bottom: var(--size-4-8);
}
#home__tasks {
margin: var(--size-4-2) 0;
display: flex;
flex-direction: row;
gap: var(--size-4-2);
white-space: nowrap;
overflow-x: auto;
}
#home__tasks > * {
margin: unset;
flex-grow: 1;
}
/**
* Home dashboard in Obsidian, built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
const options = {
locale: 'en-US',
}
const containerEl = dv.container;
// Unload all child components created by TaskList widgets
//TODO: TaskLists should garbage collect themselves
for (const childComponent of dv.component._children) {
dv.component.removeChild(childComponent);
}
const bannerEl = containerEl.createDiv({ attr: { id: "home__banner" }});
dv.view("System/scripts/Dataview/widget/Clock", {
containerEl: bannerEl,
locale: options.locale,
});
const tasksEl = containerEl.createDiv({ attr: { id: "home__tasks" }});
dv.view("System/scripts/Dataview/widget/TaskList", {
containerEl: tasksEl,
tasks: dv.page(moment().format("YYYY-MM-DD"))
?.file.tasks
.filter((t) => (t.header.subpath == "Daily")),
title: "Daily",
errorMarkdown: `[[${moment().format("YYYY-MM-DD")}]] is not created yet.`,
});
dv.view("System/scripts/Dataview/widget/TaskList", {
containerEl: tasksEl,
tasks: dv.page(moment().format("YYYY-MM-DD"))?.file.tasks
.filter((t) => (t.header.subpath == "Today")),
title: "Today",
errorMarkdown: `[[${moment().format("YYYY-MM-DD")}]] is not created yet.`,
});
const convoEl = containerEl.createDiv();
dv.view("System/scripts/Dataview/widget/Convo", {
containerEl: convoEl,
locale: options.locale,
});

<%* /**

  • Template for convo notes
  • @author Ljavuras [email protected] */ tR += await tp.file.include("[[system.common]]");

customJS.Plugins.templater.addTags(tp, ['note/convo']); -%>

/** CustomJS/Obsidian.js */
class Obsidian {
vault = {
/**
* 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) {
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 {
customJS.Error.log(
"Cannot get file content.\n" +
"Invalid parameter passed to Obsidian.getFileContent"
)
return;
}
let fileContent = await app.vault.cachedRead(tfile);
if (stripYAML) {
fileContent = fileContent.replace(/^---.*?\n---\n/s, "");
}
return fileContent;
},
};
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;
}
}
);
},
/**
* 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']
* );
* });
*/
addTags(file, tags) {
app.fileManager.processFrontMatter(
file,
(frontmatter) => {
if (!frontmatter.tags) {
frontmatter.tags = tags;
} else if (Object.prototype.toString.call(frontmatter.tags) === "[object String]") {
frontmatter.tags = [frontmatter.tags, ...tags];
} else if (Array.isArray(frontmatter.tags)) {
frontmatter.tags = [...frontmatter.tags, ...tags];
} else {
//TODO: Handle error
}
}
)
},
};
/**
* 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");
}
});
}
}
/** CustomJS/Plugins.js */
class Plugins {
/**
* Templater plugin and its API
*/
templater = {
plugin: app.plugins.plugins["templater-obsidian"],
settings: {
// Folder where templates are stored
folder: app.plugins.plugins["templater-obsidian"]
.settings.templates_folder,
},
/**
* 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 new file
*/
async createNewFileFromTemplate(filePath, templateName) {
// Sanitize template path
if (!templateName.endsWith(".md")) {
templateName += ".md";
}
let templatePath = obsidian.normalizePath(
`${this.settings.folder}/${templateName}`
);
// Create new file with template as its contents
const newFile = await app.vault.create(
filePath,
await customJS.Obsidian.vault.getFileContent(templatePath, false)
);
// Parse template with templater
await this.plugin.templater.overwrite_file_commands(newFile);
return newFile;
},
/**
* Set frontmatter of target file
* @param {Object} tp - Templater object, accessible within templates
* @param {Object<string, string>} properties - Dictionary of properties
*/
setFrontMatter(tp, properties) {
// Prevent race condition between Templater and Obsidian API
tp.hooks.on_all_templates_executed(() => {
customJS.Obsidian.frontmatter.set(
tp.config.target_file,
properties
);
});
},
/**
* Add tags to target file's frontmatter
* @param {Object} tp - Templater object, accessible when replaceing templates
* @param {Array.<string>} tags - Tags to add in frontmatter
*/
addTags(tp, tags) {
// Prevent race condition between Templater and Obsidian API
tp.hooks.on_all_templates_executed(() => {
customJS.Obsidian.frontmatter.addTags(
tp.config.target_file,
tags
);
});
},
};
}
/** TaskList/view.css */
/**
* TaskList widget built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
.widget__taskList {
display: inline-flex;
flex-direction: column;
padding: var(--size-4-2) var(--size-4-4);
margin: var(--size-4-2);
border: var(--border-width) solid var(--background-modifier-border);
border-radius: var(--radius-m);
}
.widget__taskList .widget__taskList__title {
margin-block: unset;
padding-inline: var(--size-4-1);
}
.widget__taskList .widget__taskList__content {
margin-block: unset;
}
.widget__taskList ul.contains-task-list {
margin-block: unset;
}
.widget__taskList li.dataview.task-list-item {
margin: 0;
margin-block: unset;
margin-inline: unset;
}
.widget__taskList li.dataview.task-list-item:hover {
box-shadow: none;
}
.widget__taskList li.dataview.task-list-item input {
margin-inline-start: unset;
}
/** TaskList/view.js */
/**
* TaskLisk widget built upon Dataview plugin
*
* @author ljavuras <[email protected]>
*/
/**
* @name input
* @type {Object}
* @property {HTMLElement} containerEl - Container to render within
* @property {Grouping<SListItem>} tasks - Dataview tasks to render
* @property {String} title - Tasklist title
* @property {String} content - Additional content to display
* @property {String} errorMarkdown - Error message in markdown
*/
let containerEl = input.containerEl.createEl("fieldset", { cls: "widget__taskList" });
// Render within containerEl, creates local DataviewContext
local_dv = app.plugins.plugins['dataview'].localApi(
dv.current().file.path,
dv.component,
containerEl
);
// Render TaskList
if (input.title) {
local_dv.el("legend", input.title, { cls: "widget__taskList__title" });
}
if (input.content) {
local_dv.paragraph(input.content, { cls: "widget__taskList__content" });
}
if (input.tasks) {
local_dv.taskList(input.tasks, false);
} else {
let errorEl = containerEl.createDiv({ cls: "widget__taskList__error" });
customJS.Obsidian.renderMarkdown(
input.errorMarkdown,
errorEl,
local_dv.current().file.path,
local_dv.component
);
}
@ljavuras
Copy link
Author

ljavuras commented Jun 18, 2024

Home.md

```dataviewjs
dv.view("path/to/Dataview/CustomView/Home");
```

image

@ljavuras
Copy link
Author

Copy renderMarkdown() to both Clock/view.js and TaskList/view.js, and replace the function call, if you don't want to mess with CustomJS

@ljavuras
Copy link
Author

Home with Convo widget
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment