diff --git a/src/config.ts b/src/config.ts index e064da3..0cf2a32 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ export interface DiscourseSyncSettings { apiKey: string; disUser: string; category: number; + selectedTags: string[]; } export const DEFAULT_SETTINGS: DiscourseSyncSettings = { @@ -13,6 +14,7 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = { apiKey: "apikey", disUser: "DiscourseUsername", category: 1, + selectedTags: [] }; export class DiscourseSyncSettingsTab extends PluginSettingTab { diff --git a/src/main.ts b/src/main.ts index 0e90d42..9e65a1a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -140,11 +140,13 @@ export default class DiscourseSyncPlugin extends Plugin { const body = JSON.stringify(isUpdate ? { raw: content, - edit_reason: "Updated from Obsidian" + edit_reason: "Updated from Obsidian", + tags: this.settings.selectedTags || [] } : { title: this.activeFile.name, raw: content, - category: this.settings.category + category: this.settings.category, + tags: this.settings.selectedTags || [] }); try { @@ -228,7 +230,6 @@ export default class DiscourseSyncPlugin extends Plugin { // 获取当前活动文件 const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { - console.error('No active file found'); return; } @@ -247,9 +248,11 @@ export default class DiscourseSyncPlugin extends Plugin { } await this.app.vault.modify(activeFile, newContent); - console.log('Successfully updated frontmatter with post ID:', postId); } 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 { + 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) { const syncDiscourse = (item: MenuItem) => { item.setTitle("发布到 Discourse"); @@ -306,9 +369,12 @@ export default class DiscourseSyncPlugin extends Plugin { } private async openCategoryModal() { - const categories = await this.fetchCategories(); + const [categories, tags] = await Promise.all([ + this.fetchCategories(), + this.fetchTags() + ]); 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 { plugin: DiscourseSyncPlugin; 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); this.plugin = plugin; this.categories = categories; + this.tags = tags; + this.canCreateTags = tags.length > 0 ? tags[0].canCreate : false; } onOpen() { @@ -379,8 +450,11 @@ export class SelectCategoryModal extends Modal { const isUpdate = this.plugin.activeFile.postId !== undefined; 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) { // 只在新建帖子时显示分类选择 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(); + + // 更新已选标签显示 + 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 ? '更新' : '发布', cls: 'submit-button' }); // 创建提示信息容器 - const noticeContainer = contentEl.createEl('div'); + const noticeContainer = buttonArea.createEl('div'); submitButton.onclick = async () => { if (!isUpdate) { @@ -410,6 +625,10 @@ export class SelectCategoryModal extends Modal { await this.plugin.saveSettings(); } + // 保存选中的标签 + this.plugin.settings.selectedTags = Array.from(selectedTags); + await this.plugin.saveSettings(); + // 禁用提交按钮,显示加载状态 submitButton.disabled = true; submitButton.textContent = isUpdate ? '更新中...' : '发布中...'; diff --git a/styles.css b/styles.css index b245c70..1ffa67d 100644 --- a/styles.css +++ b/styles.css @@ -10,16 +10,35 @@ If your plugin does not need CSS, delete this file. /* 基础模态框样式覆盖 */ .modal.mod-discourse-sync { max-width: 500px; - max-height: 80vh; + max-height: 90vh; background-color: var(--background-primary); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; } /* 内容区域样式 */ .discourse-sync-modal { padding: 24px; 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 { @@ -29,27 +48,37 @@ If your plugin does not need CSS, delete this file. color: var(--text-normal); } -.discourse-sync-modal .select-container { +/* 表单容器通用样式 */ +.discourse-sync-modal .select-container, +.discourse-sync-modal .tag-container { 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; margin-bottom: 8px; font-weight: 500; color: var(--text-normal); } -.discourse-sync-modal select { +/* 输入框和选择器样式 */ +.discourse-sync-modal select, +.discourse-sync-modal .tag-select-area { width: 100%; padding: 8px 12px; - height: 42px; - line-height: 1.5; border: 2px solid var(--background-modifier-border); border-radius: 4px; background-color: var(--background-primary); color: var(--text-normal); font-size: 14px; + min-height: 42px; +} + +.discourse-sync-modal select { + height: 42px; + line-height: 1.5; } .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); 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; + } +}