mirror of
https://github.com/woodchen-ink/obsidian-publish-to-discourse.git
synced 2025-07-18 05:42:05 +08:00
406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { App, Menu, MenuItem, Plugin, Modal, requestUrl, TFile } from 'obsidian';
|
|
import { DEFAULT_SETTINGS, DiscourseSyncSettings, DiscourseSyncSettingsTab } from './config';
|
|
|
|
export default class DiscourseSyncPlugin extends Plugin {
|
|
settings: DiscourseSyncSettings;
|
|
activeFile: { name: string; content: string };
|
|
async onload() {
|
|
await this.loadSettings();
|
|
this.addSettingTab(new DiscourseSyncSettingsTab(this.app, this));
|
|
this.registerEvent(
|
|
this.app.workspace.on("file-menu", (menu, file: TFile) => {
|
|
this.registerDirMenu(menu, file);
|
|
}),
|
|
);
|
|
|
|
this.addCommand({
|
|
id: "category-modal",
|
|
name: "发布到 Discourse",
|
|
callback: () => {
|
|
this.openCategoryModal();
|
|
},
|
|
});
|
|
|
|
}
|
|
|
|
async loadSettings() {
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
extractImageReferences(content: string): string[] {
|
|
const regex = /!\[\[(.*?)\]\]/g;
|
|
const matches = [];
|
|
let match;
|
|
while ((match = regex.exec(content)) !== null) {
|
|
matches.push(match[1]);
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
async uploadImages(imageReferences: string[]): Promise<string[]> {
|
|
const imageUrls: string[] = [];
|
|
for (const ref of imageReferences) {
|
|
const filePath = this.app.metadataCache.getFirstLinkpathDest(ref, this.activeFile.name)?.path;
|
|
if (filePath) {
|
|
const abstractFile = this.app.vault.getAbstractFileByPath(filePath);
|
|
if (abstractFile instanceof TFile) {
|
|
try {
|
|
const imgfile = await this.app.vault.readBinary(abstractFile);
|
|
const boundary = genBoundary();
|
|
const sBoundary = '--' + boundary + '\r\n';
|
|
const imgForm = `${sBoundary}Content-Disposition: form-data; name="file"; filename="${abstractFile.name}"\r\nContent-Type: image/${abstractFile.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;
|
|
imageUrls.push(jsonResponse.short_url);
|
|
} else {
|
|
new NotifyUser(this.app, `Error uploading image: ${response.status}`).open();
|
|
}
|
|
} catch (error) {
|
|
new NotifyUser(this.app, `Exception while uploading image: ${error}`).open();
|
|
}
|
|
} else {
|
|
new NotifyUser(this.app, `File not found in vault: ${ref}`).open();
|
|
}
|
|
} else {
|
|
new NotifyUser(this.app, `Unable to resolve file path for: ${ref}`).open();
|
|
}
|
|
}
|
|
return imageUrls;
|
|
}
|
|
|
|
async postTopic(): Promise<{ message: string; 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,
|
|
}
|
|
let content = this.activeFile.content;
|
|
|
|
// 过滤掉笔记属性部分
|
|
content = content.replace(/^---[\s\S]*?---\n/, '');
|
|
|
|
const imageReferences = this.extractImageReferences(content);
|
|
const imageUrls = await this.uploadImages(imageReferences);
|
|
|
|
imageReferences.forEach((ref, index) => {
|
|
const obsRef = `![[${ref}]]`;
|
|
const discoRef = ``;
|
|
content = content.replace(obsRef, discoRef);
|
|
});
|
|
|
|
const body = JSON.stringify({
|
|
title: this.activeFile.name,
|
|
raw: content,
|
|
category: this.settings.category
|
|
});
|
|
|
|
try {
|
|
const response = await requestUrl({
|
|
url: url,
|
|
method: "POST",
|
|
contentType: "application/json",
|
|
body,
|
|
headers,
|
|
throw: false // 设置为 false 以获取错误响应
|
|
});
|
|
|
|
if (response.status === 200) {
|
|
return { message: "Success" };
|
|
} else {
|
|
// 尝试从响应中获取错误信息
|
|
try {
|
|
const errorResponse = response.json;
|
|
// Discourse 通常会在 errors 数组中返回错误信息
|
|
if (errorResponse.errors && errorResponse.errors.length > 0) {
|
|
return {
|
|
message: "Error",
|
|
error: errorResponse.errors.join('\n')
|
|
};
|
|
}
|
|
// 有些错误可能在 error 字段中
|
|
if (errorResponse.error) {
|
|
return {
|
|
message: "Error",
|
|
error: errorResponse.error
|
|
};
|
|
}
|
|
} catch (parseError) {
|
|
// 如果无法解析错误响应
|
|
return {
|
|
message: "Error",
|
|
error: `发布失败 (${response.status})`
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
message: "Error",
|
|
error: `发布失败: ${error.message || '未知错误'}`
|
|
};
|
|
}
|
|
return { message: "Error", error: "发布失败,请重试" };
|
|
}
|
|
|
|
private async fetchCategories() {
|
|
const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`;
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
"Api-Key": this.settings.apiKey,
|
|
"Api-Username": this.settings.disUser,
|
|
};
|
|
|
|
try {
|
|
const response = await requestUrl({
|
|
url: url,
|
|
method: "GET",
|
|
contentType: "application/json",
|
|
headers,
|
|
});
|
|
|
|
|
|
const data = await response.json;
|
|
const categories = data.category_list.categories;
|
|
const allCategories = categories.flatMap((category: Category) => {
|
|
const subcategories: { id: number; name: string }[] = category.subcategory_list?.map((sub: Subcategory) => ({
|
|
id: sub.id,
|
|
name: sub.name,
|
|
})) || [];
|
|
return [
|
|
{ id: category.id, name: category.name },
|
|
...subcategories,
|
|
];
|
|
});
|
|
return allCategories;
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
registerDirMenu(menu: Menu, file: TFile) {
|
|
const syncDiscourse = (item: MenuItem) => {
|
|
item.setTitle("发布到 Discourse");
|
|
item.onClick(async () => {
|
|
this.activeFile = {
|
|
name: file.basename,
|
|
content: await this.app.vault.read(file)
|
|
};
|
|
await this.syncToDiscourse();
|
|
});
|
|
}
|
|
menu.addItem(syncDiscourse)
|
|
}
|
|
|
|
private async openCategoryModal() {
|
|
const categories = await this.fetchCategories();
|
|
if (categories.length > 0) {
|
|
new SelectCategoryModal(this.app, this, categories).open();
|
|
}
|
|
}
|
|
|
|
private async syncToDiscourse() {
|
|
await this.openCategoryModal();
|
|
}
|
|
|
|
onunload() {}
|
|
|
|
}
|
|
|
|
interface Subcategory {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface Category {
|
|
id: number;
|
|
name: string;
|
|
subcategory_list?: Subcategory[];
|
|
}
|
|
|
|
const genBoundary = (): string => {
|
|
return '----WebKitFormBoundary' + Math.random().toString(36).substring(2, 15);
|
|
}
|
|
|
|
|
|
export class NotifyUser extends Modal {
|
|
message: string;
|
|
constructor(app: App, message: string) {
|
|
super(app);
|
|
this.message = message;
|
|
}
|
|
|
|
onOpen() {
|
|
const { contentEl } = this;
|
|
contentEl.createEl("h1", { text: 'An error has occurred.' });
|
|
contentEl.createEl("h4", { text: this.message });
|
|
const okButton = contentEl.createEl('button', { text: 'Ok' });
|
|
okButton.onclick = () => {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
|
|
}
|
|
|
|
export class SelectCategoryModal extends Modal {
|
|
plugin: DiscourseSyncPlugin;
|
|
categories: {id: number; name: string}[];
|
|
constructor(app: App, plugin: DiscourseSyncPlugin, categories: {id: number; name: string }[]) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.categories = categories;
|
|
}
|
|
|
|
onOpen() {
|
|
// 添加模态框基础样式
|
|
this.modalEl.addClass('mod-discourse-sync');
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.addClass('discourse-sync-modal');
|
|
|
|
contentEl.createEl("h1", { text: '选择发布分类' });
|
|
|
|
// 创建选择器容器
|
|
const selectContainer = contentEl.createEl('div', { cls: 'select-container' });
|
|
selectContainer.createEl('label', { text: '分类' });
|
|
|
|
const selectEl = selectContainer.createEl('select');
|
|
|
|
this.categories.forEach(category => {
|
|
const option = selectEl.createEl('option', { text: category.name });
|
|
option.value = category.id.toString();
|
|
});
|
|
|
|
const submitButton = contentEl.createEl('button', {
|
|
text: '发布',
|
|
cls: 'submit-button'
|
|
});
|
|
|
|
// 创建提示信息容器
|
|
const noticeContainer = contentEl.createEl('div');
|
|
|
|
submitButton.onclick = async () => {
|
|
const selectedCategoryId = selectEl.value;
|
|
this.plugin.settings.category = parseInt(selectedCategoryId);
|
|
await this.plugin.saveSettings();
|
|
|
|
// 禁用提交按钮,显示加载状态
|
|
submitButton.disabled = true;
|
|
submitButton.textContent = '发布中...';
|
|
|
|
try {
|
|
const reply = await this.plugin.postTopic();
|
|
|
|
// 显示提示信息
|
|
noticeContainer.empty();
|
|
if (reply.message === 'Success') {
|
|
noticeContainer.createEl('div', {
|
|
cls: 'notice success',
|
|
text: '✓ 发布成功!'
|
|
});
|
|
// 成功后延迟关闭
|
|
setTimeout(() => {
|
|
this.close();
|
|
}, 1500);
|
|
} else {
|
|
const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' });
|
|
errorContainer.createEl('div', {
|
|
cls: 'error-title',
|
|
text: '发布失败'
|
|
});
|
|
|
|
// 显示 Discourse 返回的具体错误信息
|
|
errorContainer.createEl('div', {
|
|
cls: 'error-message',
|
|
text: reply.error || '发布失败,请重试'
|
|
});
|
|
|
|
// 添加重试按钮
|
|
const retryButton = errorContainer.createEl('button', {
|
|
cls: 'retry-button',
|
|
text: '重试'
|
|
});
|
|
retryButton.onclick = () => {
|
|
noticeContainer.empty();
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = '发布';
|
|
};
|
|
}
|
|
} catch (error) {
|
|
noticeContainer.empty();
|
|
const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' });
|
|
errorContainer.createEl('div', {
|
|
cls: 'error-title',
|
|
text: '发布出错'
|
|
});
|
|
errorContainer.createEl('div', {
|
|
cls: 'error-message',
|
|
text: error.message || '未知错误'
|
|
});
|
|
|
|
// 添加重试按钮
|
|
const retryButton = errorContainer.createEl('button', {
|
|
cls: 'retry-button',
|
|
text: '重试'
|
|
});
|
|
retryButton.onclick = () => {
|
|
noticeContainer.empty();
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = '发布';
|
|
};
|
|
}
|
|
|
|
// 如果发生错误,重置按钮状态
|
|
if (submitButton.disabled) {
|
|
submitButton.disabled = false;
|
|
submitButton.textContent = '发布';
|
|
}
|
|
};
|
|
}
|
|
|
|
onClose() {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
}
|
|
}
|