763 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Menu, MenuItem, Plugin, Modal, requestUrl, TFile, moment } from 'obsidian';
import { DEFAULT_SETTINGS, DiscourseSyncSettings, DiscourseSyncSettingsTab } from './config';
import * as yaml from 'yaml';
import { t, setLocale } from './i18n';
import { expandEmbeds } from './expand-embeds';
export default class DiscourseSyncPlugin extends Plugin {
settings: DiscourseSyncSettings;
activeFile: {
name: string;
content: string;
postId?: number; // Post ID field
};
async onload() {
// Set locale based on Obsidian's language setting
setLocale(moment.locale());
// Update locale when Obsidian's language changes
this.registerEvent(
this.app.workspace.on('window-open', () => {
setLocale(moment.locale());
})
);
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: t('PUBLISH_TO_DISCOURSE'),
callback: () => {
this.openCategoryModal();
},
});
// Add command to open post in browser
this.addCommand({
id: "open-in-discourse",
name: t('OPEN_IN_DISCOURSE'),
callback: () => {
this.openInDiscourse();
},
});
}
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;
// Check if frontmatter contains post ID
const frontMatter = this.getFrontMatter(content);
const postId = frontMatter?.discourse_post_id;
// Filter out note properties section
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 = `![${ref}](${imageUrls[index]})`;
content = content.replace(obsRef, discoRef);
});
const isUpdate = postId !== undefined;
const endpoint = isUpdate ? `${this.settings.baseUrl}/posts/${postId}` : url;
const method = isUpdate ? "PUT" : "POST";
const body = JSON.stringify(isUpdate ? {
raw: content,
edit_reason: "Updated from Obsidian",
tags: this.settings.selectedTags || []
} : {
title: (frontMatter?.title ? frontMatter?.title : this.activeFile.name),
raw: content,
category: this.settings.category,
tags: this.settings.selectedTags || []
});
try {
const response = await requestUrl({
url: endpoint,
method: method,
contentType: "application/json",
body,
headers,
throw: false
});
if (response.status === 200) {
if (!isUpdate) {
try {
// Get new post ID and topic ID
const responseData = response.json;
if (responseData && responseData.id) {
await this.updateFrontMatter(responseData.id, responseData.topic_id);
} else {
return {
message: "Error",
error: t('POST_ID_ERROR')
};
}
} catch (error) {
return {
message: "Error",
error: t('SAVE_POST_ID_ERROR')
};
}
}
return { message: "Success" };
} else {
try {
const errorResponse = response.json;
if (errorResponse.errors && errorResponse.errors.length > 0) {
return {
message: "Error",
error: errorResponse.errors.join('\n')
};
}
if (errorResponse.error) {
return {
message: "Error",
error: errorResponse.error
};
}
} catch (parseError) {
return {
message: "Error",
error: `${isUpdate ? t('UPDATE_FAILED') : t('PUBLISH_FAILED')} (${response.status})`
};
}
}
} catch (error) {
return {
message: "Error",
error: `${isUpdate ? t('UPDATE_FAILED') : t('PUBLISH_FAILED')}: ${error.message || t('UNKNOWN_ERROR')}`
};
}
return { message: "Error", error: `${isUpdate ? t('UPDATE_FAILED') : t('PUBLISH_FAILED')}, ${t('TRY_AGAIN')}` };
}
// Get frontmatter data
private 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;
}
// Update frontmatter, add post ID
private async updateFrontMatter(postId: number, topicId: number) {
try {
// Get current active file
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
return;
}
const content = await this.app.vault.read(activeFile);
const fm = this.getFrontMatter(content);
const discourseUrl = `${this.settings.baseUrl}/t/${topicId}`;
let newContent: string;
if (fm) {
// Update existing frontmatter
const updatedFm = {
...fm,
discourse_post_id: postId,
discourse_topic_id: topicId,
discourse_url: discourseUrl
};
newContent = content.replace(/^---\n[\s\S]*?\n---\n/, `---\n${yaml.stringify(updatedFm)}---\n`);
} else {
// Add new frontmatter
const newFm = {
discourse_post_id: postId,
discourse_topic_id: topicId,
discourse_url: discourseUrl
};
newContent = `---\n${yaml.stringify(newFm)}---\n${content}`;
}
await this.app.vault.modify(activeFile, newContent);
} catch (error) {
return {
message: "Error",
error: t('UPDATE_FAILED')
};
}
}
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 [];
}
}
private async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> {
const url = `${this.settings.baseUrl}/tags.json`;
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,
});
if (response.status === 200) {
const data = response.json;
// Get user permissions
const canCreateTags = await this.checkCanCreateTags();
// Tags list returned by Discourse is in the tags array
return data.tags.map((tag: any) => ({
name: tag.name,
canCreate: canCreateTags
}));
}
return [];
} catch (error) {
return [];
}
}
// Check if user has permission to create tags
private async checkCanCreateTags(): Promise<boolean> {
try {
const url = `${this.settings.baseUrl}/u/${this.settings.disUser}.json`;
const headers = {
"Content-Type": "application/json",
"Api-Key": this.settings.apiKey,
"Api-Username": this.settings.disUser,
};
const response = await requestUrl({
url: url,
method: "GET",
contentType: "application/json",
headers,
});
if (response.status === 200) {
const data = response.json;
// Check user's trust_level
return data.user.trust_level >= 3;
}
return false;
} catch (error) {
return false;
}
}
registerDirMenu(menu: Menu, file: TFile) {
const syncDiscourse = (item: MenuItem) => {
item.setTitle(t('PUBLISH_TO_DISCOURSE'));
item.onClick(async () => {
const content = await expandEmbeds(this.app, file);
const fm = this.getFrontMatter(content);
this.activeFile = {
name: file.basename,
content: content,
postId: fm?.discourse_post_id
};
await this.syncToDiscourse();
});
}
menu.addItem(syncDiscourse)
}
private async openCategoryModal() {
const [categories, tags] = await Promise.all([
this.fetchCategories(),
this.fetchTags()
]);
if (categories.length > 0) {
new SelectCategoryModal(this.app, this, categories, tags).open();
}
}
private async syncToDiscourse() {
await this.openCategoryModal();
}
private async openInDiscourse() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new NotifyUser(this.app, t('NO_ACTIVE_FILE')).open();
return;
}
const content = await this.app.vault.read(activeFile);
const fm = this.getFrontMatter(content);
const discourseUrl = fm?.discourse_url;
const topicId = fm?.discourse_topic_id;
if (!discourseUrl && !topicId) {
new NotifyUser(this.app, t('NO_TOPIC_ID')).open();
return;
}
const url = discourseUrl || `${this.settings.baseUrl}/t/${topicId}`;
window.open(url, '_blank');
}
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}[];
tags: { name: string; canCreate: boolean }[];
canCreateTags = false;
constructor(app: App, plugin: DiscourseSyncPlugin, 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 : false;
}
onOpen() {
// Add modal base style
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') });
// Create form area container
const formArea = contentEl.createEl('div', { cls: 'form-area' });
// Create selector container
const selectContainer = formArea.createEl('div', { cls: 'select-container' });
if (!isUpdate) {
// Only show category selector when creating a new post
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();
});
}
// Create tag selector container
const tagContainer = formArea.createEl('div', { cls: 'tag-container' });
tagContainer.createEl('label', { text: t('TAGS') });
// Create tag selector area
const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' });
// Selected tags display area
const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' });
const selectedTags = new Set<string>();
// Update selected tags display
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();
};
});
};
// Create tag input container
const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' });
// Create tag input and suggestions
const tagInput = tagInputContainer.createEl('input', {
type: 'text',
placeholder: this.canCreateTags ? t('ENTER_TAG_WITH_CREATE') : t('ENTER_TAG')
});
// Create tag suggestions container
const tagSuggestions = tagInputContainer.createEl('div', { cls: 'tag-suggestions' });
// Handle input event, show matching tags
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) {
// Get input box position and width
const inputRect = tagInput.getBoundingClientRect();
const modalRect = this.modalEl.getBoundingClientRect();
// Ensure suggestions list doesn't exceed modal width
const maxWidth = modalRect.right - inputRect.left - 24; // 24px is right padding
// Set suggestions list position and width
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();
};
});
}
}
};
// Handle enter event
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 {
// Show permission notice
const notice = contentEl.createEl('div', {
cls: 'tag-notice',
text: t('PERMISSION_ERROR')
});
setTimeout(() => {
notice.remove();
}, 2000);
}
}
tagInput.value = '';
tagSuggestions.empty();
}
};
// Handle blur event, hide suggestions
tagInput.onblur = () => {
// Delay hide, so suggestions can be clicked
setTimeout(() => {
tagSuggestions.empty();
}, 200);
};
// Handle window scroll, update suggestions list position
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`;
}
};
// Listen for scroll event
this.modalEl.addEventListener('scroll', updateSuggestionsPosition);
// Remove event listeners when modal closes
this.modalEl.onclose = () => {
this.modalEl.removeEventListener('scroll', updateSuggestionsPosition);
};
// Create button area
const buttonArea = contentEl.createEl('div', { cls: 'button-area' });
const submitButton = buttonArea.createEl('button', {
text: isUpdate ? t('UPDATE') : t('PUBLISH'),
cls: 'submit-button'
});
// Create notice container
const noticeContainer = buttonArea.createEl('div');
submitButton.onclick = async () => {
if (!isUpdate) {
const selectEl = contentEl.querySelector('select') as HTMLSelectElement;
if (!selectEl) {
return;
}
const selectedCategoryId = selectEl.value;
this.plugin.settings.category = parseInt(selectedCategoryId);
await this.plugin.saveSettings();
}
// Save selected tags
this.plugin.settings.selectedTags = Array.from(selectedTags);
await this.plugin.saveSettings();
// Disable submit button, show loading state
submitButton.disabled = true;
submitButton.textContent = isUpdate ? t('UPDATING') : t('PUBLISHING');
try {
const reply = await this.plugin.postTopic();
// Show notice
noticeContainer.empty();
if (reply.message === 'Success') {
noticeContainer.createEl('div', {
cls: 'notice success',
text: isUpdate ? t('UPDATE_SUCCESS') : t('PUBLISH_SUCCESS')
});
// Close after success
setTimeout(() => {
this.close();
}, 1500);
} else {
const errorContainer = noticeContainer.createEl('div', { cls: 'notice error' });
errorContainer.createEl('div', {
cls: 'error-title',
text: isUpdate ? t('UPDATE_ERROR') : t('PUBLISH_ERROR')
});
// Show Discourse-specific error information
errorContainer.createEl('div', {
cls: 'error-message',
text: reply.error || (isUpdate ? t('UPDATE_FAILED') + ', ' + t('TRY_AGAIN') : t('PUBLISH_FAILED') + ', ' + t('TRY_AGAIN'))
});
// Add retry button
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')
});
// Add retry button
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');
};
}
// If error occurs, reset button state
if (submitButton.disabled) {
submitButton.disabled = false;
submitButton.textContent = isUpdate ? t('UPDATE') : t('PUBLISH');
}
};
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}