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 { PluginSettingTab, Setting, App, Notice, ButtonComponent } from 'obsidian';
|
||||||
import DiscourseSyncPlugin from './main';
|
import PublishToDiscourse from './main';
|
||||||
import { t } from './i18n';
|
import { t } from './i18n';
|
||||||
|
|
||||||
export interface DiscourseSyncSettings {
|
export interface DiscourseSyncSettings {
|
||||||
@ -19,8 +19,8 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
||||||
plugin: DiscourseSyncPlugin;
|
plugin: PublishToDiscourse;
|
||||||
constructor(app: App, plugin: DiscourseSyncPlugin) {
|
constructor(app: App, plugin: PublishToDiscourse) {
|
||||||
super(app, plugin);
|
super(app, plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
|||||||
button.setButtonText(t('TESTING'));
|
button.setButtonText(t('TESTING'));
|
||||||
button.setDisabled(true);
|
button.setDisabled(true);
|
||||||
|
|
||||||
const result = await this.plugin.testApiKey();
|
const result = await this.plugin.api.testApiKey();
|
||||||
|
|
||||||
button.setButtonText(t('TEST_API_KEY'));
|
button.setButtonText(t('TEST_API_KEY'));
|
||||||
button.setDisabled(false);
|
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";
|
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),
|
* Recursively expands embedded content (including subpath references),
|
||||||
* allowing the same (file+subpath) to appear multiple times if it's *not*
|
* 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
|
// 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
|
// Pop it from stack
|
||||||
@ -101,9 +125,11 @@ function sliceSubpathContent(
|
|||||||
const { start, end } = block.position;
|
const { start, end } = block.position;
|
||||||
if (!end) {
|
if (!end) {
|
||||||
// Goes to EOF if no explicit end
|
// Goes to EOF if no explicit end
|
||||||
return fileContent.substring(start.offset);
|
const slicedContent = fileContent.substring(start.offset);
|
||||||
|
return convertFrontmatterToQuote(slicedContent);
|
||||||
} else {
|
} 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}."`)
|
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