491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
/**
|
||
* 文件: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<void> | 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);
|
||
}
|
||
} |