Compare commits

...

14 Commits
1.0.20 ... main

Author SHA1 Message Date
4170846e1f 1.0.24 2025-07-13 03:27:16 +08:00
wood chen
6acd9bcc01
Merge pull request #25 from cumany/main
支持标签组功能,支持直接从列表多选标签。
2025-07-13 03:26:46 +08:00
cumany
951eba6bc5 支持标签组功能,支持直接从列表多选标签。 2025-07-12 16:51:52 +08:00
87a903b4b5 启用默认的远程图片URL选项,允许用户在发布时使用远程图片链接。 2025-07-12 02:06:01 +08:00
c7dcbe779b 1.0.23 2025-07-12 01:59:45 +08:00
167794ce4c 移除版本更新通知功能及相关模态框,简化代码结构。更新国际化文件,删除与更新通知相关的文本。调整主文件导入内容,去除不再使用的功能。 2025-07-12 01:59:25 +08:00
e56a479a45 新增远程图片URL替换功能,允许用户在发布后将本地图片链接替换为Discourse上的远程URL。
新增支持markdown格式引用本地图片的上传.
2025-07-12 01:54:44 +08:00
4900f26222 1.0.22 2025-07-11 23:55:13 +08:00
9914ca9fd8 新增版本更新通知功能,包含更新提示模态框样式和逻辑,用户首次运行新版本时将显示更新通知,提示用户重新配置 User-API-Key。更新国际化文件以支持多语言提示。 2025-07-11 23:55:04 +08:00
b98cb24e56 1.0.21 2025-07-11 23:39:07 +08:00
16a33d0572 更新 README.md,修改配置项描述为“User Api key”,并优化样式文件,新增设置页面样式以提升用户体验。同时,简化 API 请求头的设置逻辑,确保始终使用 User-Api-Key 进行请求。 2025-07-11 23:31:49 +08:00
wood chen
8388c35baa
Merge pull request #22 from cumany/main
增加user-api-key 设置选项,支持用户自助获取。
2025-07-11 22:24:54 +08:00
cumany
441d9c3688 增加user-api-key 设置选项,支持用户自助获取。 2025-07-11 22:08:17 +08:00
48cbc72c4a 更新 GitHub Actions 工作流,简化发布说明生成逻辑,移除自定义发布说明的条件判断,始终以草稿形式创建发布。 2025-06-20 21:26:01 +08:00
16 changed files with 756 additions and 380 deletions

View File

@ -25,56 +25,11 @@ jobs:
npm install
npm run build
- name: Generate release notes
id: release_notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
# 获取上一个标签
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
# 生成发布说明
# 获取上一个标签
if [ -n "$previous_tag" ]; then
echo "## 🚀 更新内容 / What's Changed" > release_notes.md
echo "" >> release_notes.md
# 分析提交类型并分类显示
echo "### ✨ 新增功能 / New Features:" >> release_notes.md
git log --pretty=format:"%s" "$previous_tag"..HEAD | grep -i "^feat\|^add\|^新增\|^功能" | sed 's/^/- /' >> release_notes.md || echo "- 暂无" >> release_notes.md
echo "" >> release_notes.md
echo "### 🐛 问题修复 / Bug Fixes:" >> release_notes.md
git log --pretty=format:"%s" "$previous_tag"..HEAD | grep -i "^fix\|^bug\|^修复\|^修正" | sed 's/^/- /' >> release_notes.md || echo "- 暂无" >> release_notes.md
echo "" >> release_notes.md
echo "### 🔧 改进优化 / Improvements:" >> release_notes.md
git log --pretty=format:"%s" "$previous_tag"..HEAD | grep -i "^improve\|^update\|^优化\|^改进\|^更新" | sed 's/^/- /' >> release_notes.md || echo "- 暂无" >> release_notes.md
echo "" >> release_notes.md
echo "### 📝 所有提交 / All Commits:" >> release_notes.md
git log --pretty=format:"- %s ([%h](https://github.com/${{ github.repository }}/commit/%H))" "$previous_tag"..HEAD >> release_notes.md
echo "" >> release_notes.md
echo "### 📂 变更文件 / Changed Files:" >> release_notes.md
git diff --name-only "$previous_tag"..HEAD | sed 's/^/- `/' | sed 's/$/`/' >> release_notes.md
echo "" >> release_notes.md
echo "---" >> release_notes.md
echo "**完整变更日志**: https://github.com/${{ github.repository }}/compare/$previous_tag...$tag" >> release_notes.md
else
# 首次发布时使用 GitHub 自动生成的发布说明
echo "" > release_notes.md
fi
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
previous_tag=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
files=()
for file in main.js manifest.json styles.css; do
@ -86,16 +41,7 @@ jobs:
fi
done
if [ -n "$previous_tag" ]; then
# 有上一个标签,使用自定义发布说明
gh release create "$tag" \
--title="Release $tag" \
--notes-file release_notes.md \
"${files[@]}"
else
# 首次发布,使用 GitHub 自动生成的发布说明
gh release create "$tag" \
--title="Release $tag" \
--generate-notes \
"${files[@]}"
fi
gh release create "$tag" \
--title="$tag" \
--draft \
"${files[@]}"

