Files
blogflare/packages/obsidian-plugin/src/main.ts
T
2026-09-24 11:09:56 +08:00

518 lines
15 KiB
TypeScript

import { Notice, Plugin, MarkdownView, Menu, TFile } from 'obsidian';
import {
BlogFlareSettings,
DEFAULT_SETTINGS,
BlogFlareSettingTab,
} from './settings';
import { BlogFlareAPI } from './api';
import { MetaSuggestModal } from './suggest';
import { PullSuggestModal } from './pull_modal';
export default class BlogFlarePlugin extends Plugin {
settings!: BlogFlareSettings;
api!: BlogFlareAPI;
async onload() {
await this.loadSettings();
this.api = new BlogFlareAPI(this.settings);
// Add settings tab
this.addSettingTab(new BlogFlareSettingTab(this.app, this));
this.addCommand({
id: 'insert-category',
name: '插入分类',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
this.api.getCategories().then(items => {
if (items.length === 0) {
new Notice('未找到分类或尚未配置 API。');
return;
}
new MetaSuggestModal(this.app, items, 'category_slug', file, true).open();
}).catch(e => {
console.error(e);
new Notice('获取分类失败。');
});
}
return true;
}
return false;
}
});
this.addCommand({
id: 'insert-tag',
name: '插入标签',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
this.api.getTags().then(items => {
if (items.length === 0) {
new Notice('未找到标签或尚未配置 API。');
return;
}
// Tags only use name, so useSlug = false
new MetaSuggestModal(this.app, items, 'tags', file, false).open();
}).catch(e => {
console.error(e);
new Notice('获取标签失败。');
});
}
return true;
}
return false;
}
});
this.addCommand({
id: 'insert-series',
name: '插入系列',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
this.api.getSeries().then(items => {
if (items.length === 0) {
new Notice('未找到系列或尚未配置 API。');
return;
}
new MetaSuggestModal(this.app, items, 'series_slug', file, true).open();
}).catch(e => {
console.error(e);
new Notice('获取系列失败。');
});
}
return true;
}
return false;
}
});
this.addCommand({
id: 'publish-post',
name: '上传当前文章',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
void this.publishCurrentFile();
}
return true;
}
return false;
}
});
this.addCommand({
id: 'pull-current-post',
name: '下载更新当前文章',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
void this.pullCurrentFile();
}
return true;
}
return false;
}
});
this.addCommand({
id: 'pull-post',
name: '拉取文章',
callback: () => {
this.api.getPosts().then(items => {
if (items.length === 0) {
new Notice('未找到文章或 API 尚未配置。');
return;
}
new PullSuggestModal(this.app, items, this.api).open();
}).catch(e => {
console.error(e);
new Notice('获取文章列表失败。');
});
}
});
this.addCommand({
id: 'init-sync',
name: '全量拉取初始化',
callback: () => {
const mdFiles = this.app.vault.getMarkdownFiles();
if (mdFiles.length > 0) {
new Notice('全量同步失败:本地必需是空的 vault!', 5000);
return;
}
void (async () => {
try {
new Notice('开始拉取全量文章列表...');
const items = await this.api.getPosts();
if (items.length === 0) {
new Notice('未找到文章,同步结束。');
return;
}
new Notice(`发现 ${items.length} 篇文章,开始同步...`);
let successCount = 0;
for (const item of items) {
try {
const detail = await this.api.getPost(item.slug);
// 组装 Frontmatter
const frontmatterLines = [
'---',
`title: "${detail.title.replace(/"/g, '\\"')}"`,
`slug: ${detail.slug}`,
`blogflare_published_slug: ${detail.slug}`
];
if (detail.category_slug) {
frontmatterLines.push(`category_slug: ${detail.category_slug}`);
}
frontmatterLines.push('---');
const fileContent = frontmatterLines.join('\n') + '\n\n' + (detail.content || '');
const baseName = detail.title.replace(/[/\\:]/g, '-');
let dirPath = '';
const categoryDir = detail.category_name || detail.category_slug;
if (categoryDir) {
dirPath = categoryDir.replace(/[/\\:]/g, '-');
const folder = this.app.vault.getAbstractFileByPath(dirPath);
if (!folder) {
await this.app.vault.createFolder(dirPath);
}
}
const fileName = dirPath ? `${dirPath}/${baseName}.md` : `${baseName}.md`;
await this.app.vault.create(fileName, fileContent);
successCount++;
} catch (err) {
console.error(`拉取文章 ${item.slug} 失败:`, err);
}
}
new Notice(`全量同步完成!成功拉取 ${successCount}/${items.length} 篇文章。`, 5000);
} catch (e) {
console.error(e);
new Notice('全量同步请求出错,请检查网络或配置。');
}
})();
}
});
this.addCommand({
id: 'delete-post',
name: '下线云端文章',
checkCallback: (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (activeView && file) {
if (!checking) {
void (async () => {
try {
const cache = this.app.metadataCache.getFileCache(file);
const publishedSlug = cache?.frontmatter?.blogflare_published_slug as string | undefined;
if (!publishedSlug) {
new Notice('本文尚未发布到 blogflare。');
return;
}
new Notice('正在尝试下线云端文章...');
const res = await this.api.deletePost(publishedSlug);
if (res.success) {
await this.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => {
delete fm['blogflare_published_slug'];
});
new Notice('文章已成功从云端删除!');
} else {
new Notice(`下线失败: ${res.error || '未知错误'}`);
}
} catch (e) {
console.error(e);
new Notice('删除请求出错,请检查网络或配置。');
}
})();
}
return true;
}
return false;
}
});
// 统一的右下角状态栏入口
const statusBarItem = this.addStatusBarItem();
statusBarItem.setText('BlogFlare 同步');
statusBarItem.addClass('mod-clickable');
statusBarItem.addEventListener('click', (evt: MouseEvent) => {
this.showSyncMenu(evt);
});
// 增加左侧 Ribbon 图标
this.addRibbonIcon('cloud-download', 'BlogFlare 同步选项', (evt: MouseEvent) => {
this.showSyncMenu(evt);
});
}
showSyncMenu(evt: MouseEvent) {
const menu = new Menu();
menu.addItem((item) =>
item
.setTitle('上传当前文章')
.setIcon('upload-cloud')
.onClick(() => {
void this.publishCurrentFile();
})
);
menu.addItem((item) =>
item
.setTitle('下载更新当前文章')
.setIcon('download-cloud')
.onClick(() => {
void this.pullCurrentFile();
})
);
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle('下载更新全部文章')
.setIcon('refresh-cw')
.onClick(() => {
void this.pullAllPosts();
})
);
menu.showAtMouseEvent(evt);
}
async pullAllPosts() {
try {
new Notice('开始拉取全量文章列表...');
const items = await this.api.getPosts();
if (items.length === 0) {
new Notice('未找到文章,同步结束。');
return;
}
new Notice(`发现 ${items.length} 篇文章,开始同步...`);
let successCount = 0;
// 收集本地已有的 blogflare_published_slug,用于匹配是新建还是覆盖
const mdFiles = this.app.vault.getMarkdownFiles();
const slugMap = new Map<string, TFile>();
for (const f of mdFiles) {
const cache = this.app.metadataCache.getFileCache(f);
const pubSlug = cache?.frontmatter?.blogflare_published_slug as string | undefined;
if (pubSlug) {
slugMap.set(pubSlug, f);
}
}
for (const item of items) {
try {
const detail = await this.api.getPost(item.slug);
// 组装 Frontmatter
const frontmatterLines = [
'---',
`title: "${detail.title.replace(/"/g, '\\"')}"`,
`slug: ${detail.slug}`,
`blogflare_published_slug: ${detail.slug}`
];
if (detail.category_slug) {
frontmatterLines.push(`category_slug: ${detail.category_slug}`);
}
if (detail.tags && detail.tags.length > 0) {
frontmatterLines.push(`tags:`);
for (const t of detail.tags) {
frontmatterLines.push(` - ${t}`);
}
}
frontmatterLines.push('---');
const fileContent = frontmatterLines.join('\n') + '\n\n' + (detail.content || '');
const existingFile = slugMap.get(item.slug);
if (existingFile) {
// 已存在,执行覆写
await this.app.vault.modify(existingFile, fileContent);
} else {
// 不存在,执行新建
const baseName = detail.title.replace(/[/\\:]/g, '-');
let dirPath = '';
const categoryDir = detail.category_name || detail.category_slug;
if (categoryDir) {
dirPath = categoryDir.replace(/[/\\:]/g, '-');
const folder = this.app.vault.getAbstractFileByPath(dirPath);
if (!folder) {
await this.app.vault.createFolder(dirPath);
}
}
// 确保文件名唯一
let fileName = dirPath ? `${dirPath}/${baseName}.md` : `${baseName}.md`;
let counter = 1;
while (!existingFile && this.app.vault.getAbstractFileByPath(fileName)) {
fileName = dirPath ? `${dirPath}/${baseName}-${counter}.md` : `${baseName}-${counter}.md`;
counter++;
}
await this.app.vault.create(fileName, fileContent);
}
successCount++;
} catch (err) {
console.error(`拉取文章 ${item.slug} 失败:`, err);
}
}
new Notice(`全量同步完成!成功拉取/更新 ${successCount}/${items.length} 篇文章。`, 5000);
} catch (e) {
console.error(e);
new Notice('全量同步请求出错,请检查网络或配置。');
}
}
async publishCurrentFile() {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (!file) {
new Notice('没有打开的 Markdown 文件');
return;
}
try {
new Notice('开始发布文章...');
const rawContent = await this.app.vault.read(file);
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter = cache?.frontmatter;
let content = rawContent;
const frontmatterPosition = cache?.frontmatterPosition;
// 移除前置 YAML
if (frontmatterPosition) {
content = rawContent.substring(frontmatterPosition.end.offset).replace(/^\n+/, '');
}
const title = frontmatter?.title as string || file.basename;
const slug = frontmatter?.slug as string || file.basename.toLowerCase().replace(/\s+/g, '-');
const category_slug = frontmatter?.category_slug as string | undefined;
const publishedSlug = frontmatter?.blogflare_published_slug as string | undefined;
let tags: string[] | undefined = undefined;
if (frontmatter?.tags) {
tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags as string];
}
const payload = {
title,
slug,
content,
category_slug,
tags
};
if (publishedSlug) {
// 已发布,进行更新
const res = await this.api.updatePost(publishedSlug, payload);
if (res.success) {
new Notice('文章更新成功!');
} else {
new Notice(`更新失败: ${res.error || '未知错误'}`);
}
} else {
// 未发布,进行创建
const res = await this.api.createPost(payload);
if (res.success && res.slug) {
// 写入 blogflare_published_slug
await this.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => {
fm['blogflare_published_slug'] = res.slug;
});
new Notice('文章发布成功!');
} else {
new Notice(`发布失败: ${res.error || '未知错误'}`);
}
}
} catch (e) {
console.error(e);
new Notice('发布请求出错,请检查网络或配置。');
}
}
async pullCurrentFile() {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = activeView?.file;
if (!file) {
new Notice('没有打开的 Markdown 文件');
return;
}
const cache = this.app.metadataCache.getFileCache(file);
const publishedSlug = cache?.frontmatter?.blogflare_published_slug as string | undefined;
if (!publishedSlug) {
new Notice('当前文件尚未发布到 blogflare,无法下载更新。如果想拉取其他文章,请使用"拉取文章"命令。');
return;
}
try {
new Notice('开始下载文章...');
const detail = await this.api.getPost(publishedSlug);
// 组装 Frontmatter
const frontmatterLines = [
'---',
`title: "${detail.title.replace(/"/g, '\\"')}"`,
`slug: ${detail.slug}`,
`blogflare_published_slug: ${detail.slug}`
];
if (detail.category_slug) {
frontmatterLines.push(`category_slug: ${detail.category_slug}`);
}
if (detail.tags && detail.tags.length > 0) {
frontmatterLines.push(`tags:`);
for (const t of detail.tags) {
frontmatterLines.push(` - ${t}`);
}
}
frontmatterLines.push('---');
const fileContent = frontmatterLines.join('\n') + '\n\n' + (detail.content || '');
await this.app.vault.modify(file, fileContent);
new Notice('文章下载并更新成功!');
} catch (err) {
console.error('下载文章失败:', err);
new Notice('下载请求出错,请检查网络或配置。');
}
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
(await this.loadData()) as Partial<BlogFlareSettings>,
);
}
async saveSettings() {
await this.saveData(this.settings);
// re-init API with new settings
this.api = new BlogFlareAPI(this.settings);
}
}