import { App, Plugin, Vault, Setting, TAbstractFile, TFile, TFolder, TextComponent, SliderComponent, ButtonComponent, Notice, MarkdownPostProcessorContext, PluginSettingTab } from 'obsidian'; import * as fs from 'fs'; import * as path from 'path'; import {exec, ExecException} from 'child_process'; import {tmpdir} from "os"; import ErrnoException = NodeJS.ErrnoException; // Temporary directory for asymptote input output const TEMP_DIR = path.join(tmpdir(), 'obsidian-asy'); interface Settings { library: string; timeout: number; } const DEFAULT_SETTINGS: Settings = { library: '', timeout: 5000, } export default class ObsidianAsymptote extends Plugin { settings: Settings; async onload() { this.settings = DEFAULT_SETTINGS; this.registerMarkdownCodeBlockProcessor('asy', (source : string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { this.renderAsyToContainer(source, el); }); this.registerMarkdownCodeBlockProcessor('asymptote', (source : string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { this.renderAsyToContainer(source, el); }); this.addSettingTab(new ObsidianAsymptoteSettings(this.app, this)); } renderAsyToContainer(source: string, container: HTMLElement) { return new Promise((resolve, reject) => { this.renderAsyToSVG(source).then((data: string) => { container.innerHTML = data; const svg = container.getElementsByTagName('svg'); // Remove height and width so that it can be determined by CSS svg[0].removeAttribute('width'); svg[0].removeAttribute('height'); container.classList.add('asy-rendered'); resolve(); }).catch(err => { let html = document.createElement('pre'); if (err.signal === 'SIGTERM') { html.innerText = 'Child process got terminated. Perhaps try adjusting the timeout?'; } else { html.innerText = err.toString(); } container.appendChild(html); reject(err); }); }) } renderAsyToSVG(source: string) { return new Promise((resolve, reject) => { const dirPath = TEMP_DIR; // Copy over library files this.copyLibraryFiles(); fs.mkdir(dirPath, (err: ErrnoException | null) => { // File already exists error can be ignored if (err === null || err?.code === 'EEXIST') { // Generate random number to prevent name collisions const fileId = Math.floor(Math.random() * 10000000); const inFileName = `input-${fileId}.asy`; const outFileName = `output-${fileId}.svg`; const inputPath = path.join(dirPath, inFileName); fs.writeFile(inputPath, source, (err: ErrnoException | null) => { if (err) reject(err); const command = 'asy "{input}" -o "{output}" -f svg' .replace('{input}', inFileName) .replace('{output}', outFileName); exec(command, {cwd: dirPath, timeout: this.settings.timeout}, (err: ExecException | null) => { if (err) reject(err); fs.readFile(path.join(dirPath, outFileName), function (err: ErrnoException | null, data: Buffer) { if (err) reject(err) resolve(data.toString()); }); }); }); } }) }); } copyLibraryFiles() { const folder = this.app.vault.getAbstractFileByPath(this.settings.library); if (folder !== null && folder instanceof TFolder) { Vault.recurseChildren(folder, async (child : TAbstractFile) => { if (child instanceof TFile) { const file = child as TFile; if (file.extension === 'asy') { const libFile = await this.app.vault.read(file); fs.writeFileSync(path.join(TEMP_DIR, file.name), libFile); } } }); } } onunload() { } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } } class ObsidianAsymptoteSettings extends PluginSettingTab { plugin: ObsidianAsymptote; constructor(app: App, plugin: ObsidianAsymptote) { super(app, plugin); this.plugin = plugin; } display(): void { const {containerEl} = this; containerEl.empty(); new Setting(containerEl) .setName('Library') .setDesc('Folder of Asymptote library files (relative to vault root)') .addText((text: TextComponent) => { text .setPlaceholder('Enter Path') .setValue(this.plugin.settings.library) .onChange(async (value) => { this.plugin.settings.library = value; await this.plugin.saveSettings(); }) }); new Setting(containerEl) .setName('Clean Temporary Directory') .addButton((button: ButtonComponent) => { button .setButtonText('Clear') .onClick((_: MouseEvent) => { fs.rm(TEMP_DIR, { recursive: true, force: true }, (err: ErrnoException | null) => { if (err === null) { new Notice('Directory Cleared'); } else { new Notice('Error Clearing Directory') } }); }); }); new Setting(containerEl) .setName('Timeout (ms)') .addSlider((slider : SliderComponent) => { slider .setLimits(0, 30000, 100) .setValue(this.plugin.settings.timeout) .onChange(async (value) => { this.plugin.settings.timeout = value; await this.plugin.saveSettings(); }); }); } }