View File

@ -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)

View File

@ -1,7 +1,7 @@
{
"id": "publish-to-discourse",
"name": "Publish to Discourse",
"version": "1.0.20",
"version": "1.0.24",
"minAppVersion": "0.15.0",
"description": "Publish notes to the Discourse forum.",
"author": "WoodChen",

14
package-lock.json generated
View File

@ -1,14 +1,15 @@
{
"name": "obsidian-publish-to-discourse",
"version": "1.0.20",
"version": "1.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-publish-to-discourse",
"version": "1.0.20",
"version": "1.0.24",
"license": "MIT",
"dependencies": {
"node-forge": "^1.3.1",
"yaml": "^2.7.0"
},
"devDependencies": {
@ -1852,6 +1853,15 @@
"license": "MIT",
"peer": true
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/obsidian": {
"version": "1.5.7-1",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.5.7-1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "obsidian-publish-to-discourse",
"version": "1.0.20",
"version": "1.0.24",
"description": "Post notes to the Discourse forum.",
"main": "main.js",
"scripts": {
@ -26,6 +26,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"node-forge": "^1.3.1",
"yaml": "^2.7.0"
}
}

BIN
pics/20250711-233019.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -15,7 +15,7 @@ export class DiscourseAPI {
) {}
// 上传图片到Discourse
async uploadImage(file: TFile): Promise<string | null> {
async uploadImage(file: TFile): Promise<{shortUrl: string, fullUrl?: string} | null> {
try {
const imgfile = await this.app.vault.readBinary(file);
const boundary = genBoundary();
@ -38,10 +38,9 @@ export class DiscourseAPI {
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 headers: Record<string, string> = {
"User-Api-Key": this.settings.userApiKey,
"Content-Type": `multipart/form-data; boundary=${boundary}`
};
const response = await requestUrl({
@ -54,7 +53,25 @@ export class DiscourseAPI {
if (response.status == 200) {
const jsonResponse = response.json;
return jsonResponse.short_url;
let fullUrl: string | undefined;
// 处理完整URL的拼接
if (jsonResponse.url) {
// 如果返回的url已经是完整URL包含http/https直接使用
if (jsonResponse.url.startsWith('http://') || jsonResponse.url.startsWith('https://')) {
fullUrl = jsonResponse.url;
} else {
// 如果是相对路径需要与baseUrl拼接
const baseUrl = this.settings.baseUrl.replace(/\/$/, ''); // 移除尾部斜杠
const urlPath = jsonResponse.url.startsWith('/') ? jsonResponse.url : `/${jsonResponse.url}`;
fullUrl = `${baseUrl}${urlPath}`;
}
}
return {
shortUrl: jsonResponse.short_url,
fullUrl: fullUrl
};
} else {
new NotifyUser(this.app, `Error uploading image: ${response.status}`).open();
return null;
@ -68,10 +85,9 @@ 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 = {
"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 {
@ -141,10 +157,9 @@ 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 = {
"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 {
@ -222,9 +237,8 @@ export class DiscourseAPI {
async fetchCategories(): Promise<{ id: number; name: string }[]> {
try {
const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`;
const headers = {
"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({
@ -272,9 +286,8 @@ export class DiscourseAPI {
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 headers: Record<string, string> = {
"User-Api-Key": this.settings.userApiKey
};
const response = await requestUrl({
@ -290,12 +303,41 @@ export class DiscourseAPI {
if (data && data.tags) {
const canCreateTags = await this.checkCanCreateTags();
// 处理所有标签包括tag_groups中的标签
const allTags = new Map<string, { name: string; count: number }>();
// 添加普通标签
data.tags.forEach((tag: any) => {
tags.push({
allTags.set(tag.name, { name: tag.name, count: tag.count || 0 });
});
// 添加tag_groups中的标签
if (data.extras && data.extras.tag_groups) {
data.extras.tag_groups.forEach((group: any) => {
if (group.tags) {
group.tags.forEach((tag: any) => {
// 如果标签已存在取较大的count值
const existing = allTags.get(tag.name);
if (existing) {
existing.count = Math.max(existing.count, tag.count || 0);
} else {
allTags.set(tag.name, { name: tag.name, count: tag.count || 0 });
}
});
}
});
}
// 按count数量排序转换为最终格式
const sortedTags = Array.from(allTags.values())
.sort((a, b) => b.count - a.count)
.map(tag => ({
name: tag.name,
canCreate: canCreateTags
});
});
}));
tags.push(...sortedTags);
}
return tags;
@ -313,9 +355,8 @@ export class DiscourseAPI {
async checkCanCreateTags(): Promise<boolean> {
try {
const url = `${this.settings.baseUrl}/site.json`;
const headers = {
"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({
@ -340,7 +381,7 @@ export class DiscourseAPI {
// 测试API密钥
async testApiKey(): Promise<{ success: boolean; message: string }> {
if (!this.settings.baseUrl || !this.settings.apiKey || !this.settings.disUser) {
if (!this.settings.baseUrl || !this.settings.userApiKey) {
return {
success: false,
message: t('MISSING_SETTINGS')
@ -348,10 +389,9 @@ export class DiscourseAPI {
}
try {
const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`;
const headers = {
"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({
@ -363,7 +403,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')
@ -392,9 +432,8 @@ export class DiscourseAPI {
async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> {
try {
const url = `${this.settings.baseUrl}/t/${topicId}.json`;
const headers = {
"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({

View File

@ -4,18 +4,19 @@ import { t } from './i18n';
export interface DiscourseSyncSettings {
baseUrl: string;
apiKey: string;
disUser: string;
category: number;
skipH1: boolean;
useRemoteImageUrl: boolean;
userApiKey: string;
lastNotifiedVersion?: string; // 记录上次显示更新通知的版本
}
export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
baseUrl: "https://yourforum.example.com",
apiKey: "apikey",
disUser: "DiscourseUsername",
category: 1,
skipH1: false
skipH1: false,
useRemoteImageUrl: true, //默认启用
userApiKey: ""
};
export class DiscourseSyncSettingsTab extends PluginSettingTab {
@ -28,7 +29,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) =>
@ -41,33 +50,178 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName(t('API_KEY'))
.setDesc(t('API_KEY_DESC'))
.addText((text) =>
text
.setPlaceholder("api_key")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
})
);
// 显示当前的 User-API-Key
const userApiKey = this.plugin.settings.userApiKey;
const hasApiKey = userApiKey && userApiKey.trim() !== '';
new Setting(containerEl)
.setName(t('USERNAME'))
.setDesc(t('USERNAME_DESC'))
.addText((text) =>
new Setting(basicSection)
.setName(t('USER_API_KEY'))
.setDesc(hasApiKey ? t('USER_API_KEY_DESC') : t('USER_API_KEY_EMPTY'))
.addText((text) => {
text
.setPlaceholder("username")
.setValue(this.plugin.settings.disUser)
.onChange(async (value) => {
this.plugin.settings.disUser = value;
await this.plugin.saveSettings();
}),
);
.setPlaceholder(hasApiKey ? "••••••••••••••••••••••••••••••••" : t('USER_API_KEY_EMPTY'))
.setValue(hasApiKey ? userApiKey : "")
.setDisabled(true);
new Setting(containerEl)
// 设置样式让文本看起来像密码
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' });
}
});
}
});
// ====== 获取 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'
});
// 步骤 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) =>
@ -79,45 +233,16 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab {
})
);
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);
}
});
});
new Setting(publishSection)
.setName(t('USE_REMOTE_IMAGE_URL'))
.setDesc(t('USE_REMOTE_IMAGE_URL_DESC'))
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.useRemoteImageUrl)
.onChange(async (value) => {
this.plugin.settings.useRemoteImageUrl = value;
await this.plugin.saveSettings();
})
);
}
}

