mirror of
https://github.com/woodchen-ink/obsidian-publish-to-discourse.git
synced 2025-07-17 21:32:05 +08:00
Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
4170846e1f | |||
|
6acd9bcc01 | ||
|
951eba6bc5 | ||
87a903b4b5 | |||
c7dcbe779b | |||
167794ce4c | |||
e56a479a45 | |||
4900f26222 | |||
9914ca9fd8 | |||
b98cb24e56 | |||
16a33d0572 | |||
|
8388c35baa | ||
|
441d9c3688 | ||
48cbc72c4a | |||
e4f73f5a6b | |||
b24bf09efc | |||
069ce44978 | |||
25982b1541 | |||
48609ce681 | |||
2f32fca358 | |||
9c6157625a | |||
71267c53f1 | |||
0c97380a32 | |||
57863bb962 | |||
d6221a4f91 | |||
53d30650b5 | |||
862c42da61 | |||
cc1e06ec5c | |||
310be30030 | |||
08ad35babe | |||
836c9d9661 | |||
9152aa5d60 | |||
d3b678fc81 | |||
9ca83b1d15 | |||
502196429a |
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
.cursor/rules/discourse.mdc
|
||||
|
@ -1,9 +1,9 @@
|
||||
# Publish to Discourse
|
||||
|
||||
## 配置key
|
||||
## 配置User Api key
|
||||
|
||||
下载插件, 在设置页面添加"论坛地址", "API密钥(需要管理员创建)", "用户名".
|
||||

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

|
||||
|
||||
|
||||
|
||||
@ -37,7 +37,6 @@
|
||||
|
||||

