mirror of
https://github.com/woodchen-ink/obsidian-publish-to-discourse.git
synced 2025-07-18 05:42:05 +08:00
重构插件架构,解耦主要功能模块
- 将主类拆分为多个专注的模块,如 DiscourseAPI、EmbedHandler - 重命名主类为 PublishToDiscourse,提高语义清晰度 - 抽取通用工具函数到独立模块 - 优化代码结构,提高可维护性和可读性 - 调整 Front Matter 处理和嵌入内容处理逻辑
This commit is contained in:
parent
310be30030
commit
cc1e06ec5c
390
src/api.ts
Normal file
390
src/api.ts
Normal file
@ -0,0 +1,390 @@
|
||||
import { App, requestUrl, TFile } from 'obsidian';
|
||||
import { DiscourseSyncSettings } from './config';
|
||||
import { NotifyUser } from './notification';
|
||||
import { t } from './i18n';
|
||||
|
||||
// 生成随机边界字符串
|
||||
const genBoundary = (): string => {
|
||||
return 'ObsidianBoundary' + Math.random().toString(16).substring(2);
|
||||
};
|
||||
|
||||
export class DiscourseAPI {
|
||||
constructor(
|
||||
private app: App,
|
||||
private settings: DiscourseSyncSettings
|
||||
) {}
|
||||
|
||||
// 上传图片到Discourse
|
||||
async uploadImage(file: TFile): Promise<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 = {
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url: url,
|
||||
method: "POST",
|
||||
body: formDataArray.buffer,
|
||||
throw: false,
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
if (response.status == 200) {
|
||||
const jsonResponse = response.json;
|
||||
return jsonResponse.short_url;
|
||||
} else {
|
||||
new NotifyUser(this.app, `Error uploading image: ${response.status}`).open();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while uploading image: ${error}`).open();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新帖子
|
||||
async createPost(title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; postId?: number; topicId?: number; error?: string }> {
|
||||
const url = `${this.settings.baseUrl}/posts.json`;
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
raw: content,
|
||||
category: category,
|
||||
tags: tags || []
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const responseData = response.json;
|
||||
if (responseData && responseData.id) {
|
||||
return {
|
||||
success: true,
|
||||
postId: responseData.id,
|
||||
topicId: responseData.topic_id
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: t('POST_ID_ERROR')
|
||||
};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const errorResponse = response.json;
|
||||
if (errorResponse.errors && errorResponse.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.errors.join('\n')
|
||||
};
|
||||
}
|
||||
if (errorResponse.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.error
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')} (${response.status})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')} (${response.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('PUBLISH_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 更新帖子
|
||||
async updatePost(postId: number, topicId: number, title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; error?: string }> {
|
||||
const postEndpoint = `${this.settings.baseUrl}/posts/${postId}`;
|
||||
const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`;
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
try {
|
||||
// 首先更新帖子内容
|
||||
const postResponse = await requestUrl({
|
||||
url: postEndpoint,
|
||||
method: "PUT",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
raw: content,
|
||||
edit_reason: "Updated from Obsidian"
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (postResponse.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${postResponse.status})`
|
||||
};
|
||||
}
|
||||
|
||||
// 然后更新主题(标题、分类和标签)
|
||||
const topicResponse = await requestUrl({
|
||||
url: topicEndpoint,
|
||||
method: "PUT",
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
category_id: category,
|
||||
tags: tags || []
|
||||
}),
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (topicResponse.status === 200) {
|
||||
return { success: true };
|
||||
} else {
|
||||
try {
|
||||
const errorResponse = topicResponse.json;
|
||||
if (errorResponse.errors && errorResponse.errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.errors.join('\n')
|
||||
};
|
||||
}
|
||||
if (errorResponse.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorResponse.error
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${topicResponse.status})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')} (${topicResponse.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${t('UPDATE_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
async fetchCategories(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/categories.json`;
|
||||
const headers = {
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
const categories: { id: number; name: string }[] = [];
|
||||
|
||||
if (data && data.category_list && data.category_list.categories) {
|
||||
data.category_list.categories.forEach((category: any) => {
|
||||
categories.push({
|
||||
id: category.id,
|
||||
name: category.name
|
||||
});
|
||||
|
||||
// 添加子分类
|
||||
if (category.subcategory_list) {
|
||||
category.subcategory_list.forEach((subcategory: any) => {
|
||||
categories.push({
|
||||
id: subcategory.id,
|
||||
name: `${category.name} > ${subcategory.name}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return categories;
|
||||
} else {
|
||||
new NotifyUser(this.app, `Error fetching categories: ${response.status}`).open();
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching categories: ${error}`).open();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/tags.json`;
|
||||
const headers = {
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
const tags: { name: string; canCreate: boolean }[] = [];
|
||||
|
||||
if (data && data.tags) {
|
||||
const canCreateTags = await this.checkCanCreateTags();
|
||||
data.tags.forEach((tag: any) => {
|
||||
tags.push({
|
||||
name: tag.name,
|
||||
canCreate: canCreateTags
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
} else {
|
||||
new NotifyUser(this.app, `Error fetching tags: ${response.status}`).open();
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
new NotifyUser(this.app, `Exception while fetching tags: ${error}`).open();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以创建标签
|
||||
async checkCanCreateTags(): Promise<boolean> {
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/site.json`;
|
||||
const headers = {
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
if (data && data.can_create_tag) {
|
||||
return data.can_create_tag;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试API密钥
|
||||
async testApiKey(): Promise<{ success: boolean; message: string }> {
|
||||
if (!this.settings.baseUrl || !this.settings.apiKey || !this.settings.disUser) {
|
||||
return {
|
||||
success: false,
|
||||
message: t('MISSING_SETTINGS')
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`;
|
||||
const headers = {
|
||||
"Api-Key": this.settings.apiKey,
|
||||
"Api-Username": this.settings.disUser,
|
||||
};
|
||||
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "GET",
|
||||
headers,
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.json;
|
||||
if (data && data.user) {
|
||||
return {
|
||||
success: true,
|
||||
message: t('API_TEST_SUCCESS')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: t('API_KEY_INVALID')
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `${t('API_KEY_INVALID')} (${response.status})`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${t('API_KEY_INVALID')}: ${error.message || t('UNKNOWN_ERROR')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { PluginSettingTab, Setting, App, Notice, ButtonComponent } from 'obsidian';
|
||||
import DiscourseSyncPlugin from './main';
|
||||
import PublishToDiscourse from './main';
|
||||
import { t } from './i18n';
|
||||
|
||||
export interface DiscourseSyncSettings {
|
||||
@ -19,8 +19,8 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
||||
};
|
||||
|
||||
export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
||||
plugin: DiscourseSyncPlugin;
|
||||
constructor(app: App, plugin: DiscourseSyncPlugin) {
|
||||
plugin: PublishToDiscourse;
|
||||
constructor(app: App, plugin: PublishToDiscourse) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
||||
button.setButtonText(t('TESTING'));
|
||||
button.setDisabled(true);
|
||||
|
||||
const result = await this.plugin.testApiKey();
|
||||
const result = await this.plugin.api.testApiKey();
|
||||
|
||||
button.setButtonText(t('TEST_API_KEY'));
|
||||
button.setDisabled(false);
|
||||
|
77
src/embed-handler.ts
Normal file
77
src/embed-handler.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { expandEmbeds } from './expand-embeds';
|
||||
import { NotifyUser } from './notification';
|
||||
import { DiscourseAPI } from './api';
|
||||
import { isImageFile } from './utils';
|
||||
|
||||
export class EmbedHandler {
|
||||
constructor(
|
||||
private app: App,
|
||||
private api: DiscourseAPI
|
||||
) {}
|
||||
|
||||
// 提取嵌入引用
|
||||
extractEmbedReferences(content: string): string[] {
|
||||
const regex = /!\[\[(.*?)\]\]/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
// 处理嵌入内容
|
||||
async processEmbeds(embedReferences: string[], activeFileName: string): Promise<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 imageUrl = await this.api.uploadImage(abstractFile);
|
||||
uploadedUrls.push(imageUrl || "");
|
||||
} else {
|
||||
// 非图片文件,返回空字符串
|
||||
uploadedUrls.push("");
|
||||
}
|
||||
} else {
|
||||
new NotifyUser(this.app, `File not found in vault: ${ref}`).open();
|
||||
uploadedUrls.push("");
|
||||
}
|
||||
} else {
|
||||
new NotifyUser(this.app, `Unable to resolve file path for: ${ref}`).open();
|
||||
uploadedUrls.push("");
|
||||
}
|
||||
}
|
||||
return uploadedUrls;
|
||||
}
|
||||
|
||||
// 替换内容中的嵌入引用为Markdown格式
|
||||
replaceEmbedReferences(content: string, embedReferences: string[], uploadedUrls: string[]): string {
|
||||
let processedContent = content;
|
||||
embedReferences.forEach((ref, index) => {
|
||||
const obsRef = `![[${ref}]]`;
|
||||
// 只有当上传URL不为空时(即为图片)才替换为Markdown格式的图片链接
|
||||
if (uploadedUrls[index]) {
|
||||
const discoRef = ``;
|
||||
processedContent = processedContent.replace(obsRef, discoRef);
|
||||
}
|
||||
});
|
||||
return processedContent;
|
||||
}
|
||||
|
||||
// 处理文件内容,展开嵌入内容
|
||||
async expandFileContent(file: TFile): Promise<string> {
|
||||
return await expandEmbeds(this.app, file);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
945
src/main.ts
945
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();
|
||||
}
|
||||
}
|
14
src/types.ts
Normal file
14
src/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { DiscourseSyncSettings } from './config';
|
||||
|
||||
export interface ActiveFile {
|
||||
name: string;
|
||||
content: string;
|
||||
postId?: number;
|
||||
}
|
||||
|
||||
export interface PluginInterface {
|
||||
settings: DiscourseSyncSettings;
|
||||
activeFile: ActiveFile;
|
||||
saveSettings(): Promise<void>;
|
||||
publishTopic(): Promise<{ success: boolean; error?: string }>;
|
||||
}
|
294
src/ui.ts
Normal file
294
src/ui.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { App, Modal } from 'obsidian';
|
||||
import { t } from './i18n';
|
||||
import { PluginInterface } from './types';
|
||||
|
||||
// 选择分类的模态框
|
||||
export class SelectCategoryModal extends Modal {
|
||||
plugin: PluginInterface;
|
||||
categories: {id: number; name: string}[];
|
||||
tags: { name: string; canCreate: boolean }[];
|
||||
canCreateTags = false;
|
||||
|
||||
constructor(app: App, plugin: PluginInterface, categories: {id: number; name: string }[], tags: { name: string; canCreate: boolean }[]) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.categories = categories;
|
||||
this.tags = tags;
|
||||
this.canCreateTags = tags.length > 0 && tags[0].canCreate;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
// 添加模态框基础样式
|
||||
this.modalEl.addClass('mod-discourse-sync');
|
||||
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('discourse-sync-modal');
|
||||
|
||||
const isUpdate = this.plugin.activeFile.postId !== undefined;
|
||||
contentEl.createEl('h1', { text: isUpdate ? t('UPDATE_POST') : t('PUBLISH_TO_DISCOURSE') });
|
||||
|
||||
// 创建表单区域容器
|
||||
const formArea = contentEl.createEl('div', { cls: 'form-area' });
|
||||
|
||||
// 创建分类选择容器
|
||||
const selectContainer = formArea.createEl('div', { cls: 'select-container' });
|
||||
selectContainer.createEl('label', { text: t('CATEGORY') });
|
||||
const selectEl = selectContainer.createEl('select');
|
||||
|
||||
// 添加分类选项
|
||||
this.categories.forEach(category => {
|
||||
const option = selectEl.createEl('option', { text: category.name });
|
||||
option.value = category.id.toString();
|
||||
});
|
||||
|
||||
// 设置默认选中的分类
|
||||
selectEl.value = this.plugin.settings.category?.toString() || this.categories[0].id.toString();
|
||||
|
||||
// 监听分类选择变化
|
||||
selectEl.onchange = () => {
|
||||
this.plugin.settings.category = parseInt(selectEl.value);
|
||||
this.plugin.saveSettings();
|
||||
};
|
||||
|
||||
// 创建标签容器
|
||||
const tagContainer = formArea.createEl('div', { cls: 'tag-container' });
|
||||
tagContainer.createEl('label', { text: t('TAGS') });
|
||||
|
||||
// 创建标签选择区域
|
||||
const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' });
|
||||
|
||||
// 已选标签显示区域
|
||||
const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' });
|
||||
const selectedTags = new Set<string>();
|
||||
|
||||
// 初始化已选标签
|
||||
if (this.plugin.settings.selectedTags && this.plugin.settings.selectedTags.length > 0) {
|
||||
this.plugin.settings.selectedTags.forEach(tag => selectedTags.add(tag));
|
||||
}
|
||||
|
||||
// 更新标签显示
|
||||
const updateSelectedTags = () => {
|
||||
selectedTagsContainer.empty();
|
||||
selectedTags.forEach(tag => {
|
||||
const tagEl = selectedTagsContainer.createEl('span', {
|
||||
cls: 'tag',
|
||||
text: tag
|
||||
});
|
||||
const removeBtn = tagEl.createEl('span', {
|
||||
cls: 'remove-tag',
|
||||
text: '×'
|
||||
});
|
||||
removeBtn.onclick = () => {
|
||||
selectedTags.delete(tag);
|
||||
updateSelectedTags();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化标签显示
|
||||
updateSelectedTags();
|
||||
|
||||
// 创建标签输入容器
|
||||
const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' });
|
||||
|
||||
// 创建标签输入和建议
|
||||
const tagInput = tagInputContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: this.canCreateTags ? t('ENTER_TAG_WITH_CREATE') : t('ENTER_TAG')
|
||||
});
|
||||
|
||||
// 创建标签建议容器
|
||||
const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' });
|
||||
|
||||
// 处理输入事件,显示匹配的标签
|
||||
tagInput.oninput = () => {
|
||||
const value = tagInput.value.toLowerCase();
|
||||
tagSuggestions.empty();
|
||||
|
||||
if (value) {
|
||||
const matches = this.tags
|
||||
.filter(tag =>
|
||||
tag.name.toLowerCase().includes(value) &&
|
||||
!selectedTags.has(tag.name)
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
if (matches.length > 0) {
|
||||
// 获取输入框位置和宽度
|
||||
const inputRect = tagInput.getBoundingClientRect();
|
||||
const modalRect = this.modalEl.getBoundingClientRect();
|
||||
|
||||
// 确保建议列表不超过模态框宽度
|
||||
const maxWidth = modalRect.right - inputRect.left - 24; // 24px是右边距
|
||||
|
||||
// 设置建议列表位置和宽度
|
||||
tagSuggestions.style.top = `${inputRect.bottom + 4}px`;
|
||||
tagSuggestions.style.left = `${inputRect.left}px`;
|
||||
tagSuggestions.style.width = `${Math.min(inputRect.width, maxWidth)}px`;
|
||||
|
||||
matches.forEach(tag => {
|
||||
const suggestion = tagSuggestions.createEl('div', {
|
||||
cls: 'tag-suggestion',
|
||||
text: tag.name
|
||||
});
|
||||
suggestion.onclick = () => {
|
||||
selectedTags.add(tag.name);
|
||||
tagInput.value = '';
|
||||
tagSuggestions.empty();
|
||||
updateSelectedTags();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理回车事件
|
||||
tagInput.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' && tagInput.value) {
|
||||
e.preventDefault();
|
||||
const value = tagInput.value.trim();
|
||||
if (value && !selectedTags.has(value)) {
|
||||
const existingTag = this.tags.find(t => t.name.toLowerCase() === value.toLowerCase());
|
||||
if (existingTag) {
|
||||
selectedTags.add(existingTag.name);
|
||||
updateSelectedTags();
|
||||
} else if (this.canCreateTags) {
|
||||
selectedTags.add(value);
|
||||
updateSelectedTags();
|
||||
} else {
|
||||
// 显示权限提示
|
||||
const notice = contentEl.createEl('div', {
|
||||
cls: 'tag-notice',
|
||||
text: t('PERMISSION_ERROR')
|
||||
});
|
||||
setTimeout(() => {
|
||||
notice.remove();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
tagInput.value = '';
|
||||
tagSuggestions.empty();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理失焦事件,隐藏建议
|
||||
tagInput.onblur = () => {
|
||||
// 延迟隐藏,以便可以点击建议
|
||||
setTimeout(() => {
|
||||
tagSuggestions.empty();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 处理窗口滚动,更新建议列表位置
|
||||
const updateSuggestionsPosition = () => {
|
||||
if (tagSuggestions.childNodes.length > 0) {
|
||||
const inputRect = tagInput.getBoundingClientRect();
|
||||
tagSuggestions.style.top = `${inputRect.bottom + 4}px`;
|
||||
tagSuggestions.style.left = `${inputRect.left}px`;
|
||||
tagSuggestions.style.width = `${inputRect.width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件
|
||||
this.modalEl.addEventListener('scroll', updateSuggestionsPosition);
|
||||
|
||||
// 模态框关闭时移除事件监听器
|
||||
this.modalEl.onclose = () => {
|
||||
this.modalEl.removeEventListener('scroll', updateSuggestionsPosition);
|
||||
};
|
||||
|
||||
// 创建按钮区域
|
||||
const buttonArea = contentEl.createEl('div', { cls: 'button-area' });
|
||||
const submitButton = buttonArea.createEl('button', {
|
||||
text: isUpdate ? t('UPDATE') : t('PUBLISH'),
|
||||
cls: 'submit-button'
|
||||
});
|
||||
|
||||
// 创建通知容器
|
||||
const noticeContainer = buttonArea.createEl('div', { cls: 'notice-container' });
|
||||
|
||||
submitButton.onclick = async () => {
|
||||
// 保存选中的标签
|
||||
this.plugin.settings.selectedTags = Array.from(selectedTags);
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
// 禁用提交按钮,显示加载状态
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = isUpdate ? t('UPDATING') : t('PUBLISHING');
|
||||
|
||||
try {
|
||||
// 发布主题
|
||||
const result = await this.plugin.publishTopic();
|
||||
|
||||
// 显示结果
|
||||
noticeContainer.empty();
|
||||
|
||||
if (result.success) {
|
||||
// 成功
|
||||
noticeContainer.createEl('div', {
|
||||
cls: 'notice success',
|
||||
text: isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS')
|
||||
});
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 2000);
|
||||
} else {
|
||||
// 失败
|
||||
const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' });
|
||||
errorContainer.createEl('div', {
|
||||
cls: 'error-title',
|
||||
text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')
|
||||
});
|
||||
|
||||
errorContainer.createEl('div', {
|
||||
cls: 'error-message',
|
||||
text: result.error || t('UNKNOWN_ERROR')
|
||||
});
|
||||
|
||||
// 添加重试按钮
|
||||
const retryButton = errorContainer.createEl('button', {
|
||||
cls: 'retry-button',
|
||||
text: t('RETRY')
|
||||
});
|
||||
retryButton.onclick = () => {
|
||||
noticeContainer.empty();
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH');
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 显示错误
|
||||
noticeContainer.empty();
|
||||
const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' });
|
||||
errorContainer.createEl('div', {
|
||||
cls: 'error-title',
|
||||
text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')
|
||||
});
|
||||
|
||||
errorContainer.createEl('div', {
|
||||
cls: 'error-message',
|
||||
text: error.message || t('UNKNOWN_ERROR')
|
||||
});
|
||||
|
||||
// 添加重试按钮
|
||||
const retryButton = errorContainer.createEl('button', {
|
||||
cls: 'retry-button',
|
||||
text: t('RETRY')
|
||||
});
|
||||
retryButton.onclick = () => {
|
||||
noticeContainer.empty();
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH');
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
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());
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user