51
src/crypto.ts Normal file
View File

@ -0,0 +1,51 @@
// 类型声明修正
// @ts-ignore
import forge from 'node-forge';
import { t } from './i18n';
const KEY_STORAGE = 'discourse_user_api_keypair';
export interface UserApiKeyPair {
publicKeyPem: string;
privateKeyPem: string;
nonce: string;
}
// 生成RSA密钥对和nonce
export function generateKeyPairAndNonce(): UserApiKeyPair {
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096, e: 0x10001 });
const publicKeyPem = forge.pki.publicKeyToPem(keypair.publicKey);
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
const nonce = forge.util.bytesToHex(forge.random.getBytesSync(16));
return { publicKeyPem, privateKeyPem, nonce };
}
// 保存密钥对到localStorage
export function saveKeyPair(pair: UserApiKeyPair) {
localStorage.setItem(KEY_STORAGE, JSON.stringify(pair));
}
// 读取密钥对
export function loadKeyPair(): UserApiKeyPair | null {
const raw = localStorage.getItem(KEY_STORAGE);
if (!raw) return null;
return JSON.parse(raw);
}
// 清除密钥对
export function clearKeyPair() {
localStorage.removeItem(KEY_STORAGE);
}
// 解密payload校验nonce返回user-api-key
export async function decryptUserApiKey(payload: string): Promise<string> {
const pair = loadKeyPair();
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(t('CRYPTO_PAYLOAD_NO_KEY'));
if (json.nonce !== pair.nonce) throw new Error(t('CRYPTO_NONCE_INVALID'));
return json.key;
}

