Enhance DiscourseSyncPlugin with tag management features

- Updated styles for modal and form areas to improve layout and usability.
- Added support for selecting and managing tags in the DiscourseSync settings.
- Implemented fetching of available tags from the Discourse API.
- Enhanced the category selection modal to include tag selection functionality.
- Updated settings interface to store selected tags.
- Improved error handling and user feedback during tag operations.
This commit is contained in:
wood chen 2025-01-24 17:09:58 +08:00
parent efbb0a5c9c
commit ce2d944ee6
3 changed files with 433 additions and 17 deletions

View File

@ -6,6 +6,7 @@ export interface DiscourseSyncSettings {
apiKey: string; apiKey: string;
disUser: string; disUser: string;
category: number; category: number;
selectedTags: string[];
} }
export const DEFAULT_SETTINGS: DiscourseSyncSettings = { export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
@ -13,6 +14,7 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
apiKey: "apikey", apiKey: "apikey",
disUser: "DiscourseUsername", disUser: "DiscourseUsername",
category: 1, category: 1,
selectedTags: []
}; };
export class DiscourseSyncSettingsTab extends PluginSettingTab { export class DiscourseSyncSettingsTab extends PluginSettingTab {

View File

@ -140,11 +140,13 @@ export default class DiscourseSyncPlugin extends Plugin {
const body = JSON.stringify(isUpdate ? { const body = JSON.stringify(isUpdate ? {
raw: content, raw: content,
edit_reason: "Updated from Obsidian" edit_reason: "Updated from Obsidian",
tags: this.settings.selectedTags || []
} : { } : {
title: this.activeFile.name, title: this.activeFile.name,
raw: content, raw: content,
category: this.settings.category category: this.settings.category,
tags: this.settings.selectedTags || []
}); });
try { try {
@ -228,7 +230,6 @@ export default class DiscourseSyncPlugin extends Plugin {
// 获取当前活动文件 // 获取当前活动文件
const activeFile = this.app.workspace.getActiveFile(); const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) { if (!activeFile) {
console.error('No active file found');
return; return;
} }
@ -247,9 +248,11 @@ export default class DiscourseSyncPlugin extends Plugin {
} }
await this.app.vault.modify(activeFile, newContent); await this.app.vault.modify(activeFile, newContent);
console.log('Successfully updated frontmatter with post ID:', postId);
} catch (error) { } catch (error) {
console.error('Error updating frontmatter:', error); return {
message: "Error",
error: "更新失败"
};
} }
} }
@ -288,6 +291,66 @@ export default class DiscourseSyncPlugin extends Plugin {
} }
} }
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;
// 获取用户权限信息
const canCreateTags = await this.checkCanCreateTags();
// Discourse 返回的 tags 列表在 tags 数组中
return data.tags.map((tag: any) => ({
name: tag.name,
canCreate: canCreateTags
}));
}
return [];
} catch (error) {
return [];
}
}
// 检查用户是否有创建标签的权限
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;
// 检查用户的 trust_level
return data.user.trust_level >= 3;
}
return false;
} catch (error) {
return false;
}
}
registerDirMenu(menu: Menu, file: TFile) { registerDirMenu(menu: Menu, file: TFile) {
const syncDiscourse = (item: MenuItem) => { const syncDiscourse = (item: MenuItem) => {
item.setTitle("发布到 Discourse"); item.setTitle("发布到 Discourse");
@ -306,9 +369,12 @@ export default class DiscourseSyncPlugin extends Plugin {
} }
private async openCategoryModal() { private async openCategoryModal() {
const categories = await this.fetchCategories(); const [categories, tags] = await Promise.all([
this.fetchCategories(),
this.fetchTags()
]);
if (categories.length > 0) { if (categories.length > 0) {
new SelectCategoryModal(this.app, this, categories).open(); new SelectCategoryModal(this.app, this, categories, tags).open();
} }
} }
@ -363,10 +429,15 @@ export class NotifyUser extends Modal {
export class SelectCategoryModal extends Modal { export class SelectCategoryModal extends Modal {
plugin: DiscourseSyncPlugin; plugin: DiscourseSyncPlugin;
categories: {id: number; name: string}[]; categories: {id: number; name: string}[];
constructor(app: App, 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); super(app);
this.plugin = plugin; this.plugin = plugin;
this.categories = categories; this.categories = categories;
this.tags = tags;
this.canCreateTags = tags.length > 0 ? tags[0].canCreate : false;
} }
onOpen() { onOpen() {
@ -379,8 +450,11 @@ export class SelectCategoryModal extends Modal {
const isUpdate = this.plugin.activeFile.postId !== undefined; const isUpdate = this.plugin.activeFile.postId !== undefined;
contentEl.createEl("h1", { text: isUpdate ? '更新帖子' : '发布到 Discourse' }); contentEl.createEl("h1", { text: isUpdate ? '更新帖子' : '发布到 Discourse' });
// 创建表单区域容器
const formArea = contentEl.createEl('div', { cls: 'form-area' });
// 创建选择器容器 // 创建选择器容器
const selectContainer = contentEl.createEl('div', { cls: 'select-container' }); const selectContainer = formArea.createEl('div', { cls: 'select-container' });
if (!isUpdate) { if (!isUpdate) {
// 只在新建帖子时显示分类选择 // 只在新建帖子时显示分类选择
selectContainer.createEl('label', { text: '分类' }); selectContainer.createEl('label', { text: '分类' });
@ -391,13 +465,154 @@ export class SelectCategoryModal extends Modal {
}); });
} }
const submitButton = contentEl.createEl('button', { // 创建标签选择容器
const tagContainer = formArea.createEl('div', { cls: 'tag-container' });
tagContainer.createEl('label', { text: '标签' });
// 创建标签选择区域
const tagSelectArea = tagContainer.createEl('div', { cls: 'tag-select-area' });
// 已选标签显示区域
const selectedTagsContainer = tagSelectArea.createEl('div', { cls: 'selected-tags' });
const selectedTags = new Set<string>();
// 更新已选标签显示
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();
};
});
};
// 创建标签输入容器
const tagInputContainer = tagSelectArea.createEl('div', { cls: 'tag-input-container' });
// 创建标签输入和建议
const tagInput = tagInputContainer.createEl('input', {
type: 'text',
placeholder: this.canCreateTags ? '输入标签名称(回车添加)' : '输入标签名称(回车添加)'
});
// 创建标签建议容器
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 是右侧padding
// 设置建议列表的位置和宽度
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: '权限不足,只能使用已有的标签'
});
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 ? '更新' : '发布', text: isUpdate ? '更新' : '发布',
cls: 'submit-button' cls: 'submit-button'
}); });
// 创建提示信息容器 // 创建提示信息容器
const noticeContainer = contentEl.createEl('div'); const noticeContainer = buttonArea.createEl('div');
submitButton.onclick = async () => { submitButton.onclick = async () => {
if (!isUpdate) { if (!isUpdate) {
@ -410,6 +625,10 @@ export class SelectCategoryModal extends Modal {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
} }
// 保存选中的标签
this.plugin.settings.selectedTags = Array.from(selectedTags);
await this.plugin.saveSettings();
// 禁用提交按钮,显示加载状态 // 禁用提交按钮,显示加载状态
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = isUpdate ? '更新中...' : '发布中...'; submitButton.textContent = isUpdate ? '更新中...' : '发布中...';

View File

@ -10,16 +10,35 @@ If your plugin does not need CSS, delete this file.
/* 基础模态框样式覆盖 */ /* 基础模态框样式覆盖 */
.modal.mod-discourse-sync { .modal.mod-discourse-sync {
max-width: 500px; max-width: 500px;
max-height: 80vh; max-height: 90vh;
background-color: var(--background-primary); background-color: var(--background-primary);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
} }
/* 内容区域样式 */ /* 内容区域样式 */
.discourse-sync-modal { .discourse-sync-modal {
padding: 24px; padding: 24px;
width: 100%; width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* 表单区域 */
.discourse-sync-modal .form-area {
flex-grow: 1;
min-height: 0;
margin-bottom: 16px;
}
/* 按钮区域固定在底部 */
.discourse-sync-modal .button-area {
flex-shrink: 0;
margin-top: auto;
} }
.discourse-sync-modal h1 { .discourse-sync-modal h1 {
@ -29,27 +48,37 @@ If your plugin does not need CSS, delete this file.
color: var(--text-normal); color: var(--text-normal);
} }
.discourse-sync-modal .select-container { /* 表单容器通用样式 */
.discourse-sync-modal .select-container,
.discourse-sync-modal .tag-container {
margin-bottom: 24px; margin-bottom: 24px;
padding: 0; /* 移除内边距 */
} }
.discourse-sync-modal .select-container label { .discourse-sync-modal .select-container label,
.discourse-sync-modal .tag-container label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500; font-weight: 500;
color: var(--text-normal); color: var(--text-normal);
} }
.discourse-sync-modal select { /* 输入框和选择器样式 */
.discourse-sync-modal select,
.discourse-sync-modal .tag-select-area {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
height: 42px;
line-height: 1.5;
border: 2px solid var(--background-modifier-border); border: 2px solid var(--background-modifier-border);
border-radius: 4px; border-radius: 4px;
background-color: var(--background-primary); background-color: var(--background-primary);
color: var(--text-normal); color: var(--text-normal);
font-size: 14px; font-size: 14px;
min-height: 42px;
}
.discourse-sync-modal select {
height: 42px;
line-height: 1.5;
} }
.discourse-sync-modal select:focus { .discourse-sync-modal select:focus {
@ -141,3 +170,169 @@ If your plugin does not need CSS, delete this file.
background-color: rgb(255, 82, 82); background-color: rgb(255, 82, 82);
color: white; color: white;
} }
/* 标签选择区域样式 */
.discourse-sync-modal .tag-select-area {
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
}
.discourse-sync-modal .selected-tags {
display: none; /* 默认隐藏 */
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
min-height: 28px;
}
.discourse-sync-modal .selected-tags:not(:empty) {
display: flex; /* 当有内容时显示 */
}
.discourse-sync-modal .tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.discourse-sync-modal .remove-tag {
cursor: pointer;
font-size: 14px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
margin-left: 4px;
}
.discourse-sync-modal .remove-tag:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.discourse-sync-modal input[type="text"] {
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-normal);
font-size: 14px;
outline: none;
}
.discourse-sync-modal .suggestions-container {
max-height: 250px;
overflow-y: auto;
border-bottom: 1px solid var(--background-modifier-border);
}
/* 标签输入容器 */
.discourse-sync-modal .tag-input-container {
position: relative;
width: 100%;
}
/* 标签建议下拉框 */
.discourse-sync-modal .tag-suggestions {
position: fixed;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
max-height: 180px;
overflow-y: auto;
margin-top: 4px;
display: none; /* 默认隐藏 */
}
.discourse-sync-modal .tag-suggestions:not(:empty) {
display: block; /* 当有内容时显示 */
}
/* 标签建议项 */
.discourse-sync-modal .tag-suggestion {
padding: 8px 12px;
cursor: pointer;
color: var(--text-normal);
font-size: 14px;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--background-primary);
}
.discourse-sync-modal .tag-suggestion:hover {
background-color: var(--background-modifier-hover);
}
/* 美化滚动条 */
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar {
width: 4px;
}
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-track {
background: transparent;
}
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-border);
border-radius: 2px;
}
.discourse-sync-modal .tag-suggestions::-webkit-scrollbar-thumb:hover {
background-color: var(--background-modifier-border-hover);
}
/* 移除 tag-container 的 z-index避免干扰 */
.discourse-sync-modal .tag-container {
position: relative;
}
.discourse-sync-modal .tag-notice {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 20px;
background-color: rgb(255, 235, 235);
border: 1px solid rgba(255, 82, 82, 0.2);
color: rgb(255, 82, 82);
border-radius: 4px;
font-size: 13px;
text-align: center;
z-index: 1002;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 80%;
white-space: nowrap;
opacity: 0;
animation: fadeInOut 2s ease-in-out forwards;
}
@keyframes fadeInOut {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}