diff --git a/README.md b/README.md index 778ca8b..22d4a99 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Publish to Discourse -## 配置key +## 配置User Api key -下载插件, 在设置页面添加"论坛地址", "API密钥(需要管理员创建)", "用户名". -![image](https://github.com/user-attachments/assets/6c75ebb6-d028-4055-9616-2fb2931932ff) +下载插件, 在设置页面添加"论坛地址"后配置. +![image](/pics/20250711-233019.gif) diff --git a/pics/20250711-233019.gif b/pics/20250711-233019.gif new file mode 100644 index 0000000..fa60f2c Binary files /dev/null and b/pics/20250711-233019.gif differ diff --git a/src/api.ts b/src/api.ts index 7ba360e..ef07fb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -38,16 +38,10 @@ export class DiscourseAPI { formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength); const url = `${this.settings.baseUrl}/uploads.json`; - const headers: Record = this.settings.userApiKey - ? { - "User-Api-Key": this.settings.userApiKey, - "Content-Type": `multipart/form-data; boundary=${boundary}` - } - : { - "Api-Key": this.settings.apiKey || '', - "Api-Username": this.settings.disUser || '', - "Content-Type": `multipart/form-data; boundary=${boundary}` - }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": `multipart/form-data; boundary=${boundary}` + }; const response = await requestUrl({ url: url, @@ -73,16 +67,10 @@ export class DiscourseAPI { // 创建新帖子 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: Record = this.settings.userApiKey - ? { - "User-Api-Key": this.settings.userApiKey, - "Content-Type": "application/json" - } - : { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey || '', - "Api-Username": this.settings.disUser || '' - }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": "application/json" + }; try { const response = await requestUrl({ @@ -151,16 +139,10 @@ export class DiscourseAPI { 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: Record = this.settings.userApiKey - ? { - "User-Api-Key": this.settings.userApiKey, - "Content-Type": "application/json" - } - : { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey || '', - "Api-Username": this.settings.disUser || '' - }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": "application/json" + }; try { // 首先更新帖子内容 @@ -237,9 +219,9 @@ export class DiscourseAPI { async fetchCategories(): Promise<{ id: number; name: string }[]> { try { const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`; - const headers: Record = this.settings.userApiKey - ? { "User-Api-Key": this.settings.userApiKey } - : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey + }; const response = await requestUrl({ url, @@ -286,9 +268,9 @@ export class DiscourseAPI { async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> { try { const url = `${this.settings.baseUrl}/tags.json`; - const headers: Record = this.settings.userApiKey - ? { "User-Api-Key": this.settings.userApiKey } - : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey + }; const response = await requestUrl({ url, @@ -326,9 +308,9 @@ export class DiscourseAPI { async checkCanCreateTags(): Promise { try { const url = `${this.settings.baseUrl}/site.json`; - const headers: Record = this.settings.userApiKey - ? { "User-Api-Key": this.settings.userApiKey } - : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey + }; const response = await requestUrl({ url, @@ -352,7 +334,7 @@ export class DiscourseAPI { // 测试API密钥 async testApiKey(): Promise<{ success: boolean; message: string }> { - if (!this.settings.baseUrl || (!this.settings.apiKey && !this.settings.userApiKey) || !this.settings.disUser) { + if (!this.settings.baseUrl || !this.settings.userApiKey) { return { success: false, message: t('MISSING_SETTINGS') @@ -360,10 +342,10 @@ export class DiscourseAPI { } try { - const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`; - const headers: Record = this.settings.userApiKey - ? { "User-Api-Key": this.settings.userApiKey } - : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; + const url = `${this.settings.baseUrl}/site.json`; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey + }; const response = await requestUrl({ url, @@ -374,7 +356,7 @@ export class DiscourseAPI { if (response.status === 200) { const data = response.json; - if (data && data.user) { + if (data) { return { success: true, message: t('API_TEST_SUCCESS') @@ -403,9 +385,9 @@ export class DiscourseAPI { async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> { try { const url = `${this.settings.baseUrl}/t/${topicId}.json`; - const headers: Record = this.settings.userApiKey - ? { "User-Api-Key": this.settings.userApiKey } - : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; + const headers: Record = { + "User-Api-Key": this.settings.userApiKey + }; const response = await requestUrl({ url, diff --git a/src/config.ts b/src/config.ts index 074d1a9..d6cafca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,20 +4,16 @@ import { t } from './i18n'; export interface DiscourseSyncSettings { baseUrl: string; - apiKey: string; - disUser: string; category: number; skipH1: boolean; - userApiKey?: string; // 新增 + userApiKey: string; } export const DEFAULT_SETTINGS: DiscourseSyncSettings = { baseUrl: "https://yourforum.example.com", - apiKey: "apikey", - disUser: "DiscourseUsername", category: 1, skipH1: false, - userApiKey: "" // 新增 + userApiKey: "" }; export class DiscourseSyncSettingsTab extends PluginSettingTab { @@ -30,7 +26,15 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - new Setting(containerEl) + // ====== 基础配置 ====== + const basicSection = containerEl.createDiv('discourse-config-section'); + basicSection.createEl('h2', { text: '🔧 ' + t('CONFIG_BASIC_TITLE') }); + basicSection.createEl('p', { + text: t('CONFIG_BASIC_DESC'), + cls: 'setting-item-description' + }); + + new Setting(basicSection) .setName(t('FORUM_URL')) .setDesc(t('FORUM_URL_DESC')) .addText((text) => @@ -43,33 +47,178 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName(t('API_KEY')) - .setDesc(t('API_KEY_DESC')) - .addText((text) => + // 显示当前的 User-API-Key + const userApiKey = this.plugin.settings.userApiKey; + const hasApiKey = userApiKey && userApiKey.trim() !== ''; + + new Setting(basicSection) + .setName(t('USER_API_KEY')) + .setDesc(hasApiKey ? t('USER_API_KEY_DESC') : t('USER_API_KEY_EMPTY')) + .addText((text) => { text - .setPlaceholder("api_key") - .setValue(this.plugin.settings.apiKey) - .onChange(async (value) => { - this.plugin.settings.apiKey = value; - await this.plugin.saveSettings(); - }) - ); + .setPlaceholder(hasApiKey ? "••••••••••••••••••••••••••••••••" : t('USER_API_KEY_EMPTY')) + .setValue(hasApiKey ? userApiKey : "") + .setDisabled(true); + + // 设置样式让文本看起来像密码 + if (hasApiKey) { + text.inputEl.style.fontFamily = 'monospace'; + text.inputEl.style.fontSize = '12px'; + text.inputEl.style.color = 'var(--text-muted)'; + } + }) + .addButton((button: ButtonComponent) => { + if (hasApiKey) { + button + .setButtonText("📋 " + t('COPY_API_KEY')) + .setTooltip(t('COPY_API_KEY')) + .onClick(async () => { + try { + await navigator.clipboard.writeText(userApiKey); + new Notice(t('API_KEY_COPIED'), 3000); + } catch (error) { + // 降级方案:使用传统的复制方法 + const textArea = document.createElement('textarea'); + textArea.value = userApiKey; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + new Notice(t('API_KEY_COPIED'), 3000); + } + }); + } else { + button + .setButtonText("⬇️ 获取") + .setTooltip("跳转到获取 API Key 的流程") + .onClick(() => { + // 滚动到 API Key 获取区域 + const apiSection = containerEl.querySelector('.discourse-config-section:nth-child(2)'); + if (apiSection) { + apiSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } + }); - new Setting(containerEl) - .setName(t('USERNAME')) - .setDesc(t('USERNAME_DESC')) - .addText((text) => - text - .setPlaceholder("username") - .setValue(this.plugin.settings.disUser) - .onChange(async (value) => { - this.plugin.settings.disUser = value; - await this.plugin.saveSettings(); - }), - ); + // ====== 获取 User-API-Key ====== + const apiSection = containerEl.createDiv('discourse-config-section'); + apiSection.createEl('h2', { text: '🔑 ' + t('CONFIG_API_TITLE') }); + apiSection.createEl('p', { + text: t('CONFIG_API_DESC'), + cls: 'setting-item-description' + }); - new Setting(containerEl) + // 步骤 1: 确认论坛地址 + const step1 = apiSection.createDiv('discourse-step'); + step1.createDiv('discourse-step-title').textContent = t('STEP_VERIFY_URL'); + step1.createDiv('discourse-step-description').textContent = t('STEP_VERIFY_URL_DESC'); + + // 步骤 2: 生成授权链接 + const step2 = apiSection.createDiv('discourse-step'); + step2.createDiv('discourse-step-title').textContent = t('STEP_GENERATE_AUTH'); + step2.createDiv('discourse-step-description').textContent = t('STEP_GENERATE_AUTH_DESC'); + + new Setting(step2) + .setName(t('GENERATE_AUTH_LINK')) + .setDesc(t('GENERATE_AUTH_DESC')) + .addButton((button: ButtonComponent) => { + button.setButtonText("🚀 " + t('GENERATE_AUTH_LINK')); + button.onClick(async () => { + const { generateKeyPairAndNonce, saveKeyPair } = await import("./crypto"); + const pair = generateKeyPairAndNonce(); + saveKeyPair(pair); + const url = `${this.plugin.settings.baseUrl.replace(/\/$/,"")}/user-api-key/new?` + + `application_name=Obsidian%20Discourse%20Plugin&client_id=obsidian-${Date.now()}&scopes=read,write&public_key=${encodeURIComponent(pair.publicKeyPem)}&nonce=${pair.nonce}`; + window.open(url, '_blank'); + new Notice(t('AUTH_LINK_GENERATED'), 8000); + this.display(); + }); + }); + + // 步骤 3: 完成授权并复制 Payload + const step3 = apiSection.createDiv('discourse-step'); + step3.createDiv('discourse-step-title').textContent = t('STEP_AUTHORIZE'); + step3.createDiv('discourse-step-description').textContent = t('STEP_AUTHORIZE_DESC'); + + // 步骤 4: 解密并保存 User-API-Key + const step4 = apiSection.createDiv('discourse-step'); + step4.createDiv('discourse-step-title').textContent = t('STEP_DECRYPT'); + step4.createDiv('discourse-step-description').textContent = t('STEP_DECRYPT_DESC'); + + new Setting(step4) + .setName(t('DECRYPT_PAYLOAD')) + .setDesc(t('DECRYPT_PAYLOAD_DESC')) + .addText((text) => { + text.setPlaceholder(t('PAYLOAD_PLACEHOLDER')); + text.inputEl.style.width = '80%'; + (text as any).payloadValue = ''; + text.onChange((value) => { + (text as any).payloadValue = value; + }); + }) + .addButton((button: ButtonComponent) => { + button.setButtonText("🔓 " + t('DECRYPT_AND_SAVE')); + button.onClick(async () => { + const { decryptUserApiKey, clearKeyPair } = await import("./crypto"); + const payload = (containerEl.querySelector(`input[placeholder="${t('PAYLOAD_PLACEHOLDER')}"]`) as HTMLInputElement)?.value; + if (!payload) { new Notice("请先粘贴payload"); return; } + try { + const userApiKey = await decryptUserApiKey(payload); + this.plugin.settings.userApiKey = userApiKey; + await this.plugin.saveSettings(); + clearKeyPair(); + new Notice(t('DECRYPT_SUCCESS'), 5000); + this.display(); + } catch (e) { + new Notice(t('DECRYPT_FAILED') + e, 8000); + } + }); + }); + + // 步骤 5: 测试连接 + const step5 = apiSection.createDiv('discourse-step'); + step5.createDiv('discourse-step-title').textContent = t('STEP_TEST'); + step5.createDiv('discourse-step-description').textContent = t('STEP_TEST_DESC'); + + new Setting(step5) + .setName(t('TEST_API_KEY')) + .setDesc(t('STEP_TEST_DESC')) + .addButton((button: ButtonComponent) => { + button + .setButtonText("🔍 " + t('TEST_API_KEY')) + .setCta() + .onClick(async () => { + button.setButtonText("🔄 " + t('TESTING')); + button.setDisabled(true); + + const result = await this.plugin.api.testApiKey(); + + button.setButtonText("🔍 " + t('TEST_API_KEY')); + button.setDisabled(false); + + if (result.success) { + new Notice("✅ " + result.message, 5000); + } else { + // 使用 Obsidian 的默认 Notice 进行错误提示 + const formattedMessage = typeof result.message === 'string' + ? result.message + : JSON.stringify(result.message, null, 2); + + new Notice("❌ " + t('API_TEST_FAILED') + "\n" + formattedMessage, 8000); + } + }); + }); + + // ====== 发布选项 ====== + const publishSection = containerEl.createDiv('discourse-config-section'); + publishSection.createEl('h2', { text: '📝 ' + t('CONFIG_PUBLISH_TITLE') }); + publishSection.createEl('p', { + text: t('CONFIG_PUBLISH_DESC'), + cls: 'setting-item-description' + }); + + new Setting(publishSection) .setName(t('SKIP_H1')) .setDesc(t('SKIP_H1_DESC')) .addToggle((toggle) => @@ -80,100 +229,5 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); - - new Setting(containerEl) - .setName("User-Api-Key") - .setDesc("通过Discourse授权后获得的User-Api-Key,优先用于API请求") - .addText((text) => - text - .setPlaceholder("user_api_key") - .setValue(this.plugin.settings.userApiKey || "") - .setDisabled(true) - ) - .addButton((button: ButtonComponent) => { - button.setButtonText("生成User-Api-Key"); - button.onClick(async () => { - const { generateKeyPairAndNonce, saveKeyPair, loadKeyPair, clearKeyPair } = await import("./crypto"); - const pair = generateKeyPairAndNonce(); - saveKeyPair(pair); - const url = `${this.plugin.settings.baseUrl.replace(/\/$/,"")}/user-api-key/new?` + - `application_name=Obsidian%20Discourse%20Plugin&client_id=obsidian-${Date.now()}&scopes=read,write&public_key=${encodeURIComponent(pair.publicKeyPem)}&nonce=${pair.nonce}`; - window.open(url, '_blank'); - new Notice("已生成密钥对并跳转授权页面,请授权后粘贴payload。", 8000); - this.display(); - }); - }); - - // payload输入框和解密按钮 - new Setting(containerEl) - .setName("解密payload") - .setDesc("请粘贴Discourse返回的payload,自动解密user-api-key") - .addText((text) => { - text.setPlaceholder("payload base64"); - text.inputEl.style.width = '80%'; - (text as any).payloadValue = ''; - text.onChange((value) => { - (text as any).payloadValue = value; - }); - }) - .addButton((button: ButtonComponent) => { - button.setButtonText("解密并保存"); - button.onClick(async () => { - const { decryptUserApiKey, clearKeyPair } = await import("./crypto"); - const payload = (containerEl.querySelector('input[placeholder="payload base64"]') as HTMLInputElement)?.value; - if (!payload) { new Notice("请先粘贴payload"); return; } - try { - const userApiKey = await decryptUserApiKey(payload); - this.plugin.settings.userApiKey = userApiKey; - await this.plugin.saveSettings(); - clearKeyPair(); - new Notice("User-Api-Key解密成功!", 5000); - this.display(); - } catch (e) { - new Notice("User-Api-Key解密失败: " + e, 8000); - } - }); - }); - - new Setting(containerEl) - .setName(t('TEST_API_KEY')) - .setDesc('') - .addButton((button: ButtonComponent) => { - button - .setButtonText(t('TEST_API_KEY')) - .setCta() - .onClick(async () => { - button.setButtonText(t('TESTING')); - button.setDisabled(true); - - const result = await this.plugin.api.testApiKey(); - - button.setButtonText(t('TEST_API_KEY')); - button.setDisabled(false); - - if (result.success) { - new Notice(result.message, 5000); - } else { - const errorEl = containerEl.createDiv('discourse-api-error'); - errorEl.createEl('h3', { text: t('API_TEST_FAILED') }); - - const formattedMessage = typeof result.message === 'string' - ? result.message - : JSON.stringify(result.message, null, 2); - - errorEl.createEl('p', { text: formattedMessage }); - - errorEl.style.backgroundColor = 'rgba(255, 0, 0, 0.1)'; - errorEl.style.border = '1px solid rgba(255, 0, 0, 0.3)'; - errorEl.style.borderRadius = '5px'; - errorEl.style.padding = '10px'; - errorEl.style.marginTop = '10px'; - - setTimeout(() => { - errorEl.remove(); - }, 10000); - } - }); - }); } } diff --git a/src/crypto.ts b/src/crypto.ts index deda1f7..9303280 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,6 +1,7 @@ // 类型声明修正 // @ts-ignore import forge from 'node-forge'; +import { t } from './i18n'; const KEY_STORAGE = 'discourse_user_api_keypair'; @@ -39,12 +40,12 @@ export function clearKeyPair() { // 解密payload,校验nonce,返回user-api-key export async function decryptUserApiKey(payload: string): Promise { const pair = loadKeyPair(); - if (!pair) throw new Error('请先生成密钥对'); + if (!pair) throw new Error(t('CRYPTO_NEED_GEN_KEYPAIR')); const privateKey = forge.pki.privateKeyFromPem(pair.privateKeyPem); const encryptedBytes = forge.util.decode64(payload.trim().replace(/\s/g, '')); const decrypted = privateKey.decrypt(encryptedBytes, 'RSAES-PKCS1-V1_5'); const json = JSON.parse(decrypted); - if (!json.key) throw new Error('payload内容无key字段'); - if (json.nonce !== pair.nonce) throw new Error('nonce校验失败'); + if (!json.key) throw new Error(t('CRYPTO_PAYLOAD_NO_KEY')); + if (json.nonce !== pair.nonce) throw new Error(t('CRYPTO_NONCE_INVALID')); return json.key; } \ No newline at end of file diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4d6d444..79954db 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2,17 +2,14 @@ export default { // Settings page 'FORUM_URL': 'Forum URL', 'FORUM_URL_DESC': 'The URL of your Discourse forum', - 'API_KEY': 'API Key', - 'API_KEY_DESC': "API key created in '/admin/api/keys'", - 'USERNAME': 'Username', - 'USERNAME_DESC': 'Your Discourse username', + 'SKIP_H1': 'Skip First Heading', 'SKIP_H1_DESC': 'Skip the first heading (H1) when publishing to Discourse', 'TEST_API_KEY': 'Test Connection', 'TESTING': 'Testing...', 'API_TEST_SUCCESS': 'Connection successful! API key is valid', 'API_TEST_FAILED': 'API key test failed', - 'MISSING_CREDENTIALS': 'Please fill in Forum URL, API Key and Username first', + 'MISSING_SETTINGS': 'Please fill in Forum URL and User-Api-Key first', // Publish page 'PUBLISH_TO_DISCOURSE': 'Publish to Discourse', @@ -41,6 +38,10 @@ export default { 'TRY_AGAIN': 'Please try again', 'POST_ID_ERROR': 'Published successfully but failed to get post ID', 'SAVE_POST_ID_ERROR': 'Published successfully but failed to save post ID', + // crypto.ts error messages + 'CRYPTO_NEED_GEN_KEYPAIR': 'Please generate the key pair first', + 'CRYPTO_PAYLOAD_NO_KEY': 'No key field in payload', + 'CRYPTO_NONCE_INVALID': 'Nonce validation failed', // Open in Discourse 'OPEN_IN_DISCOURSE': 'Open in Discourse', @@ -53,5 +54,37 @@ export default { 'LOCAL_CATEGORY': 'Local Category (set in frontmatter)', 'REMOTE_CATEGORY': 'Remote Category (from Discourse)', 'KEEP_LOCAL_CATEGORY': 'Keep Local Category', - 'USE_REMOTE_CATEGORY': 'Use Remote Category' + 'USE_REMOTE_CATEGORY': 'Use Remote Category', + + // Configuration page + 'CONFIG_BASIC_TITLE': 'Basic Configuration', + 'CONFIG_BASIC_DESC': 'Set up basic information for your Discourse forum', + 'CONFIG_API_TITLE': 'Get User-API-Key', + 'CONFIG_API_DESC': 'Obtain API key through Discourse official authorization process. Please follow these steps:', + 'CONFIG_PUBLISH_TITLE': 'Publishing Options', + 'CONFIG_PUBLISH_DESC': 'Customize behavior when publishing to Discourse', + 'STEP_VERIFY_URL': 'Step 1: Verify Forum URL', + 'STEP_VERIFY_URL_DESC': 'Please ensure the forum URL above is correct before proceeding to the next step', + 'STEP_GENERATE_AUTH': 'Step 2: Generate Authorization Link', + 'STEP_GENERATE_AUTH_DESC': 'Click the button below to generate authorization link and jump to Discourse authorization page:', + 'STEP_AUTHORIZE': 'Step 3: Complete Authorization and Copy Payload', + 'STEP_AUTHORIZE_DESC': 'After clicking Authorize on Discourse authorization page, a payload text box will appear. Please copy its content:', + 'STEP_DECRYPT': 'Step 4: Decrypt and Save User-API-Key', + 'STEP_DECRYPT_DESC': 'Paste the copied payload into the input box below, then click "Decrypt and Save":', + 'STEP_TEST': 'Step 5: Test Connection', + 'STEP_TEST_DESC': 'Verify that User-API-Key is configured correctly:', + 'GENERATE_AUTH_LINK': 'Generate Authorization Link', + 'GENERATE_AUTH_DESC': 'Generate key pair and redirect to Discourse authorization page', + 'DECRYPT_PAYLOAD': 'Decrypt Authorization Result', + 'DECRYPT_PAYLOAD_DESC': 'Paste the payload copied from Discourse authorization page', + 'PAYLOAD_PLACEHOLDER': 'Paste payload (base64 format)', + 'DECRYPT_AND_SAVE': 'Decrypt and Save', + 'AUTH_LINK_GENERATED': 'Key pair generated and redirecting to authorization page. Please click Authorize button on the authorization page.', + 'DECRYPT_SUCCESS': '✅ User-Api-Key decrypted successfully!', + 'DECRYPT_FAILED': '❌ User-Api-Key decryption failed: ', + 'USER_API_KEY': 'User-API-Key', + 'USER_API_KEY_DESC': 'Current configured User-API-Key (read-only)', + 'USER_API_KEY_EMPTY': 'Please use the process below to obtain one', + 'COPY_API_KEY': 'Copy', + 'API_KEY_COPIED': '✅ API Key copied to clipboard' } \ No newline at end of file diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index dc78498..7d53caa 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -2,17 +2,14 @@ export default { // 设置页面 'FORUM_URL': '论坛地址', 'FORUM_URL_DESC': 'Discourse 论坛的网址', - 'API_KEY': 'API 密钥', - 'API_KEY_DESC': "在'/admin/api/keys'中创建的 API 密钥", - 'USERNAME': '用户名', - 'USERNAME_DESC': 'Discourse 用户名', + 'SKIP_H1': '跳过一级标题', 'SKIP_H1_DESC': '发布到 Discourse 时跳过笔记中的一级标题', 'TEST_API_KEY': '测试连接', 'TESTING': '测试中...', 'API_TEST_SUCCESS': '连接成功!API密钥有效', 'API_TEST_FAILED': 'API密钥测试失败', - 'MISSING_CREDENTIALS': '请先填写论坛地址、API密钥和用户名', + 'MISSING_SETTINGS': '请先填写论坛地址和User-Api-Key', // 发布页面 'PUBLISH_TO_DISCOURSE': '发布到 Discourse', @@ -41,6 +38,10 @@ export default { 'TRY_AGAIN': '请重试', 'POST_ID_ERROR': '发布成功但无法获取帖子ID', 'SAVE_POST_ID_ERROR': '发布成功但无法保存帖子ID', + // crypto.ts error messages + 'CRYPTO_NEED_GEN_KEYPAIR': '请先生成密钥对', + 'CRYPTO_PAYLOAD_NO_KEY': 'payload内容无key字段', + 'CRYPTO_NONCE_INVALID': 'nonce校验失败', // Open in Discourse 'OPEN_IN_DISCOURSE': '在 Discourse 中打开', @@ -53,5 +54,37 @@ export default { 'LOCAL_CATEGORY': '本地分类(frontmatter中设置)', 'REMOTE_CATEGORY': '远程分类(Discourse上的分类)', 'KEEP_LOCAL_CATEGORY': '保持本地分类', - 'USE_REMOTE_CATEGORY': '使用远程分类' + 'USE_REMOTE_CATEGORY': '使用远程分类', + + // 配置页面 + 'CONFIG_BASIC_TITLE': '基础配置', + 'CONFIG_BASIC_DESC': '设置 Discourse 论坛的基本信息', + 'CONFIG_API_TITLE': '获取 User-API-Key', + 'CONFIG_API_DESC': '通过 Discourse 官方授权流程获取 API 密钥,请按照以下步骤操作:', + 'CONFIG_PUBLISH_TITLE': '发布选项', + 'CONFIG_PUBLISH_DESC': '自定义发布到 Discourse 时的行为', + 'STEP_VERIFY_URL': '步骤 1: 确认论坛地址', + 'STEP_VERIFY_URL_DESC': '请确保上面的论坛地址是正确的,然后继续下一步', + 'STEP_GENERATE_AUTH': '步骤 2: 生成授权链接', + 'STEP_GENERATE_AUTH_DESC': '点击下面的按钮生成授权链接,并跳转到 Discourse 授权页面:', + 'STEP_AUTHORIZE': '步骤 3: 完成授权并复制 Payload', + 'STEP_AUTHORIZE_DESC': '在 Discourse 授权页面点击 Authorize 后,会显示一个 payload 文本框,请复制其中的内容:', + 'STEP_DECRYPT': '步骤 4: 解密并保存 User-API-Key', + 'STEP_DECRYPT_DESC': '将复制的 payload 粘贴到下面的输入框中,然后点击"解密并保存":', + 'STEP_TEST': '步骤 5: 测试连接', + 'STEP_TEST_DESC': '验证 User-API-Key 是否配置正确:', + 'GENERATE_AUTH_LINK': '生成授权链接', + 'GENERATE_AUTH_DESC': '生成密钥对并跳转到 Discourse 授权页面', + 'DECRYPT_PAYLOAD': '解密授权结果', + 'DECRYPT_PAYLOAD_DESC': '粘贴从 Discourse 授权页面复制的 payload', + 'PAYLOAD_PLACEHOLDER': '粘贴 payload (base64 格式)', + 'DECRYPT_AND_SAVE': '解密并保存', + 'AUTH_LINK_GENERATED': '已生成密钥对并跳转授权页面,请在授权页面点击 Authorize 按钮。', + 'DECRYPT_SUCCESS': '✅ User-Api-Key解密成功!', + 'DECRYPT_FAILED': '❌ User-Api-Key解密失败: ', + 'USER_API_KEY': 'User-API-Key', + 'USER_API_KEY_DESC': '当前配置的 User-API-Key(只读)', + 'USER_API_KEY_EMPTY': '请使用下面的流程获取', + 'COPY_API_KEY': '复制', + 'API_KEY_COPIED': '✅ API Key 已复制到剪贴板' } \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts index 3cd1639..ddbf8d5 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,4 +1,4 @@ -import { App, Modal } from 'obsidian'; +import { App, Modal, Notice } from 'obsidian'; import { t } from './i18n'; import { PluginInterface } from './types'; @@ -158,13 +158,7 @@ export class SelectCategoryModal extends Modal { updateSelectedTags(); } else { // 显示权限提示 - const notice = contentEl.createEl('div', { - cls: 'tag-notice', - text: t('PERMISSION_ERROR') - }); - setTimeout(() => { - notice.remove(); - }, 2000); + new Notice(t('PERMISSION_ERROR'), 3000); } } tagInput.value = ''; @@ -205,9 +199,6 @@ export class SelectCategoryModal extends Modal { cls: 'submit-button' }); - // 创建通知容器 - const noticeContainer = buttonArea.createEl('div', { cls: 'notice-container' }); - submitButton.onclick = async () => { // 保存当前选择的标签到activeFile对象 this.plugin.activeFile.tags = Array.from(selectedTags); @@ -220,68 +211,33 @@ export class SelectCategoryModal extends Modal { // 发布主题 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') - }); + new Notice(isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS'), 5000); // 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') - }); + // 失败 - 使用 Obsidian 原生 Notice + const errorMessage = (isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')) + + '\n' + (result.error || t('UNKNOWN_ERROR')); + new Notice(errorMessage, 8000); - 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'); - }; + } + } catch (error) { + // 显示错误 - 使用 Obsidian 原生 Notice + const errorMessage = (isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')) + + '\n' + (error.message || t('UNKNOWN_ERROR')); + new Notice(errorMessage, 8000); + + // 重置按钮状态 + submitButton.disabled = false; + submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH'); } }; } diff --git a/styles.css b/styles.css index 3000c1e..e5458d4 100644 --- a/styles.css +++ b/styles.css @@ -7,6 +7,45 @@ If your plugin does not need CSS, delete this file. */ +/* Settings Page Styles */ +.discourse-config-section { + margin: 20px 0; + padding: var(--size-4-4); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-secondary); +} + +.discourse-config-section h2 { + margin: 0 0 var(--size-2-3) 0; + color: var(--text-accent); + border-bottom: 2px solid var(--interactive-accent); + padding-bottom: var(--size-2-1); + font-size: var(--font-text-size); + font-weight: var(--font-weight-bold); +} + +.discourse-step { + margin: var(--size-2-3) 0; + padding: var(--size-2-3); + border-left: 3px solid var(--interactive-accent); + background: var(--background-primary); + border-radius: 0 var(--radius-s) var(--radius-s) 0; +} + +.discourse-step-title { + font-weight: var(--font-weight-bold); + color: var(--text-accent); + margin-bottom: var(--size-2-1); + font-size: var(--font-ui-small); +} + +.discourse-step-description { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + margin-bottom: var(--size-2-2); +} + /* Basic Modal Style Override */ .modal.mod-discourse-sync { max-width: 500px; @@ -134,86 +173,7 @@ If your plugin does not need CSS, delete this file. /* 这些规则已经被上面的 !important 规则覆盖了,移除重复 */ -/* Notice Style */ -.discourse-sync-modal .notice { - margin-top: 16px; - padding: 16px; - border-radius: var(--radius-m); - text-align: left; - font-size: 14px; - box-shadow: var(--shadow-s); - border: 1px solid var(--background-modifier-border); - transition: all 0.3s ease; -} -.discourse-sync-modal .notice.success { - background-color: var(--background-modifier-success-hover); - color: var(--text-success); - text-align: center; - font-weight: 500; -} - -.discourse-sync-modal .notice.error { - /* 基础样式,会被 !important 规则覆盖 */ -} - -/* 确保错误通知在所有主题下都可见 */ -.discourse-sync-modal .notice.error { - background: var(--background-modifier-error) !important; - border: 1px solid var(--background-modifier-error-border) !important; - color: var(--text-error) !important; -} - -.discourse-sync-modal .error-title { - font-size: 15px; - font-weight: 600; - margin-bottom: 8px; - display: flex; - align-items: center; - gap: 6px; -} - -.discourse-sync-modal .error-title::before { - content: "⚠️"; - font-size: 16px; -} - -.discourse-sync-modal .error-message { - font-size: 13px; - line-height: 1.5; -} - -/* 确保错误标题和消息在所有主题下都可见 */ -.discourse-sync-modal .error-title { - color: var(--text-error) !important; -} - -.discourse-sync-modal .error-message { - color: var(--text-error) !important; - opacity: 0.9 !important; -} - -.discourse-sync-modal .retry-button { - margin-top: 12px; - padding: 6px 16px; - background-color: transparent; - border-radius: var(--radius-s); - cursor: pointer; - font-size: 13px; - font-weight: 500; - transition: all 0.2s ease; -} - -/* 确保重试按钮在所有主题下都可见 */ -.discourse-sync-modal .retry-button { - color: var(--text-error) !important; - border-color: var(--text-error) !important; -} - -.discourse-sync-modal .retry-button:hover { - background-color: var(--text-error) !important; - color: var(--text-on-accent) !important; -} /* Tag Select Area Style */ .discourse-sync-modal .tag-select-area { @@ -382,29 +342,7 @@ If your plugin does not need CSS, delete this file. position: relative; } -.discourse-sync-modal .tag-notice { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - padding: 12px 20px; - border-radius: 4px; - font-size: 13px; - text-align: center; - z-index: 1002; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - max-width: 80%; - white-space: nowrap; - opacity: 0; - animation: fadeInOut 2s ease-in-out forwards; -} -/* 确保标签通知在所有主题下都可见 */ -.discourse-sync-modal .tag-notice { - background-color: var(--background-modifier-error) !important; - border: 1px solid var(--background-modifier-error-border) !important; - color: var(--text-error) !important; -} @keyframes fadeInOut { 0% {