From 917943386e3e86050c9151eed444659c0e844594 Mon Sep 17 00:00:00 2001 From: africa1207 Date: Tue, 25 Mar 2025 21:31:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AF=BC=E5=87=BAobs?= =?UTF-8?q?idian=EF=BC=8C=E8=87=AA=E5=8A=A8=E9=80=89=E6=8B=A9=E5=BA=93?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E9=85=8D=E7=BD=AE=20(#3854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 优化导出obsidian,自动选择库路径,不再需要手动配置 * fix: eslint报错 * feat: 增加预设置默认仓库 * fix: 解决合并冲突 --- src/main/ipc.ts | 11 + src/main/services/ObsidianVaultService.ts | 167 ++++++++++ src/preload/index.ts | 5 + .../src/components/ObsidianExportDialog.tsx | 307 +++++++++++++++++- .../components/Popups/ObsidianExportPopup.tsx | 18 +- src/renderer/src/i18n/locales/en-us.json | 30 +- src/renderer/src/i18n/locales/ja-jp.json | 30 +- src/renderer/src/i18n/locales/ru-ru.json | 30 +- src/renderer/src/i18n/locales/zh-cn.json | 34 +- src/renderer/src/i18n/locales/zh-tw.json | 30 +- .../settings/DataSettings/DataSettings.tsx | 14 +- .../DataSettings/ObsidianSettings.tsx | 137 ++++---- src/renderer/src/store/settings.ts | 29 +- src/renderer/src/types/electron.d.ts | 9 + src/renderer/src/utils/export.ts | 39 ++- 15 files changed, 702 insertions(+), 188 deletions(-) create mode 100644 src/main/services/ObsidianVaultService.ts create mode 100644 src/renderer/src/types/electron.d.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 144728cb..b2cbfd2f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -17,6 +17,7 @@ import FileStorage from './services/FileStorage' import { GeminiService } from './services/GeminiService' import KnowledgeService from './services/KnowledgeService' import MCPService from './services/MCPService' +import ObsidianVaultService from './services/ObsidianVaultService' import * as NutstoreService from './services/NutstoreService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' @@ -31,6 +32,7 @@ const fileManager = new FileStorage() const backupManager = new BackupManager() const exportService = new ExportService(fileManager) const mcpService = new MCPService() +const obsidianVaultService = new ObsidianVaultService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) @@ -300,6 +302,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('copilot:logout', CopilotService.logout) ipcMain.handle('copilot:get-user', CopilotService.getUser) + // Obsidian service + ipcMain.handle('obsidian:get-vaults', () => { + return obsidianVaultService.getVaults() + }) + + ipcMain.handle('obsidian:get-files', (_event, vaultName) => { + return obsidianVaultService.getFilesByVaultName(vaultName) + }) + // nutstore ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl) ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token)) diff --git a/src/main/services/ObsidianVaultService.ts b/src/main/services/ObsidianVaultService.ts new file mode 100644 index 00000000..544c9c07 --- /dev/null +++ b/src/main/services/ObsidianVaultService.ts @@ -0,0 +1,167 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +interface VaultInfo { + path: string + name: string +} + +interface FileInfo { + path: string + type: 'folder' | 'markdown' + name: string +} + +class ObsidianVaultService { + private obsidianConfigPath: string + + constructor() { + // 根据操作系统获取Obsidian配置文件路径 + if (process.platform === 'win32') { + this.obsidianConfigPath = path.join(app.getPath('appData'), 'obsidian', 'obsidian.json') + } else if (process.platform === 'darwin') { + this.obsidianConfigPath = path.join( + app.getPath('home'), + 'Library', + 'Application Support', + 'obsidian', + 'obsidian.json' + ) + } else { + // Linux + this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json') + } + } + + /** + * 获取所有的Obsidian Vault + */ + getVaults(): VaultInfo[] { + try { + if (!fs.existsSync(this.obsidianConfigPath)) { + return [] + } + + const configContent = fs.readFileSync(this.obsidianConfigPath, 'utf8') + const config = JSON.parse(configContent) + + if (!config.vaults) { + return [] + } + + return Object.entries(config.vaults).map(([, vault]: [string, any]) => ({ + path: vault.path, + name: vault.name || path.basename(vault.path) + })) + } catch (error) { + console.error('获取Obsidian Vault失败:', error) + return [] + } + } + + /** + * 获取Vault中的文件夹和Markdown文件结构 + */ + getVaultStructure(vaultPath: string): FileInfo[] { + const results: FileInfo[] = [] + + try { + // 检查vault路径是否存在 + if (!fs.existsSync(vaultPath)) { + console.error('Vault路径不存在:', vaultPath) + return [] + } + + // 检查是否是目录 + const stats = fs.statSync(vaultPath) + if (!stats.isDirectory()) { + console.error('Vault路径不是一个目录:', vaultPath) + return [] + } + + this.traverseDirectory(vaultPath, '', results) + } catch (error) { + console.error('读取Vault文件夹结构失败:', error) + } + + return results + } + + /** + * 递归遍历目录获取所有文件夹和Markdown文件 + */ + private traverseDirectory(dirPath: string, relativePath: string, results: FileInfo[]) { + try { + // 首先添加当前文件夹 + if (relativePath) { + results.push({ + path: relativePath, + type: 'folder', + name: path.basename(relativePath) + }) + } + + // 确保目录存在且可访问 + if (!fs.existsSync(dirPath)) { + console.error('目录不存在:', dirPath) + return + } + + let items + try { + items = fs.readdirSync(dirPath, { withFileTypes: true }) + } catch (err) { + console.error(`无法读取目录 ${dirPath}:`, err) + return + } + + for (const item of items) { + // 忽略以.开头的隐藏文件夹和文件 + if (item.name.startsWith('.')) { + continue + } + + const newRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name + const fullPath = path.join(dirPath, item.name) + + if (item.isDirectory()) { + this.traverseDirectory(fullPath, newRelativePath, results) + } else if (item.isFile() && item.name.endsWith('.md')) { + // 收集.md文件 + results.push({ + path: newRelativePath, + type: 'markdown', + name: item.name + }) + } + } + } catch (error) { + console.error(`遍历目录出错 ${dirPath}:`, error) + } + } + + /** + * 获取指定Vault的文件夹和Markdown文件结构 + * @param vaultName vault名称 + */ + getFilesByVaultName(vaultName: string): FileInfo[] { + try { + const vaults = this.getVaults() + const vault = vaults.find((v) => v.name === vaultName) + + if (!vault) { + console.error('未找到指定名称的Vault:', vaultName) + return [] + } + + console.log('获取Vault文件结构:', vault.name, vault.path) + return this.getVaultStructure(vault.path) + } catch (error) { + console.error('获取Vault文件结构时发生错误:', error) + return [] + } + } +} + +export default ObsidianVaultService diff --git a/src/preload/index.ts b/src/preload/index.ts index 4b91963c..fbfbc6d3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -174,6 +174,11 @@ if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) + contextBridge.exposeInMainWorld('obsidian', { + getVaults: () => ipcRenderer.invoke('obsidian:get-vaults'), + getFolders: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName), + getFiles: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName) + }) } catch (error) { console.error(error) } diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index ec37b8a7..857d6be1 100644 --- a/src/renderer/src/components/ObsidianExportDialog.tsx +++ b/src/renderer/src/components/ObsidianExportDialog.tsx @@ -1,36 +1,223 @@ import i18n from '@renderer/i18n' +import store from '@renderer/store' import { exportMarkdownToObsidian } from '@renderer/utils/export' -import { Form, Input, Modal, Select } from 'antd' -import React, { useState } from 'react' +import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd' +import React, { useEffect, useState } from 'react' const { Option } = Select interface ObsidianExportDialogProps { title: string markdown: string - open: boolean // 使用 open 属性替代 visible + open: boolean onClose: (success: boolean) => void obsidianTags: string | null processingMethod: string | '3' //默认新增(存在就覆盖) } +interface FileInfo { + path: string + type: 'folder' | 'markdown' + name: string +} + +// 转换文件信息数组为树形结构 +const convertToTreeData = (files: FileInfo[]) => { + const treeData: any[] = [ + { + title: i18n.t('chat.topics.export.obsidian_root_directory'), + value: '', + isLeaf: false, + selectable: true + } + ] + + // 记录已创建的节点路径 + const pathMap: Record = { + '': treeData[0] + } + + // 先按类型分组,确保先处理文件夹 + const folders = files.filter((file) => file.type === 'folder') + const mdFiles = files.filter((file) => file.type === 'markdown') + + // 按路径排序,确保父文件夹先被创建 + const sortedFolders = [...folders].sort((a, b) => a.path.split('/').length - b.path.split('/').length) + + // 先处理所有文件夹,构建目录结构 + for (const folder of sortedFolders) { + const parts = folder.path.split('/') + let currentPath = '' + let parentPath = '' + + // 遍历文件夹路径的每一部分,确保创建完整路径 + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + + // 构建当前路径 + currentPath = currentPath ? `${currentPath}/${part}` : part + + // 如果这个路径节点还没创建 + if (!pathMap[currentPath]) { + const node = { + title: part, + value: currentPath, + key: currentPath, + isLeaf: false, + selectable: true, + children: [] + } + + // 获取父节点,将当前节点添加到父节点的children中 + const parentNode = pathMap[parentPath] + if (parentNode) { + if (!parentNode.children) { + parentNode.children = [] + } + parentNode.children.push(node) + } + + pathMap[currentPath] = node + } + + // 更新父路径为当前路径,为下一级做准备 + parentPath = currentPath + } + } + + // 然后处理md文件 + for (const file of mdFiles) { + const fullPath = file.path + const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/')) + const fileName = file.name + + // 获取父文件夹节点 + const parentNode = pathMap[dirPath] || pathMap[''] + + // 创建文件节点 + const fileNode = { + title: fileName, + value: fullPath, + isLeaf: true, + selectable: true, + icon: 📄 + } + + // 添加到父节点 + if (!parentNode.children) { + parentNode.children = [] + } + parentNode.children.push(fileNode) + } + + return treeData +} + const ObsidianExportDialog: React.FC = ({ title, markdown, - obsidianTags, - processingMethod, open, - onClose + onClose, + obsidianTags, + processingMethod }) => { + const defaultObsidianVault = store.getState().settings.defaultObsidianVault const [state, setState] = useState({ - title: title, + title, tags: obsidianTags || '', createdAt: new Date().toISOString().split('T')[0], source: 'Cherry Studio', - processingMethod: processingMethod + processingMethod: processingMethod, + folder: '' }) + const [vaults, setVaults] = useState>([]) + const [files, setFiles] = useState([]) + const [fileTreeData, setFileTreeData] = useState([]) + const [selectedVault, setSelectedVault] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // 处理文件数据转为树形结构 + useEffect(() => { + if (files.length > 0) { + const treeData = convertToTreeData(files) + setFileTreeData(treeData) + } else { + setFileTreeData([ + { + title: i18n.t('chat.topics.export.obsidian_root_directory'), + value: '', + isLeaf: false, + selectable: true + } + ]) + } + }, [files]) + + // 组件加载时获取Vault列表 + useEffect(() => { + const fetchVaults = async () => { + try { + setLoading(true) + setError(null) + const vaultsData = await window.obsidian.getVaults() + + if (vaultsData.length === 0) { + setError(i18n.t('chat.topics.export.obsidian_no_vaults')) + setLoading(false) + return + } + + setVaults(vaultsData) + + // 如果没有选择的vault,使用默认值或第一个 + const vaultToUse = defaultObsidianVault || vaultsData[0]?.name + if (vaultToUse) { + setSelectedVault(vaultToUse) + + // 获取选中vault的文件和文件夹 + const filesData = await window.obsidian.getFiles(vaultToUse) + setFiles(filesData) + } + } catch (error) { + console.error('获取Obsidian Vault失败:', error) + setError(i18n.t('chat.topics.export.obsidian_fetch_error')) + } finally { + setLoading(false) + } + } + + fetchVaults() + }, [defaultObsidianVault]) + + // 当选择的vault变化时,获取其文件和文件夹 + useEffect(() => { + if (selectedVault) { + const fetchFiles = async () => { + try { + setLoading(true) + setError(null) + const filesData = await window.obsidian.getFiles(selectedVault) + setFiles(filesData) + } catch (error) { + console.error('获取Obsidian文件失败:', error) + setError(i18n.t('chat.topics.export.obsidian_fetch_folders_error')) + } finally { + setLoading(false) + } + } + + fetchFiles() + } + }, [selectedVault]) + const handleOk = async () => { + if (!selectedVault) { + setError(i18n.t('chat.topics.export.obsidian_no_vault_selected')) + return + } + //构建content 并复制到粘贴板 let content = '' if (state.processingMethod !== '3') { @@ -45,10 +232,18 @@ const ObsidianExportDialog: React.FC = ({ } if (content === '') { window.message.error(i18n.t('chat.topics.export.obsidian_export_failed')) + return } + await navigator.clipboard.writeText(content) - markdown = '' - exportMarkdownToObsidian(state) + + // 导出到Obsidian + exportMarkdownToObsidian({ + ...state, + folder: state.folder, + vault: selectedVault + }) + onClose(true) } @@ -60,18 +255,56 @@ const ObsidianExportDialog: React.FC = ({ setState((prevState) => ({ ...prevState, [key]: value })) } + const handleVaultChange = (value: string) => { + setSelectedVault(value) + // 文件夹会通过useEffect自动获取 + setState((prevState) => ({ + ...prevState, + folder: '' + })) + } + + // 处理文件选择 + const handleFileSelect = (value: string) => { + // 更新folder值 + handleChange('folder', value) + + // 检查是否选中md文件 + if (value) { + const selectedFile = files.find((file) => file.path === value) + if (selectedFile) { + if (selectedFile.type === 'markdown') { + // 如果是md文件,自动设置标题为文件名并设置处理方式为1(追加) + const fileName = selectedFile.name + const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName + handleChange('title', titleWithoutExt) + handleChange('processingMethod', '1') + } else { + // 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建) + handleChange('processingMethod', '3') + handleChange('title', title) + } + } + } + } + return ( + {error && } +
= ({ placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')} /> + + + {vaults.length > 0 ? ( + + ) : ( + + )} + + + + + {selectedVault ? ( + + ) : ( + + )} + + + = ({ placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')} /> + - - - - - - {t('settings.data.obsidian.folder')} - - - - - - - - - {t('settings.data.obsidian.tags')} - - - + {t('settings.data.obsidian.default_vault')} + + + {vaults.length > 0 ? ( + + ) : ( + + )} + diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 844a1f27..d4cd2c8b 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -90,12 +90,9 @@ export interface SettingsState { yuqueToken: string | null yuqueUrl: string | null yuqueRepoId: string | null - //obsidian settings obsidianVault, obisidanFolder - obsidianValut: string | null - obsidianFolder: string | null - obsidianTages: string | null joplinToken: string | null joplinUrl: string | null + defaultObsidianVault: string | null // 思源笔记配置 siyuanApiUrl: string | null siyuanToken: string | null @@ -172,11 +169,9 @@ const initialState: SettingsState = { yuqueToken: '', yuqueUrl: '', yuqueRepoId: '', - obsidianValut: '', - obsidianFolder: '', - obsidianTages: '', joplinToken: '', joplinUrl: '', + defaultObsidianVault: null, // 思源笔记配置初始值 siyuanApiUrl: null, siyuanToken: null, @@ -386,15 +381,6 @@ const settingsSlice = createSlice({ setYuqueUrl: (state, action: PayloadAction) => { state.yuqueUrl = action.payload }, - setObsidianValut: (state, action: PayloadAction) => { - state.obsidianValut = action.payload - }, - setObsidianFolder: (state, action: PayloadAction) => { - state.obsidianFolder = action.payload - }, - setObsidianTages: (state, action: PayloadAction) => { - state.obsidianTages = action.payload - }, setJoplinToken: (state, action: PayloadAction) => { state.joplinToken = action.payload }, @@ -415,6 +401,9 @@ const settingsSlice = createSlice({ }, setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => { state.messageNavigation = action.payload + }, + setDefaultObsidianVault: (state, action: PayloadAction) => { + state.defaultObsidianVault = action.payload } } }) @@ -484,16 +473,14 @@ export const { setYuqueToken, setYuqueRepoId, setYuqueUrl, - setObsidianValut, - setObsidianFolder, - setObsidianTages, setJoplinToken, setJoplinUrl, + setMessageNavigation, + setDefaultObsidianVault, setSiyuanApiUrl, setSiyuanToken, setSiyuanBoxId, - setSiyuanRootPath, - setMessageNavigation + setSiyuanRootPath } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/electron.d.ts b/src/renderer/src/types/electron.d.ts new file mode 100644 index 00000000..7059da90 --- /dev/null +++ b/src/renderer/src/types/electron.d.ts @@ -0,0 +1,9 @@ +interface ObsidianAPI { + getVaults: () => Promise> + getFiles: (vaultName: string) => Promise> + getFolders: (vaultName: string) => Promise> +} + +interface Window { + obsidian: ObsidianAPI +} diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 05b27399..3583d384 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -326,31 +326,49 @@ export const exportMarkdownToYuque = async (title: string, content: string) => { * @param attributes.source 来源 * @param attributes.tags 标签 * @param attributes.processingMethod 处理方式 + * @param attributes.folder 选择的文件夹路径或文件路径 + * @param attributes.vault 选择的Vault名称 */ export const exportMarkdownToObsidian = async (attributes: any) => { try { - const obsidianValut = store.getState().settings.obsidianValut - const obsidianFolder = store.getState().settings.obsidianFolder + // 从参数获取Vault名称 + const obsidianValut = attributes.vault + let obsidianFolder = attributes.folder || '' + let isMarkdownFile = false - if (!obsidianValut || !obsidianFolder) { + if (!obsidianValut) { window.message.error(i18n.t('chat.topics.export.obsidian_not_configured')) return } - let path = '' if (!attributes.title) { window.message.error(i18n.t('chat.topics.export.obsidian_title_required')) return } - //构建保存路径添加以 / 结尾 - if (!obsidianFolder.endsWith('/')) { - path = obsidianFolder + '/' + // 检查是否选择了.md文件 + if (obsidianFolder && obsidianFolder.endsWith('.md')) { + isMarkdownFile = true } - //构建文件名 - const fileName = transformObsidianFileName(attributes.title) - let obsidianUrl = `obsidian://new?file=${encodeURIComponent(path + fileName)}&vault=${encodeURIComponent(obsidianValut)}&clipboard` + let filePath = '' + + // 如果是.md文件,直接使用该文件路径 + if (isMarkdownFile) { + filePath = obsidianFolder + } else { + // 否则构建路径 + //构建保存路径添加以 / 结尾 + if (obsidianFolder && !obsidianFolder.endsWith('/')) { + obsidianFolder = obsidianFolder + '/' + } + + //构建文件名 + const fileName = transformObsidianFileName(attributes.title) + filePath = obsidianFolder + fileName + '.md' + } + + let obsidianUrl = `obsidian://new?file=${encodeURIComponent(filePath)}&vault=${encodeURIComponent(obsidianValut)}&clipboard` if (attributes.processingMethod === '3') { obsidianUrl += '&overwrite=true' @@ -359,6 +377,7 @@ export const exportMarkdownToObsidian = async (attributes: any) => { } else if (attributes.processingMethod === '1') { obsidianUrl += '&append=true' } + window.open(obsidianUrl) window.message.success(i18n.t('chat.topics.export.obsidian_export_success')) } catch (error) {