From cc1e06ec5cccf49b58dbe532a1284a8e767cc66c Mon Sep 17 00:00:00 2001 From: wood chen Date: Mon, 10 Mar 2025 19:33:50 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=8F=92=E4=BB=B6=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E8=A7=A3=E8=80=A6=E4=B8=BB=E8=A6=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将主类拆分为多个专注的模块,如 DiscourseAPI、EmbedHandler - 重命名主类为 PublishToDiscourse,提高语义清晰度 - 抽取通用工具函数到独立模块 - 优化代码结构,提高可维护性和可读性 - 调整 Front Matter 处理和嵌入内容处理逻辑 --- src/api.ts | 390 ++++++++++++++++++ src/config.ts | 8 +- src/embed-handler.ts | 77 ++++ src/expand-embeds.ts | 35 +- src/main.ts | 945 +++++++------------------------------------ src/notification.ts | 20 + src/types.ts | 14 + src/ui.ts | 294 ++++++++++++++ src/utils.ts | 27 ++ 9 files changed, 999 insertions(+), 811 deletions(-) create mode 100644 src/api.ts create mode 100644 src/embed-handler.ts create mode 100644 src/notification.ts create mode 100644 src/types.ts create mode 100644 src/ui.ts create mode 100644 src/utils.ts diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..a29aad2 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,390 @@ +import { App, requestUrl, TFile } from 'obsidian'; +import { DiscourseSyncSettings } from './config'; +import { NotifyUser } from './notification'; +import { t } from './i18n'; + +// 生成随机边界字符串 +const genBoundary = (): string => { + return 'ObsidianBoundary' + Math.random().toString(16).substring(2); +}; + +export class DiscourseAPI { + constructor( + private app: App, + private settings: DiscourseSyncSettings + ) {} + + // 上传图片到Discourse + async uploadImage(file: TFile): Promise { + try { + const imgfile = await this.app.vault.readBinary(file); + const boundary = genBoundary(); + const sBoundary = '--' + boundary + '\r\n'; + const imgForm = `${sBoundary}Content-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: image/${file.extension}\r\n\r\n`; + + let body = ''; + body += `\r\n${sBoundary}Content-Disposition: form-data; name="type"\r\n\r\ncomposer\r\n`; + body += `${sBoundary}Content-Disposition: form-data; name="synchronous"\r\n\r\ntrue\r\n`; + + const eBoundary = '\r\n--' + boundary + '--\r\n'; + const imgFormArray = new TextEncoder().encode(imgForm); + const bodyArray = new TextEncoder().encode(body); + const endBoundaryArray = new TextEncoder().encode(eBoundary); + + const formDataArray = new Uint8Array(imgFormArray.length + imgfile.byteLength + bodyArray.length + endBoundaryArray.length); + formDataArray.set(imgFormArray, 0); + formDataArray.set(new Uint8Array(imgfile), imgFormArray.length); + formDataArray.set(bodyArray, imgFormArray.length + imgfile.byteLength); + formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength); + + const url = `${this.settings.baseUrl}/uploads.json`; + const headers = { + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }; + + const response = await requestUrl({ + url: url, + method: "POST", + body: formDataArray.buffer, + throw: false, + headers: headers, + }); + + if (response.status == 200) { + const jsonResponse = response.json; + return jsonResponse.short_url; + } else { + new NotifyUser(this.app, `Error uploading image: ${response.status}`).open(); + return null; + } + } catch (error) { + new NotifyUser(this.app, `Exception while uploading image: ${error}`).open(); + return null; + } + } + + // 创建新帖子 + async createPost(title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; postId?: number; topicId?: number; error?: string }> { + const url = `${this.settings.baseUrl}/posts.json`; + const headers = { + "Content-Type": "application/json", + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + try { + const response = await requestUrl({ + url, + method: "POST", + contentType: "application/json", + body: JSON.stringify({ + title: title, + raw: content, + category: category, + tags: tags || [] + }), + headers, + throw: false + }); + + if (response.status === 200) { + const responseData = response.json; + if (responseData && responseData.id) { + return { + success: true, + postId: responseData.id, + topicId: responseData.topic_id + }; + } else { + return { + success: false, + error: t('POST_ID_ERROR') + }; + } + } else { + try { + const errorResponse = response.json; + if (errorResponse.errors && errorResponse.errors.length > 0) { + return { + success: false, + error: errorResponse.errors.join('\n') + }; + } + if (errorResponse.error) { + return { + success: false, + error: errorResponse.error + }; + } + } catch (parseError) { + return { + success: false, + error: `${t('PUBLISH_FAILED')} (${response.status})` + }; + } + return { + success: false, + error: `${t('PUBLISH_FAILED')} (${response.status})` + }; + } + } catch (error) { + return { + success: false, + error: `${t('PUBLISH_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}` + }; + } + } + + // 更新帖子 + async updatePost(postId: number, topicId: number, title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; error?: string }> { + const postEndpoint = `${this.settings.baseUrl}/posts/${postId}`; + const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`; + const headers = { + "Content-Type": "application/json", + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + try { + // 首先更新帖子内容 + const postResponse = await requestUrl({ + url: postEndpoint, + method: "PUT", + contentType: "application/json", + body: JSON.stringify({ + raw: content, + edit_reason: "Updated from Obsidian" + }), + headers, + throw: false + }); + + if (postResponse.status !== 200) { + return { + success: false, + error: `${t('UPDATE_FAILED')} (${postResponse.status})` + }; + } + + // 然后更新主题(标题、分类和标签) + const topicResponse = await requestUrl({ + url: topicEndpoint, + method: "PUT", + contentType: "application/json", + body: JSON.stringify({ + title: title, + category_id: category, + tags: tags || [] + }), + headers, + throw: false + }); + + if (topicResponse.status === 200) { + return { success: true }; + } else { + try { + const errorResponse = topicResponse.json; + if (errorResponse.errors && errorResponse.errors.length > 0) { + return { + success: false, + error: errorResponse.errors.join('\n') + }; + } + if (errorResponse.error) { + return { + success: false, + error: errorResponse.error + }; + } + } catch (parseError) { + return { + success: false, + error: `${t('UPDATE_FAILED')} (${topicResponse.status})` + }; + } + return { + success: false, + error: `${t('UPDATE_FAILED')} (${topicResponse.status})` + }; + } + } catch (error) { + return { + success: false, + error: `${t('UPDATE_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}` + }; + } + } + + // 获取分类列表 + async fetchCategories(): Promise<{ id: number; name: string }[]> { + try { + const url = `${this.settings.baseUrl}/categories.json`; + const headers = { + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + const response = await requestUrl({ + url, + method: "GET", + headers, + throw: false + }); + + if (response.status === 200) { + const data = response.json; + const categories: { id: number; name: string }[] = []; + + if (data && data.category_list && data.category_list.categories) { + data.category_list.categories.forEach((category: any) => { + categories.push({ + id: category.id, + name: category.name + }); + + // 添加子分类 + if (category.subcategory_list) { + category.subcategory_list.forEach((subcategory: any) => { + categories.push({ + id: subcategory.id, + name: `${category.name} > ${subcategory.name}` + }); + }); + } + }); + } + + return categories; + } else { + new NotifyUser(this.app, `Error fetching categories: ${response.status}`).open(); + return []; + } + } catch (error) { + new NotifyUser(this.app, `Exception while fetching categories: ${error}`).open(); + return []; + } + } + + // 获取标签列表 + async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> { + try { + const url = `${this.settings.baseUrl}/tags.json`; + const headers = { + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + const response = await requestUrl({ + url, + method: "GET", + headers, + throw: false + }); + + if (response.status === 200) { + const data = response.json; + const tags: { name: string; canCreate: boolean }[] = []; + + if (data && data.tags) { + const canCreateTags = await this.checkCanCreateTags(); + data.tags.forEach((tag: any) => { + tags.push({ + name: tag.name, + canCreate: canCreateTags + }); + }); + } + + return tags; + } else { + new NotifyUser(this.app, `Error fetching tags: ${response.status}`).open(); + return []; + } + } catch (error) { + new NotifyUser(this.app, `Exception while fetching tags: ${error}`).open(); + return []; + } + } + + // 检查是否可以创建标签 + async checkCanCreateTags(): Promise { + try { + const url = `${this.settings.baseUrl}/site.json`; + const headers = { + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + const response = await requestUrl({ + url, + method: "GET", + headers, + throw: false + }); + + if (response.status === 200) { + const data = response.json; + if (data && data.can_create_tag) { + return data.can_create_tag; + } + } + + return false; + } catch (error) { + return false; + } + } + + // 测试API密钥 + async testApiKey(): Promise<{ success: boolean; message: string }> { + if (!this.settings.baseUrl || !this.settings.apiKey || !this.settings.disUser) { + return { + success: false, + message: t('MISSING_SETTINGS') + }; + } + + try { + const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`; + const headers = { + "Api-Key": this.settings.apiKey, + "Api-Username": this.settings.disUser, + }; + + const response = await requestUrl({ + url, + method: "GET", + headers, + throw: false + }); + + if (response.status === 200) { + const data = response.json; + if (data && data.user) { + return { + success: true, + message: t('API_TEST_SUCCESS') + }; + } else { + return { + success: false, + message: t('API_KEY_INVALID') + }; + } + } else { + return { + success: false, + message: `${t('API_KEY_INVALID')} (${response.status})` + }; + } + } catch (error) { + return { + success: false, + message: `${t('API_KEY_INVALID')}: ${error.message || t('UNKNOWN_ERROR')}` + }; + } + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 1fc33af..4776ae9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { PluginSettingTab, Setting, App, Notice, ButtonComponent } from 'obsidian'; -import DiscourseSyncPlugin from './main'; +import PublishToDiscourse from './main'; import { t } from './i18n'; export interface DiscourseSyncSettings { @@ -19,8 +19,8 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = { }; export class DiscourseSyncSettingsTab extends PluginSettingTab { - plugin: DiscourseSyncPlugin; - constructor(app: App, plugin: DiscourseSyncPlugin) { + plugin: PublishToDiscourse; + constructor(app: App, plugin: PublishToDiscourse) { super(app, plugin); } @@ -78,7 +78,7 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab { button.setButtonText(t('TESTING')); button.setDisabled(true); - const result = await this.plugin.testApiKey(); + const result = await this.plugin.api.testApiKey(); button.setButtonText(t('TEST_API_KEY')); button.setDisabled(false); diff --git a/src/embed-handler.ts b/src/embed-handler.ts new file mode 100644 index 0000000..c687263 --- /dev/null +++ b/src/embed-handler.ts @@ -0,0 +1,77 @@ +import { App, TFile } from 'obsidian'; +import { expandEmbeds } from './expand-embeds'; +import { NotifyUser } from './notification'; +import { DiscourseAPI } from './api'; +import { isImageFile } from './utils'; + +export class EmbedHandler { + constructor( + private app: App, + private api: DiscourseAPI + ) {} + + // 提取嵌入引用 + extractEmbedReferences(content: string): string[] { + const regex = /!\[\[(.*?)\]\]/g; + const matches = []; + let match; + while ((match = regex.exec(content)) !== null) { + matches.push(match[1]); + } + return matches; + } + + // 处理嵌入内容 + async processEmbeds(embedReferences: string[], activeFileName: string): Promise { + const uploadedUrls: string[] = []; + for (const ref of embedReferences) { + // 处理带有#的文件路径,分离文件名和标题部分 + let filePart = ref; + const hashIndex = filePart.indexOf("#"); + if (hashIndex >= 0) { + filePart = filePart.substring(0, hashIndex).trim(); + } + + const filePath = this.app.metadataCache.getFirstLinkpathDest(filePart, activeFileName)?.path; + if (filePath) { + const abstractFile = this.app.vault.getAbstractFileByPath(filePath); + if (abstractFile instanceof TFile) { + // 检查是否为图片或PDF文件 + if (isImageFile(abstractFile)) { + const imageUrl = await this.api.uploadImage(abstractFile); + uploadedUrls.push(imageUrl || ""); + } else { + // 非图片文件,返回空字符串 + uploadedUrls.push(""); + } + } else { + new NotifyUser(this.app, `File not found in vault: ${ref}`).open(); + uploadedUrls.push(""); + } + } else { + new NotifyUser(this.app, `Unable to resolve file path for: ${ref}`).open(); + uploadedUrls.push(""); + } + } + return uploadedUrls; + } + + // 替换内容中的嵌入引用为Markdown格式 + replaceEmbedReferences(content: string, embedReferences: string[], uploadedUrls: string[]): string { + let processedContent = content; + embedReferences.forEach((ref, index) => { + const obsRef = `![[${ref}]]`; + // 只有当上传URL不为空时(即为图片)才替换为Markdown格式的图片链接 + if (uploadedUrls[index]) { + const discoRef = `![${ref}](${uploadedUrls[index]})`; + processedContent = processedContent.replace(obsRef, discoRef); + } + }); + return processedContent; + } + + // 处理文件内容,展开嵌入内容 + async expandFileContent(file: TFile): Promise { + return await expandEmbeds(this.app, file); + } +} \ No newline at end of file diff --git a/src/expand-embeds.ts b/src/expand-embeds.ts index 668be6d..84588da 100644 --- a/src/expand-embeds.ts +++ b/src/expand-embeds.ts @@ -15,6 +15,27 @@ import { App, TFile, CachedMetadata } from "obsidian"; +/** + * 将frontmatter转换为Markdown引用格式 + */ +function convertFrontmatterToQuote(content: string): string { + // 检查是否有frontmatter + const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (!fmMatch) return content; + + // 提取frontmatter内容 + const fmContent = fmMatch[1]; + + // 将frontmatter内容转换为引用格式 + const quotedFm = fmContent + .split('\n') + .map(line => `> ${line}`) + .join('\n'); + + // 替换原始frontmatter + return content.replace(/^---\n[\s\S]*?\n---\n/, `${quotedFm}\n\n`); +} + /** * Recursively expands embedded content (including subpath references), * allowing the same (file+subpath) to appear multiple times if it's *not* @@ -64,7 +85,10 @@ export async function expandEmbeds( } // Recursively expand that subpath - return expandEmbeds(app, linkedTFile, stack, sub); + const expandedContent = await expandEmbeds(app, linkedTFile, stack, sub); + + // 将嵌入内容中的frontmatter转换为引用格式 + return convertFrontmatterToQuote(expandedContent); }); // Pop it from stack @@ -101,9 +125,11 @@ function sliceSubpathContent( const { start, end } = block.position; if (!end) { // Goes to EOF if no explicit end - return fileContent.substring(start.offset); + const slicedContent = fileContent.substring(start.offset); + return convertFrontmatterToQuote(slicedContent); } else { - return fileContent.substring(start.offset, end.offset); + const slicedContent = fileContent.substring(start.offset, end.offset); + return convertFrontmatterToQuote(slicedContent); } } @@ -147,7 +173,8 @@ function sliceHeading(content: string, fileCache: CachedMetadata, headingName: s } console.log(`"Sliceheading for ${heading}, level ${thisLevel}, offsets ${startOffset} and ${endOffset}."`) - return content.substring(startOffset, endOffset).trim(); + const slicedContent = content.substring(startOffset, endOffset).trim(); + return convertFrontmatterToQuote(slicedContent); } /** diff --git a/src/main.ts b/src/main.ts index 7bd941b..66de625 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,35 +1,50 @@ -import { App, Menu, MenuItem, Plugin, Modal, requestUrl, TFile, moment } from 'obsidian'; +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 } from './ui'; +import { NotifyUser } from './notification'; +import { getFrontMatter, removeFrontMatter } from './utils'; +import { ActiveFile, PluginInterface } from './types'; -export default class DiscourseSyncPlugin extends Plugin { +export default class PublishToDiscourse extends Plugin implements PluginInterface { settings: DiscourseSyncSettings; - activeFile: { - name: string; - content: string; - postId?: number; // Post ID field - }; + activeFile: ActiveFile; + api: DiscourseAPI; + embedHandler: EmbedHandler; + async onload() { - // Set locale based on Obsidian's language setting + // 设置语言 setLocale(moment.locale()); - // Update locale when Obsidian's language changes + // 当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: "category-modal", name: t('PUBLISH_TO_DISCOURSE'), @@ -38,7 +53,7 @@ export default class DiscourseSyncPlugin extends Plugin { }, }); - // Add command to open post in browser + // 添加在浏览器中打开帖子的命令 this.addCommand({ id: "open-in-discourse", name: t('OPEN_IN_DISCOURSE'), @@ -46,7 +61,6 @@ export default class DiscourseSyncPlugin extends Plugin { this.openInDiscourse(); }, }); - } async loadSettings() { @@ -57,460 +71,13 @@ export default class DiscourseSyncPlugin extends Plugin { await this.saveData(this.settings); } - extractImageReferences(content: string): string[] { - const regex = /!\[\[(.*?)\]\]/g; - const matches = []; - let match; - while ((match = regex.exec(content)) !== null) { - matches.push(match[1]); - } - return matches; - } - - async uploadImages(imageReferences: string[]): Promise { - const imageUrls: string[] = []; - for (const ref of imageReferences) { - const filePath = this.app.metadataCache.getFirstLinkpathDest(ref, this.activeFile.name)?.path; - if (filePath) { - const abstractFile = this.app.vault.getAbstractFileByPath(filePath); - if (abstractFile instanceof TFile) { - try { - const imgfile = await this.app.vault.readBinary(abstractFile); - const boundary = genBoundary(); - const sBoundary = '--' + boundary + '\r\n'; - const imgForm = `${sBoundary}Content-Disposition: form-data; name="file"; filename="${abstractFile.name}"\r\nContent-Type: image/${abstractFile.extension}\r\n\r\n`; - - - let body = ''; - body += `\r\n${sBoundary}Content-Disposition: form-data; name="type"\r\n\r\ncomposer\r\n`; - body += `${sBoundary}Content-Disposition: form-data; name="synchronous"\r\n\r\ntrue\r\n`; - - const eBoundary = '\r\n--' + boundary + '--\r\n'; - const imgFormArray = new TextEncoder().encode(imgForm); - const bodyArray = new TextEncoder().encode(body); - const endBoundaryArray = new TextEncoder().encode(eBoundary); - - const formDataArray = new Uint8Array(imgFormArray.length + imgfile.byteLength + bodyArray.length + endBoundaryArray.length); - formDataArray.set(imgFormArray, 0); - formDataArray.set(new Uint8Array(imgfile), imgFormArray.length); - formDataArray.set(bodyArray, imgFormArray.length + imgfile.byteLength); - formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength); - - const url = `${this.settings.baseUrl}/uploads.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }; - - const response = await requestUrl({ - url: url, - method: "POST", - body: formDataArray.buffer, - throw: false, - headers: headers, - }); - - if (response.status == 200) { - const jsonResponse = response.json; - imageUrls.push(jsonResponse.short_url); - } else { - new NotifyUser(this.app, `Error uploading image: ${response.status}`).open(); - } - } catch (error) { - new NotifyUser(this.app, `Exception while uploading image: ${error}`).open(); - } - } else { - new NotifyUser(this.app, `File not found in vault: ${ref}`).open(); - } - } else { - new NotifyUser(this.app, `Unable to resolve file path for: ${ref}`).open(); - } - } - return imageUrls; - } - - async postTopic(): Promise<{ message: string; error?: string }> { - const url = `${this.settings.baseUrl}/posts.json`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - } - let content = this.activeFile.content; - - // Check if frontmatter contains post ID - const frontMatter = this.getFrontMatter(content); - const postId = frontMatter?.discourse_post_id; - const topicId = frontMatter?.discourse_topic_id; - - // Filter out note properties section - content = content.replace(/^---[\s\S]*?---\n/, ''); - - const imageReferences = this.extractImageReferences(content); - const imageUrls = await this.uploadImages(imageReferences); - - imageReferences.forEach((ref, index) => { - const obsRef = `![[${ref}]]`; - const discoRef = `![${ref}](${imageUrls[index]})`; - content = content.replace(obsRef, discoRef); - }); - - const isUpdate = postId !== undefined; - - if (isUpdate) { - // For updating a post - const postEndpoint = `${this.settings.baseUrl}/posts/${postId}`; - const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`; - - // First update the post content - try { - const postResponse = await requestUrl({ - url: postEndpoint, - method: "PUT", - contentType: "application/json", - body: JSON.stringify({ - raw: content, - edit_reason: "Updated from Obsidian" - }), - headers, - throw: false - }); - - if (postResponse.status !== 200) { - return { - message: "Error", - error: `${t('UPDATE_FAILED')} (${postResponse.status})` - }; - } - - // Then update the topic (for title and tags) - const topicResponse = await requestUrl({ - url: topicEndpoint, - method: "PUT", - contentType: "application/json", - body: JSON.stringify({ - title: (frontMatter?.title ? frontMatter?.title : this.activeFile.name), - tags: this.settings.selectedTags || [] - }), - headers, - throw: false - }); - - if (topicResponse.status === 200) { - return { message: "Success" }; - } else { - try { - const errorResponse = topicResponse.json; - if (errorResponse.errors && errorResponse.errors.length > 0) { - return { - message: "Error", - error: errorResponse.errors.join('\n') - }; - } - if (errorResponse.error) { - return { - message: "Error", - error: errorResponse.error - }; - } - } catch (parseError) { - return { - message: "Error", - error: `${t('UPDATE_FAILED')} (${topicResponse.status})` - }; - } - } - } catch (error) { - return { - message: "Error", - error: `${t('UPDATE_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}` - }; - } - } else { - // For creating a new post - try { - const response = await requestUrl({ - url, - method: "POST", - contentType: "application/json", - body: JSON.stringify({ - title: (frontMatter?.title ? frontMatter?.title : this.activeFile.name), - raw: content, - category: this.settings.category, - tags: this.settings.selectedTags || [] - }), - headers, - throw: false - }); - - if (response.status === 200) { - try { - // Get new post ID and topic ID - const responseData = response.json; - if (responseData && responseData.id) { - await this.updateFrontMatter(responseData.id, responseData.topic_id); - } else { - return { - message: "Error", - error: t('POST_ID_ERROR') - }; - } - } catch (error) { - return { - message: "Error", - error: t('SAVE_POST_ID_ERROR') - }; - } - return { message: "Success" }; - } else { - try { - const errorResponse = response.json; - if (errorResponse.errors && errorResponse.errors.length > 0) { - return { - message: "Error", - error: errorResponse.errors.join('\n') - }; - } - if (errorResponse.error) { - return { - message: "Error", - error: errorResponse.error - }; - } - } catch (parseError) { - return { - message: "Error", - error: `${t('PUBLISH_FAILED')} (${response.status})` - }; - } - } - } catch (error) { - return { - message: "Error", - error: `${t('PUBLISH_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}` - }; - } - } - return { message: "Error", error: `${isUpdate ? t('UPDATE_FAILED') : t('PUBLISH_FAILED')}, ${t('TRY_AGAIN')}` }; - } - - // Get frontmatter data - private getFrontMatter(content: string): any { - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (fmMatch) { - try { - return yaml.parse(fmMatch[1]); - } catch (e) { - return null; - } - } - return null; - } - - // Update frontmatter, add post ID - private async updateFrontMatter(postId: number, topicId: number) { - try { - // Get current active file - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) { - return; - } - - const content = await this.app.vault.read(activeFile); - const fm = this.getFrontMatter(content); - const discourseUrl = `${this.settings.baseUrl}/t/${topicId}`; - - let newContent: string; - if (fm) { - // Update existing frontmatter - const updatedFm = { - ...fm, - discourse_post_id: postId, - discourse_topic_id: topicId, - discourse_url: discourseUrl - }; - newContent = content.replace(/^---\n[\s\S]*?\n---\n/, `---\n${yaml.stringify(updatedFm)}---\n`); - } else { - // Add new frontmatter - const newFm = { - discourse_post_id: postId, - discourse_topic_id: topicId, - discourse_url: discourseUrl - }; - newContent = `---\n${yaml.stringify(newFm)}---\n${content}`; - } - - await this.app.vault.modify(activeFile, newContent); - // 更新 activeFile 对象,确保它反映最新状态 - this.activeFile = { - name: activeFile.basename, - content: newContent, - postId: postId - }; - } catch (error) { - return { - message: "Error", - error: t('UPDATE_FAILED') - }; - } - } - - private async fetchCategories() { - const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; - - try { - const response = await requestUrl({ - url: url, - method: "GET", - contentType: "application/json", - headers, - }); - - - const data = await response.json; - const categories = data.category_list.categories; - const allCategories = categories.flatMap((category: Category) => { - const subcategories: { id: number; name: string }[] = category.subcategory_list?.map((sub: Subcategory) => ({ - id: sub.id, - name: sub.name, - })) || []; - return [ - { id: category.id, name: category.name }, - ...subcategories, - ]; - }); - return allCategories; - } catch (error) { - return []; - } - } - - private async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> { - const url = `${this.settings.baseUrl}/tags.json`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; - - try { - const response = await requestUrl({ - url: url, - method: "GET", - contentType: "application/json", - headers, - }); - - if (response.status === 200) { - const data = response.json; - // Get user permissions - const canCreateTags = await this.checkCanCreateTags(); - // Tags list returned by Discourse is in the tags array - return data.tags.map((tag: any) => ({ - name: tag.name, - canCreate: canCreateTags - })); - } - return []; - } catch (error) { - return []; - } - } - - // Check if user has permission to create tags - private async checkCanCreateTags(): Promise { - try { - const url = `${this.settings.baseUrl}/u/${this.settings.disUser}.json`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; - - const response = await requestUrl({ - url: url, - method: "GET", - contentType: "application/json", - headers, - }); - - if (response.status === 200) { - const data = response.json; - // Check user's trust_level - return data.user.trust_level >= 3; - } - return false; - } catch (error) { - return false; - } - } - - /** - * 测试API密钥是否有效 - * @returns 返回测试结果,包含成功状态和消息 - */ - async testApiKey(): Promise<{ success: boolean; message: string }> { - if (!this.settings.baseUrl || !this.settings.apiKey || !this.settings.disUser) { - return { - success: false, - message: t('MISSING_CREDENTIALS') - }; - } - - const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; - - try { - const response = await requestUrl({ - url: url, - method: "GET", - contentType: "application/json", - headers, - throw: false - }); - - if (response.status === 200) { - return { - success: true, - message: t('API_TEST_SUCCESS') - }; - } else { - let errorMessage; - try { - const errorData = await response.json; - errorMessage = errorData?.errors || errorData?.error || `${t('API_TEST_FAILED')} (${response.status})`; - } catch (e) { - errorMessage = `${t('API_TEST_FAILED')} (${response.status})`; - } - return { - success: false, - message: errorMessage - }; - } - } catch (error) { - return { - success: false, - message: error.message || t('UNKNOWN_ERROR') - }; - } - } - + // 注册目录菜单 registerDirMenu(menu: Menu, file: TFile) { const syncDiscourse = (item: MenuItem) => { item.setTitle(t('PUBLISH_TO_DISCOURSE')); item.onClick(async () => { const content = await expandEmbeds(this.app, file); - const fm = this.getFrontMatter(content); + const fm = getFrontMatter(content); this.activeFile = { name: file.basename, content: content, @@ -522,6 +89,7 @@ export default class DiscourseSyncPlugin extends Plugin { menu.addItem(syncDiscourse) } + // 打开分类选择模态框 private async openCategoryModal() { // 每次都重新获取 activeFile 的最新内容,不使用缓存 const activeFile = this.app.workspace.getActiveFile(); @@ -530,8 +98,9 @@ export default class DiscourseSyncPlugin extends Plugin { return; } - const content = await this.app.vault.read(activeFile); - const fm = this.getFrontMatter(content); + // 使用expandEmbeds处理嵌入内容,而不是直接读取文件内容 + const content = await expandEmbeds(this.app, activeFile); + const fm = getFrontMatter(content); this.activeFile = { name: activeFile.basename, content: content, @@ -539,18 +108,20 @@ export default class DiscourseSyncPlugin extends Plugin { }; const [categories, tags] = await Promise.all([ - this.fetchCategories(), - this.fetchTags() + this.api.fetchCategories(), + this.api.fetchTags() ]); if (categories.length > 0) { new SelectCategoryModal(this.app, this, categories, tags).open(); } } + // 同步到Discourse private async syncToDiscourse() { await this.openCategoryModal(); } + // 在Discourse中打开 private async openInDiscourse() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { @@ -559,7 +130,7 @@ export default class DiscourseSyncPlugin extends Plugin { } const content = await this.app.vault.read(activeFile); - const fm = this.getFrontMatter(content); + const fm = getFrontMatter(content); const discourseUrl = fm?.discourse_url; const topicId = fm?.discourse_topic_id; @@ -572,344 +143,112 @@ export default class DiscourseSyncPlugin extends Plugin { 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); + + // 获取Front Matter + const frontMatter = getFrontMatter(this.activeFile.content); + const postId = frontMatter?.discourse_post_id; + const topicId = frontMatter?.discourse_topic_id; + const isUpdate = postId !== undefined; + + // 发布或更新帖子 + let result; + try { + if (isUpdate) { + // 更新帖子 + result = await this.api.updatePost( + postId, + topicId, + (frontMatter?.title ? frontMatter?.title : this.activeFile.name), + content, + this.settings.category, + this.settings.selectedTags || [] + ); + } else { + // 创建新帖子 + result = await this.api.createPost( + (frontMatter?.title ? frontMatter?.title : this.activeFile.name), + content, + this.settings.category, + this.settings.selectedTags || [] + ); + + // 如果创建成功,更新Front Matter + if (result.success && result.postId && result.topicId) { + await this.updateFrontMatter(result.postId, result.topicId); + } + } + + // 返回结果 + return result; + } catch (error) { + // 返回错误 + return { + success: false, + error: error.message || t('UNKNOWN_ERROR') + }; + } + } + + // 更新Front Matter + private async updateFrontMatter(postId: number, topicId: number) { + 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}`; + + let newContent: string; + if (fm) { + // 更新现有Front Matter + const updatedFm = { + ...fm, + discourse_post_id: postId, + discourse_topic_id: topicId, + discourse_url: discourseUrl + }; + 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 + }; + newContent = `---\n${yaml.stringify(newFm)}---\n${content}`; + } + + await this.app.vault.modify(activeFile, newContent); + // 更新activeFile对象 + this.activeFile = { + name: activeFile.basename, + content: newContent, + postId: postId + }; + } catch (error) { + new NotifyUser(this.app, t('UPDATE_FAILED')).open(); + } + } + onunload() {} - } -interface Subcategory { - id: number; - name: string; -} - -interface Category { - id: number; - name: string; - subcategory_list?: Subcategory[]; -} - -const genBoundary = (): string => { - return '----WebKitFormBoundary' + Math.random().toString(36).substring(2, 15); -} - - -export class NotifyUser extends Modal { - message: string; - constructor(app: App, message: string) { - super(app); - this.message = message; - } - - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: 'An error has occurred.' }); - contentEl.createEl("h4", { text: this.message }); - const okButton = contentEl.createEl('button', { text: 'Ok' }); - okButton.onclick = () => { - this.close(); - } - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } - -} - -export class SelectCategoryModal extends Modal { - plugin: DiscourseSyncPlugin; - categories: {id: number; name: string}[]; - tags: { name: string; canCreate: boolean }[]; - canCreateTags = false; - - constructor(app: App, plugin: DiscourseSyncPlugin, categories: {id: number; name: string }[], tags: { name: string; canCreate: boolean }[]) { - super(app); - this.plugin = plugin; - this.categories = categories; - this.tags = tags; - this.canCreateTags = tags.length > 0 ? tags[0].canCreate : false; - } - - onOpen() { - // Add modal base style - this.modalEl.addClass('mod-discourse-sync'); - const { contentEl } = this; - contentEl.empty(); - contentEl.addClass('discourse-sync-modal'); - - // 确保activeFile存在 - if (!this.plugin.activeFile) { - contentEl.createEl("h1", { text: t('ERROR') }); - contentEl.createEl("p", { text: t('NO_ACTIVE_FILE') }); - return; - } - - const isUpdate = this.plugin.activeFile.postId !== undefined; - contentEl.createEl("h1", { text: isUpdate ? t('UPDATE_POST') : t('PUBLISH_TO_DISCOURSE') }); - - // Create form area container - const formArea = contentEl.createEl('div', { cls: 'form-area' }); - - // Create selector container - const selectContainer = formArea.createEl('div', { cls: 'select-container' }); - if (!isUpdate) { - // Only show category selector when creating a new post - selectContainer.createEl('label', { text: t('CATEGORY') }); - const selectEl = selectContainer.createEl('select'); - this.categories.forEach(category => { - const option = selectEl.createEl('option', { text: category.name }); - option.value = category.id.toString(); - }); - } - - // Create tag selector container - const tagContainer = formArea.createEl('div', { cls: 'tag-container' }); - tagContainer.createEl('label', { text: t('TAGS') }); - - // Create tag selector area - const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' }); - - // Selected tags display area - const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' }); - const selectedTags = new Set(); - - // Initialize with existing tags if updating - if (isUpdate && this.plugin.settings.selectedTags && this.plugin.settings.selectedTags.length > 0) { - this.plugin.settings.selectedTags.forEach(tag => selectedTags.add(tag)); - } - - // Update selected tags display - const updateSelectedTags = () => { - selectedTagsContainer.empty(); - selectedTags.forEach(tag => { - const tagEl = selectedTagsContainer.createEl('span', { - cls: 'tag', - text: tag - }); - const removeBtn = tagEl.createEl('span', { - cls: 'remove-tag', - text: '×' - }); - removeBtn.onclick = () => { - selectedTags.delete(tag); - updateSelectedTags(); - }; - }); - }; - - // Initialize tag display - updateSelectedTags(); - - // Create tag input container - const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' }); - - // Create tag input and suggestions - const tagInput = tagInputContainer.createEl('input', { - type: 'text', - placeholder: this.canCreateTags ? t('ENTER_TAG_WITH_CREATE') : t('ENTER_TAG') - }); - - // Create tag suggestions container - const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' }); - - // Handle input event, show matching tags - tagInput.oninput = () => { - const value = tagInput.value.toLowerCase(); - tagSuggestions.empty(); - - if (value) { - const matches = this.tags - .filter(tag => - tag.name.toLowerCase().includes(value) && - !selectedTags.has(tag.name) - ) - .slice(0, 10); - - if (matches.length > 0) { - // Get input box position and width - const inputRect = tagInput.getBoundingClientRect(); - const modalRect = this.modalEl.getBoundingClientRect(); - - // Ensure suggestions list doesn't exceed modal width - const maxWidth = modalRect.right - inputRect.left - 24; // 24px is right padding - - // Set suggestions list position and width - tagSuggestions.style.top = `${inputRect.bottom + 4}px`; - tagSuggestions.style.left = `${inputRect.left}px`; - tagSuggestions.style.width = `${Math.min(inputRect.width, maxWidth)}px`; - - matches.forEach(tag => { - const suggestion = tagSuggestions.createEl('div', { - cls: 'tag-suggestion', - text: tag.name - }); - suggestion.onclick = () => { - selectedTags.add(tag.name); - tagInput.value = ''; - tagSuggestions.empty(); - updateSelectedTags(); - }; - }); - } - } - }; - - // Handle enter event - tagInput.onkeydown = (e) => { - if (e.key === 'Enter' && tagInput.value) { - e.preventDefault(); - const value = tagInput.value.trim(); - if (value && !selectedTags.has(value)) { - const existingTag = this.tags.find(t => t.name.toLowerCase() === value.toLowerCase()); - if (existingTag) { - selectedTags.add(existingTag.name); - updateSelectedTags(); - } else if (this.canCreateTags) { - selectedTags.add(value); - updateSelectedTags(); - } else { - // Show permission notice - const notice = contentEl.createEl('div', { - cls: 'tag-notice', - text: t('PERMISSION_ERROR') - }); - setTimeout(() => { - notice.remove(); - }, 2000); - } - } - tagInput.value = ''; - tagSuggestions.empty(); - } - }; - - // Handle blur event, hide suggestions - tagInput.onblur = () => { - // Delay hide, so suggestions can be clicked - setTimeout(() => { - tagSuggestions.empty(); - }, 200); - }; - - // Handle window scroll, update suggestions list position - const updateSuggestionsPosition = () => { - if (tagSuggestions.childNodes.length > 0) { - const inputRect = tagInput.getBoundingClientRect(); - tagSuggestions.style.top = `${inputRect.bottom + 4}px`; - tagSuggestions.style.left = `${inputRect.left}px`; - tagSuggestions.style.width = `${inputRect.width}px`; - } - }; - - // Listen for scroll event - this.modalEl.addEventListener('scroll', updateSuggestionsPosition); - - // Remove event listeners when modal closes - this.modalEl.onclose = () => { - this.modalEl.removeEventListener('scroll', updateSuggestionsPosition); - }; - - // Create button area - const buttonArea = contentEl.createEl('div', { cls: 'button-area' }); - const submitButton = buttonArea.createEl('button', { - text: isUpdate ? t('UPDATE') : t('PUBLISH'), - cls: 'submit-button' - }); - - // Create notice container - const noticeContainer = buttonArea.createEl('div'); - - submitButton.onclick = async () => { - if (!isUpdate) { - const selectEl = contentEl.querySelector('select') as HTMLSelectElement; - if (!selectEl) { - return; - } - const selectedCategoryId = selectEl.value; - this.plugin.settings.category = parseInt(selectedCategoryId); - await this.plugin.saveSettings(); - } - - // Save selected tags - this.plugin.settings.selectedTags = Array.from(selectedTags); - await this.plugin.saveSettings(); - - // Disable submit button, show loading state - submitButton.disabled = true; - submitButton.textContent = isUpdate ? t('UPDATING') : t('PUBLISHING'); - - try { - const reply = await this.plugin.postTopic(); - - // Show notice - noticeContainer.empty(); - if (reply.message === 'Success') { - noticeContainer.createEl('div', { - cls: 'notice success', - text: isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS') - }); - // Close after success - setTimeout(() => { - this.close(); - }, 1500); - } else { - const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' }); - errorContainer.createEl('div', { - cls: 'error-title', - text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR') - }); - - // Show Discourse-specific error information - errorContainer.createEl('div', { - cls: 'error-message', - text: reply.error || (isUpdate ? t('UPDATE_FAILED') + ', ' + t('TRY_AGAIN') : t('PUBLISH_FAILED') + ', ' + t('TRY_AGAIN')) - }); - - // Add retry button - const retryButton = errorContainer.createEl('button', { - cls: 'retry-button', - text: t('RETRY') - }); - retryButton.onclick = () => { - noticeContainer.empty(); - submitButton.disabled = false; - submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); - }; - } - } catch (error) { - noticeContainer.empty(); - const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' }); - errorContainer.createEl('div', { - cls: 'error-title', - text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR') - }); - errorContainer.createEl('div', { - cls: 'error-message', - text: error.message || t('UNKNOWN_ERROR') - }); - - // Add retry button - const retryButton = errorContainer.createEl('button', { - cls: 'retry-button', - text: t('RETRY') - }); - retryButton.onclick = () => { - noticeContainer.empty(); - submitButton.disabled = false; - submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); - }; - } - - // If error occurs, reset button state - if (submitButton.disabled) { - submitButton.disabled = false; - submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); - } - }; - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} diff --git a/src/notification.ts b/src/notification.ts new file mode 100644 index 0000000..4c1cb90 --- /dev/null +++ b/src/notification.ts @@ -0,0 +1,20 @@ +import { App, Modal } from 'obsidian'; + +// 通知用户的模态框 +export class NotifyUser extends Modal { + message: string; + constructor(app: App, message: string) { + super(app); + this.message = message; + } + + onOpen() { + const { contentEl } = this; + contentEl.setText(this.message); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6de616a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,14 @@ +import { DiscourseSyncSettings } from './config'; + +export interface ActiveFile { + name: string; + content: string; + postId?: number; +} + +export interface PluginInterface { + settings: DiscourseSyncSettings; + activeFile: ActiveFile; + saveSettings(): Promise; + publishTopic(): Promise<{ success: boolean; error?: string }>; +} \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..1d9f856 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,294 @@ +import { App, Modal } from 'obsidian'; +import { t } from './i18n'; +import { PluginInterface } from './types'; + +// 选择分类的模态框 +export class SelectCategoryModal extends Modal { + plugin: PluginInterface; + categories: {id: number; name: string}[]; + tags: { name: string; canCreate: boolean }[]; + canCreateTags = false; + + constructor(app: App, plugin: PluginInterface, categories: {id: number; name: string }[], tags: { name: string; canCreate: boolean }[]) { + super(app); + this.plugin = plugin; + this.categories = categories; + this.tags = tags; + this.canCreateTags = tags.length > 0 && tags[0].canCreate; + } + + onOpen() { + // 添加模态框基础样式 + this.modalEl.addClass('mod-discourse-sync'); + + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('discourse-sync-modal'); + + const isUpdate = this.plugin.activeFile.postId !== undefined; + contentEl.createEl('h1', { text: isUpdate ? t('UPDATE_POST') : t('PUBLISH_TO_DISCOURSE') }); + + // 创建表单区域容器 + const formArea = contentEl.createEl('div', { cls: 'form-area' }); + + // 创建分类选择容器 + const selectContainer = formArea.createEl('div', { cls: 'select-container' }); + selectContainer.createEl('label', { text: t('CATEGORY') }); + const selectEl = selectContainer.createEl('select'); + + // 添加分类选项 + this.categories.forEach(category => { + const option = selectEl.createEl('option', { text: category.name }); + option.value = category.id.toString(); + }); + + // 设置默认选中的分类 + selectEl.value = this.plugin.settings.category?.toString() || this.categories[0].id.toString(); + + // 监听分类选择变化 + selectEl.onchange = () => { + this.plugin.settings.category = parseInt(selectEl.value); + this.plugin.saveSettings(); + }; + + // 创建标签容器 + const tagContainer = formArea.createEl('div', { cls: 'tag-container' }); + tagContainer.createEl('label', { text: t('TAGS') }); + + // 创建标签选择区域 + const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' }); + + // 已选标签显示区域 + const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' }); + const selectedTags = new Set(); + + // 初始化已选标签 + if (this.plugin.settings.selectedTags && this.plugin.settings.selectedTags.length > 0) { + this.plugin.settings.selectedTags.forEach(tag => selectedTags.add(tag)); + } + + // 更新标签显示 + const updateSelectedTags = () => { + selectedTagsContainer.empty(); + selectedTags.forEach(tag => { + const tagEl = selectedTagsContainer.createEl('span', { + cls: 'tag', + text: tag + }); + const removeBtn = tagEl.createEl('span', { + cls: 'remove-tag', + text: '×' + }); + removeBtn.onclick = () => { + selectedTags.delete(tag); + updateSelectedTags(); + }; + }); + }; + + // 初始化标签显示 + updateSelectedTags(); + + // 创建标签输入容器 + const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' }); + + // 创建标签输入和建议 + const tagInput = tagInputContainer.createEl('input', { + type: 'text', + placeholder: this.canCreateTags ? t('ENTER_TAG_WITH_CREATE') : t('ENTER_TAG') + }); + + // 创建标签建议容器 + const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' }); + + // 处理输入事件,显示匹配的标签 + tagInput.oninput = () => { + const value = tagInput.value.toLowerCase(); + tagSuggestions.empty(); + + if (value) { + const matches = this.tags + .filter(tag => + tag.name.toLowerCase().includes(value) && + !selectedTags.has(tag.name) + ) + .slice(0, 10); + + if (matches.length > 0) { + // 获取输入框位置和宽度 + const inputRect = tagInput.getBoundingClientRect(); + const modalRect = this.modalEl.getBoundingClientRect(); + + // 确保建议列表不超过模态框宽度 + const maxWidth = modalRect.right - inputRect.left - 24; // 24px是右边距 + + // 设置建议列表位置和宽度 + tagSuggestions.style.top = `${inputRect.bottom + 4}px`; + tagSuggestions.style.left = `${inputRect.left}px`; + tagSuggestions.style.width = `${Math.min(inputRect.width, maxWidth)}px`; + + matches.forEach(tag => { + const suggestion = tagSuggestions.createEl('div', { + cls: 'tag-suggestion', + text: tag.name + }); + suggestion.onclick = () => { + selectedTags.add(tag.name); + tagInput.value = ''; + tagSuggestions.empty(); + updateSelectedTags(); + }; + }); + } + } + }; + + // 处理回车事件 + tagInput.onkeydown = (e) => { + if (e.key === 'Enter' && tagInput.value) { + e.preventDefault(); + const value = tagInput.value.trim(); + if (value && !selectedTags.has(value)) { + const existingTag = this.tags.find(t => t.name.toLowerCase() === value.toLowerCase()); + if (existingTag) { + selectedTags.add(existingTag.name); + updateSelectedTags(); + } else if (this.canCreateTags) { + selectedTags.add(value); + updateSelectedTags(); + } else { + // 显示权限提示 + const notice = contentEl.createEl('div', { + cls: 'tag-notice', + text: t('PERMISSION_ERROR') + }); + setTimeout(() => { + notice.remove(); + }, 2000); + } + } + tagInput.value = ''; + tagSuggestions.empty(); + } + }; + + // 处理失焦事件,隐藏建议 + tagInput.onblur = () => { + // 延迟隐藏,以便可以点击建议 + setTimeout(() => { + tagSuggestions.empty(); + }, 200); + }; + + // 处理窗口滚动,更新建议列表位置 + const updateSuggestionsPosition = () => { + if (tagSuggestions.childNodes.length > 0) { + const inputRect = tagInput.getBoundingClientRect(); + tagSuggestions.style.top = `${inputRect.bottom + 4}px`; + tagSuggestions.style.left = `${inputRect.left}px`; + tagSuggestions.style.width = `${inputRect.width}px`; + } + }; + + // 监听滚动事件 + this.modalEl.addEventListener('scroll', updateSuggestionsPosition); + + // 模态框关闭时移除事件监听器 + this.modalEl.onclose = () => { + this.modalEl.removeEventListener('scroll', updateSuggestionsPosition); + }; + + // 创建按钮区域 + const buttonArea = contentEl.createEl('div', { cls: 'button-area' }); + const submitButton = buttonArea.createEl('button', { + text: isUpdate ? t('UPDATE') : t('PUBLISH'), + cls: 'submit-button' + }); + + // 创建通知容器 + const noticeContainer = buttonArea.createEl('div', { cls: 'notice-container' }); + + submitButton.onclick = async () => { + // 保存选中的标签 + this.plugin.settings.selectedTags = Array.from(selectedTags); + await this.plugin.saveSettings(); + + // 禁用提交按钮,显示加载状态 + submitButton.disabled = true; + submitButton.textContent = isUpdate ? t('UPDATING') : t('PUBLISHING'); + + try { + // 发布主题 + const result = await this.plugin.publishTopic(); + + // 显示结果 + noticeContainer.empty(); + + if (result.success) { + // 成功 + noticeContainer.createEl('div', { + cls: 'notice success', + text: isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS') + }); + + // 2秒后自动关闭 + setTimeout(() => { + this.close(); + }, 2000); + } else { + // 失败 + const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' }); + errorContainer.createEl('div', { + cls: 'error-title', + text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR') + }); + + errorContainer.createEl('div', { + cls: 'error-message', + text: result.error || t('UNKNOWN_ERROR') + }); + + // 添加重试按钮 + const retryButton = errorContainer.createEl('button', { + cls: 'retry-button', + text: t('RETRY') + }); + retryButton.onclick = () => { + noticeContainer.empty(); + submitButton.disabled = false; + submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); + }; + } + } catch (error) { + // 显示错误 + noticeContainer.empty(); + const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' }); + errorContainer.createEl('div', { + cls: 'error-title', + text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR') + }); + + errorContainer.createEl('div', { + cls: 'error-message', + text: error.message || t('UNKNOWN_ERROR') + }); + + // 添加重试按钮 + const retryButton = errorContainer.createEl('button', { + cls: 'retry-button', + text: t('RETRY') + }); + retryButton.onclick = () => { + noticeContainer.empty(); + submitButton.disabled = false; + submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); + }; + } + }; + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1af1a45 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,27 @@ +import { TFile } from 'obsidian'; +import * as yaml from 'yaml'; + + +// 从内容中提取Front Matter +export function getFrontMatter(content: string): any { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch) { + try { + return yaml.parse(fmMatch[1]); + } catch (e) { + return null; + } + } + return null; +} + +// 移除内容中的Front Matter +export function removeFrontMatter(content: string): string { + return content.replace(/^---[\s\S]*?---\n/, ''); +} + +// 检查文件是否为图片或PDF +export function isImageFile(file: TFile): boolean { + const imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "pdf"]; + return imageExtensions.includes(file.extension.toLowerCase()); +} \ No newline at end of file