From 7096f812340adbeb467968010cfb0bd8d4471115 Mon Sep 17 00:00:00 2001 From: africa1207 Date: Mon, 17 Mar 2025 12:08:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=88=B0obsidian=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=AF=BC=E5=87=BA=E8=B7=AF=E5=BE=84=20(#3373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增加导出到obsidian功能,可选择导出路径 * feat: 增加将内容导出到已有md文件 * fix: 修复日文翻译 --- .../src/assets/images/apps/obsidian.svg | 1 + .../src/components/ObsidianFolderSelector.tsx | 228 ++++++++++++++++++ .../components/Popups/ObsidianExportPopup.tsx | 72 ++++++ src/renderer/src/i18n/locales/en-us.json | 24 ++ src/renderer/src/i18n/locales/ja-jp.json | 34 ++- src/renderer/src/i18n/locales/ru-ru.json | 24 ++ src/renderer/src/i18n/locales/zh-cn.json | 24 ++ src/renderer/src/i18n/locales/zh-tw.json | 24 ++ .../pages/home/Messages/MessageMenubar.tsx | 10 + .../src/pages/home/Tabs/TopicsTab.tsx | 10 + .../settings/DataSettings/DataSettings.tsx | 10 +- .../DataSettings/ObsidianSettings.tsx | 121 ++++++++++ src/renderer/src/store/settings.ts | 16 +- src/renderer/src/utils/export.ts | 61 +++++ 14 files changed, 651 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/assets/images/apps/obsidian.svg create mode 100644 src/renderer/src/components/ObsidianFolderSelector.tsx create mode 100644 src/renderer/src/components/Popups/ObsidianExportPopup.tsx create mode 100644 src/renderer/src/pages/settings/DataSettings/ObsidianSettings.tsx diff --git a/src/renderer/src/assets/images/apps/obsidian.svg b/src/renderer/src/assets/images/apps/obsidian.svg new file mode 100644 index 00000000..dbf69ac8 --- /dev/null +++ b/src/renderer/src/assets/images/apps/obsidian.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/components/ObsidianFolderSelector.tsx b/src/renderer/src/components/ObsidianFolderSelector.tsx new file mode 100644 index 00000000..cb6f6170 --- /dev/null +++ b/src/renderer/src/components/ObsidianFolderSelector.tsx @@ -0,0 +1,228 @@ +import { FileOutlined, FolderOutlined } from '@ant-design/icons' +import { Spin, Switch, Tree } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface Props { + defaultPath: string + obsidianUrl: string + obsidianApiKey: string + onPathChange: (path: string, isMdFile: boolean) => void +} + +interface TreeNode { + title: string + key: string + isLeaf: boolean + isMdFile?: boolean + children?: TreeNode[] +} + +const ObsidianFolderSelector: FC = ({ defaultPath, obsidianUrl, obsidianApiKey, onPathChange }) => { + const { t } = useTranslation() + const [treeData, setTreeData] = useState([]) + const [loading, setLoading] = useState(false) + const [expandedKeys, setExpandedKeys] = useState(['/']) + const [showMdFiles, setShowMdFiles] = useState(false) + // 当前选中的节点信息 + const [currentSelection, setCurrentSelection] = useState({ + path: defaultPath, + isMdFile: false + }) + // 使用key强制Tree组件重新渲染 + const [treeKey, setTreeKey] = useState(0) + + // 只初始化根节点,不立即加载内容 + useEffect(() => { + initializeRootNode() + }, [showMdFiles]) + + // 初始化根节点,但不自动加载子节点 + const initializeRootNode = () => { + const rootNode: TreeNode = { + title: '/', + key: '/', + isLeaf: false + } + + setTreeData([rootNode]) + } + + // 异步加载子节点 + const loadData = async (node: any) => { + if (node.isLeaf) return // 如果是叶子节点(md文件),不加载子节点 + + setLoading(true) + try { + // 确保路径末尾有斜杠 + const path = node.key === '/' ? '' : node.key + const requestPath = path.endsWith('/') ? path : `${path}/` + + const response = await fetch(`${obsidianUrl}vault${requestPath}`, { + headers: { + Authorization: `Bearer ${obsidianApiKey}` + } + }) + const data = await response.json() + + if (!response.ok || (!data?.files && data?.errorCode !== 40400)) { + throw new Error('获取文件夹失败') + } + + const childNodes: TreeNode[] = (data.files || []) + .filter((file: string) => file.endsWith('/') || (showMdFiles && file.endsWith('.md'))) // 根据开关状态决定是否显示md文件 + .map((file: string) => { + // 修复路径问题,避免重复的斜杠 + const normalizedFile = file.replace('/', '') + const isMdFile = file.endsWith('.md') + const childPath = requestPath.endsWith('/') + ? `${requestPath}${normalizedFile}${isMdFile ? '' : '/'}` + : `${requestPath}/${normalizedFile}${isMdFile ? '' : '/'}` + + return { + title: normalizedFile, + key: childPath, + isLeaf: isMdFile, + isMdFile + } + }) + + // 更新节点的子节点 + setTreeData((origin) => { + const loop = (data: TreeNode[], key: string, children: TreeNode[]): TreeNode[] => { + return data.map((item) => { + if (item.key === key) { + return { + ...item, + children + } + } + if (item.children) { + return { + ...item, + children: loop(item.children, key, children) + } + } + return item + }) + } + return loop(origin, node.key, childNodes) + }) + } catch (error) { + window.message.error(t('chat.topics.export.obsidian_fetch_failed')) + } finally { + setLoading(false) + } + } + + // 处理开关切换 + const handleSwitchChange = (checked: boolean) => { + setShowMdFiles(checked) + // 重置选择 + setCurrentSelection({ + path: defaultPath, + isMdFile: false + }) + onPathChange(defaultPath, false) + + // 重置Tree状态并强制重新渲染 + setTreeData([]) + setExpandedKeys(['/']) + + // 递增key值以强制Tree组件完全重新渲染 + setTreeKey((prev) => prev + 1) + + // 延迟初始化根节点,让状态完全清除 + setTimeout(() => { + initializeRootNode() + }, 50) + } + + // 自定义图标,为md文件和文件夹显示不同的图标 + const renderIcon = (props: any) => { + const { data } = props + if (data.isMdFile) { + return + } + return + } + + return ( + + + {t('chat.topics.export.obsidian_show_md_files')} + + + + + setExpandedKeys(keys as string[])} + treeData={treeData} + loadData={loadData} + onSelect={(selectedKeys, info) => { + if (selectedKeys.length > 0) { + const path = selectedKeys[0] as string + const isMdFile = !!(info.node as any).isMdFile + + setCurrentSelection({ + path, + isMdFile + }) + + onPathChange?.(path, isMdFile) + } + }} + showLine + showIcon + icon={renderIcon} + /> + + +
+ {currentSelection.path !== defaultPath && ( + + {t('chat.topics.export.obsidian_selected_path')}: {currentSelection.path} + + )} +
+
+ ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 400px; +` + +const TreeContainer = styled.div` + flex: 1; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 10px; + margin-bottom: 10px; + height: 320px; +` + +const SwitchContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding: 0 10px; +` + +const SelectedPath = styled.div` + font-size: 12px; + color: var(--color-text-2); + margin-top: 5px; + padding: 0 10px; + word-break: break-all; +` + +export default ObsidianFolderSelector diff --git a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx new file mode 100644 index 00000000..b258e9cd --- /dev/null +++ b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx @@ -0,0 +1,72 @@ +import ObsidianFolderSelector from '@renderer/components/ObsidianFolderSelector' +import i18n from '@renderer/i18n' +import store from '@renderer/store' +import { exportMarkdownToObsidian } from '@renderer/utils/export' + +interface ObsidianExportOptions { + title: string + markdown: string +} + +// 用于显示 Obsidian 导出对话框 +const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise => { + const { title, markdown } = options + const obsidianUrl = store.getState().settings.obsidianUrl + const obsidianApiKey = store.getState().settings.obsidianApiKey + + if (!obsidianUrl || !obsidianApiKey) { + window.message.error(i18n.t('chat.topics.export.obsidian_not_configured')) + return false + } + + try { + // 创建一个状态变量来存储选择的路径 + let selectedPath = '/' + let selectedIsMdFile = false + + // 显示文件夹选择对话框 + return new Promise((resolve) => { + window.modal.confirm({ + title: i18n.t('chat.topics.export.obsidian_select_folder'), + content: ( + { + selectedPath = path + selectedIsMdFile = isMdFile + }} + /> + ), + width: 600, + icon: null, + closable: true, + maskClosable: true, + centered: true, + okButtonProps: { type: 'primary' }, + okText: i18n.t('chat.topics.export.obsidian_select_folder.btn'), + onOk: () => { + // 如果选择的是md文件,则使用选择的文件名而不是传入的标题 + const fileName = selectedIsMdFile ? selectedPath.split('/').pop()?.replace('.md', '') : title + + exportMarkdownToObsidian(fileName as string, markdown, selectedPath, selectedIsMdFile) + resolve(true) + }, + onCancel: () => { + resolve(false) + } + }) + }) + } catch (error) { + window.message.error(i18n.t('chat.topics.export.obsidian_fetch_failed')) + console.error(error) + return false + } +} + +const ObsidianExportPopup = { + show: showObsidianExportDialog +} + +export default ObsidianExportPopup diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 09d41cdc..849ed332 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -162,6 +162,15 @@ "topics.export.title": "Export", "topics.export.word": "Export as Word", "topics.export.yuque": "Export to Yuque", + "topics.export.obsidian": "Export to Obsidian", + "topics.export.obsidian_not_configured": "Obsidian not configured", + "topics.export.obsidian_fetch_failed": "Failed to fetch Obsidian folder structure", + "topics.export.obsidian_select_folder": "Select Obsidian folder", + "topics.export.obsidian_select_folder.btn": "Confirm", + "topics.export.obsidian_export_success": "Export success", + "topics.export.obsidian_export_failed": "Export failed", + "topics.export.obsidian_show_md_files": "Show MD Files", + "topics.export.obsidian_selected_path": "Selected Path", "topics.list": "Topic List", "topics.move_to": "Move to", "topics.pinned": "Pinned Topics", @@ -748,6 +757,21 @@ "title": "Yuque Configuration", "token": "Yuque Token", "token_placeholder": "Please enter the Yuque Token" + }, + "obsidian": { + "check": { + "button": "Check", + "empty_url": "Please enter the Obsidian REST API URL first", + "empty_api_key": "Please enter the Obsidian API Key first", + "fail": "Obsidian connection verification failed", + "success": "Obsidian connection verification successful" + }, + "help": "Install the Obsidian plugin Local REST API first, then get the Obsidian API Key", + "url": "Obsidian Knowledge Base URL", + "url_placeholder": "http://127.0.0.1:27123/", + "title": "Obsidian Configuration", + "api_key": "Obsidian API Key", + "api_key_placeholder": "Please enter the Obsidian API Key" } }, "display.assistant.title": "Assistant Settings", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9d455a41..f53dc489 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -162,6 +162,15 @@ "topics.export.title": "エクスポート", "topics.export.word": "Wordとしてエクスポート", "topics.export.yuque": "語雀にエクスポート", + "topics.export.obsidian": "Obsidian にエクスポート", + "topics.export.obsidian_not_configured": "Obsidian 未設定", + "topics.export.obsidian_fetch_failed": "Obsidian ファイルフォルダ構造取得失敗", + "topics.export.obsidian_select_folder": "Obsidian ファイルフォルダ選択", + "topics.export.obsidian_select_folder.btn": "確定", + "topics.export.obsidian_export_success": "エクスポート成功", + "topics.export.obsidian_export_failed": "エクスポート失敗", + "topics.export.obsidian_show_md_files": "mdファイルを表示", + "topics.export.obsidian_selected_path": "選択済みパス", "topics.list": "トピックリスト", "topics.move_to": "移動先", "topics.pinned": "トピックを固定", @@ -691,8 +700,8 @@ "markdown_export.help": "入力された場合、エクスポート時に自動的にこのパスに保存されます。未入力の場合、保存ダイアログが表示されます。", "notion.api_key": "Notion APIキー", "notion.api_key_placeholder": "Notion APIキーを入力してください", - "notion.auto_split": "내보내기 시 자동 분할", - "notion.auto_split_tip": "긴 주제를 Notion으로 내보낼 때 자동으로 페이지 분할", + "notion.auto_split": "ダイアログをエクスポートすると自動ページ分割", + "notion.auto_split_tip": "ダイアログが長い場合、Notionに自動的にページ分割してエクスポートします", "notion.check": { "button": "確認", "empty_api_key": "Api_keyが設定されていません", @@ -706,9 +715,9 @@ "notion.help": "Notion 設定ドキュメント", "notion.page_name_key": "ページタイトルフィールド名", "notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です", - "notion.split_size": "분할 크기", - "notion.split_size_help": "권장: 무료 플랜 90, Plus 플랜 24990, 기본값 90", - "notion.split_size_placeholder": "페이지당 블록 제한 입력(기본값 90)", + "notion.split_size": "自動ページ分割サイズ", + "notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90", + "notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)", "notion.title": "Notion 設定", "title": "データ設定", "webdav": { @@ -748,6 +757,21 @@ "title": "Yuque設定", "token": "Yuqueトークン", "token_placeholder": "Yuqueトークンを入力してください" + }, + "obsidian": { + "check": { + "button": "確認", + "empty_url": "Obsidian REST API URL を先に入力してください", + "empty_api_key": "Obsidian API Key を先に入力してください", + "fail": "Obsidian 接続確認に失敗しました", + "success": "Obsidian 接続確認に成功しました" + }, + "help": "Obsidian プラグイン Local REST API を先にインストールしてください。その後、Obsidian API Key を取得してください", + "url": "Obsidian 知識ベース URL", + "url_placeholder": "http://127.0.0.1:27123/", + "title": "Obsidian 設定", + "api_key": "Obsidian API Key", + "api_key_placeholder": "Obsidian API Key を入力してください" } }, "display.assistant.title": "アシスタント設定", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 14dd4c62..9ccd2d13 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -162,6 +162,15 @@ "topics.export.title": "Экспорт", "topics.export.word": "Экспорт как Word", "topics.export.yuque": "Экспорт в Yuque", + "topics.export.obsidian": "Экспорт в Obsidian", + "topics.export.obsidian_not_configured": "Obsidian не настроен", + "topics.export.obsidian_fetch_failed": "Не удалось получить структуру файлов Obsidian", + "topics.export.obsidian_select_folder": "Выберите папку Obsidian", + "topics.export.obsidian_select_folder.btn": "Определить", + "topics.export.obsidian_export_success": "Экспорт успешно завершен", + "topics.export.obsidian_export_failed": "Экспорт не удалось", + "topics.export.obsidian_show_md_files": "Показать файлы MD", + "topics.export.obsidian_selected_path": "Выбранный путь", "topics.list": "Список топиков", "topics.move_to": "Переместить в", "topics.pinned": "Закрепленные темы", @@ -748,6 +757,21 @@ "title": "Настройка Yuque", "token": "Токен Yuque", "token_placeholder": "Введите токен Yuque" + }, + "obsidian": { + "check": { + "button": "Проверить", + "empty_url": "Сначала введите URL REST API Obsidian", + "empty_api_key": "Сначала введите API Key Obsidian", + "fail": "Не удалось проверить подключение к Obsidian", + "success": "Подключение к Obsidian успешно проверено" + }, + "help": "Сначала установите плагин Local REST API Obsidian, затем получите API Key Obsidian", + "url": "URL базы знаний Obsidian", + "url_placeholder": "http://127.0.0.1:27123/", + "title": "Настройка Obsidian", + "api_key": "API Key Obsidian", + "api_key_placeholder": "Введите API Key Obsidian" } }, "display.assistant.title": "Настройки ассистентов", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 470057a5..a933b69f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -162,6 +162,15 @@ "topics.export.title": "导出", "topics.export.word": "导出为 Word", "topics.export.yuque": "导出到语雀", + "topics.export.obsidian": "导出到 Obsidian", + "topics.export.obsidian_not_configured": "Obsidian 未配置", + "topics.export.obsidian_fetch_failed": "获取 Obsidian 文件夹结构失败", + "topics.export.obsidian_select_folder": "选择 Obsidian 文件夹", + "topics.export.obsidian_select_folder.btn": "确定", + "topics.export.obsidian_export_success": "导出成功", + "topics.export.obsidian_export_failed": "导出失败", + "topics.export.obsidian_show_md_files": "显示md文件", + "topics.export.obsidian_selected_path": "已选择路径", "topics.list": "话题列表", "topics.move_to": "移动到", "topics.pinned": "固定话题", @@ -748,6 +757,21 @@ "title": "语雀配置", "token": "语雀 Token", "token_placeholder": "请输入语雀Token" + }, + "obsidian": { + "check": { + "button": "检查", + "empty_url": "请先输入 Obsidian REST API URL", + "empty_api_key": "请先输入 Obsidian API Key", + "fail": "Obsidian 连接验证失败", + "success": "Obsidian 连接验证成功" + }, + "help": "先安装 Obsidian 插件 Local REST API,然后获取 Obsidian API Key", + "url": "Obsidian 知识库 URL", + "url_placeholder": "http://127.0.0.1:27123/", + "title": "Obsidian 配置", + "api_key": "Obsidian API Key", + "api_key_placeholder": "请输入 Obsidian API Key" } }, "display.assistant.title": "助手设置", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b92546f3..eec4668a 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -162,6 +162,15 @@ "topics.export.title": "匯出", "topics.export.word": "匯出為 Word", "topics.export.yuque": "匯出到語雀", + "topics.export.obsidian": "匯出到 Obsidian", + "topics.export.obsidian_not_configured": "Obsidian 未配置", + "topics.export.obsidian_fetch_failed": "獲取 Obsidian 文件夾結構失敗", + "topics.export.obsidian_select_folder": "選擇 Obsidian 文件夾", + "topics.export.obsidian_select_folder.btn": "確定", + "topics.export.obsidian_export_success": "匯出成功", + "topics.export.obsidian_export_failed": "匯出失敗", + "topics.export.obsidian_show_md_files": "顯示md文件", + "topics.export.obsidian_selected_path": "已選擇路徑", "topics.list": "話題列表", "topics.move_to": "移動到", "topics.pinned": "固定話題", @@ -748,6 +757,21 @@ "title": "語雀設定", "token": "語雀 Token", "token_placeholder": "請輸入語雀 Token" + }, + "obsidian": { + "check": { + "button": "檢查", + "empty_url": "請先輸入 Obsidian REST API URL", + "empty_api_key": "請先輸入 Obsidian API Key", + "fail": "Obsidian 連接驗證失敗", + "success": "Obsidian 連接驗證成功" + }, + "help": "先安裝 Obsidian 插件 Local REST API,然後獲取 Obsidian API Key", + "url": "Obsidian 知識庫 URL", + "url_placeholder": "http://127.0.0.1:27123/", + "title": "Obsidian 設定", + "api_key": "Obsidian API Key", + "api_key_placeholder": "請輸入 Obsidian API Key" } }, "display.assistant.title": "助手設定", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 16679982..107ae99b 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -12,6 +12,7 @@ import { TranslationOutlined } from '@ant-design/icons' import { UploadOutlined } from '@ant-design/icons' +import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' @@ -212,6 +213,15 @@ const MessageMenubar: FC = (props) => { const markdown = messageToMarkdown(message) exportMarkdownToYuque(title, markdown) } + }, + { + label: t('chat.topics.export.obsidian'), + key: 'obsidian', + onClick: async () => { + const markdown = messageToMarkdown(message) + const title = getMessageTitle(message) + await ObsidianExportPopup.show({ title, markdown }) + } } ] } diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index f1c1642b..6d11f749 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -10,6 +10,7 @@ import { } from '@ant-design/icons' import DragableList from '@renderer/components/DragableList' import CopyIcon from '@renderer/components/Icons/CopyIcon' +import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup' import Scrollbar from '@renderer/components/Scrollbar' import { isMac } from '@renderer/config/constant' @@ -25,6 +26,7 @@ import { Assistant, Topic } from '@renderer/types' import { removeSpecialCharactersForFileName } from '@renderer/utils' import { copyTopicAsMarkdown } from '@renderer/utils/copy' import { + exportMarkdownToNotion, exportMarkdownToYuque, exportTopicAsMarkdown, exportTopicToNotion, @@ -254,6 +256,14 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const markdown = await topicToMarkdown(topic) exportMarkdownToYuque(topic.name, markdown) } + }, + { + label: t('chat.topics.export.obsidian'), + key: 'obsidian', + onClick: async () => { + const markdown = await topicToMarkdown(topic) + await ObsidianExportPopup.show({ title: topic.name, markdown }) + } } ] } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 2cbdd394..e2fa8483 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -26,6 +26,8 @@ import MarkdownExportSettings from './MarkdownExportSettings' import NotionSettings from './NotionSettings' import WebDavSettings from './WebDavSettings' import YuqueSettings from './YuqueSettings' +import ObsidianSettings from './ObsidianSettings' +import ObsidianIcon from '@renderer/assets/images/apps/obsidian.svg' const DataSettings: FC = () => { const { t } = useTranslation() @@ -43,7 +45,12 @@ const DataSettings: FC = () => { icon: }, { key: 'notion', title: 'settings.data.notion.title', icon: }, - { key: 'yuque', title: 'settings.data.yuque.title', icon: } + { key: 'yuque', title: 'settings.data.yuque.title', icon: }, + { + key: 'obsidian', + title: 'settings.data.obsidian.title', + icon: obsidian + } ] useEffect(() => { @@ -180,6 +187,7 @@ const DataSettings: FC = () => { {menu === 'markdown_export' && } {menu === 'notion' && } {menu === 'yuque' && } + {menu === 'obsidian' && } ) diff --git a/src/renderer/src/pages/settings/DataSettings/ObsidianSettings.tsx b/src/renderer/src/pages/settings/DataSettings/ObsidianSettings.tsx new file mode 100644 index 00000000..405bf26b --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/ObsidianSettings.tsx @@ -0,0 +1,121 @@ +import { InfoCircleOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import MinApp from '@renderer/components/MinApp' +import { useTheme } from '@renderer/context/ThemeProvider' +import { RootState, useAppDispatch } from '@renderer/store' +import { setObsidianApiKey, setObsidianUrl } from '@renderer/store/settings' +import { Button, Tooltip } from 'antd' +import Input from 'antd/es/input/Input' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const ObsidianSettings: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + const dispatch = useAppDispatch() + + const obsidianApiKey = useSelector((state: RootState) => state.settings.obsidianApiKey) + const obsidianUrl = useSelector((state: RootState) => state.settings.obsidianUrl) + + const handleObsidianApiKeyChange = (e: React.ChangeEvent) => { + dispatch(setObsidianApiKey(e.target.value)) + } + + const handleObsidianUrlChange = (e: React.ChangeEvent) => { + dispatch(setObsidianUrl(e.target.value)) + } + + const handleObsidianUrlBlur = (e: React.FocusEvent) => { + let url = e.target.value + // 确保URL以/结尾,但只在失去焦点时执行 + if (url && !url.endsWith('/')) { + url = `${url}/` + dispatch(setObsidianUrl(url)) + } + } + + const handleObsidianConnectionCheck = async () => { + try { + if (!obsidianApiKey) { + window.message.error(t('settings.data.obsidian.check.empty_api_key')) + return + } + if (!obsidianUrl) { + window.message.error(t('settings.data.obsidian.check.empty_url')) + return + } + + const response = await fetch(`${obsidianUrl}`, { + headers: { + Authorization: `Bearer ${obsidianApiKey}` + } + }) + + const data = await response.json() + + if (!response.ok || !data?.authenticated) { + window.message.error(t('settings.data.obsidian.check.fail')) + return + } + + window.message.success(t('settings.data.obsidian.check.success')) + } catch (e) { + window.message.error(t('settings.data.obsidian.check.fail')) + } + } + + const handleObsidianHelpClick = () => { + MinApp.start({ + id: 'obsidian-help', + name: 'Obsidian Help', + url: 'https://github.com/coddingtonbear/obsidian-local-rest-api' + }) + } + + return ( + + {t('settings.data.obsidian.title')} + + + {t('settings.data.obsidian.url')} + + + + + + + + {t('settings.data.obsidian.api_key')} + + + + + + + + + + + ) +} + +export default ObsidianSettings diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 35be6eac..fc42dfee 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -79,6 +79,8 @@ export interface SettingsState { yuqueToken: string | null yuqueUrl: string | null yuqueRepoId: string | null + obsidianApiKey: string | null + obsidianUrl: string | null } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -143,7 +145,9 @@ const initialState: SettingsState = { notionSplitSize: 90, yuqueToken: '', yuqueUrl: '', - yuqueRepoId: '' + yuqueRepoId: '', + obsidianApiKey: '', + obsidianUrl: '' } const settingsSlice = createSlice({ @@ -332,6 +336,12 @@ const settingsSlice = createSlice({ }, setYuqueUrl: (state, action: PayloadAction) => { state.yuqueUrl = action.payload + }, + setObsidianApiKey: (state, action: PayloadAction) => { + state.obsidianApiKey = action.payload + }, + setObsidianUrl: (state, action: PayloadAction) => { + state.obsidianUrl = action.payload } } }) @@ -395,7 +405,9 @@ export const { setNotionSplitSize, setYuqueToken, setYuqueRepoId, - setYuqueUrl + setYuqueUrl, + setObsidianApiKey, + setObsidianUrl } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 403d1da2..01086b35 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -316,3 +316,64 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { setExportState({ isExporting: false }) } } + +/** + * 导出Markdown到Obsidian + */ +export const exportMarkdownToObsidian = async ( + fileName: string, + markdown: string, + selectedPath: string, + isMdFile: boolean = false +) => { + try { + const obsidianUrl = store.getState().settings.obsidianUrl + const obsidianApiKey = store.getState().settings.obsidianApiKey + + if (!obsidianUrl || !obsidianApiKey) { + window.message.error(i18n.t('chat.topics.export.obsidian_not_configured')) + return + } + + // 如果是md文件,直接将内容追加到该文件 + if (isMdFile) { + const response = await fetch(`${obsidianUrl}vault${selectedPath}`, { + method: 'POST', + headers: { + 'Content-Type': 'text/markdown', + Authorization: `Bearer ${obsidianApiKey}` + }, + body: `\n\n${markdown}` // 添加两个换行后追加内容 + }) + + if (!response.ok) { + window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) + return + } + } else { + // 创建新文件 + const sanitizedFileName = removeSpecialCharactersForFileName(fileName) + const path = selectedPath === '/' ? '' : selectedPath + const fullPath = path.endsWith('/') ? `${path}${sanitizedFileName}.md` : `${path}/${sanitizedFileName}.md` + + const response = await fetch(`${obsidianUrl}vault${fullPath}`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/markdown', + Authorization: `Bearer ${obsidianApiKey}` + }, + body: markdown + }) + + if (!response.ok) { + window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) + return + } + } + + window.message.success(i18n.t('chat.topics.export.obsidian_export_success')) + } catch (error) { + console.error('导出到Obsidian失败:', error) + window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) + } +}