View File

@ -11,17 +11,30 @@ export class EmbedHandler {
// 提取嵌入引用
extractEmbedReferences(content: string): string[] {
const regex = /!\[\[(.*?)\]\]/g;
const matches = [];
const references: string[] = [];
// 匹配 ![[...]] 格式 (Wiki格式)
const wikiRegex = /!\[\[(.*?)\]\]/g;
let match;
while ((match = regex.exec(content)) !== null) {
matches.push(match[1]);
while ((match = wikiRegex.exec(content)) !== null) {
references.push(match[1]);
}
return matches;
// 匹配 ![](path) 格式 (Markdown格式)
const markdownRegex = /!\[.*?\]\(([^)]+)\)/g;
while ((match = markdownRegex.exec(content)) !== null) {
// 过滤掉网络URL只处理本地文件路径
const path = match[1];
if (!path.startsWith('http://') && !path.startsWith('https://') && !path.startsWith('upload://')) {
references.push(path);
}
}
return references;
}
// 处理嵌入内容
async processEmbeds(embedReferences: string[], activeFileName: string): Promise<string[]> {
async processEmbeds(embedReferences: string[], activeFileName: string, useRemoteUrl = false): Promise<string[]> {
const uploadedUrls: string[] = [];
for (const ref of embedReferences) {
// 处理带有#的文件路径,分离文件名和标题部分
@ -37,8 +50,14 @@ export class EmbedHandler {
if (abstractFile instanceof TFile) {
// 检查是否为图片或PDF文件
if (isImageFile(abstractFile)) {
const imageUrl = await this.api.uploadImage(abstractFile);
uploadedUrls.push(imageUrl || "");
const imageResult = await this.api.uploadImage(abstractFile);
if (imageResult) {
// 根据配置选择使用短URL还是完整URL
const urlToUse = useRemoteUrl && imageResult.fullUrl ? imageResult.fullUrl : imageResult.shortUrl;
uploadedUrls.push(urlToUse);
} else {
uploadedUrls.push("");
}
} else {
// 非图片文件,返回空字符串
uploadedUrls.push("");
@ -58,14 +77,23 @@ export class EmbedHandler {
// 替换内容中的嵌入引用为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);
// 处理 ![[...]] 格式 (Wiki格式)
const wikiRef = `![[${ref}]]`;
const wikiReplacement = `![${ref}](${uploadedUrls[index]})`;
processedContent = processedContent.replace(wikiRef, wikiReplacement);
// 处理 ![](path) 格式 (Markdown格式)
// 创建正则表达式来匹配具体的路径
const escapedRef = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const markdownRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedRef}\\)`, 'g');
const markdownReplacement = `![$1](${uploadedUrls[index]})`;
processedContent = processedContent.replace(markdownRegex, markdownReplacement);
}
});
return processedContent;
}
}

View File

@ -2,17 +2,16 @@ 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',
'USE_REMOTE_IMAGE_URL': 'Use Remote Image URLs',
'USE_REMOTE_IMAGE_URL_DESC': 'Replace local image links with remote URLs from Discourse after publishing',
'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',
@ -20,7 +19,7 @@ export default {
'CATEGORY': 'Category',
'TAGS': 'Tags',
'ENTER_TAG': 'Enter tag name (press Enter to add)',
'ENTER_TAG_WITH_CREATE': 'Enter tag name (can create new tags)',
'ENTER_TAG_WITH_CREATE': 'Enter tag name (press Enter to add) (can create new tags)',
'PUBLISHING': 'Publishing...',
'UPDATING': 'Updating...',
'PUBLISH': 'Publish',
@ -41,6 +40,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 +56,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'
}

View File

@ -2,17 +2,16 @@ 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 时跳过笔记中的一级标题',
'USE_REMOTE_IMAGE_URL': '使用远程图片URL',
'USE_REMOTE_IMAGE_URL_DESC': '发布后用Discourse上的远程图片URL替换本地文章中的图片链接',
'TEST_API_KEY': '测试连接',
'TESTING': '测试中...',
'API_TEST_SUCCESS': '连接成功API密钥有效',
'API_TEST_FAILED': 'API密钥测试失败',
'MISSING_CREDENTIALS': '请先填写论坛地址、API密钥和用户名',
'MISSING_SETTINGS': '请先填写论坛地址和User-Api-Key',
// 发布页面
'PUBLISH_TO_DISCOURSE': '发布到 Discourse',
@ -20,7 +19,7 @@ export default {
'CATEGORY': '分类',
'TAGS': '标签',
'ENTER_TAG': '输入标签名称(回车添加)',
'ENTER_TAG_WITH_CREATE': '输入标签名称(可创建新标签)',
'ENTER_TAG_WITH_CREATE': '输入标签名称(回车添加)(可创建新标签)',
'PUBLISHING': '发布中...',
'UPDATING': '更新中...',
'PUBLISH': '发布',
@ -41,6 +40,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 +56,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 已复制到剪贴板'
}

View File

@ -213,7 +213,7 @@ export default class PublishToDiscourse extends Plugin implements PluginInterfac
const embedReferences = this.embedHandler.extractEmbedReferences(content);
// 处理嵌入内容
const uploadedUrls = await this.embedHandler.processEmbeds(embedReferences, this.activeFile.name);
const uploadedUrls = await this.embedHandler.processEmbeds(embedReferences, this.activeFile.name, this.settings.useRemoteImageUrl);
// 替换嵌入引用为Markdown格式
content = this.embedHandler.replaceEmbedReferences(content, embedReferences, uploadedUrls);
@ -250,6 +250,11 @@ export default class PublishToDiscourse extends Plugin implements PluginInterfac
// 如果更新成功更新Front Matter
if (result.success) {
await this.updateFrontMatter(postId, topicId, currentTags);
// 如果启用了远程URL替换更新本地文件中的图片链接
if (this.settings.useRemoteImageUrl) {
await this.updateLocalImageLinks(embedReferences, uploadedUrls);
}
}
} else {
// 创建新帖子
@ -263,6 +268,11 @@ export default class PublishToDiscourse extends Plugin implements PluginInterfac
// 如果创建成功更新Front Matter
if (result.success && result.postId && result.topicId) {
await this.updateFrontMatter(result.postId, result.topicId, currentTags);
// 如果启用了远程URL替换更新本地文件中的图片链接
if (this.settings.useRemoteImageUrl) {
await this.updateLocalImageLinks(embedReferences, uploadedUrls);
}
}
}
@ -336,6 +346,47 @@ export default class PublishToDiscourse extends Plugin implements PluginInterfac
}
}
// 更新本地文件中的图片链接为远程URL
private async updateLocalImageLinks(embedReferences: string[], uploadedUrls: string[]) {
try {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
return;
}
let content = await this.app.vault.read(activeFile);
let hasChanges = false;
embedReferences.forEach((ref, index) => {
if (uploadedUrls[index]) {
// 替换 ![[...]] 格式 (Wiki格式)
const wikiRef = `![[${ref}]]`;
const wikiReplacement = `![${ref}](${uploadedUrls[index]})`;
if (content.includes(wikiRef)) {
content = content.replace(wikiRef, wikiReplacement);
hasChanges = true;
}
// 替换 ![](path) 格式 (Markdown格式)
const escapedRef = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const markdownRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedRef}\\)`, 'g');
const markdownReplacement = `![$1](${uploadedUrls[index]})`;
if (markdownRegex.test(content)) {
content = content.replace(markdownRegex, markdownReplacement);
hasChanges = true;
}
}
});
// 只有在有变更时才保存文件
if (hasChanges) {
await this.app.vault.modify(activeFile, content);
}
} catch (error) {
console.error('Failed to update local image links:', error);
}
}
onunload() {}
}