|
||||
|
||||
当然, 我可以帮你优化这段表达, 并提供中英文对照版本。以下是优化后的内容:
|
||||
|
||||
---
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "publish-to-discourse",
|
||||
"name": "Publish to Discourse",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.24",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Publish notes to the Discourse forum.",
|
||||
"author": "WoodChen",
|
||||
|
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "obsidian-publish-to-discourse",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-publish-to-discourse",
|
||||
"version": "1.0.10",
|
||||
"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",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-publish-to-discourse",
|
||||
"version": "1.0.10",
|
||||
"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
BIN
pics/20250711-233019.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
471
src/api.ts
Normal file
471
src/api.ts
Normal file
@ -0,0 +1,471 @@
|
||||
import { App, requestUrl, TFile } from 'obsidian';
|
||||
import { DiscourseSyncSettings } from './config';
|
||||
import { NotifyUser } from './notification';
|
||||
import { t } from './i18n';
|
||||
|
||||
// 生成随机边界字符串
|
||||
const genBoundary = (): string => {
|
||||
return 'ObsidianBoundary' + Math.random().toString(16).substring(2);
|
||||
};
|
||||
|
||||
export class DiscourseAPI {
|
||||
constructor(
|
||||
private app: App,
|
||||
private settings: DiscourseSyncSettings
|
||||
) {}
|
||||
|
||||
// 上传图片到Discourse
|
||||
async uploadImage(file: TFile): Promise<{shortUrl: string, fullUrl?: string} | null> {
|
||||
try {
|
||||
const imgfile = await this.app.vault.readBinary(file);
|
||||
const boundary = genBoundary();
|
||||
const sBoundary = '--' + boundary + '\r\n';
|
||||
const imgForm = `${sBoundary}Content-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: image/${file.extension}\r\n\r\n`;
|
||||
|
||||
let body = '';
|
||||
body += `\r\n${sBoundary}Content-Disposition: form-data; name="type"\r\n\r\ncomposer\r\n`;
|
||||
body += `${sBoundary}Content-Disposition: form-data; name="synchronous"\r\n\r\ntrue\r\n`;
|
||||
|
||||
const eBoundary = '\r\n--' + boundary + '--\r\n';
|
||||
const imgFormArray = new TextEncoder().encode(imgForm);
|
||||
const bodyArray = new TextEncoder().encode(body);
|
||||
const endBoundaryArray = new TextEncoder().encode(eBoundary);
|
||||
|
||||
const formDataArray = new Uint8Array(imgFormArray.length + imgfile.byteLength + bodyArray.length + endBoundaryArray.length);
|
||||
formDataArray.set(imgFormArray, 0);
|
||||
formDataArray.set(new Uint8Array(imgfile), imgFormArray.length);
|
||||
formDataArray.set(bodyArray, imgFormArray.length + imgfile.byteLength);
|
||||
formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength);
|
||||
|
||||
const url = `${this.settings.baseUrl}/uploads.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey,
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url: url,
|
||||
method: "POST",
|
||||
body: formDataArray.buffer,
|
||||
throw: false,
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (response.status == 200) {
|
||||
const jsonResponse = response.json;
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while uploading image: ${error}`).open();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新帖子
|
||||
async createPost(title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; postId?: number; topicId?: number; error?: string }> {
|
||||
const url = `${this.settings.baseUrl}/posts.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
raw: content,
|
||||
category: category,
|
||||
tags: tags || []
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const responseData = response.json;
|
||||
if (responseData && responseData.id) {
|
||||
return {
|
||||
success: true,
|
||||
postId: responseData.id,
|
||||
topicId: responseData.topic_id
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: t('POST_ID_ERROR')
|
||||
};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const errorResponse = response.json;
|
||||
if (errorResponse.errors && errorResponse.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.errors.join('\n')
|
||||
};
|
||||
}
|
||||
if (errorResponse.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.error
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')} (${response.status})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')} (${response.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 更新帖子
|
||||
async updatePost(postId: number, topicId: number, title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; error?: string }> {
|
||||
const postEndpoint = `${this.settings.baseUrl}/posts/${postId}`;
|
||||
const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
try {
|
||||
// 首先更新帖子内容
|
||||
const postResponse = await requestUrl({
|
||||
url: postEndpoint,
|
||||
method: "PUT",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
raw: content,
|
||||
edit_reason: "Updated from Obsidian"
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (postResponse.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${postResponse.status})`
|
||||
};
|
||||
}
|
||||
|
||||
// 然后更新主题(标题、分类和标签)
|
||||
const topicResponse = await requestUrl({
|
||||
url: topicEndpoint,
|
||||
method: "PUT",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
category_id: category,
|
||||
tags: tags || []
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (topicResponse.status === 200) {
|
||||
return { success: true };
|
||||
} else {
|
||||
try {
|
||||
const errorResponse = topicResponse.json;
|
||||
if (errorResponse.errors && errorResponse.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.errors.join('\n')
|
||||
};
|
||||
}
|
||||
if (errorResponse.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.error
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${topicResponse.status})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${topicResponse.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
async fetchCategories(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
const categories: { id: number; name: string }[] = [];
|
||||
|
||||
if (data && data.category_list && data.category_list.categories) {
|
||||
data.category_list.categories.forEach((category: any) => {
|
||||
categories.push({
|
||||
id: category.id,
|
||||
name: category.name
|
||||
});
|
||||
|
||||
// 添加子分类
|
||||
if (category.subcategory_list) {
|
||||
category.subcategory_list.forEach((subcategory: any) => {
|
||||
categories.push({
|
||||
id: subcategory.id,
|
||||
name: `${category.name} > ${subcategory.name}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
} else {
|
||||
new NotifyUser(this.app, `Error fetching categories: ${response.status}`).open();
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching categories: ${error}`).open();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/tags.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
const tags: { name: string; canCreate: boolean }[] = [];
|
||||
|
||||
if (data && data.tags) {
|
||||
const canCreateTags = await this.checkCanCreateTags();
|
||||
|
||||
// 处理所有标签(包括tag_groups中的标签)
|
||||
const allTags = new Map<string, { name: string; count: number }>();
|
||||
|
||||
// 添加普通标签
|
||||
data.tags.forEach((tag: any) => {
|
||||
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;
|
||||
} else {
|
||||
new NotifyUser(this.app, `Error fetching tags: ${response.status}`).open();
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching tags: ${error}`).open();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以创建标签
|
||||
async checkCanCreateTags(): Promise<boolean> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/site.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
if (data && data.can_create_tag) {
|
||||
return data.can_create_tag;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试API密钥
|
||||
async testApiKey(): Promise<{ success: boolean; message: string }> {
|
||||
if (!this.settings.baseUrl || !this.settings.userApiKey) {
|
||||
return {
|
||||
success: false,
|
||||
message: t('MISSING_SETTINGS')
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/site.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
if (data) {
|
||||
return {
|
||||
success: true,
|
||||
message: t('API_TEST_SUCCESS')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: t('API_KEY_INVALID')
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `${t('API_KEY_INVALID')} (${response.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${t('API_KEY_INVALID')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取特定主题的标签和分类信息
|
||||
async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/t/${topicId}.json`;
|
||||
const headers: Record<string, string> = {
|
||||
"User-Api-Key": this.settings.userApiKey
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
return {
|
||||
tags: data?.tags || [],
|
||||
categoryId: data?.category_id
|
||||
};
|
||||
}
|
||||
|
||||
return { tags: [] };
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching topic info: ${error}`).open();
|
||||
return { tags: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 获取特定主题的标签
|
||||
async fetchTopicTags(topicId: number): Promise<string[]> {
|
||||
try {
|
||||
const topicInfo = await this.fetchTopicInfo(topicId);
|
||||
return topicInfo.tags;
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching topic tags: ${error}`).open();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
236
src/config.ts
236
src/config.ts
@ -1,26 +1,27 @@
|
||||
import { PluginSettingTab, Setting, App } from 'obsidian';
|
||||
import DiscourseSyncPlugin from './main';
|
||||
import { PluginSettingTab, Setting, App, Notice, ButtonComponent } from 'obsidian';
|
||||
import PublishToDiscourse from './main';
|
||||
import { t } from './i18n';
|
||||
|
||||
export interface DiscourseSyncSettings {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
disUser: string;
|
||||
category: number;
|
||||
selectedTags: string[];
|
||||
skipH1: boolean;
|
||||
useRemoteImageUrl: boolean;
|
||||
userApiKey: string;
|
||||
lastNotifiedVersion?: string; // 记录上次显示更新通知的版本
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
||||
baseUrl: "https://yourforum.example.com",
|
||||
apiKey: "apikey",
|
||||
disUser: "DiscourseUsername",
|
||||
category: 1,
|
||||
selectedTags: []
|
||||
skipH1: false,
|
||||
useRemoteImageUrl: true, //默认启用
|
||||
userApiKey: ""
|
||||
};
|
||||
|
||||
export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
||||
plugin: DiscourseSyncPlugin;
|
||||
constructor(app: App, plugin: DiscourseSyncPlugin) {
|
||||
plugin: PublishToDiscourse;
|
||||
constructor(app: App, plugin: PublishToDiscourse) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
@ -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,30 +50,199 @@ 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)
|
||||
.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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ====== 获取 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) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.skipH1)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.apiKey = value;
|
||||
this.plugin.settings.skipH1 = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('USERNAME'))
|
||||
.setDesc(t('USERNAME_DESC'))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("username")
|
||||
.setValue(this.plugin.settings.disUser)
|
||||
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.disUser = value;
|
||||
this.plugin.settings.useRemoteImageUrl = value;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
51
src/crypto.ts
Normal file
51
src/crypto.ts
Normal 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;
|
||||
}
|
99
src/embed-handler.ts
Normal file
99
src/embed-handler.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { NotifyUser } from './notification';
|
||||
import { DiscourseAPI } from './api';
|
||||
import { isImageFile } from './utils';
|
||||
|
||||
export class EmbedHandler {
|
||||
constructor(
|
||||
private app: App,
|
||||
private api: DiscourseAPI
|
||||
) {}
|
||||
|
||||
// 提取嵌入引用
|
||||
extractEmbedReferences(content: string): string[] {
|
||||
const references: string[] = [];
|
||||
|
||||
// 匹配 ![[...]] 格式 (Wiki格式)
|
||||
const wikiRegex = /!\[\[(.*?)\]\]/g;
|
||||
let match;
|
||||
while ((match = wikiRegex.exec(content)) !== null) {
|
||||
references.push(match[1]);
|
||||
}
|
||||
|
||||
// 匹配  格式 (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, useRemoteUrl = false): Promise<string[]> {
|
||||
const uploadedUrls: string[] = [];
|
||||
for (const ref of embedReferences) {
|
||||
// 处理带有#的文件路径,分离文件名和标题部分
|
||||
let filePart = ref;
|
||||
const hashIndex = filePart.indexOf("#");
|
||||
if (hashIndex >= 0) {
|
||||
filePart = filePart.substring(0, hashIndex).trim();
|
||||
}
|
||||
|
||||
const filePath = this.app.metadataCache.getFirstLinkpathDest(filePart, activeFileName)?.path;
|
||||
if (filePath) {
|
||||
const abstractFile = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (abstractFile instanceof TFile) {
|
||||
// 检查是否为图片或PDF文件
|
||||
if (isImageFile(abstractFile)) {
|
||||
const 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("");
|
||||
}
|
||||
} else {
|
||||
new NotifyUser(this.app, `File not found in vault: ${ref}`).open();
|
||||
uploadedUrls.push("");
|
||||
}
|
||||
} else {
|
||||
new NotifyUser(this.app, `Unable to resolve file path for: ${ref}`).open();
|
||||
uploadedUrls.push("");
|
||||
}
|
||||
}
|
||||
return uploadedUrls;
|
||||
}
|
||||
|
||||
// 替换内容中的嵌入引用为Markdown格式
|
||||
replaceEmbedReferences(content: string, embedReferences: string[], uploadedUrls: string[]): string {
|
||||
let processedContent = content;
|
||||
|
||||
embedReferences.forEach((ref, index) => {
|
||||
if (uploadedUrls[index]) {
|
||||
// 处理 ![[...]] 格式 (Wiki格式)
|
||||
const wikiRef = `![[${ref}]]`;
|
||||
const wikiReplacement = ``;
|
||||
processedContent = processedContent.replace(wikiRef, wikiReplacement);
|
||||
|
||||
// 处理  格式 (Markdown格式)
|
||||
// 创建正则表达式来匹配具体的路径
|
||||
const escapedRef = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const markdownRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedRef}\\)`, 'g');
|
||||
const markdownReplacement = ``;
|
||||
processedContent = processedContent.replace(markdownRegex, markdownReplacement);
|
||||
}
|
||||
});
|
||||
|
||||
return processedContent;
|
||||
}
|
||||
}
|
@ -15,6 +15,27 @@
|
||||
|
||||
import { App, TFile, CachedMetadata } from "obsidian";
|
||||
|
||||
/**
|
||||
* 将frontmatter转换为Markdown引用格式
|
||||
*/
|
||||
function convertFrontmatterToQuote(content: string): string {
|
||||
// 检查是否有frontmatter
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!fmMatch) return content;
|
||||
|
||||
// 提取frontmatter内容
|
||||
const fmContent = fmMatch[1];
|
||||
|
||||
// 将frontmatter内容转换为引用格式
|
||||
const quotedFm = fmContent
|
||||
.split('\n')
|
||||
.map(line => `> ${line}`)
|
||||
.join('\n');
|
||||
|
||||
// 替换原始frontmatter
|
||||
return content.replace(/^---\n[\s\S]*?\n---\n/, `${quotedFm}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expands embedded content (including subpath references),
|
||||
* allowing the same (file+subpath) to appear multiple times if it's *not*
|
||||
@ -64,7 +85,10 @@ export async function expandEmbeds(
|
||||
}
|
||||
|
||||
// Recursively expand that subpath
|
||||
return expandEmbeds(app, linkedTFile, stack, sub);
|
||||
const expandedContent = await expandEmbeds(app, linkedTFile, stack, sub);
|
||||
|
||||
// 将嵌入内容中的frontmatter转换为引用格式
|
||||
return convertFrontmatterToQuote(expandedContent);
|
||||
});
|
||||
|
||||
// Pop it from stack
|
||||
@ -101,9 +125,11 @@ function sliceSubpathContent(
|
||||
const { start, end } = block.position;
|
||||
if (!end) {
|
||||
// Goes to EOF if no explicit end
|
||||
return fileContent.substring(start.offset);
|
||||
const slicedContent = fileContent.substring(start.offset);
|
||||
return convertFrontmatterToQuote(slicedContent);
|
||||
} else {
|
||||
return fileContent.substring(start.offset, end.offset);
|
||||
const slicedContent = fileContent.substring(start.offset, end.offset);
|
||||
return convertFrontmatterToQuote(slicedContent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,7 +173,8 @@ function sliceHeading(content: string, fileCache: CachedMetadata, headingName: s
|
||||
}
|
||||
console.log(`"Sliceheading for ${heading}, level ${thisLevel}, offsets ${startOffset} and ${endOffset}."`)
|
||||
|
||||
return content.substring(startOffset, endOffset).trim();
|
||||
const slicedContent = content.substring(startOffset, endOffset).trim();
|
||||
return convertFrontmatterToQuote(slicedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,10 +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_SETTINGS': 'Please fill in Forum URL and User-Api-Key first',
|
||||
|
||||
// Publish page
|
||||
'PUBLISH_TO_DISCOURSE': 'Publish to Discourse',
|
||||
@ -13,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',
|
||||
@ -34,9 +40,53 @@ 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',
|
||||
'NO_ACTIVE_FILE': 'No active file',
|
||||
'NO_TOPIC_ID': 'This note has not been published to Discourse yet'
|
||||
'NO_TOPIC_ID': 'This note has not been published to Discourse yet',
|
||||
|
||||
// Category conflict
|
||||
'CATEGORY_CONFLICT_TITLE': 'Category Conflict',
|
||||
'CATEGORY_CONFLICT_DESC': 'The local category setting differs from the remote category on Discourse. Please choose which category to use:',
|
||||
'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',
|
||||
|
||||
// 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,10 +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_SETTINGS': '请先填写论坛地址和User-Api-Key',
|
||||
|
||||
// 发布页面
|
||||
'PUBLISH_TO_DISCOURSE': '发布到 Discourse',
|
||||
@ -13,7 +19,7 @@ export default {
|
||||
'CATEGORY': '分类',
|
||||
'TAGS': '标签',
|
||||
'ENTER_TAG': '输入标签名称(回车添加)',
|
||||
'ENTER_TAG_WITH_CREATE': '输入标签名称(可创建新标签)',
|
||||
'ENTER_TAG_WITH_CREATE': '输入标签名称(回车添加)(可创建新标签)',
|
||||
'PUBLISHING': '发布中...',
|
||||
'UPDATING': '更新中...',
|
||||
'PUBLISH': '发布',
|
||||
@ -34,9 +40,53 @@ 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 中打开',
|
||||
'NO_ACTIVE_FILE': '没有打开的文件',
|
||||
'NO_TOPIC_ID': '此笔记尚未发布到 Discourse'
|
||||
'NO_TOPIC_ID': '此笔记尚未发布到 Discourse',
|
||||
|
||||
// 分类冲突
|
||||
'CATEGORY_CONFLICT_TITLE': '分类冲突',
|
||||
'CATEGORY_CONFLICT_DESC': '检测到本地设置的分类与 Discourse 上的分类不同,请选择要使用的分类:',
|
||||
'LOCAL_CATEGORY': '本地分类(frontmatter中设置)',
|
||||
'REMOTE_CATEGORY': '远程分类(Discourse上的分类)',
|
||||
'KEEP_LOCAL_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 已复制到剪贴板'
|
||||
}
|
1023
src/main.ts
1023
src/main.ts
File diff suppressed because it is too large
Load Diff
20
src/notification.ts
Normal file
20
src/notification.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { App, Modal } from 'obsidian';
|
||||
|
||||
// 通知用户的模态框
|
||||
export class NotifyUser extends Modal {
|
||||
message: string;
|
||||
constructor(app: App, message: string) {
|
||||
super(app);
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.setText(this.message);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
15
src/types.ts
Normal file
15
src/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { DiscourseSyncSettings } from './config';
|
||||
|
||||
export interface ActiveFile {
|
||||
name: string;
|
||||
content: string;
|
||||
postId?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface PluginInterface {
|
||||
settings: DiscourseSyncSettings;
|
||||
activeFile: ActiveFile;
|
||||
saveSettings(): Promise<void>;
|
||||
publishTopic(): Promise<{ success: boolean; error?: string }>;
|
||||
}
|
391
src/ui.ts
Normal file
391
src/ui.ts
Normal file
@ -0,0 +1,391 @@
|
||||
import { App, Modal, Notice } from 'obsidian';
|
||||
import { t } from './i18n';
|
||||
import { PluginInterface } from './types';
|
||||
|
||||
// 选择分类的模态框
|
||||
export class SelectCategoryModal extends Modal {
|
||||
plugin: PluginInterface;
|
||||
categories: {id: number; name: string}[];
|
||||
tags: { name: string; canCreate: boolean }[];
|
||||
canCreateTags = false;
|
||||
|
||||
constructor(app: App, plugin: PluginInterface, categories: {id: number; name: string }[], tags: { name: string; canCreate: boolean }[]) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.categories = categories;
|
||||
this.tags = tags;
|
||||
this.canCreateTags = tags.length > 0 && tags[0].canCreate;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
// 添加模态框基础样式
|
||||
this.modalEl.addClass('mod-discourse-sync');
|
||||
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('discourse-sync-modal');
|
||||
|
||||
const isUpdate = this.plugin.activeFile.postId !== undefined;
|
||||
contentEl.createEl('h1', { text: isUpdate ? t('UPDATE_POST') : t('PUBLISH_TO_DISCOURSE') });
|
||||
|
||||
// 创建表单区域容器
|
||||
const formArea = contentEl.createEl('div', { cls: 'form-area' });
|
||||
|
||||
// 创建分类选择容器
|
||||
const selectContainer = formArea.createEl('div', { cls: 'select-container' });
|
||||
selectContainer.createEl('label', { text: t('CATEGORY') });
|
||||
const selectEl = selectContainer.createEl('select');
|
||||
|
||||
// 添加分类选项
|
||||
this.categories.forEach(category => {
|
||||
const option = selectEl.createEl('option', { text: category.name });
|
||||
option.value = category.id.toString();
|
||||
});
|
||||
|
||||
// 设置默认选中的分类
|
||||
selectEl.value = this.plugin.settings.category?.toString() || this.categories[0].id.toString();
|
||||
|
||||
// 监听分类选择变化
|
||||
selectEl.onchange = () => {
|
||||
this.plugin.settings.category = parseInt(selectEl.value);
|
||||
this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
// 创建标签容器
|
||||
const tagContainer = formArea.createEl('div', { cls: 'tag-container' });
|
||||
tagContainer.createEl('label', { text: t('TAGS') });
|
||||
|
||||
// 创建标签选择区域
|
||||
const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' });
|
||||
|
||||
// 已选标签显示区域
|
||||
const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' });
|
||||
const selectedTags = new Set<string>();
|
||||
|
||||
// 初始化已选标签
|
||||
if (this.plugin.activeFile.tags && this.plugin.activeFile.tags.length > 0) {
|
||||
this.plugin.activeFile.tags.forEach(tag => selectedTags.add(tag));
|
||||
}
|
||||
|
||||
// 更新标签显示
|
||||
const updateSelectedTags = () => {
|
||||
selectedTagsContainer.empty();
|
||||
selectedTags.forEach(tag => {
|
||||
const tagEl = selectedTagsContainer.createEl('span', {
|
||||
cls: 'tag',
|
||||
text: tag
|
||||
});
|
||||
const removeBtn = tagEl.createEl('span', {
|
||||
cls: 'remove-tag',
|
||||
text: '×'
|
||||
});
|
||||
removeBtn.onclick = () => {
|
||||
selectedTags.delete(tag);
|
||||
updateSelectedTags();
|
||||
showDefaultTags(); // 重新显示网格,让移除的标签重新出现
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化标签显示
|
||||
updateSelectedTags();
|
||||
|
||||
// 创建标签输入容器
|
||||
const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' });
|
||||
|
||||
// 创建标签输入和建议
|
||||
const tagInput = tagInputContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: this.canCreateTags ? t('ENTER_TAG_WITH_CREATE') : t('ENTER_TAG')
|
||||
});
|
||||
|
||||
// 创建标签建议容器
|
||||
const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' });
|
||||
|
||||
// 创建默认标签网格容器
|
||||
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();
|
||||
|
||||
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, 20); // 搜索结果显示更多
|
||||
|
||||
if (matches.length > 0) {
|
||||
// 获取输入框位置和宽度
|
||||
const inputRect = tagInput.getBoundingClientRect();
|
||||
const modalRect = this.modalEl.getBoundingClientRect();
|
||||
|
||||
// 确保建议列表不超过模态框宽度
|
||||
const maxWidth = modalRect.right - inputRect.left - 24; // 24px是右边距
|
||||
|
||||
// 设置建议列表位置和宽度
|
||||
tagSuggestions.style.top = `${inputRect.bottom + 4}px`;
|
||||
tagSuggestions.style.left = `${inputRect.left}px`;
|
||||
tagSuggestions.style.width = `${Math.min(inputRect.width, maxWidth)}px`;
|
||||
tagSuggestions.style.display = 'block';
|
||||
|
||||
matches.forEach(tag => {
|
||||
const suggestion = tagSuggestions.createEl('div', {
|
||||
cls: 'tag-suggestion',
|
||||
text: tag.name
|
||||
});
|
||||
suggestion.onclick = () => {
|
||||
selectedTags.add(tag.name);
|
||||
tagInput.value = '';
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理回车事件
|
||||
tagInput.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' && tagInput.value) {
|
||||
e.preventDefault();
|
||||
const value = tagInput.value.trim();
|
||||
if (value && !selectedTags.has(value)) {
|
||||
const existingTag = this.tags.find(t => t.name.toLowerCase() === value.toLowerCase());
|
||||
if (existingTag) {
|
||||
selectedTags.add(existingTag.name);
|
||||
updateSelectedTags();
|
||||
showDefaultTags();
|
||||
} else if (this.canCreateTags) {
|
||||
selectedTags.add(value);
|
||||
updateSelectedTags();
|
||||
showDefaultTags();
|
||||
} else {
|
||||
// 显示权限提示
|
||||
new Notice(t('PERMISSION_ERROR'), 3000);
|
||||
}
|
||||
}
|
||||
tagInput.value = '';
|
||||
tagSuggestions.style.display = 'none';
|
||||
defaultTagsGrid.style.display = 'grid';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理失焦事件,隐藏建议
|
||||
tagInput.onblur = () => {
|
||||
// 延迟隐藏,以便可以点击建议
|
||||
setTimeout(() => {
|
||||
tagSuggestions.style.display = 'none';
|
||||
defaultTagsGrid.style.display = 'grid';
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 处理窗口滚动,更新建议列表位置
|
||||
const updateSuggestionsPosition = () => {
|
||||
if (tagSuggestions.childNodes.length > 0) {
|
||||
const inputRect = tagInput.getBoundingClientRect();
|
||||
tagSuggestions.style.top = `${inputRect.bottom + 4}px`;
|
||||
tagSuggestions.style.left = `${inputRect.left}px`;
|
||||
tagSuggestions.style.width = `${inputRect.width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件
|
||||
this.modalEl.addEventListener('scroll', updateSuggestionsPosition);
|
||||
|
||||
// 模态框关闭时移除事件监听器
|
||||
this.modalEl.onclose = () => {
|
||||
this.modalEl.removeEventListener('scroll', updateSuggestionsPosition);
|
||||
};
|
||||
|
||||
// 创建按钮区域
|
||||
const buttonArea = contentEl.createEl('div', { cls: 'button-area' });
|
||||
const submitButton = buttonArea.createEl('button', {
|
||||
text: isUpdate ? t('UPDATE') : t('PUBLISH'),
|
||||
cls: 'submit-button'
|
||||
});
|
||||
|
||||
submitButton.onclick = async () => {
|
||||
// 保存当前选择的标签到activeFile对象
|
||||
this.plugin.activeFile.tags = Array.from(selectedTags);
|
||||
|
||||
// 禁用提交按钮,显示加载状态
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = isUpdate ? t('UPDATING') : t('PUBLISHING');
|
||||
|
||||
try {
|
||||
// 发布主题
|
||||
const result = await this.plugin.publishTopic();
|
||||
|
||||
if (result.success) {
|
||||
// 成功
|
||||
new Notice(isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS'), 5000);
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 2000);
|
||||
} else {
|
||||
// 失败 - 使用 Obsidian 原生 Notice
|
||||
const errorMessage = (isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')) +
|
||||
'\n' + (result.error || t('UNKNOWN_ERROR'));
|
||||
new Notice(errorMessage, 8000);
|
||||
|
||||
// 重置按钮状态
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// 分类冲突确认对话框
|
||||
export class CategoryConflictModal extends Modal {
|
||||
plugin: PluginInterface;
|
||||
localCategoryId: number;
|
||||
localCategoryName: string;
|
||||
remoteCategoryId: number;
|
||||
remoteCategoryName: string;
|
||||
resolve: (useRemote: boolean) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: PluginInterface,
|
||||
localCategoryId: number,
|
||||
localCategoryName: string,
|
||||
remoteCategoryId: number,
|
||||
remoteCategoryName: string
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.localCategoryId = localCategoryId;
|
||||
this.localCategoryName = localCategoryName;
|
||||
this.remoteCategoryId = remoteCategoryId;
|
||||
this.remoteCategoryName = remoteCategoryName;
|
||||
}
|
||||
|
||||
// 返回一个Promise,让调用者等待用户选择
|
||||
showAndWait(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
this.resolve = resolve;
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('discourse-category-conflict-modal');
|
||||
|
||||
// 标题
|
||||
contentEl.createEl('h2', { text: t('CATEGORY_CONFLICT_TITLE') });
|
||||
|
||||
// 说明文字
|
||||
const description = contentEl.createEl('div', { cls: 'conflict-description' });
|
||||
description.createEl('p', { text: t('CATEGORY_CONFLICT_DESC') });
|
||||
|
||||
// 分类对比
|
||||
const comparisonContainer = contentEl.createEl('div', { cls: 'category-comparison' });
|
||||
|
||||
// 本地分类
|
||||
const localContainer = comparisonContainer.createEl('div', { cls: 'category-option local' });
|
||||
localContainer.createEl('h3', { text: t('LOCAL_CATEGORY') });
|
||||
localContainer.createEl('div', {
|
||||
cls: 'category-name',
|
||||
text: `${this.localCategoryName} (ID: ${this.localCategoryId})`
|
||||
});
|
||||
|
||||
// 远程分类
|
||||
const remoteContainer = comparisonContainer.createEl('div', { cls: 'category-option remote' });
|
||||
remoteContainer.createEl('h3', { text: t('REMOTE_CATEGORY') });
|
||||
remoteContainer.createEl('div', {
|
||||
cls: 'category-name',
|
||||
text: `${this.remoteCategoryName} (ID: ${this.remoteCategoryId})`
|
||||
});
|
||||
|
||||
// 按钮区域
|
||||
const buttonArea = contentEl.createEl('div', { cls: 'button-area' });
|
||||
|
||||
// 保持本地分类按钮
|
||||
const keepLocalButton = buttonArea.createEl('button', {
|
||||
cls: 'keep-local-button',
|
||||
text: t('KEEP_LOCAL_CATEGORY')
|
||||
});
|
||||
keepLocalButton.onclick = () => {
|
||||
this.resolve(false);
|
||||
this.close();
|
||||
};
|
||||
|
||||
// 使用远程分类按钮
|
||||
const useRemoteButton = buttonArea.createEl('button', {
|
||||
cls: 'use-remote-button',
|
||||
text: t('USE_REMOTE_CATEGORY')
|
||||
});
|
||||
useRemoteButton.onclick = () => {
|
||||
this.resolve(true);
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// 如果用户直接关闭对话框,默认保持本地设置
|
||||
if (this.resolve) {
|
||||
this.resolve(false);
|
||||
}
|
||||
}
|
||||
}
|
27
src/utils.ts
Normal file
27
src/utils.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { TFile } from 'obsidian';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
|
||||
// 从内容中提取Front Matter
|
||||
export function getFrontMatter(content: string): any {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (fmMatch) {
|
||||
try {
|
||||
return yaml.parse(fmMatch[1]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 移除内容中的Front Matter
|
||||
export function removeFrontMatter(content: string): string {
|
||||
return content.replace(/^---[\s\S]*?---\n/, '');
|
||||
}
|
||||
|
||||
// 检查文件是否为图片或PDF
|
||||
export function isImageFile(file: TFile): boolean {
|
||||
const imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "pdf"];
|
||||
return imageExtensions.includes(file.extension.toLowerCase());
|
||||
}
|
466
styles.css
466
styles.css
@ -7,15 +7,55 @@ 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;
|
||||
max-height: 90vh;
|
||||
background-color: var(--background-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
border-radius: var(--radius-l);
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* Content Area Style */
|
||||
@ -69,107 +109,71 @@ If your plugin does not need CSS, delete this file.
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: 14px;
|
||||
min-height: 42px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.discourse-sync-modal select {
|
||||
height: 42px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.discourse-sync-modal select:hover {
|
||||
border-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.discourse-sync-modal select:focus {
|
||||
border-color: var(--interactive-accent);
|
||||
border-color: var(--interactive-accent) !important;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .submit-button {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-m);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
/* 确保按钮有合适的尺寸和阴影 */
|
||||
min-height: 42px;
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
/* 确保按钮在所有主题下都可见 */
|
||||
.discourse-sync-modal .submit-button {
|
||||
/* 强制设置具体的样式值,避免被主题覆盖 */
|
||||
background-color: var(--interactive-accent) !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
border: 1px solid var(--interactive-accent) !important;
|
||||
/* 确保按钮有足够的对比度 */
|
||||
filter: contrast(1.1) saturate(1.1);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .submit-button:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
background-color: var(--interactive-accent-hover) !important;
|
||||
border-color: var(--interactive-accent-hover) !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .submit-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--background-modifier-border) !important;
|
||||
color: var(--text-muted) !important;
|
||||
border-color: var(--background-modifier-border) !important;
|
||||
opacity: 1 !important;
|
||||
filter: none;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Notice Style */
|
||||
.discourse-sync-modal .notice {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
/* 这些规则已经被上面的 !important 规则覆盖了,移除重复 */
|
||||
|
||||
.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 {
|
||||
background: rgb(255, 235, 235);
|
||||
border: 1px solid rgba(255, 82, 82, 0.2);
|
||||
color: rgb(255, 82, 82);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .error-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: rgb(255, 82, 82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .error-title::before {
|
||||
content: "⚠️";
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .error-message {
|
||||
color: rgb(255, 82, 82);
|
||||
opacity: 0.8;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .retry-button {
|
||||
margin-top: 12px;
|
||||
padding: 6px 16px;
|
||||
background-color: transparent;
|
||||
color: rgb(255, 82, 82);
|
||||
border: 1px solid rgb(255, 82, 82);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .retry-button:hover {
|
||||
background-color: rgb(255, 82, 82);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tag Select Area Style */
|
||||
.discourse-sync-modal .tag-select-area {
|
||||
@ -195,32 +199,93 @@ 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);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-s);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag:hover {
|
||||
background-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: rgba(255, 255, 255, 0.2);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .remove-tag:hover {
|
||||
opacity: 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"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
@ -229,6 +294,17 @@ If your plugin does not need CSS, delete this file.
|
||||
color: var(--text-normal);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.discourse-sync-modal input[type="text"]:focus {
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.discourse-sync-modal input[type="text"]::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .suggestions-container {
|
||||
@ -248,13 +324,14 @@ If your plugin does not need CSS, delete this file.
|
||||
position: fixed;
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-l);
|
||||
z-index: 1001;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
display: none; /* Hidden by default */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestions:not(:empty) {
|
||||
@ -273,28 +350,40 @@ If your plugin does not need CSS, delete this file.
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: var(--background-primary);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-s);
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestion:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-accent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestion:active {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* Tag Suggestion Scrollbar Style */
|
||||
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: var(--background-secondary);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-thumb {
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--radius-s);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
background-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
/* Remove tag-container z-index to avoid interference */
|
||||
@ -302,25 +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;
|
||||
background-color: rgb(255, 235, 235);
|
||||
border: 1px solid rgba(255, 82, 82, 0.2);
|
||||
color: rgb(255, 82, 82);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
@ -336,3 +407,192 @@ If your plugin does not need CSS, delete this file.
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Category Conflict Modal Styles */
|
||||
.discourse-category-conflict-modal {
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
background-color: var(--background-primary);
|
||||
border-radius: var(--radius-l);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .conflict-description {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .conflict-description p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-comparison {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-option {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
text-align: center;
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-option.local {
|
||||
border-color: var(--color-blue);
|
||||
background-color: var(--color-blue-hover);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-option.remote {
|
||||
border-color: var(--color-orange);
|
||||
background-color: var(--color-orange-hover);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-option h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .category-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .button-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .keep-local-button,
|
||||
.discourse-category-conflict-modal .use-remote-button {
|
||||
flex: 1;
|
||||
max-width: 180px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-m);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 42px;
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .keep-local-button {
|
||||
background-color: var(--color-blue);
|
||||
color: white;
|
||||
border: 1px solid var(--color-blue);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .keep-local-button:hover {
|
||||
background-color: var(--color-blue-hover);
|
||||
border-color: var(--color-blue-hover);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .use-remote-button {
|
||||
background-color: var(--color-orange);
|
||||
color: white;
|
||||
border: 1px solid var(--color-orange);
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .use-remote-button:hover {
|
||||
background-color: var(--color-orange-hover);
|
||||
border-color: var(--color-orange-hover);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 600px) {
|
||||
.discourse-category-conflict-modal .category-comparison {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .button-area {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.discourse-category-conflict-modal .keep-local-button,
|
||||
.discourse-category-conflict-modal .use-remote-button {
|
||||
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);
|
||||
}
|
||||
|
@ -9,5 +9,19 @@
|
||||
"1.0.7": "0.15.0",
|
||||
"1.0.8": "0.15.0",
|
||||
"1.0.9": "0.15.0",
|
||||
"1.0.10": "0.15.0"
|
||||
"1.0.10": "0.15.0",
|
||||
"1.0.11": "0.15.0",
|
||||
"1.0.12": "0.15.0",
|
||||
"1.0.13": "0.15.0",
|
||||
"1.0.14": "0.15.0",
|
||||
"1.0.15": "0.15.0",
|
||||
"1.0.16": "0.15.0",
|
||||
"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.21": "0.15.0",
|
||||
"1.0.22": "0.15.0",
|
||||
"1.0.23": "0.15.0",
|
||||
"1.0.24": "0.15.0"
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user