864 lines
30 KiB
TypeScript
864 lines
30 KiB
TypeScript
/**
|
||
* 文件:article-render.ts
|
||
* 作用:文章渲染与转换辅助。
|
||
*/
|
||
|
||
import { App, ItemView, Workspace, Notice, sanitizeHTMLToDom, apiVersion, TFile, MarkdownRenderer, FrontMatterCache } from 'obsidian';
|
||
import { applyCSS } from './utils';
|
||
import { UploadImageToWx } from './imagelib';
|
||
import { NMPSettings } from './settings';
|
||
import AssetsManager from './assets';
|
||
import InlineCSS from './inline-css';
|
||
import { wxGetToken, wxAddDraft, wxBatchGetMaterial, DraftArticle, DraftImageMediaId, DraftImages, wxAddDraftImages } from './wechat/weixin-api';
|
||
import { MDRendererCallback } from './markdown/extension';
|
||
import { MarkedParser } from './markdown/parser';
|
||
import { LocalImageManager, LocalFile } from './markdown/local-file';
|
||
import { CardDataManager } from './markdown/code';
|
||
import { debounce } from './utils';
|
||
import { PrepareImageLib, IsImageLibReady, WebpToJPG } from './imagelib';
|
||
import { toPng } from 'html-to-image';
|
||
import * as path from 'path';
|
||
import { stat, readdir } from 'fs/promises';
|
||
|
||
|
||
const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims;
|
||
|
||
// gallery 配置迁移到 NMPSettings(galleryPrePath, galleryNumPic)
|
||
// 匹配示例:{{<gallery dir="/img/guanzhan/1" figcaption="毕业展" mppickall=1/>}}{{<load-photoswipe>}}
|
||
// 支持可选 figcaption 以及 mppickall=1/0(无引号数字或布尔),若 mppickall=1 则选取目录内全部图片
|
||
// 说明:为保持简单,mppickall 只支持 0/1,不写或写 0 则按限制数量。
|
||
// mppickall 允许:mppickall=1 | mppickall='1' | mppickall="1" (0 同理)
|
||
const GALLERY_SHORTCODE_REGEX = /{{<gallery\s+dir="([^"]+)"(?:\s+figcaption="([^"]*)")?(?:\s+mppickall=(?:"(1|0)"|'(1|0)'|(1|0)))?\s*\/?>}}\s*{{<load-photoswipe>}}/g;
|
||
// 块级 gallery:
|
||
// {{<gallery>}}\n{{<figure src="/img/a.png" caption=".." >}}\n...\n{{</gallery>}}
|
||
// 需要提取所有 figure 的 src basename 生成多行 wikilink
|
||
const GALLERY_BLOCK_REGEX = /{{<gallery>}}([\s\S]*?){{<\/gallery>}}/g;
|
||
// figure 支持 src 或 link 属性,两者取其一
|
||
const FIGURE_IN_GALLERY_REGEX = /{{<figure\s+(?:src|link)="([^"]+)"[^>]*>}}/g;
|
||
|
||
async function listLocalImages(dirAbs: string): Promise<string[]> {
|
||
try {
|
||
const stats = await stat(dirAbs);
|
||
if (!stats.isDirectory()) return [];
|
||
} catch { return []; }
|
||
try {
|
||
const files = await readdir(dirAbs);
|
||
return files.filter(f => /(png|jpe?g|gif|bmp|webp|svg)$/i.test(f)).sort();
|
||
} catch { return []; }
|
||
}
|
||
|
||
function pickImages(all: string[], limit: number): string[] {
|
||
if (all.length <= limit) return all;
|
||
// 简单:前 n;可扩展为随机
|
||
return all.slice(0, limit);
|
||
}
|
||
|
||
async function transformGalleryShortcodes(md: string, prePath: string, numPic: number): Promise<string> {
|
||
// 逐个替换(异步)—— 使用 replace + 手动遍历实现
|
||
const matches: { full: string; dir: string; caption?: string; pickAll?: boolean }[] = [];
|
||
md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption, q1, q2, plain) => {
|
||
const flag = q1 || q2 || plain; // 三个捕获组任选其一
|
||
matches.push({ full, dir, caption, pickAll: flag === '1' });
|
||
return full;
|
||
});
|
||
let result = md;
|
||
for (const m of matches) {
|
||
const absDir = path.join(prePath, m.dir.replace(/^\//, '')); // 拼接绝对路径
|
||
const imgs = await listLocalImages(absDir);
|
||
const picked = m.pickAll ? imgs : pickImages(imgs, numPic);
|
||
if (picked.length === 0) {
|
||
// 无图则清空短代码(或保留原样,这里按需求替换为空)
|
||
result = result.replace(m.full, '');
|
||
continue;
|
||
}
|
||
const repl = picked.map(f => `![[${f}]]`).join('\n');
|
||
result = result.replace(m.full, repl);
|
||
}
|
||
// 处理无 dir 的块级 gallery(只做本地名提取,不访问文件系统)
|
||
result = result.replace(GALLERY_BLOCK_REGEX, (full: string, inner: string) => {
|
||
const names: string[] = [];
|
||
inner.replace(FIGURE_IN_GALLERY_REGEX, (_f: string, src: string) => {
|
||
if (!src) return _f;
|
||
const clean = src.split('#')[0].split('?')[0];
|
||
const base = clean.split('/').pop();
|
||
if (base && /(png|jpe?g|gif|bmp|webp|svg)$/i.test(base)) {
|
||
names.push(base);
|
||
}
|
||
return _f;
|
||
});
|
||
if (names.length === 0) return '';
|
||
return names.map(n => `![[${n}]]`).join('\n');
|
||
});
|
||
return result;
|
||
}
|
||
|
||
export class ArticleRender implements MDRendererCallback {
|
||
app: App;
|
||
itemView: ItemView;
|
||
workspace: Workspace;
|
||
styleEl: HTMLElement;
|
||
articleDiv: HTMLDivElement;
|
||
settings: NMPSettings;
|
||
assetsManager: AssetsManager;
|
||
articleHTML: string;
|
||
title: string;
|
||
_currentTheme: string;
|
||
_currentHighlight: string;
|
||
_currentAppId: string;
|
||
markedParser: MarkedParser;
|
||
cachedElements: Map<string, string> = new Map();
|
||
debouncedRenderMarkdown: (...args: any[]) => void;
|
||
originalMarkdown: string | null = null; // 保存去除前处理前的原始 Markdown
|
||
|
||
constructor(app: App, itemView: ItemView, styleEl: HTMLElement, articleDiv: HTMLDivElement) {
|
||
this.app = app;
|
||
this.itemView = itemView;
|
||
this.styleEl = styleEl;
|
||
this.articleDiv = articleDiv;
|
||
this.settings = NMPSettings.getInstance();
|
||
this.assetsManager = AssetsManager.getInstance();
|
||
this.articleHTML = '';
|
||
this.title = '';
|
||
this._currentTheme = 'default';
|
||
this._currentHighlight = 'default';
|
||
this.markedParser = new MarkedParser(app, this);
|
||
this.debouncedRenderMarkdown = debounce(this.renderMarkdown.bind(this), 1000);
|
||
}
|
||
|
||
set currentTheme(value: string) {
|
||
this._currentTheme = value;
|
||
}
|
||
|
||
get currentTheme() {
|
||
const { theme } = this.getMetadata();
|
||
if (theme) {
|
||
return theme;
|
||
}
|
||
return this._currentTheme;
|
||
}
|
||
|
||
set currentHighlight(value: string) {
|
||
this._currentHighlight = value;
|
||
}
|
||
|
||
get currentHighlight() {
|
||
const { highlight } = this.getMetadata();
|
||
if (highlight) {
|
||
return highlight;
|
||
}
|
||
return this._currentHighlight;
|
||
}
|
||
|
||
isOldTheme() {
|
||
const theme = this.assetsManager.getTheme(this.currentTheme);
|
||
if (theme) {
|
||
return theme.css.indexOf('.note2any') < 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
setArticle(article: string) {
|
||
this.articleDiv.empty();
|
||
let className = 'note2any';
|
||
// 兼容旧版本样式
|
||
if (this.isOldTheme()) {
|
||
className = this.currentTheme;
|
||
}
|
||
const html = `<section class="${className}" id="article-section">${article}</section>`;
|
||
const doc = sanitizeHTMLToDom(html);
|
||
if (doc.firstChild) {
|
||
this.articleDiv.appendChild(doc.firstChild);
|
||
}
|
||
}
|
||
|
||
setStyle(css: string) {
|
||
this.styleEl.empty();
|
||
this.styleEl.appendChild(document.createTextNode(css));
|
||
}
|
||
|
||
reloadStyle() {
|
||
this.setStyle(this.getCSS());
|
||
}
|
||
|
||
getArticleSection() {
|
||
return this.articleDiv.querySelector('#article-section') as HTMLElement;
|
||
}
|
||
|
||
getArticleContent() {
|
||
const content = this.articleDiv.innerHTML;
|
||
let html = applyCSS(content, this.getCSS());
|
||
// 处理话题多余内容
|
||
html = html.replace(/rel="noopener nofollow"/g, '');
|
||
html = html.replace(/target="_blank"/g, '');
|
||
html = html.replace(/data-leaf=""/g, 'leaf=""');
|
||
return CardDataManager.getInstance().restoreCard(html);
|
||
}
|
||
|
||
getArticleText() {
|
||
return this.articleDiv.innerText.trimStart();
|
||
}
|
||
|
||
errorContent(error: any) {
|
||
return '<h1>渲染失败!</h1><br/>'
|
||
+ '如需帮助请前往 <a href="https://biboer.cn/gitea/gavin/note2any/issues">https://biboer.cn/gitea/gavin/note2any/issues</a> 反馈<br/><br/>'
|
||
+ '如果方便,请提供引发错误的完整Markdown内容。<br/><br/>'
|
||
+ '<br/>Obsidian版本:' + apiVersion
|
||
+ '<br/>错误信息:<br/>'
|
||
+ `${error}`;
|
||
}
|
||
|
||
async renderMarkdown(af: TFile | null = null) {
|
||
try {
|
||
let md = '';
|
||
if (af && af.extension.toLocaleLowerCase() === 'md') {
|
||
md = await this.app.vault.adapter.read(af.path);
|
||
this.title = af.basename;
|
||
}
|
||
else {
|
||
md = '没有可渲染的笔记或文件不支持渲染';
|
||
}
|
||
this.originalMarkdown = md; // 保存原始内容(含 frontmatter)供封面/摘要自动提取
|
||
if (md.startsWith('---')) {
|
||
md = md.replace(FRONT_MATTER_REGEX, '');
|
||
}
|
||
|
||
// 处理 gallery 短代码 -> wikilink 图片列表
|
||
md = await transformGalleryShortcodes(md, this.settings.galleryPrePath, this.settings.galleryNumPic);
|
||
|
||
// 自定义行级语法转换: [fig .../] 以及 || 前缀块
|
||
md = this.applyCustomInlineBlocks(md);
|
||
|
||
// 将标准 markdown 图片语法转为 wikilink 语法,便于现有 LocalImageManager 识别
|
||
if (this.settings.enableMarkdownImageToWikilink) {
|
||
// 匹配 ;不跨行;忽略包含空格的 URL 末尾注释
|
||
// 捕获路径和文件名,文件名取最后一段
|
||
md = md.replace(/!\[[^\]]*\]\(([^)\s]+)\)/g, (full, p1) => {
|
||
try {
|
||
// 去掉可能的 query/hash
|
||
const clean = p1.split('#')[0].split('?')[0];
|
||
const filename = clean.split('/').pop();
|
||
if (!filename) return full; // 无法解析
|
||
// 仅当是常见图片扩展时才替换
|
||
if (!filename.match(/\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i)) return full;
|
||
return `![[${filename}]]`;
|
||
} catch { return full; }
|
||
});
|
||
}
|
||
|
||
this.articleHTML = await this.markedParser.parse(md);
|
||
this.setStyle(this.getCSS());
|
||
this.setArticle(this.articleHTML);
|
||
await this.processCachedElements();
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
this.setArticle(this.errorContent(e));
|
||
}
|
||
}
|
||
// 自定义 fig 与 || 语法转换
|
||
private applyCustomInlineBlocks(md: string): string {
|
||
const figPattern = /\[fig([^\n\]]*?)\/\]/g; // [fig text/]
|
||
md = md.replace(figPattern, (_m, inner) => {
|
||
const content = inner.trim();
|
||
return `<span style="font-style: italic; font-size: 14px; background-color: #f5f5f5; padding: 2px;">${content}</span>`;
|
||
});
|
||
|
||
// 颜色映射:默认|| 与 ||g, ||r, ||b, ||y
|
||
const blockStyles: Record<string, string> = {
|
||
'': "background-color:#E5E4E2;",
|
||
'r': "color:white;background-color:#6F4E37;",
|
||
'g': "background-color:#BCE954;",
|
||
'b': "background-color:#B6B6B4;",
|
||
'y': "background-color:#FFFFC2;",
|
||
};
|
||
const baseStyle = "font-family:'Microsoft YaHei',sans-serif;font-size:14px; padding:10px;border-radius:20px;line-height:30px;";
|
||
// 仅匹配行首 ||[flag]? 空格 之后的单行内容,避免吞并后续多行
|
||
md = md.split(/\n/).map(line => {
|
||
// 例如 || 内容, ||r 内容
|
||
const m = line.match(/^\|\|(r|g|b|y)?\s+(.*)$/);
|
||
if (!m) return line;
|
||
const flag = m[1] || '';
|
||
const text = m[2];
|
||
const style = baseStyle + (blockStyles[flag] || blockStyles['']);
|
||
return `<p style="${style}">${text}</p>`;
|
||
}).join('\n');
|
||
return md;
|
||
}
|
||
getCSS() {
|
||
try {
|
||
const theme = this.assetsManager.getTheme(this.currentTheme);
|
||
const highlight = this.assetsManager.getHighlight(this.currentHighlight);
|
||
const customCSS = this.settings.customCSSNote.length > 0 || this.settings.useCustomCss ? this.assetsManager.customCSS : '';
|
||
const baseCSS = this.settings.baseCSS ? `.note2any {${this.settings.baseCSS}}` : '';
|
||
return `${InlineCSS}\n\n${highlight!.css}\n\n${theme!.css}\n\n${baseCSS}\n\n${customCSS}`;
|
||
} catch (error) {
|
||
console.error(error);
|
||
new Notice(`获取样式失败${this.currentTheme}|${this.currentHighlight},请检查主题是否正确安装。`);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
updateStyle(styleName: string) {
|
||
this.currentTheme = styleName;
|
||
this.setStyle(this.getCSS());
|
||
}
|
||
|
||
updateHighLight(styleName: string) {
|
||
this.currentHighlight = styleName;
|
||
this.setStyle(this.getCSS());
|
||
}
|
||
|
||
getFrontmatterValue(frontmatter: FrontMatterCache, key: string) {
|
||
const value = frontmatter[key];
|
||
|
||
if (value instanceof Array) {
|
||
return value[0];
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
getMetadata() {
|
||
let res: DraftArticle = {
|
||
title: '',
|
||
author: undefined,
|
||
digest: undefined,
|
||
content: '',
|
||
content_source_url: undefined,
|
||
cover: undefined,
|
||
thumb_media_id: '',
|
||
need_open_comment: undefined,
|
||
only_fans_can_comment: undefined,
|
||
pic_crop_235_1: undefined,
|
||
pic_crop_1_1: undefined,
|
||
appid: undefined,
|
||
theme: undefined,
|
||
highlight: undefined,
|
||
}
|
||
const file = this.app.workspace.getActiveFile();
|
||
if (!file) return res;
|
||
// 避免频繁刷屏:仅当路径变化或超过 3s 再输出一次
|
||
try {
|
||
const globalAny = window as any;
|
||
const now = Date.now();
|
||
if (!globalAny.__note2any_lastPathLog ||
|
||
globalAny.__note2any_lastPathLog.path !== file.path ||
|
||
now - globalAny.__note2any_lastPathLog.time > 3000) {
|
||
console.log('[note2any] active file path:', file.path);
|
||
globalAny.__note2any_lastPathLog = { path: file.path, time: now };
|
||
}
|
||
} catch {}
|
||
const metadata = this.app.metadataCache.getFileCache(file);
|
||
if (metadata?.frontmatter) {
|
||
const keys = this.assetsManager.expertSettings.frontmatter;
|
||
const frontmatter = metadata.frontmatter;
|
||
// frontmatter 优先:如果存在 title/author 则直接取之
|
||
const fmTitle = this.getFrontmatterValue(frontmatter, keys.title) || frontmatter['title'];
|
||
const fmAuthor = this.getFrontmatterValue(frontmatter, keys.author) || frontmatter['author'];
|
||
if (fmTitle) res.title = fmTitle;
|
||
if (fmAuthor) res.author = fmAuthor;
|
||
res.digest = this.getFrontmatterValue(frontmatter, keys.digest);
|
||
res.content_source_url = this.getFrontmatterValue(frontmatter, keys.content_source_url);
|
||
if (!this.settings.ignoreFrontmatterImage) {
|
||
let fmCover = this.getFrontmatterValue(frontmatter, keys.cover) || frontmatter['cover'] || frontmatter['image'];
|
||
if (typeof fmCover === 'string') {
|
||
fmCover = fmCover.trim();
|
||
if (fmCover === '') {
|
||
fmCover = undefined;
|
||
} else if (/^https?:\/\//i.test(fmCover)) {
|
||
// 远程 URL,保持原样(后续逻辑支持 http 上传)
|
||
} else {
|
||
// 本地路径:可能是 /img/xxx.png 或 相对路径 foo/bar.png
|
||
const clean = fmCover.replace(/^"|"$/g, '').split('#')[0].split('?')[0];
|
||
const base = clean.split('/').pop();
|
||
if (base) {
|
||
fmCover = `![[${base}]]`;
|
||
}
|
||
}
|
||
}
|
||
res.cover = fmCover;
|
||
} else {
|
||
res.cover = undefined; // 忽略 frontmatter
|
||
}
|
||
res.thumb_media_id = this.getFrontmatterValue(frontmatter, keys.thumb_media_id);
|
||
if (frontmatter[keys.need_open_comment] !== undefined) {
|
||
res.need_open_comment = frontmatter[keys.need_open_comment] ? 1 : 0;
|
||
}
|
||
res.only_fans_can_comment = frontmatter[keys.only_fans_can_comment] ? 1 : undefined;
|
||
res.appid = this.getFrontmatterValue(frontmatter, keys.appid);
|
||
if (res.appid && !res.appid.startsWith('wx')) {
|
||
res.appid = this.settings.wxInfo.find(wx => wx.name === res.appid)?.appid;
|
||
}
|
||
res.theme = this.getFrontmatterValue(frontmatter, keys.theme);
|
||
res.highlight = this.getFrontmatterValue(frontmatter, keys.highlight);
|
||
if (frontmatter[keys.crop]) {
|
||
res.pic_crop_235_1 = '0_0_1_0.5';
|
||
res.pic_crop_1_1 = '0_0.525_0.404_1';
|
||
}
|
||
}
|
||
else if (this.originalMarkdown?.startsWith('---')) {
|
||
// 元数据缓存未命中时的手动轻量解析(不引入 yaml 依赖,逐行扫描直到第二个 ---)
|
||
try {
|
||
const lines = this.originalMarkdown.split(/\r?\n/);
|
||
if (lines[0].trim() === '---') {
|
||
let i = 1;
|
||
for (; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (line.trim() === '---') break;
|
||
const m = line.match(/^(\w[\w-]*):\s*(.*)$/); // key: value 形式
|
||
if (!m) continue;
|
||
const k = m[1].toLowerCase();
|
||
const v = m[2].trim();
|
||
if (k === 'title' && !res.title) res.title = v;
|
||
if (k === 'author' && !res.author) res.author = v;
|
||
if (!this.settings.ignoreFrontmatterImage) {
|
||
if ((k === 'image' || k === 'cover') && !res.cover && v) {
|
||
let val = v.replace(/^"|"$/g, '').trim();
|
||
if (val) {
|
||
if (/^https?:\/\//i.test(val)) {
|
||
res.cover = val; // URL
|
||
} else {
|
||
const clean = val.split('#')[0].split('?')[0];
|
||
const base = clean.split('/').pop();
|
||
if (base) res.cover = `![[${base}]]`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('fallback frontmatter parse failed', err);
|
||
}
|
||
}
|
||
|
||
// 如果未显式指定封面,尝试从正文首图( markdown 或 wikilink ) 提取,按出现顺序优先
|
||
if (!res.cover && this.originalMarkdown) {
|
||
let body = this.originalMarkdown;
|
||
if (body.startsWith('---')) body = body.replace(FRONT_MATTER_REGEX, '');
|
||
|
||
// 同时匹配两种形式并比较 index
|
||
const mdImgPattern = /!\[[^\]]*\]\(([^)\s]+)\)/g; // group1 为路径
|
||
const wikilinkPattern = /!\[\[(.+?)\]\]/g; // group1 为文件名或 path
|
||
interface Candidate { idx:number; basename:string; }
|
||
const candidates: Candidate[] = [];
|
||
|
||
let m: RegExpExecArray | null;
|
||
while ((m = mdImgPattern.exec(body)) !== null) {
|
||
const rawPath = m[1];
|
||
if (/^https?:\/\//i.test(rawPath)) continue; // 跳过远程
|
||
const clean = rawPath.split('#')[0].split('?')[0];
|
||
const basename = clean.split('/').pop();
|
||
if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) {
|
||
candidates.push({ idx: m.index, basename });
|
||
}
|
||
}
|
||
while ((m = wikilinkPattern.exec(body)) !== null) {
|
||
const inner = m[1].trim();
|
||
const clean = inner.split('#')[0].split('?')[0];
|
||
const basename = clean.split('/').pop();
|
||
if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) {
|
||
candidates.push({ idx: m.index, basename });
|
||
}
|
||
}
|
||
if (candidates.length > 0) {
|
||
candidates.sort((a,b)=> a.idx - b.idx);
|
||
res.cover = `![[${candidates[0].basename}]]`;
|
||
} else if (!res.cover) {
|
||
// 没有找到任何图片候选,应用默认封面(如果配置了)
|
||
const def = this.settings.defaultCoverPic?.trim();
|
||
if (def) {
|
||
if (/^!\[\[.*\]\]$/.test(def) || /^https?:\/\//i.test(def)) {
|
||
res.cover = def;
|
||
} else {
|
||
const base = def.split('/').pop();
|
||
if (base) res.cover = `![[${base}]]`;
|
||
}
|
||
if (res.cover) {
|
||
try { console.log('[note2any] use default cover:', def, '->', res.cover); } catch {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (res.need_open_comment === undefined) {
|
||
res.need_open_comment = this.settings.needOpenComment ? 1 : 0;
|
||
}
|
||
return res;
|
||
}
|
||
|
||
async uploadVaultCover(name: string, token: string) {
|
||
const LocalFileRegex = /^!\[\[(.*?)\]\]/;
|
||
const matches = name.match(LocalFileRegex);
|
||
let fileName = '';
|
||
if (matches && matches.length > 1) {
|
||
fileName = matches[1];
|
||
}
|
||
else {
|
||
fileName = name;
|
||
}
|
||
const vault = this.app.vault;
|
||
const file = this.assetsManager.searchFile(fileName) as TFile;
|
||
if (!file) {
|
||
throw new Error('找不到封面文件: ' + fileName);
|
||
}
|
||
const fileData = await vault.readBinary(file);
|
||
|
||
return await this.uploadCover(new Blob([fileData]), file.name, token);
|
||
}
|
||
|
||
async uploadCover(data: Blob, filename: string, token: string) {
|
||
if (filename.toLowerCase().endsWith('.webp')) {
|
||
await PrepareImageLib();
|
||
if (IsImageLibReady()) {
|
||
const jpgUint8 = WebpToJPG(await data.arrayBuffer());
|
||
// 使用底层 ArrayBuffer 构造 Blob,避免 TypeScript 在某些配置下对 ArrayBufferLike 的严格类型检查报错
|
||
data = new Blob([jpgUint8.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||
filename = filename.toLowerCase().replace('.webp', '.jpg');
|
||
}
|
||
}
|
||
|
||
const res = await UploadImageToWx(data, filename, token, 'image');
|
||
if (res.media_id) {
|
||
return res.media_id;
|
||
}
|
||
console.error('upload cover fail: ' + res.errmsg);
|
||
throw new Error('上传封面失败: ' + res.errmsg);
|
||
}
|
||
|
||
async getDefaultCover(token: string) {
|
||
const res = await wxBatchGetMaterial(token, 'image');
|
||
if (res.item_count > 0) {
|
||
return res.item[0].media_id;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async getToken(appid: string) {
|
||
const secret = this.getSecret(appid);
|
||
const res = await wxGetToken(this.settings.authKey, appid, secret);
|
||
if (res.status != 200) {
|
||
const data = res.json;
|
||
throw new Error('获取token失败: ' + data.message);
|
||
}
|
||
const token = res.json.token;
|
||
if (token === '') {
|
||
throw new Error('获取token失败: ' + res.json.message);
|
||
}
|
||
return token;
|
||
}
|
||
|
||
async uploadImages(appid: string) {
|
||
if (!this.settings.authKey) {
|
||
throw new Error('请先设置注册码(AuthKey)');
|
||
}
|
||
|
||
let metadata = this.getMetadata();
|
||
if (metadata.appid) {
|
||
appid = metadata.appid;
|
||
}
|
||
|
||
if (!appid || appid.length == 0) {
|
||
throw new Error('请先选择公众号');
|
||
}
|
||
|
||
// 获取token
|
||
const token = await this.getToken(appid);
|
||
if (token === '') {
|
||
return;
|
||
}
|
||
|
||
await this.cachedElementsToImages();
|
||
|
||
const lm = LocalImageManager.getInstance();
|
||
// 上传图片
|
||
await lm.uploadLocalImage(token, this.app.vault);
|
||
// 上传图床图片
|
||
await lm.uploadRemoteImage(this.articleDiv, token);
|
||
// 替换图片链接
|
||
lm.replaceImages(this.articleDiv);
|
||
|
||
await this.copyArticle();
|
||
}
|
||
|
||
async copyArticle() {
|
||
const content = this.getArticleContent();
|
||
await navigator.clipboard.write([new ClipboardItem({
|
||
'text/html': new Blob([content], { type: 'text/html' })
|
||
})])
|
||
}
|
||
|
||
getSecret(appid: string) {
|
||
for (const wx of this.settings.wxInfo) {
|
||
if (wx.appid === appid) {
|
||
return wx.secret.replace('SECRET', '');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async postArticle(appid:string, localCover: File | null = null) {
|
||
if (!this.settings.authKey) {
|
||
throw new Error('请先设置注册码(AuthKey)');
|
||
}
|
||
|
||
let metadata = this.getMetadata();
|
||
if (metadata.appid) {
|
||
appid = metadata.appid;
|
||
}
|
||
|
||
if (!appid || appid.length == 0) {
|
||
throw new Error('请先选择公众号');
|
||
}
|
||
// 获取token
|
||
const token = await this.getToken(appid);
|
||
if (token === '') {
|
||
throw new Error('获取token失败,请检查网络链接!');
|
||
}
|
||
await this.cachedElementsToImages();
|
||
const lm = LocalImageManager.getInstance();
|
||
// 上传图片
|
||
await lm.uploadLocalImage(token, this.app.vault);
|
||
// 上传图床图片
|
||
await lm.uploadRemoteImage(this.articleDiv, token);
|
||
// 替换图片链接
|
||
lm.replaceImages(this.articleDiv);
|
||
// 上传封面
|
||
let mediaId = metadata.thumb_media_id;
|
||
if (!mediaId) {
|
||
if (metadata.cover) {
|
||
// 上传仓库里的图片
|
||
if (metadata.cover.startsWith('http')) {
|
||
const res = await LocalImageManager.getInstance().uploadImageFromUrl(metadata.cover, token, 'image');
|
||
if (res.media_id) {
|
||
mediaId = res.media_id;
|
||
}
|
||
else {
|
||
throw new Error('上传封面失败:' + res.errmsg);
|
||
}
|
||
}
|
||
else {
|
||
mediaId = await this.uploadVaultCover(metadata.cover, token);
|
||
}
|
||
}
|
||
else if (localCover) {
|
||
mediaId = await this.uploadCover(localCover, localCover.name, token);
|
||
}
|
||
else {
|
||
mediaId = await this.getDefaultCover(token);
|
||
}
|
||
}
|
||
|
||
if (mediaId === '') {
|
||
throw new Error('请先上传图片或者设置默认封面');
|
||
}
|
||
|
||
metadata.title = metadata.title || this.title;
|
||
metadata.content = this.getArticleContent();
|
||
metadata.thumb_media_id = mediaId;
|
||
|
||
// 创建草稿
|
||
const res = await wxAddDraft(token, metadata);
|
||
|
||
if (res.status != 200) {
|
||
console.error(res.text);
|
||
throw new Error(`创建草稿失败, https状态码: ${res.status} 可能是文章包含异常内容,请尝试手动复制到公众号编辑器!`);
|
||
}
|
||
|
||
const draft = res.json;
|
||
if (draft.media_id) {
|
||
return draft.media_id;
|
||
}
|
||
else {
|
||
console.error(JSON.stringify(draft));
|
||
throw new Error('发布失败!' + draft.errmsg);
|
||
}
|
||
}
|
||
|
||
async postImages(appid: string) {
|
||
if (!this.settings.authKey) {
|
||
throw new Error('请先设置注册码(AuthKey)');
|
||
}
|
||
|
||
let metadata = this.getMetadata();
|
||
if (metadata.appid) {
|
||
appid = metadata.appid;
|
||
}
|
||
|
||
if (!appid || appid.length == 0) {
|
||
throw new Error('请先选择公众号');
|
||
}
|
||
|
||
// 获取token
|
||
const token = await this.getToken(appid);
|
||
if (token === '') {
|
||
throw new Error('获取token失败,请检查网络链接!');
|
||
}
|
||
|
||
const imageList: DraftImageMediaId[] = [];
|
||
const lm = LocalImageManager.getInstance();
|
||
// 上传图片
|
||
await lm.uploadLocalImage(token, this.app.vault, 'image');
|
||
// 上传图床图片
|
||
await lm.uploadRemoteImage(this.articleDiv, token, 'image');
|
||
|
||
const images = lm.getImageInfos(this.articleDiv);
|
||
for (const image of images) {
|
||
if (!image.media_id) {
|
||
console.warn('miss media id:', image.resUrl);
|
||
continue;
|
||
}
|
||
imageList.push({
|
||
image_media_id: image.media_id,
|
||
});
|
||
}
|
||
|
||
if (imageList.length === 0) {
|
||
throw new Error('没有图片需要发布!');
|
||
}
|
||
|
||
const content = this.getArticleText();
|
||
|
||
const imagesData: DraftImages = {
|
||
article_type: 'newspic',
|
||
title: metadata.title || this.title,
|
||
content: content,
|
||
need_open_commnet: metadata.need_open_comment ?? 0,
|
||
only_fans_can_comment: metadata.only_fans_can_comment || 0,
|
||
image_info: {
|
||
image_list: imageList,
|
||
}
|
||
}
|
||
// 创建草稿
|
||
const res = await wxAddDraftImages(token, imagesData);
|
||
|
||
if (res.status != 200) {
|
||
console.error(res.text);
|
||
throw new Error(`创建图片/文字失败, https状态码: ${res.status} ${res.text}!`);
|
||
}
|
||
|
||
const draft = res.json;
|
||
if (draft.media_id) {
|
||
return draft.media_id;
|
||
}
|
||
else {
|
||
console.error(JSON.stringify(draft));
|
||
throw new Error('发布失败!' + draft.errmsg);
|
||
}
|
||
}
|
||
|
||
async exportHTML() {
|
||
await this.cachedElementsToImages();
|
||
const lm = LocalImageManager.getInstance();
|
||
const content = await lm.embleImages(this.articleDiv, this.app.vault);
|
||
const globalStyle = await this.assetsManager.getStyle();
|
||
const html = applyCSS(content, this.getCSS() + globalStyle);
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = this.title + '.html';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
a.remove();
|
||
}
|
||
|
||
async processCachedElements() {
|
||
const af = this.app.workspace.getActiveFile();
|
||
if (!af) {
|
||
console.error('当前没有打开文件,无法处理缓存元素');
|
||
return;
|
||
}
|
||
for (const [key, value] of this.cachedElements) {
|
||
const [category, id] = key.split(':');
|
||
if (category === 'mermaid' || category === 'excalidraw') {
|
||
const container = this.articleDiv.querySelector('#' + id) as HTMLElement;
|
||
if (container) {
|
||
await MarkdownRenderer.render(this.app, value, container, af.path, this.itemView);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async cachedElementsToImages() {
|
||
for (const [key, cached] of this.cachedElements) {
|
||
const [category, elementId] = key.split(':');
|
||
const container = this.articleDiv.querySelector(`#${elementId}`) as HTMLElement;
|
||
if (!container) continue;
|
||
|
||
if (category === 'mermaid') {
|
||
await this.replaceMermaidWithImage(container, elementId);
|
||
} else if (category === 'excalidraw') {
|
||
await this.replaceExcalidrawWithImage(container, elementId);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async replaceMermaidWithImage(container: HTMLElement, id: string) {
|
||
const mermaidContainer = container.querySelector('.mermaid') as HTMLElement;
|
||
if (!mermaidContainer || !mermaidContainer.children.length) return;
|
||
|
||
const svg = mermaidContainer.querySelector('svg');
|
||
if (!svg) return;
|
||
|
||
try {
|
||
const pngDataUrl = await toPng(mermaidContainer.firstElementChild as HTMLElement, { pixelRatio: 2 });
|
||
const img = document.createElement('img');
|
||
img.id = `img-${id}`;
|
||
img.src = pngDataUrl;
|
||
img.style.width = `${svg.clientWidth}px`;
|
||
img.style.height = 'auto';
|
||
|
||
container.replaceChild(img, mermaidContainer);
|
||
} catch (error) {
|
||
console.warn(`Failed to render Mermaid diagram: ${id}`, error);
|
||
}
|
||
}
|
||
|
||
private async replaceExcalidrawWithImage(container: HTMLElement, id: string) {
|
||
const innerDiv = container.querySelector('div') as HTMLElement;
|
||
if (!innerDiv) return;
|
||
|
||
if (NMPSettings.getInstance().excalidrawToPNG) {
|
||
const originalImg = container.querySelector('img') as HTMLImageElement;
|
||
if (!originalImg) return;
|
||
|
||
const style = originalImg.getAttribute('style') || '';
|
||
try {
|
||
const pngDataUrl = await toPng(originalImg, { pixelRatio: 2 });
|
||
|
||
const img = document.createElement('img');
|
||
img.id = `img-${id}`;
|
||
img.src = pngDataUrl;
|
||
img.setAttribute('style', style);
|
||
|
||
container.replaceChild(img, container.firstChild!);
|
||
} catch (error) {
|
||
console.warn(`Failed to render Excalidraw image: ${id}`, error);
|
||
}
|
||
} else {
|
||
const svg = await LocalFile.renderExcalidraw(innerDiv.innerHTML);
|
||
this.updateElementByID(id, svg);
|
||
}
|
||
}
|
||
|
||
updateElementByID(id: string, html: string): void {
|
||
const item = this.articleDiv.querySelector('#' + id) as HTMLElement;
|
||
if (!item) return;
|
||
const doc = sanitizeHTMLToDom(html);
|
||
item.empty();
|
||
if (doc.childElementCount > 0) {
|
||
for (const child of doc.children) {
|
||
item.appendChild(child.cloneNode(true)); // 使用 cloneNode 复制节点以避免移动它
|
||
}
|
||
}
|
||
else {
|
||
item.innerText = '渲染失败';
|
||
}
|
||
}
|
||
|
||
cacheElement(category: string, id: string, data: string): void {
|
||
const key = category + ':' + id;
|
||
this.cachedElements.set(key, data);
|
||
}
|
||
}
|