134
src/ui.ts
View File

@ -1,4 +1,4 @@
import { App, Modal } from 'obsidian';
import { App, Modal, Notice } from 'obsidian';
import { t } from './i18n';
import { PluginInterface } from './types';
@ -82,6 +82,7 @@ export class SelectCategoryModal extends Modal {
removeBtn.onclick = () => {
selectedTags.delete(tag);
updateSelectedTags();
showDefaultTags(); // 重新显示网格,让移除的标签重新出现
};
});
};
@ -101,18 +102,47 @@ export class SelectCategoryModal extends Modal {
// 创建标签建议容器
const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' });
// 创建默认标签网格容器
const defaultTagsGrid = tagInputContainer.createEl('div', { cls: 'default-tags-grid' });
// 显示默认标签网格
const showDefaultTags = () => {
defaultTagsGrid.empty();
const availableTags = this.tags.filter(tag => !selectedTags.has(tag.name)).slice(0, 20);
if (availableTags.length > 0) {
availableTags.forEach(tag => {
const tagEl = defaultTagsGrid.createEl('span', {
cls: 'grid-tag',
text: tag.name
});
tagEl.onclick = () => {
selectedTags.add(tag.name);
updateSelectedTags();
showDefaultTags(); // 重新显示网格,移除已选标签
};
});
}
};
// 初始化显示默认标签网格
showDefaultTags();
// 处理输入事件,显示匹配的标签
tagInput.oninput = () => {
const value = tagInput.value.toLowerCase();
tagSuggestions.empty();
if (value) {
// 有输入时隐藏默认网格,显示搜索结果
defaultTagsGrid.style.display = 'none';
tagSuggestions.empty();
const matches = this.tags
.filter(tag =>
tag.name.toLowerCase().includes(value) &&
!selectedTags.has(tag.name)
)
.slice(0, 10);
.slice(0, 20); // 搜索结果显示更多
if (matches.length > 0) {
// 获取输入框位置和宽度
@ -126,6 +156,7 @@ export class SelectCategoryModal extends Modal {
tagSuggestions.style.top = `${inputRect.bottom + 4}px`;
tagSuggestions.style.left = `${inputRect.left}px`;
tagSuggestions.style.width = `${Math.min(inputRect.width, maxWidth)}px`;
tagSuggestions.style.display = 'block';
matches.forEach(tag => {
const suggestion = tagSuggestions.createEl('div', {
@ -135,11 +166,20 @@ export class SelectCategoryModal extends Modal {
suggestion.onclick = () => {
selectedTags.add(tag.name);
tagInput.value = '';
tagSuggestions.empty();
tagSuggestions.style.display = 'none';
defaultTagsGrid.style.display = 'grid';
updateSelectedTags();
showDefaultTags();
};
});
} else {
tagSuggestions.style.display = 'none';
}
} else {
// 无输入时显示默认网格,隐藏搜索结果
tagSuggestions.style.display = 'none';
defaultTagsGrid.style.display = 'grid';
showDefaultTags();
}
};
@ -153,22 +193,19 @@ export class SelectCategoryModal extends Modal {
if (existingTag) {
selectedTags.add(existingTag.name);
updateSelectedTags();
showDefaultTags();
} else if (this.canCreateTags) {
selectedTags.add(value);
updateSelectedTags();
showDefaultTags();
} 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 = '';
tagSuggestions.empty();
tagSuggestions.style.display = 'none';
defaultTagsGrid.style.display = 'grid';
}
};
@ -176,7 +213,8 @@ export class SelectCategoryModal extends Modal {
tagInput.onblur = () => {
// 延迟隐藏,以便可以点击建议
setTimeout(() => {
tagSuggestions.empty();
tagSuggestions.style.display = 'none';
defaultTagsGrid.style.display = 'grid';
}, 200);
};
@ -205,9 +243,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 +255,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');
}
};
}

View File

@ -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 {
@ -239,42 +199,91 @@ If your plugin does not need CSS, delete this file.
.discourse-sync-modal .tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
padding: 4px 8px;
border-radius: var(--radius-s);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--interactive-accent);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discourse-sync-modal .tag:hover {
background-color: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-s);
}
.discourse-sync-modal .remove-tag {
margin-left: 6px;
cursor: pointer;
font-weight: bold;
font-size: 14px;
width: 16px;
height: 16px;
line-height: 1;
opacity: 0.8;
transition: opacity 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--background-modifier-hover);
margin-left: 4px;
transition: all 0.2s ease;
opacity: 0.7;
background-color: rgba(255, 255, 255, 0.2);
}
.discourse-sync-modal .remove-tag:hover {
background-color: var(--background-modifier-error);
color: var(--text-on-accent);
opacity: 1;
transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.3);
}
/* 默认标签网格样式 */
.discourse-sync-modal .default-tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
margin-top: 8px;
padding: 8px;
background-color: var(--background-secondary);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
}
.discourse-sync-modal .grid-tag {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--background-primary);
color: var(--text-normal);
padding: 6px 8px;
border-radius: var(--radius-s);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--background-modifier-border);
text-align: center;
min-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discourse-sync-modal .grid-tag:hover {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
transform: translateY(-1px);
box-shadow: var(--shadow-s);
}
.discourse-sync-modal .grid-tag:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.discourse-sync-modal input[type="text"] {
@ -382,29 +391,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% {
@ -545,3 +532,67 @@ If your plugin does not need CSS, delete this file.
max-width: none;
}
}
/* Update Notice Modal Styles */
.discourse-update-notice-modal {
padding: var(--size-4-4);
max-width: 500px;
background-color: var(--background-primary);
border-radius: var(--radius-l);
}
.discourse-update-notice-modal .update-notice-title {
margin: 0 0 var(--size-2-3) 0;
font-size: var(--font-text-size);
font-weight: var(--font-weight-bold);
color: var(--text-accent);
text-align: center;
}
.discourse-update-notice-modal .update-notice-message {
margin-bottom: var(--size-4-4);
text-align: left;
}
.discourse-update-notice-modal .update-notice-message p {
margin: 0;
color: var(--text-normal);
line-height: var(--line-height-normal);
font-size: var(--font-ui-medium);
}
.discourse-update-notice-modal .update-notice-buttons {
display: flex;
gap: var(--size-2-3);
justify-content: center;
}
.discourse-update-notice-modal .update-notice-buttons button {
flex: 1;
max-width: 150px;
padding: var(--size-2-2) var(--size-2-4);
border-radius: var(--radius-m);
cursor: pointer;
font-weight: var(--font-weight-medium);
font-size: var(--font-ui-medium);
transition: all 0.2s ease;
min-height: 36px;
border: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
color: var(--text-normal);
}
.discourse-update-notice-modal .update-notice-buttons button:hover {
background-color: var(--background-modifier-hover);
}
.discourse-update-notice-modal .update-notice-buttons button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.discourse-update-notice-modal .update-notice-buttons button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
border-color: var(--interactive-accent-hover);
}

View File

@ -19,5 +19,9 @@
"1.0.17": "0.15.0",
"1.0.18": "0.15.0",
"1.0.19": "0.15.0",
"1.0.20": "0.15.0"
"1.0.20": "0.15.0",
"1.0.21": "0.15.0",
"1.0.22": "0.15.0",
"1.0.23": "0.15.0",
"1.0.24": "0.15.0"
}