/** * 文件:assets.ts * 功能:资源管理(图标 / 静态资源引用 / 动态加载)。 */ import { App, PluginManifest, Notice, requestUrl, FileSystemAdapter, TAbstractFile, TFile, TFolder } from "obsidian"; import * as zip from "@zip.js/zip.js"; import DefaultTheme from "./default-theme"; import DefaultHighlight from "./default-highlight"; import { NMPSettings } from "./settings"; import { ExpertSettings, defaultExpertSettings, expertSettingsFromString } from "./expert-settings"; export interface Theme { name: string className: string desc: string author: string css: string } export interface Highlight { name: string url: string css: string } export default class AssetsManager { app: App; defaultTheme: Theme = DefaultTheme; manifest: PluginManifest; themes: Theme[]; highlights: Highlight[]; assetsPath: string; themesPath: string; hilightPath: string; customCSS: string = ''; themeCfg: string; hilightCfg: string; customCSSPath: string; iconsPath: string; wasmPath: string; expertSettings: ExpertSettings; isLoaded: boolean = false; private loadingPromise: Promise | null = null; // 防止重复并发加载 private static instance: AssetsManager; // 静态方法,用于获取实例 public static getInstance(): AssetsManager { if (!AssetsManager.instance) { AssetsManager.instance = new AssetsManager(); } return AssetsManager.instance; } public static setup(app: App, manifest: PluginManifest) { AssetsManager.getInstance()._setup(app, manifest); } private _setup(app: App, manifest: PluginManifest) { this.app = app; this.manifest = manifest; this.assetsPath = this.app.vault.configDir + '/plugins/' + this.manifest.id + '/assets/'; this.themesPath = this.assetsPath + 'themes/'; this.hilightPath = this.assetsPath + 'highlights/'; this.themeCfg = this.assetsPath + 'themes.json'; this.hilightCfg = this.assetsPath + 'highlights.json'; this.customCSSPath = this.assetsPath + 'custom.css'; this.iconsPath = this.assetsPath + 'icons/'; this.wasmPath = this.assetsPath + 'lib.wasm'; } private constructor() { } async loadAssets() { if (this.isLoaded) return; if (this.loadingPromise) { // 已经在加载中,复用同一个 promise return this.loadingPromise; } console.time('[Assets] loadAssets'); this.loadingPromise = (async () => { try { // 并行加载互不依赖的资源,加速启动 await Promise.all([ this.loadThemes().catch(e => console.error('[Assets] loadThemes 失败', e)), this.loadHighlights().catch(e => console.error('[Assets] loadHighlights 失败', e)), this.loadCustomCSS().catch(e => console.error('[Assets] loadCustomCSS 失败', e)), this.loadExpertSettings().catch(e => console.error('[Assets] loadExpertSettings 失败', e)), ]); this.isLoaded = true; console.log('[Assets] 资源加载完成', { themeCount: this.themes?.length ?? 0, highlightCount: this.highlights?.length ?? 0, customCSS: this.customCSS?.length ?? 0 }); } finally { console.timeEnd('[Assets] loadAssets'); this.loadingPromise = null; } })(); return this.loadingPromise; } async loadThemes() { try { console.log('[Assets] loadThemes:start'); if (!await this.app.vault.adapter.exists(this.themeCfg)) { new Notice('主题资源未下载,请前往设置下载!'); this.themes = [this.defaultTheme]; console.log('[Assets] loadThemes:themes.json missing -> default only'); return; } const data = await this.app.vault.adapter.read(this.themeCfg); if (data) { const themes = JSON.parse(data); await this.loadCSS(themes); this.themes = [this.defaultTheme, ... themes]; console.log('[Assets] loadThemes:done', { count: this.themes.length }); } } catch (error) { console.error(error); new Notice('themes.json解析失败!'); } } async loadCSS(themes: Theme[]) { try { await Promise.all( themes.map(async (theme) => { try { const cssFile = this.themesPath + theme.className + '.css'; const cssContent = await this.app.vault.adapter.read(cssFile); if (cssContent) { theme.css = cssContent; } } catch (e) { console.warn('[Assets] 读取主题 CSS 失败', theme.className, e); } }) ); } catch (error) { console.error(error); new Notice('读取CSS失败!'); } } async loadCustomCSS() { try { console.log('[Assets] loadCustomCSS:start'); const customCSSNote = NMPSettings.getInstance().customCSSNote; if (customCSSNote != '') { const file = this.searchFile(customCSSNote); if (file) { const cssContent = await this.app.vault.adapter.read(file.path); if (cssContent) { this.customCSS = cssContent.replace(/```css/gi, '').replace(/```/g, ''); } } else { new Notice(customCSSNote + '自定义CSS文件不存在!'); } return; } if (!await this.app.vault.adapter.exists(this.customCSSPath)) { return; } const cssContent = await this.app.vault.adapter.read(this.customCSSPath); if (cssContent) { this.customCSS = cssContent; } console.log('[Assets] loadCustomCSS:done', { hasContent: this.customCSS.length > 0 }); } catch (error) { console.error(error); new Notice('读取CSS失败!'); } } async loadExpertSettings() { try { console.log('[Assets] loadExpertSettings:start'); const note = NMPSettings.getInstance().expertSettingsNote; if (note != '') { const file = this.searchFile(note); if (file) { let content = await this.app.vault.adapter.read(file.path); if (content) { this.expertSettings = expertSettingsFromString(content); } else { this.expertSettings = defaultExpertSettings; new Notice(note + '专家设置文件内容为空!'); } } else { this.expertSettings = defaultExpertSettings; new Notice(note + '专家设置不存在!'); } } else { this.expertSettings = defaultExpertSettings; } console.log('[Assets] loadExpertSettings:done'); } catch (error) { console.error(error); new Notice('读取专家设置失败!'); } } async loadHighlights() { try { console.log('[Assets] loadHighlights:start'); const defaultHighlight = {name: '默认', url: '', css: DefaultHighlight}; this.highlights = [defaultHighlight]; if (!await this.app.vault.adapter.exists(this.hilightCfg)) { new Notice('高亮资源未下载,请前往设置下载!'); console.log('[Assets] loadHighlights:highlights.json missing -> default only'); return; } const data = await this.app.vault.adapter.read(this.hilightCfg); if (data) { const items = JSON.parse(data); for (const item of items) { const cssFile = this.hilightPath + item.name + '.css'; const cssContent = await this.app.vault.adapter.read(cssFile); this.highlights.push({name: item.name, url: item.url, css: cssContent}); } console.log('[Assets] loadHighlights:done', { count: this.highlights.length }); } } catch (error) { console.error(error); new Notice('highlights.json解析失败!'); } } async loadIcon(name: string) { const icon = this.iconsPath + name + '.svg'; if (!await this.app.vault.adapter.exists(icon)) { return ''; } const iconContent = await this.app.vault.adapter.read(icon); if (iconContent) { return iconContent; } return ''; } async loadWasm() { if (!await this.app.vault.adapter.exists(this.wasmPath)) { return null; } const wasmContent = await this.app.vault.adapter.readBinary(this.wasmPath); if (wasmContent) { return wasmContent; } return null; } getTheme(themeName: string) { if (themeName === '') { return this.themes[0]; } for (const theme of this.themes) { if (theme.name.toLowerCase() === themeName.toLowerCase() || theme.className.toLowerCase() === themeName.toLowerCase()) { return theme; } } // 找不到主题时返回第一个主题(默认主题) console.warn(`[Assets] 主题 "${themeName}" 未找到,使用默认主题`); return this.themes[0]; } getHighlight(highlightName: string) { if (highlightName === '') { return this.highlights[0]; } for (const highlight of this.highlights) { if (highlight.name.toLowerCase() === highlightName.toLowerCase()) { return highlight; } } // 找不到高亮时返回第一个高亮(默认高亮) console.warn(`[Assets] 高亮 "${highlightName}" 未找到,使用默认高亮`); return this.highlights[0]; } getThemeURL() { const version = this.manifest.version; return `https://biboer.cn/gitea/gavin/note2any/releases/download/${version}/assets.zip`; } async getStyle() { const file = this.app.vault.configDir + '/plugins/' + this.manifest.id + '/styles.css'; if (!await this.app.vault.adapter.exists(file)) { return ''; } const data = await this.app.vault.adapter.read(file); if (data) { return data; } return ''; } async downloadThemes() { try { if (await this.app.vault.adapter.exists(this.themeCfg)) { new Notice('主题资源已存在!') return; } const res = await requestUrl(this.getThemeURL()); const data = res.arrayBuffer; await this.unzip(new Blob([data])); await this.loadAssets(); new Notice('主题下载完成!'); } catch (error) { console.error(error); await this.removeThemes(); new Notice('主题下载失败, 请检查网络!'); } } async unzip(data:Blob) { const zipFileReader = new zip.BlobReader(data); const zipReader = new zip.ZipReader(zipFileReader); const entries = await zipReader.getEntries(); if (!await this.app.vault.adapter.exists(this.assetsPath)) { await this.app.vault.adapter.mkdir(this.assetsPath); } for (const entry of entries) { if (entry.directory) { const dirPath = this.assetsPath + entry.filename; await this.app.vault.adapter.mkdir(dirPath); } else { const filePath = this.assetsPath + entry.filename; const blobWriter = new zip.Uint8ArrayWriter(); if (entry.getData) { const data = await entry.getData(blobWriter); await this.app.vault.adapter.writeBinary(filePath, data.buffer as ArrayBuffer); } } } await zipReader.close(); } async removeThemes() { try { const adapter = this.app.vault.adapter; if (await adapter.exists(this.themeCfg)) { await adapter.remove(this.themeCfg); } if (await adapter.exists(this.hilightCfg)) { await adapter.remove(this.hilightCfg); } if (await adapter.exists(this.themesPath)) { await adapter.rmdir(this.themesPath, true); } if (await adapter.exists(this.hilightPath)) { await adapter.rmdir(this.hilightPath, true); } await this.loadAssets(); new Notice('清空完成!'); } catch (error) { console.error(error); new Notice('清空主题失败!'); } } async openAssets() { const path = require('path'); const adapter = this.app.vault.adapter as FileSystemAdapter; const vaultRoot = adapter.getBasePath(); const assets = this.assetsPath; if (!await adapter.exists(assets)) { await adapter.mkdir(assets); } const dst = path.join(vaultRoot, assets); const { shell } = require('electron'); shell.openPath(dst); } searchFile(nameOrPath: string): TAbstractFile | null { const resolvedPath = this.resolvePath(nameOrPath); const vault= this.app.vault; const attachmentFolderPath = vault.config.attachmentFolderPath || ''; let localPath = resolvedPath; let file = null; // 先按路径查找 file = vault.getFileByPath(resolvedPath); if (file) { return file; } // 在根目录查找 file = vault.getFileByPath(nameOrPath); if (file) { return file; } // 从附件文件夹查找 if (attachmentFolderPath != '') { localPath = attachmentFolderPath + '/' + nameOrPath; file = vault.getFileByPath(localPath) if (file) { return file; } localPath = attachmentFolderPath + '/' + resolvedPath; file = vault.getFileByPath(localPath) if (file) { return file; } } // 最后查找所有文件,这里只需要判断文件名 const files = vault.getAllLoadedFiles(); for (let f of files) { if (f instanceof TFolder) continue file = f as TFile; if (file.basename === nameOrPath || file.name === nameOrPath) { return f; } } return null; } getResourcePath(path: string): {resUrl:string, filePath:string}|null { const file = this.searchFile(path) as TFile; if (file == null) { return null; } const resUrl = this.app.vault.getResourcePath(file); return {resUrl, filePath: file.path}; } resolvePath(relativePath: string): string { const basePath = this.getActiveFileDir(); if (!relativePath.includes('/')) { return relativePath; } const stack = basePath.split("/"); const parts = relativePath.split("/"); stack.pop(); // Remove the current file name (or empty string) for (const part of parts) { if (part === ".") continue; if (part === "..") stack.pop(); else stack.push(part); } return stack.join("/"); } getActiveFileDir() { const af = this.app.workspace.getActiveFile(); if (af == null) { return ''; } const parts = af.path.split('/'); parts.pop(); if (parts.length == 0) { return ''; } return parts.join('/'); } async readFileBinary(path: string) { const vault= this.app.vault; const file = this.searchFile(path) as TFile; if (file == null) { return null; } return await vault.readBinary(file); } }