import { Menu, MenuItem, Plugin, TFile, moment } from 'obsidian'; import { DEFAULT_SETTINGS, DiscourseSyncSettings, DiscourseSyncSettingsTab } from './config'; import * as yaml from 'yaml'; import { t, setLocale } from './i18n'; import { expandEmbeds } from './expand-embeds'; import { DiscourseAPI } from './api'; import { EmbedHandler } from './embed-handler'; import { SelectCategoryModal, CategoryConflictModal } from './ui'; import { NotifyUser } from './notification'; import { getFrontMatter, removeFrontMatter } from './utils'; import { ActiveFile, PluginInterface } from './types'; export default class PublishToDiscourse extends Plugin implements PluginInterface { settings: DiscourseSyncSettings; activeFile: ActiveFile; api: DiscourseAPI; embedHandler: EmbedHandler; async onload() { // 设置语言 setLocale(moment.locale()); // 当Obsidian语言变化时更新语言 this.registerEvent( this.app.workspace.on('window-open', () => { setLocale(moment.locale()); }) ); // 加载设置 await this.loadSettings(); // 初始化API和嵌入处理器 this.api = new DiscourseAPI(this.app, this.settings); this.embedHandler = new EmbedHandler(this.app, this.api); // 添加设置选项卡 this.addSettingTab(new DiscourseSyncSettingsTab(this.app, this)); // 注册文件菜单 this.registerEvent( this.app.workspace.on("file-menu", (menu, file: TFile) => { this.registerDirMenu(menu, file); }), ); // 添加发布命令 this.addCommand({ id: "publish-to-discourse", name: t('PUBLISH_TO_DISCOURSE'), callback: () => { this.publishToDiscourse(); }, }); // 添加在浏览器中打开帖子的命令 this.addCommand({ id: "open-in-discourse", name: t('OPEN_IN_DISCOURSE'), callback: () => { this.openInDiscourse(); }, }); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } // 注册目录菜单 registerDirMenu(menu: Menu, file: TFile) { const syncDiscourse = (item: MenuItem) => { item.setTitle(t('PUBLISH_TO_DISCOURSE')); item.onClick(async () => { await this.publishToDiscourse(file); }); } menu.addItem(syncDiscourse) } // 同步到Discourse - 统一入口点 private async publishToDiscourse(file?: TFile) { // 如果提供了特定文件,使用该文件;否则使用当前活动文件 const targetFile = file || this.app.workspace.getActiveFile(); if (!targetFile) { new NotifyUser(this.app, t('NO_ACTIVE_FILE')).open(); return; } // 使用expandEmbeds处理嵌入内容 const content = await expandEmbeds(this.app, targetFile); const fm = getFrontMatter(content); const postId = fm?.discourse_post_id; const topicId = fm?.discourse_topic_id; const isUpdate = postId !== undefined && topicId !== undefined; // 初始化activeFile对象 this.activeFile = { name: targetFile.basename, content: content, postId: postId, // 从frontmatter中获取标签,如果没有则使用空数组 tags: fm?.discourse_tags || [] }; // 获取分类和标签列表 const [categories, tags] = await Promise.all([ this.api.fetchCategories(), this.api.fetchTags() ]); // 如果是更新帖子,先从Discourse获取最新标签和分类 if (isUpdate) { try { const topicInfo = await this.api.fetchTopicInfo(topicId); // 用Discourse上的标签覆盖本地标签 if (topicInfo.tags.length > 0) { this.activeFile.tags = topicInfo.tags; console.log(`Updated tags from Discourse: ${topicInfo.tags.join(', ')}`); } // 处理分类冲突 if (topicInfo.categoryId) { const localCategoryId = fm?.discourse_category_id; const remoteCategoryId = topicInfo.categoryId; // 如果本地有设置分类ID且与远程不同,询问用户 if (localCategoryId && localCategoryId !== remoteCategoryId) { const localCategory = categories.find(c => c.id === localCategoryId); const remoteCategory = categories.find(c => c.id === remoteCategoryId); if (localCategory && remoteCategory) { const conflictModal = new CategoryConflictModal( this.app, this, localCategoryId, localCategory.name, remoteCategoryId, remoteCategory.name ); const useRemote = await conflictModal.showAndWait(); if (useRemote) { this.settings.category = remoteCategoryId; console.log(`User chose remote category: ${remoteCategory.name} (${remoteCategoryId})`); } else { this.settings.category = localCategoryId; console.log(`User kept local category: ${localCategory.name} (${localCategoryId})`); } } else { // 如果找不到分类信息,使用远程分类 this.settings.category = remoteCategoryId; } } else if (localCategoryId) { // 如果本地有设置且与远程相同,使用本地设置 this.settings.category = localCategoryId; console.log(`Using local category: ${localCategoryId}`); } else { // 如果本地没有设置,使用远程分类 const category = categories.find(c => c.id === remoteCategoryId); if (category) { this.settings.category = category.id; console.log(`Using remote category: ${category.name} (${category.id})`); } } } } catch (error) { console.error("Failed to fetch topic info from Discourse:", error); // 如果获取失败,继续使用本地标签和分类 } } if (categories.length > 0) { new SelectCategoryModal(this.app, this, categories, tags).open(); } } // 在Discourse中打开 private async openInDiscourse() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { new NotifyUser(this.app, t('NO_ACTIVE_FILE')).open(); return; } const content = await this.app.vault.read(activeFile); const fm = getFrontMatter(content); const discourseUrl = fm?.discourse_url; const topicId = fm?.discourse_topic_id; if (!discourseUrl && !topicId) { new NotifyUser(this.app, t('NO_TOPIC_ID')).open(); return; } const url = discourseUrl || `${this.settings.baseUrl}/t/${topicId}`; window.open(url, '_blank'); } // 发布主题 async publishTopic(): Promise<{ success: boolean; error?: string }> { let content = this.activeFile.content; // 移除Front Matter content = removeFrontMatter(content); // 提取嵌入引用 const embedReferences = this.embedHandler.extractEmbedReferences(content); // 处理嵌入内容 const uploadedUrls = await this.embedHandler.processEmbeds(embedReferences, this.activeFile.name); // 替换嵌入引用为Markdown格式 content = this.embedHandler.replaceEmbedReferences(content, embedReferences, uploadedUrls); // 如果启用了"跳过一级标题"选项,则删除所有H1标题 if (this.settings.skipH1) { // 匹配Markdown中的所有H1标题(# 标题) content = content.replace(/^\s*# [^\n]+\n?/gm, ''); } // 获取Front Matter const frontMatter = getFrontMatter(this.activeFile.content); const postId = frontMatter?.discourse_post_id; const topicId = frontMatter?.discourse_topic_id; const isUpdate = postId !== undefined; // 获取当前选择的标签 const currentTags = this.activeFile.tags || []; // 发布或更新帖子 let result; try { if (isUpdate) { // 更新帖子 result = await this.api.updatePost( postId, topicId, (frontMatter?.title ? frontMatter?.title : this.activeFile.name), content, this.settings.category, currentTags ); // 如果更新成功,更新Front Matter if (result.success) { await this.updateFrontMatter(postId, topicId, currentTags); } } else { // 创建新帖子 result = await this.api.createPost( (frontMatter?.title ? frontMatter?.title : this.activeFile.name), content, this.settings.category, currentTags ); // 如果创建成功,更新Front Matter if (result.success && result.postId && result.topicId) { await this.updateFrontMatter(result.postId, result.topicId, currentTags); } } // 返回结果 return result; } catch (error) { // 返回错误 return { success: false, error: error.message || t('UNKNOWN_ERROR') }; } } // 更新Front Matter private async updateFrontMatter(postId: number, topicId: number, tags: string[]) { try { // 获取当前活动文件 const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { return; } const content = await this.app.vault.read(activeFile); const fm = getFrontMatter(content); const discourseUrl = `${this.settings.baseUrl}/t/${topicId}`; // 获取当前分类信息 const categoryId = this.settings.category; // 查找分类名称 const categories = await this.api.fetchCategories(); const category = categories.find(c => c.id === categoryId); const categoryName = category ? category.name : ''; let newContent: string; if (fm) { // 更新现有Front Matter const updatedFm = { ...fm, discourse_post_id: postId, discourse_topic_id: topicId, discourse_url: discourseUrl, discourse_tags: tags, discourse_category_id: categoryId, discourse_category: categoryName }; newContent = content.replace(/^---\n[\s\S]*?\n---\n/, `---\n${yaml.stringify(updatedFm)}---\n`); } else { // 添加新Front Matter const newFm = { discourse_post_id: postId, discourse_topic_id: topicId, discourse_url: discourseUrl, discourse_tags: tags, discourse_category_id: categoryId, discourse_category: categoryName }; newContent = `---\n${yaml.stringify(newFm)}---\n${content}`; } await this.app.vault.modify(activeFile, newContent); // 更新activeFile对象 this.activeFile = { name: activeFile.basename, content: newContent, postId: postId, tags: tags }; } catch (error) { new NotifyUser(this.app, t('UPDATE_FAILED')).open(); } } onunload() {} }