mirror of
https://github.com/woodchen-ink/obsidian-publish-to-discourse.git
synced 2025-07-17 21:32:05 +08:00
更新 README.md,修改配置项描述为“User Api key”,并优化样式文件,新增设置页面样式以提升用户体验。同时,简化 API 请求头的设置逻辑,确保始终使用 User-Api-Key 进行请求。
This commit is contained in:
parent
8388c35baa
commit
16a33d0572
@ -1,9 +1,9 @@
|
||||
# Publish to Discourse
|
||||
|
||||
## 配置key
|
||||
## 配置User Api key
|
||||
|
||||
下载插件, 在设置页面添加"论坛地址", "API密钥(需要管理员创建)", "用户名".
|
||||

|
||||
下载插件, 在设置页面添加"论坛地址"后配置.
|
||||

|
||||
|
||||
|
||||
|
||||
|
BIN
pics/20250711-233019.gif
Normal file
BIN
pics/20250711-233019.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
78
src/api.ts
78
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<string, string> = 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<string, string> = {
|
||||
"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<string, string> = 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<string, string> = {
|
||||
"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<string, string> = 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<string, string> = {
|
||||
"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<string, string> = this.settings.userApiKey
|
||||
? { "User-Api-Key": this.settings.userApiKey }
|
||||
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, string> = this.settings.userApiKey
|
||||
? { "User-Api-Key": this.settings.userApiKey }
|
||||
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
@ -326,9 +308,9 @@ export class DiscourseAPI {
|
||||
async checkCanCreateTags(): Promise<boolean> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/site.json`;
|
||||
const headers: Record<string, string> = this.settings.userApiKey
|
||||
? { "User-Api-Key": this.settings.userApiKey }
|
||||
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, string> = 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<string, string> = {
|
||||
"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<string, string> = this.settings.userApiKey
|
||||
? { "User-Api-Key": this.settings.userApiKey }
|
||||
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
|
306
src/config.ts
306
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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<string> {
|
||||
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;
|
||||
}
|
@ -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'
|
||||
}
|
@ -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 已复制到剪贴板'
|
||||
}
|
80
src/ui.ts
80
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
140
styles.css
140
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% {
|
||||
|
Loading…
x
Reference in New Issue
Block a user