Files
obsidian-excalidraw-plugin/src/core/managers/FileManager.ts
zsviczian 8240d87fa8
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
2.13.0 code, but not yet released. Manifest version not updated
2025-06-29 22:56:02 +02:00

584 lines
21 KiB
TypeScript

import { debug } from "src/utils/debugHelper";
import { App, FrontMatterCache, MarkdownView, MetadataCache, normalizePath, Notice, TAbstractFile, TFile, WorkspaceLeaf } from "obsidian";
import { BLANK_DRAWING, DARK_BLANK_DRAWING, DEVICE, EXPORT_TYPES, FRONTMATTER, FRONTMATTER_KEYS, JSON_parse, nanoid, VIEW_TYPE_EXCALIDRAW } from "src/constants/constants";
import { Prompt, templatePromt } from "src/shared/Dialogs/Prompt";
import { changeThemeOfExcalidrawMD, ExcalidrawData, getMarkdownDrawingSection } from "../../shared/ExcalidrawData";
import ExcalidrawView, { getTextMode } from "src/view/ExcalidrawView";
import ExcalidrawPlugin from "src/core/main";
import { DEBUGGING } from "src/utils/debugHelper";
import { checkAndCreateFolder, createFileAndAwaitMetacacheUpdate, download, getIMGFilename, getLink, getListOfTemplateFiles, getNewUniqueFilepath } from "src/utils/fileUtils";
import { PaneTarget } from "src/utils/modifierkeyHelper";
import { getExcalidrawViews, getNewOrAdjacentLeaf, isObsidianThemeDark, openLeaf } from "src/utils/obsidianUtils";
import { errorlog, getExportTheme } from "src/utils/utils";
export class PluginFileManager {
private plugin: ExcalidrawPlugin;
private app: App;
private excalidrawFiles: Set<TFile> = new Set<TFile>();
get settings() {
return this.plugin.settings;
}
constructor(plugin: ExcalidrawPlugin) {
this.plugin = plugin;
this.app = plugin.app;
}
public async initialize() {
await this.plugin.awaitInit();
const metaCache: MetadataCache = this.app.metadataCache;
metaCache.getCachedFiles().forEach((filename: string) => {
const fm = metaCache.getCache(filename)?.frontmatter;
if (
(fm && typeof fm[FRONTMATTER_KEYS["plugin"].name] !== "undefined") ||
filename.match(/\.excalidraw$/)
) {
this.updateFileCache(
this.app.vault.getAbstractFileByPath(filename) as TFile,
fm,
);
}
});
}
public isExcalidrawFile(f: TFile): boolean {
if(!f) return false;
if (f.extension === "excalidraw") {
return true;
}
const fileCache = f ? this.plugin.app.metadataCache.getFileCache(f) : null;
return !!fileCache?.frontmatter && !!fileCache.frontmatter[FRONTMATTER_KEYS["plugin"].name];
}
//managing my own list of Excalidraw files because in the onDelete event handler
//the file object is already gone from metadataCache, thus I can't check if it was an Excalidraw file
public updateFileCache(
file: TFile,
frontmatter?: FrontMatterCache,
deleted: boolean = false,
) {
if (frontmatter && typeof frontmatter[FRONTMATTER_KEYS["plugin"].name] !== "undefined") {
this.excalidrawFiles.add(file);
return;
}
if (!deleted && file.extension === "excalidraw") {
this.excalidrawFiles.add(file);
return;
}
this.excalidrawFiles.delete(file);
}
public getExcalidrawFiles(): Set<TFile> {
return this.excalidrawFiles;
}
public destroy() {
this.excalidrawFiles.clear();
}
public async createDrawing(
filename: string,
foldername?: string,
initData?: string,
): Promise<TFile> {
const folderpath = normalizePath(
foldername ? foldername : this.settings.folder,
);
await checkAndCreateFolder(folderpath); //create folder if it does not exist
const fname = getNewUniqueFilepath(this.app.vault, filename, folderpath);
const file = await this.app.vault.create(
fname,
initData ?? (await this.plugin.getBlankDrawing()),
);
//wait for metadata cache
let counter = 0;
while(file instanceof TFile && !this.isExcalidrawFile(file) && counter++<10) {
await sleep(50);
}
if(counter > 10) {
errorlog({file, error: "new drawing not recognized as an excalidraw file", fn: this.createDrawing});
}
return file;
}
public async getBlankDrawing(): Promise<string> {
const templates = getListOfTemplateFiles(this.plugin);
if(templates) {
const template = await templatePromt(templates, this.app);
if (template && template instanceof TFile) {
if (
(template.extension == "md" && !this.settings.compatibilityMode) ||
(template.extension == "excalidraw" && this.settings.compatibilityMode)
) {
const data = await this.app.vault.read(template);
if (data) {
return this.settings.matchTheme
? changeThemeOfExcalidrawMD(data)
: data;
}
}
}
}
if (this.settings.compatibilityMode) {
return this.settings.matchTheme && isObsidianThemeDark()
? DARK_BLANK_DRAWING
: BLANK_DRAWING;
}
const blank =
this.settings.matchTheme && isObsidianThemeDark()
? DARK_BLANK_DRAWING
: BLANK_DRAWING;
return `${FRONTMATTER}\n${getMarkdownDrawingSection(
blank,
this.settings.compress,
)}`;
}
public async embedDrawing(file: TFile) {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.file) {
const excalidrawRelativePath = this.app.metadataCache.fileToLinktext(
file,
activeView.file.path,
this.settings.embedType === "excalidraw",
);
const editor = activeView.editor;
//embed Excalidraw
if (this.settings.embedType === "excalidraw") {
editor.replaceSelection(
getLink(this.plugin, {path: excalidrawRelativePath}),
);
editor.focus();
return;
}
//embed image
let theme = this.settings.autoExportLightAndDark
? getExportTheme (
this.plugin,
file,
this.settings.exportWithTheme
? isObsidianThemeDark() ? "dark":"light"
: "light"
)
: "";
theme = (theme === "")
? ""
: theme + ".";
const exportExtension = theme+this.settings.embedType.toLowerCase();
let imageFullpath = getIMGFilename(
file.path,
exportExtension,
);
if(this.plugin.ea?.onImageExportPathHook) {
try {
imageFullpath = this.plugin.ea.onImageExportPathHook({
exportFilepath: imageFullpath,
exportExtension,
excalidrawFile: file,
action: "export",
}) ?? imageFullpath;
} catch (e) {
errorlog({where: "FileManager.embedDrawing", fn: this.plugin.ea.onImageExportPathHook, error: e});
}
}
const createFile = async (path: string):Promise<TFile> => {
return await createFileAndAwaitMetacacheUpdate(this.app, path,
this.settings.embedType === "SVG"
? `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>`
: new Uint8Array([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x01, // width: 1
0x00, 0x00, 0x00, 0x01, // height: 1
0x08, 0x06, 0x00, 0x00, 0x00, // bit depth: 8, color type: 6 (RGBA), compression: 0, filter: 0, interlace: 0
0x1F, 0x15, 0xC4, 0x89, // IHDR CRC
0x00, 0x00, 0x00, 0x0B, // IDAT chunk length
0x49, 0x44, 0x41, 0x54, // IDAT
0x78, 0x9C, 0x62, 0x00, 0x02, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, // compressed data (1x1 transparent pixel)
0x0A, 0x2D, 0xB4, // IDAT CRC
0x00, 0x00, 0x00, 0x00, // IEND chunk length
0x49, 0x45, 0x4E, 0x44, // IEND
0xAE, 0x42, 0x60, 0x82 // IEND CRC
]).buffer
);
}
let imgFile = this.app.vault.getFileByPath(imageFullpath);
if (!imgFile) {
imgFile = await createFile(imageFullpath);
}
const imageRelativePath = this.app.metadataCache.fileToLinktext(
imgFile,
activeView.file.path,
false,
);
//will hold incorrect value if theme==="", however in that case it won't be used
const otherTheme = theme === "dark." ? "light." : "dark.";
//if the hook tinkers with the extension, then I cannot predict the other theme's extension
//it would become a messy heuristic to try to guess the other theme's extension
const otherImageRelativePath = ((theme === "") || !imageRelativePath.endsWith(exportExtension))
? null
: (imageRelativePath.substring(0, imageRelativePath.lastIndexOf(exportExtension)) + otherTheme+this.settings.embedType.toLowerCase());
if(otherImageRelativePath) {
await createFile(
imgFile.path.substring(0, imgFile.path.lastIndexOf(exportExtension)) + otherTheme+this.settings.embedType.toLowerCase()
);
}
const inclCom = this.settings.embedMarkdownCommentLinks;
editor.replaceSelection(
this.settings.embedWikiLink
? `![[${imageRelativePath}]]\n` +
(inclCom
? `%%[[${excalidrawRelativePath}|🖋 Edit in Excalidraw]]${
otherImageRelativePath
? ", and the [["+otherImageRelativePath+"|"+otherTheme.split(".")[0]+" exported image]]"
: ""
}%%`
: "")
: `![](${encodeURI(imageRelativePath)})\n` +
(inclCom ? `%%[🖋 Edit in Excalidraw](${encodeURI(excalidrawRelativePath,
)})${otherImageRelativePath?", and the ["+otherTheme.split(".")[0]+" exported image]("+encodeURI(otherImageRelativePath)+")":""}%%` : ""),
);
editor.focus();
}
}
public async exportLibrary() {
if (DEVICE.isMobile) {
const prompt = new Prompt(
this.app,
"Please provide a filename",
"my-library",
"filename, leave blank to cancel action",
);
prompt.openAndGetValue(async (filename: string) => {
if (!filename) {
return;
}
filename = `${filename}.excalidrawlib`;
const folderpath = normalizePath(this.settings.folder);
await checkAndCreateFolder(folderpath); //create folder if it does not exist
const fname = getNewUniqueFilepath(
this.app.vault,
filename,
folderpath,
);
this.app.vault.create(fname, this.settings.library);
new Notice(`Exported library to ${fname}`, 6000);
});
return;
}
download(
"data:text/plain;charset=utf-8",
encodeURIComponent(JSON.stringify(this.settings.library2, null, "\t")),
"my-obsidian-library.excalidrawlib",
);
}
/**
* Opens a drawing file
* @param drawingFile
* @param location
* @param active
* @param subpath
* @param justCreated
* @param popoutLocation
*/
public openDrawing(
drawingFile: TFile,
location: PaneTarget,
active: boolean = false,
subpath?: string,
justCreated: boolean = false,
popoutLocation?: {x?: number, y?: number, width?: number, height?: number},
) {
const fnGetLeaf = ():WorkspaceLeaf => {
if(location === "md-properties") {
location = "new-tab";
}
let leaf: WorkspaceLeaf;
if(location === "popout-window") {
leaf = this.app.workspace.openPopoutLeaf(popoutLocation);
}
if(location === "new-tab") {
leaf = this.app.workspace.getLeaf('tab');
}
if(!leaf) {
leaf = this.app.workspace.getLeaf(false);
if ((leaf.view.getViewType() !== 'empty') && (location === "new-pane")) {
leaf = getNewOrAdjacentLeaf(this.plugin, leaf)
}
}
return leaf;
}
const {leaf, promise} = openLeaf({
plugin: this.plugin,
fnGetLeaf: () => fnGetLeaf(),
file: drawingFile,
openState:!subpath || subpath === ""
? {active}
: { active, eState: { subpath } }
});
promise.then(()=>{
const ea = this.plugin.ea;
if(justCreated && ea.onFileCreateHook) {
try {
ea.onFileCreateHook({
ea,
excalidrawFile: drawingFile,
view: leaf.view as ExcalidrawView,
});
} catch(e) {
console.error(e);
}
}
})
}
/**
* Extracts the text elements from an Excalidraw scene into a string of ids as headers followed by the text contents
* @param {string} data - Excalidraw scene JSON string
* @returns {string} - Text starting with the "# Text Elements" header and followed by each "## id-value" and text
*/
public async exportSceneToMD(data: string, compressOverride?: boolean): Promise<string> {
if (!data) {
return "";
}
const excalidrawData = JSON_parse(data);
const textElements = excalidrawData.elements?.filter(
(el: any) => el.type == "text",
);
let outString = `# Excalidraw Data\n\n## Text Elements\n`;
let id: string;
for (const te of textElements) {
id = te.id;
//replacing Excalidraw text IDs with my own, because default IDs may contain
//characters not recognized by Obsidian block references
//also Excalidraw IDs are inconveniently long
if (te.id.length > 8) {
id = nanoid();
data = data.replaceAll(te.id, id); //brute force approach to replace all occurrences.
}
outString += `${te.originalText ?? te.text} ^${id}\n\n`;
}
return (
outString +
getMarkdownDrawingSection(
JSON.stringify(JSON_parse(data), null, "\t"),
typeof compressOverride === "undefined"
? this.settings.compress
: compressOverride,
)
);
}
// -------------------------------------------------------
// ------------------ Event Handlers ---------------------
// -------------------------------------------------------
/**
* watch filename change to rename .svg, .png; to sync to .md; to update links
* @param file
* @param oldPath
* @returns
*/
public async renameEventHandler (file: TAbstractFile, oldPath: string) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.renameEventHandler, `ExcalidrawPlugin.renameEventHandler`, file, oldPath);
if (!(file instanceof TFile)) {
return;
}
if (!this.isExcalidrawFile(file)) {
return;
}
if (!this.settings.keepInSync) {
return;
}
const imgMap = new Map<string, {oldImgPath: string, newImgPath: string}>();
[EXPORT_TYPES, "excalidraw"].flat().forEach(ext => {
let oldImgPath = getIMGFilename(oldPath, ext);
let newImgPath = getIMGFilename(file.path, ext);
if(this.plugin.ea?.onImageExportPathHook) {
try {
oldImgPath = this.plugin.ea.onImageExportPathHook({
exportFilepath: oldImgPath,
exportExtension: ext,
excalidrawFile: file,
oldExcalidrawPath: oldPath,
action: "move",
}) ?? oldImgPath;
newImgPath = this.plugin.ea.onImageExportPathHook({
exportFilepath: newImgPath,
exportExtension: ext,
excalidrawFile: file,
action: "export",
}) ?? newImgPath;
} catch (e) {
errorlog({where: "FileManager.renameEventHandler", fn: this.plugin.ea.onImageExportPathHook, error: e});
}
}
imgMap.set(ext, { oldImgPath, newImgPath });
});
imgMap.forEach((path, ext) => {
const imgFile = this.app.vault.getFileByPath(
normalizePath(path.oldImgPath),
);
if (imgFile) {
this.app.fileManager.renameFile(imgFile, normalizePath(path.newImgPath));
}
});
}
public async modifyEventHandler (file: TFile) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.modifyEventHandler,`FileManager.modifyEventHandler`, file);
const excalidrawViews = getExcalidrawViews(this.app);
excalidrawViews.forEach(async (excalidrawView) => {
if(excalidrawView.semaphores?.viewunload) {
return;
}
if (
excalidrawView.file &&
(excalidrawView.file.path === file.path ||
(file.extension === "excalidraw" &&
`${file.path.substring(
0,
file.path.lastIndexOf(".excalidraw"),
)}.md` === excalidrawView.file.path))
) {
if(excalidrawView.semaphores?.preventReload) {
excalidrawView.semaphores.preventReload = false;
return;
}
// Avoid synchronizing or reloading if the user hasn't interacted with the file for 5 minutes.
// This prevents complex sync issues when multiple remote changes occur outside an active collaboration session.
// The following logic handles a rare edge case where:
// 1. The user opens an Excalidraw file.
// 2. Immediately splits the view without saving Excalidraw (since no changes were made).
// 3. Switches the new split view to Markdown, edits the file, and quickly returns to Excalidraw.
// 4. The "modify" event may fire while Excalidraw is active, triggering an unwanted reload and zoom reset.
// To address this:
// - We check if the user is currently editing the Markdown version of the Excalidraw file in a split view.
// - As a heuristic, we also check for recent leaf switches.
// This is not perfectly accurate (e.g., rapid switching between views within a few seconds),
// but it is sufficient to avoid most edge cases without introducing complexity.
// Edge case impact:
// - In extremely rare situations, an update arriving within the "recent switch" timeframe (e.g., from Obsidian Sync)
// might not trigger a reload. This is unlikely and an acceptable trade-off for better user experience.
const activeView = this.app.workspace.activeLeaf.view;
const isEditingMarkdownSideInSplitView = ((activeView !== excalidrawView) &&
activeView instanceof MarkdownView && activeView.file === excalidrawView.file) ||
(activeView === excalidrawView && this.plugin.isRecentSplitViewSwitch());
if(!isEditingMarkdownSideInSplitView && (excalidrawView.lastSaveTimestamp + 300000 < Date.now())) {
excalidrawView.reload(true, excalidrawView.file);
return;
}
if(file.extension==="md") {
if(excalidrawView.semaphores?.embeddableIsEditingSelf) return;
const inData = new ExcalidrawData(this.plugin);
const data = await this.app.vault.read(file);
await inData.loadData(data,file,getTextMode(data));
excalidrawView.synchronizeWithData(inData);
inData.destroy();
if(excalidrawView?.isDirty()) {
if(excalidrawView.autosaveTimer && excalidrawView.autosaveFunction) {
clearTimeout(excalidrawView.autosaveTimer);
}
if(excalidrawView.autosaveFunction) {
excalidrawView.autosaveFunction();
}
}
} else {
excalidrawView.reload(true, excalidrawView.file);
}
}
});
}
/**
* watch file delete and delete corresponding .svg and .png
* @param file
* @returns
*/
public async deleteEventHandler (file: TFile) {
(process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.deleteEventHandler,`ExcalidrawPlugin.deleteEventHandler`, file);
if (!(file instanceof TFile)) {
return;
}
const isExcalidarwFile = this.getExcalidrawFiles().has(file);
this.updateFileCache(file, undefined, true);
if (!isExcalidarwFile) {
return;
}
//close excalidraw view where this file is open
const excalidrawViews = getExcalidrawViews(this.app);
for (const excalidrawView of excalidrawViews) {
if (file?.path && excalidrawView?.file?.path === file.path) {
await excalidrawView.leaf.setViewState({
type: VIEW_TYPE_EXCALIDRAW,
state: { file: null },
});
}
}
//delete PNG and SVG files as well
if (this.settings.keepInSync) {
const imgMap = new Map<string, string>();
[EXPORT_TYPES, "excalidraw"].flat().forEach(ext => {
let imgPath = getIMGFilename(file.path, ext);
if(this.plugin.ea?.onImageExportPathHook) {
try {
imgPath = this.plugin.ea.onImageExportPathHook({
exportFilepath: imgPath,
exportExtension: ext,
excalidrawFile: file,
action: "delete",
}) ?? imgPath;
} catch (e) {
errorlog({where: "FileManager.deleteEventHandler", fn: this.plugin.ea.onImageExportPathHook, error: e});
}
}
imgMap.set(ext, imgPath);
});
window.setTimeout(() => {
imgMap.forEach((imgPath: string, ext: string) => {
const imgFile = this.app.vault.getFileByPath(
normalizePath(imgPath),
);
if (imgFile) {
this.app.vault.delete(imgFile);
}
});
}, 500);
}
};
}