Created
March 27, 2025 00:11
-
-
Save ljavuras/34a4a18c2bd85b1bbc6776972779428f to your computer and use it in GitHub Desktop.
Datacore utilities
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
/** | |
* @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}` | |
); | |
} | |
} | |
} |
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
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