Skip to content

Instantly share code, notes, and snippets.

@ljavuras
Created March 27, 2025 00:11
Show Gist options
  • Save ljavuras/34a4a18c2bd85b1bbc6776972779428f to your computer and use it in GitHub Desktop.
Save ljavuras/34a4a18c2bd85b1bbc6776972779428f to your computer and use it in GitHub Desktop.
Datacore utilities
/**
* @author Ljavuras <[email protected]>
*/
class Violet extends obsidian.Component {
app = customJS.app;
/** Package infos: manifest.json, settings.json, path */
packages = {};
/** Settings of your own, and friend settings from other packages */
settings = { all: {} };
/** Determines package behavior, parsed from settings */
config = {
customjs: { files: [], mapping: {} }
};
constructor() {
super();
this.initViolet();
}
require(packageId) {
return this.packages[packageId]?.customjs;
}
getIdByClassName(className) {
return this.config.customjs.mapping[className]?.id;
}
get packageId() {
return "violet-core";
}
async initViolet() {
await this.loadSelfSettings();
await this.loadPackages();
this.loadSettings();
this.createConfig();
await this.mountCJSInstances();
this.load(); // Load all child components (packages)
}
createConfig() {
this.config.packagesPath = this.settings.packagesPath;
for (const [id, settings] of Object.entries(this.settings.all)) {
const packagePath = this.packages[id].path;
const scripts = []
.concat(
settings.customjs.files?.map(path =>
this.app.vault.getFileByPath(
obsidian.normalizePath(`${packagePath}/${path}`)
)
)
)
.concat(
settings.customjs.folders
?.map(path =>
this.app.vault.getFolderByPath(
obsidian.normalizePath(`${packagePath}/${path}`)
)
)
?.map((folder) => {
let scripts = [];
obsidian.Vault.recurseChildren(folder, (file) => {
if (file instanceof obsidian.TFile
&& file.name.endsWith(".js")
) {
scripts.push(file);
}
});
return scripts;
})
?.flat()
)
.filter(file => file); // remove null
this.config.customjs.files = this.config.customjs.files.concat(scripts);
scripts.map((file) => {
this.config.customjs.mapping[getClassNameByScript(file)] = {
id: id,
file: file
};
});
}
/**
* Assumes every CustomJS script's name is identical to the class written
* in them. Allows non-alphabetical characters to prepend script name.
* - _Violet.js => class Violet
* - script/001Template.js => class Template
*
* @todo actually parse file content to get accurate class name
*/
function getClassNameByScript(file) {
return file.basename
.match(/^[^a-zA-Z]*(?<className>.*)$/)?.groups?.className;
}
}
/**
* Load settings of only this package
* @returns {object}
*/
async loadSelfSettings() {
// Find violet-core/settings.json
// TODO(perf): avoid filter all files in vault, read CustomJS settings,
// and find violet-core package location
const settingsFile = app.vault.getFiles().filter(
(tfile) => tfile.path.endsWith("/violet-core/settings.json")
)?.[0];
// TODO: handle error
if (!settingsFile) { return; }
this.settings = Object.assign(
this.settings,
JSON.parse(await app.vault.cachedRead(settingsFile))
)
return this.settings;
}
/**
* Compose settings from self and friend packages
* @returns {object}
*/
loadSettings() {
for (const [id, packageInfo] of Object.entries(this.packages)) {
if (this.packageId === id) {
this.settings.all[id] = packageInfo.settings;
continue;
}
let friendSettings = packageInfo?.settings?.friendSettings?.[this.packageId];
if (friendSettings) {
this.settings.all[id] = friendSettings;
}
}
return this.settings;
}
/**
* Spawn packages' CustomJS instance, and mount as child componenet.
*/
async mountCJSInstances() {
// Spawn CustomJS instances
for (const [className, { id, file }] of Object.entries(this.config.customjs.mapping)) {
// Skip `evalFile()` for self
if (this.packageId === id
&& this.constructor.name === className) {
continue;
}
// Create CustomJS class instance
await customJS.app.plugins.getPlugin('customjs').evalFile(file.path);
let instance = customJS[className];
if (!instance) return;
// Mount class instance to Violet.packages[id].customJS
this.packages[id].customjs = this.packages[id].customjs ?? {};
this.packages[id].customjs[className] = instance;
// Mount class instancs as Violet component child
this.addChild(instance);
}
}
/**
* Fetech all package configs.
*
* CustomJS classes doesn't know their own path (efficiently), thus their
* info are supplied by this package.
*/
async loadPackages() {
// Parse all settings.json & manifest.json under package path
const packageConfigsFiles = app.vault.getFiles()
.filter(
// Get all config files under package path
(tfile) => tfile.path.startsWith(this.settings.packagesPath)
&& (tfile.path.endsWith("/settings.json")
|| tfile.path.endsWith("/manifest.json"))
)
await Promise.all(packageConfigsFiles.map(async (configFile) => {
await this.parsePackageConfigs(configFile);
})
);
}
async parsePackageConfigs(configFile) {
// Assumes package folder == package id
const packageId = configFile.parent.name;
const configContent = await app.vault.cachedRead(configFile)
.then((configJSON) => JSON.parse(configJSON));
if (!this.packages[packageId]) {
this.packages[packageId] = {};
}
if (configFile.basename == "settings") {
this.packages[packageId].settings = configContent;
} else if (configFile.basename == "manifest") {
this.packages[packageId].manifest = configContent;
}
// Workaround for CustomJS not enabling classes to get script info
this.packages[packageId].path = configFile?.parent?.path;
return configContent;
}
deconstructor() {
this.unload();
}
Package = class extends obsidian.Component {
Violet = customJS.Violet;
get packageId() {
return this.Violet.getIdByClassName(this.constructor.name);
}
get path() {
return this.Violet.packages[this.packageId].path;
}
getPackage(id) {
return this.Violet.packages[id];
}
loadManifest(force = false) {
if (!force && this.manifest) { return this.manifest; }
this.manifest = this.Violet.packages[this.packageId].manifest;
return this.manifest;
}
loadSettings(force = false) {
if (!force && this.settings) { return this.settings; }
this.settings = this.Violet.packages[this.packageId].settings;
this.settings.all = {}
for (const [id, packageInfo] of Object.entries(this.Violet.packages)) {
if (this.packageId === id) {
this.settings.all[id] = packageInfo.settings;
continue;
}
let friendSettings = packageInfo.settings?.friendSettings?.[this.packageId];
if (friendSettings) {
this.settings.all[id] = friendSettings;
}
}
return this.settings;
}
async saveSettings() {
const { all, ...settings } = this.settings;
await this.app.vault.adapter.write(
`${this.path}/settings.json`,
JSON.stringify(settings, null, 4)
);
}
/**
* Add command, see {@link https://docs.obsidian.md/Plugins/User+interface/Commands}
*
* Command id and name will be prefixed with package id and name.
* Commands will be unregistered automatically upon unload.
*/
addCommand(command) {
let manifest = this.loadManifest();
if (!manifest.id || !manifest.name) {
throw new Error("Cannot add command, package manifest is incomplete.");
}
if (!command.id || !command.name) {
throw new Error("Cannot add command, bad command arguments.")
}
command.id = `violet:${manifest.id}:${command.id}`;
command.name = `[Package] ${manifest.name}: ${command.name}`;
customJS.app.commands.addCommand(command);
this.register((() =>
customJS.app.commands.removeCommand(command.id)
));
return command;
}
removeCommand(command) {
let manifest = this.loadManifest();
if (!manifest.id) {
throw new Error("Cannot remove command, package id is missing.");
}
if (!command.id) {
throw new Error("Cannot remove command, command id is missing.")
}
customJS.app.commands.removeCommand(
`violet:${manifest.id}:${command.id}`
);
}
}
}
class Datacore extends customJS.Violet.Package {
app = customJS.app;
vault = app.vault;
core = datacore.core;
constructor() {
super();
// class VioletDatacoreLocalApi extends DatacoreLocalApi
Object.setPrototypeOf(
this.VioletDatacoreLocalApi.prototype,
datacore.local()
);
}
async onload() {
// https://github.com/blacksmithgu/datacore/blob/966f22896ec3cbb4f2160a049c2b2d072d276880/src/index/types/indexable.ts#L77
const INDEXABLE_EXTENSIONS = new Set(["md", "markdown", "canvas"]);
// Additional extensions we wish to trigger useIndexUpdate hooks (which
// triggers useFileMetadata, useFile...etc)
const ADDITIONAL_EXTENSIONS = new Set(["js", "jsx", "ts", "tsx", "css"]);
// Union of UNDEXABLE_EXTENSIONS and ADDITIONAL_EXTENSIONS
const FULL_EXTENSIONS = new Set();
INDEXABLE_EXTENSIONS.forEach((value) => FULL_EXTENSIONS.add(value));
ADDITIONAL_EXTENSIONS.forEach((value) => FULL_EXTENSIONS.add(value));
// Fix datacore doesn't trigger update on file delete
// https://github.com/blacksmithgu/datacore/issues/82
this.registerEvent(
this.vault.on("delete", (file) => {
if (!(file instanceof obsidian.TFile)) return;
if (FULL_EXTENSIONS.has(file.extension.toLowerCase())) {
this.core.trigger("update", this.core.revision);
}
})
);
// Triggers index update for additional file extensions
this.registerEvent(
this.vault.on("create", (file) => {
if (!(file instanceof obsidian.TFile)) return;
if (ADDITIONAL_EXTENSIONS.has(file.extension.toLowerCase())) {
this.core.trigger("update", this.core.revision);
}
})
);
this.registerEvent(
this.vault.on("modify", (file) => {
if (!(file instanceof obsidian.TFile)) return;
if (ADDITIONAL_EXTENSIONS.has(file.extension.toLowerCase())) {
this.core.trigger("update", this.core.revision);
}
})
);
this.loadConfig();
}
loadConfig() {
this.loadSettings(true);
this.config = { files: [], folders: [], components: {} };
for (const [id, setting] of Object.entries(this.settings.all)) {
this.config.files = this.config.files.concat(
setting.files?.map(path => ({
violet: id,
path: this.getPackage(id).path + '/' + path
})) ?? []
);
this.config.folders = this.config.folders.concat(
setting.folders?.map(path => ({
violet: id,
path: this.getPackage(id).path + '/' + path
})) ?? []
);
}
this.config.files.forEach((fileItem) => {
const componentName = fileItem.path
.match(/([^\/\\]+)\.[jt]sx?$/)?.[1];
this.config.components[fileItem.violet] = {
[componentName]: fileItem.path
};
});
const componentExtRegex = /^[jt]sx?$/;
this.config.folders.forEach((folderItem) => {
customJS.Obsidian.vault.getFilesFromFolder(folderItem.path)
.forEach((file) => {
if (!componentExtRegex.test(file.extension)) { return; }
this.config.components[folderItem.violet] = {
[file.basename]: file.path
};
});
});
}
getScriptPath(packageId, scriptName) {
return this.config.components[packageId]?.[scriptName];
}
/**
* Wraps DatacoreLocalApi, patch dc behavior
*
* @example
* dc = Datacore.wrap(dc);
* // Use dc as usual
*
* @param {DatacoreLocalApi} dc - dc object passed into datacore codeblocks
* @returns {VioletDatacoreLocalApi}
*/
wrap(dc) {
return new this.VioletDatacoreLocalApi(dc);
}
// class VioletDatacoreLocalApi extends DatacoreLocalApi
VioletDatacoreLocalApi = class {
vault = customJS.app.vault;
violetDatacore = customJS.Datacore;
constructor(dc) {
// dc is already wrapped, skip wrapping
if (dc instanceof this.constructor) {
return dc;
}
// Copy members from dc to maintain state
for (const [key, value] of Object.entries(dc)) {
if (typeof value !== "function") {
this[key] = value;
}
}
}
/**
* Loads a script. There are 3 ways to load a script:
* 1. Path: Loads a script by path
* 2. Link: Loads a script from markdown file
* 3. Package: Loads a script by package and script name
*
* Every dc of the script loaded by this function will be pre-wrapped
* with VioletDatacoreLocalApi.
*
* @param {string|Link} pathOrPackage - File path, Datacore Link to a
* section where the codeblock is located, or package name.
* @param {string} scriptName - Name of the loaded script. Loads a
* script by package when specified.
* @returns {any}
*/
async require(pathOrPackage, scriptName) {
const path = scriptName
? this.violetDatacore.getScriptPath(pathOrPackage, scriptName)
: pathOrPackage;
// https://github.com/blacksmithgu/datacore/blob/966f22896ec3cbb4f2160a049c2b2d072d276880/src/api/local-api.tsx#L92-L95
const result = await this.scriptCache.load(path, { dc: this });
let scriptObject = result.orElseThrow();
const stylePath = path.replace(/\.[jt]sx?$/, ".css");
const styleFile = this.vault.getFileByPath(stylePath);
if (styleFile) {
const styleContent = await this.vault.cachedRead(styleFile);
const { h, Fragment } = this.preact;
function addStyle(Component) {
if (typeof Component === "function") {
const ComponentWrapper = Component.name + "Wrapper";
return ({[ComponentWrapper]: () =>
h(Fragment, { children: [
h("style", { scope: " ", children: styleContent }),
h(Component)
]})
})[ComponentWrapper];
} else {
let component = {}
for (const [key, value] of Object.entries(Component)) {
component[key] = addStyle(value);
}
return component;
}
}
scriptObject = addStyle(scriptObject);
}
return scriptObject;
}
/**
* Use the result returned by a script from the given path or link.
* Automatically updates the returned result when the source updates.
*
* @example
* return function() {
* const { MyComponent } = dc.useScript("path/to/component.tsx");
* return <MyComponent />
* }
*
* @param {string|Link} pathOrPackage - File path, Datacore Link to a
* section where the codeblock is located, or package name.
* @param {string} scriptName - Name of the loaded script. Loads a
* script by package when specified.
* @returns {any}
*/
useScript(pathOrPackage, scriptName) {
const path = scriptName
? this.violetDatacore.getScriptPath(pathOrPackage, scriptName)
: pathOrPackage;
const filePath = this.scriptCache.pathkey(path);
const [script, setScript] = this.useState();
const scriptRevision = this.useFile(filePath)?.$revision;
const styleRevision = this.useFile(
filePath.replace(/\.[jt]sx?$/, ".css")
)?.$revision;
const [error, setError] = this.useState();
this.useEffect(() => {
// Datacore doesn't anticipate script to change, delete cached
// script object to force reload
const key = this.scriptCache.pathkey(path);
this.scriptCache.scripts.delete(key);
this.require(path)
.then(component => setScript(() => component))
.finally(() => error && setError())
.catch(e => setError(e));
new obsidian.Notice("Script reloaded");
}, [path, scriptRevision, styleRevision]);
if (error) {
console.error(error);
return;
}
return script;
}
/**
* Gets the path of a function component, only works if the component is
* being returned.
*
* @example
* // Within the component
* const path = dc.scriptPath(this);
*
* @param {functionComponent} functionComponent - Preact function component
* @returns {string}
*/
scriptPath(functionComponent) {
for (const [key, value] of this.scriptCache.scripts) {
if (value.object.toString() === functionComponent.toString()) {
return key;
}
for (const [name, component] of Object.entries(value.object)) {
if (component.toString() === functionComponent.toString()) {
return key;
}
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment