feat: 优化导出obsidian,自动选择库路径,不再需要手动配置 (#3854)

* feat: 优化导出obsidian,自动选择库路径,不再需要手动配置

* fix: eslint报错

* feat: 增加预设置默认仓库

* fix: 解决合并冲突
This commit is contained in:
africa1207 2025-03-25 21:31:22 +08:00 committed by GitHub
parent aee0f9ea3f
commit 917943386e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 702 additions and 188 deletions

View File

@ -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))

View File

@ -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

View File

@ -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)
}

View File

@ -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<string, any> = {
'': 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: <span style={{ marginRight: 4 }}>📄</span>
}
// 添加到父节点
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(fileNode)
}
return treeData
}
const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
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<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
const [fileTreeData, setFileTreeData] = useState<any[]>([])
const [selectedVault, setSelectedVault] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(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<ObsidianExportDialogProps> = ({
}
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<ObsidianExportDialogProps> = ({
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 (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
open={open} // 使用 open 属性
open={open}
onOk={handleOk}
onCancel={handleCancel}
width={600}
closable
maskClosable
centered
okButtonProps={{ type: 'primary' }}
okButtonProps={{
type: 'primary',
disabled: vaults.length === 0 || loading || !!error
}}
okText={i18n.t('chat.topics.export.obsidian_btn')}>
{error && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
@ -80,6 +313,55 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
{vaults.length > 0 ? (
<Select
loading={loading}
value={selectedVault}
onChange={handleVaultChange}
placeholder={i18n.t('chat.topics.export.obsidian_vault_placeholder')}
style={{ width: '100%' }}>
{vaults.map((vault) => (
<Option key={vault.name} value={vault.name}>
{vault.name}
</Option>
))}
</Select>
) : (
<Empty
description={
loading
? i18n.t('chat.topics.export.obsidian_loading')
: i18n.t('chat.topics.export.obsidian_no_vaults')
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
<TreeSelect
value={state.folder}
onChange={handleFileSelect}
placeholder={i18n.t('chat.topics.export.obsidian_path_placeholder')}
style={{ width: '100%' }}
showSearch
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeDefaultExpandAll={false}
treeNodeFilterProp="title"
treeData={fileTreeData}></TreeSelect>
) : (
<Empty
description={i18n.t('chat.topics.export.obsidian_select_vault_first')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
@ -101,6 +383,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
<Select
value={state.processingMethod}

View File

@ -1,6 +1,4 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { createRoot } from 'react-dom/client'
interface ObsidianExportOptions {
@ -17,14 +15,6 @@ interface ObsidianExportOptions {
* @returns
*/
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
const obsidianValut = store.getState().settings.obsidianValut
const obsidianFolder = store.getState().settings.obsidianFolder
if (!obsidianValut || !obsidianFolder) {
window.message.error(i18n.t('chat.topics.export.obsidian_not_configured'))
return false
}
return new Promise<boolean>((resolve) => {
const div = document.createElement('div')
document.body.appendChild(div)
@ -35,12 +25,12 @@ const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise
document.body.removeChild(div)
resolve(success)
}
const obsidianTags = store.getState().settings.obsidianTages
// 不再从store中获取tag配置
root.render(
<ObsidianExportDialog
title={options.title}
markdown={options.markdown}
obsidianTags={obsidianTags}
obsidianTags=""
processingMethod={options.processingMethod}
open={true}
onClose={handleClose}
@ -49,8 +39,6 @@ const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise
})
}
const ObsidianExportPopup = {
export default {
show: showObsidianExportDialog
}
export default ObsidianExportPopup

View File

@ -180,13 +180,16 @@
"topics.export.md": "Export as markdown",
"topics.export.notion": "Export to Notion",
"topics.export.obsidian": "Export to Obsidian",
"topics.export.obsidian_vault": "Vault",
"topics.export.obsidian_vault_placeholder": "Please select the vault name",
"topics.export.obsidian_path": "Path",
"topics.export.obsidian_path_placeholder": "Please select the path",
"topics.export.obsidian_atributes": "Configure Note Attributes",
"topics.export.obsidian_btn": "Confirm",
"topics.export.obsidian_created": "Creation Time",
"topics.export.obsidian_created_placeholder": "Please select the creation time",
"topics.export.obsidian_export_failed": "Export failed",
"topics.export.obsidian_export_success": "Export success",
"topics.export.obsidian_not_configured": "Obsidian not configured",
"topics.export.obsidian_operate": "Operation Method",
"topics.export.obsidian_operate_append": "Append",
"topics.export.obsidian_operate_new_or_overwrite": "Create New (Overwrite if it exists)",
@ -199,6 +202,13 @@
"topics.export.obsidian_title": "Title",
"topics.export.obsidian_title_placeholder": "Please enter the title",
"topics.export.obsidian_title_required": "The title cannot be empty",
"topics.export.obsidian_no_vaults": "No Obsidian vaults found",
"topics.export.obsidian_loading": "Loading...",
"topics.export.obsidian_fetch_error": "Failed to fetch Obsidian vaults",
"topics.export.obsidian_fetch_folders_error": "Failed to fetch folder structure",
"topics.export.obsidian_no_vault_selected": "Please select a vault first",
"topics.export.obsidian_select_vault_first": "Please select a vault first",
"topics.export.obsidian_root_directory": "Root Directory",
"topics.export.title": "Export",
"topics.export.word": "Export as Word",
"topics.export.yuque": "Export to Yuque",
@ -796,15 +806,6 @@
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
"notion.title": "Notion Configuration",
"obsidian": {
"folder": "Folder",
"folder_placeholder": "Please enter the folder name",
"tags": "Global Tags",
"tags_placeholder": "Please enter the tag name, separate multiple tags with commas",
"title": "Obsidian Configuration",
"vault": "Vault",
"vault_placeholder": "Please enter the vault name"
},
"title": "Data Settings",
"webdav": {
"autoSync": "Auto Backup",
@ -850,6 +851,15 @@
"token": "Yuque Token",
"token_placeholder": "Please enter the Yuque Token"
},
"obsidian": {
"title": "Obsidian Configuration",
"default_vault": "Default Obsidian Vault",
"default_vault_placeholder": "Please select the default Obsidian vault",
"default_vault_loading": "Loading Obsidian vault...",
"default_vault_no_vaults": "No Obsidian vaults found",
"default_vault_fetch_error": "Failed to fetch Obsidian vault",
"default_vault_export_failed": "Export failed"
},
"siyuan": {
"title": "Siyuan Note Configuration",
"api_url": "Siyuan Note API URL",

View File

@ -180,13 +180,16 @@
"topics.export.md": "Markdownとしてエクスポート",
"topics.export.notion": "Notion にエクスポート",
"topics.export.obsidian": "Obsidian にエクスポート",
"topics.export.obsidian_vault": "保管庫",
"topics.export.obsidian_vault_placeholder": "保管庫名を選択してください",
"topics.export.obsidian_path": "パス",
"topics.export.obsidian_path_placeholder": "パスを選択してください",
"topics.export.obsidian_atributes": "ノートの属性を設定",
"topics.export.obsidian_btn": "確定",
"topics.export.obsidian_created": "作成日時",
"topics.export.obsidian_created_placeholder": "作成日時を選択してください",
"topics.export.obsidian_export_failed": "エクスポート失敗",
"topics.export.obsidian_export_success": "エクスポート成功",
"topics.export.obsidian_not_configured": "Obsidian 未設定",
"topics.export.obsidian_operate": "処理方法",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新規作成(既に存在する場合は上書き)",
@ -199,6 +202,13 @@
"topics.export.obsidian_title": "タイトル",
"topics.export.obsidian_title_placeholder": "タイトルを入力してください",
"topics.export.obsidian_title_required": "タイトルは空白にできません",
"topics.export.obsidian_no_vaults": "Obsidianの保管庫が見つかりません",
"topics.export.obsidian_loading": "読み込み中...",
"topics.export.obsidian_fetch_error": "Obsidianの保管庫の取得に失敗しました",
"topics.export.obsidian_fetch_folders_error": "フォルダ構造の取得に失敗しました",
"topics.export.obsidian_no_vault_selected": "保管庫を選択してください",
"topics.export.obsidian_select_vault_first": "最初に保管庫を選択してください",
"topics.export.obsidian_root_directory": "ルートディレクトリ",
"topics.export.title": "エクスポート",
"topics.export.word": "Wordとしてエクスポート",
"topics.export.yuque": "語雀にエクスポート",
@ -796,15 +806,6 @@
"notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90",
"notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)",
"notion.title": "Notion 設定",
"obsidian": {
"folder": "フォルダー",
"folder_placeholder": "フォルダーの名前を入力してください",
"tags": "グローバルタグ",
"tags_placeholder": "タグの名前を入力してください。複数のタグは英語のコンマで区切ってください",
"title": "Obsidian の設定",
"vault": "ヴォールト(保管庫)",
"vault_placeholder": "保管庫の名前を入力してください"
},
"title": "データ設定",
"webdav": {
"autoSync": "自動バックアップ",
@ -850,6 +851,15 @@
"token": "Yuqueトークン",
"token_placeholder": "Yuqueトークンを入力してください"
},
"obsidian": {
"title": "Obsidian 設定",
"default_vault": "デフォルトの Obsidian 保管庫",
"default_vault_placeholder": "デフォルトの Obsidian 保管庫を選択してください",
"default_vault_loading": "Obsidian 保管庫を取得中...",
"default_vault_no_vaults": "Obsidian 保管庫が見つかりません",
"default_vault_fetch_error": "Obsidian 保管庫の取得に失敗しました",
"default_vault_export_failed": "エクスポートに失敗しました"
},
"siyuan": {
"title": "思源ノート設定",
"api_url": "APIアドレス",

View File

@ -180,13 +180,16 @@
"topics.export.md": "Экспорт как markdown",
"topics.export.notion": "Экспорт в Notion",
"topics.export.obsidian": "Экспорт в Obsidian",
"topics.export.obsidian_vault": "Хранилище",
"topics.export.obsidian_vault_placeholder": "Выберите имя хранилища",
"topics.export.obsidian_path": "Путь",
"topics.export.obsidian_path_placeholder": "Выберите путь",
"topics.export.obsidian_atributes": "Настроить атрибуты заметки",
"topics.export.obsidian_btn": "Подтвердить",
"topics.export.obsidian_created": "Дата создания",
"topics.export.obsidian_created_placeholder": "Пожалуйста, выберите дату создания",
"topics.export.obsidian_export_failed": "Экспорт не удалось",
"topics.export.obsidian_export_success": "Экспорт успешно завершен",
"topics.export.obsidian_not_configured": "Obsidian не настроен",
"topics.export.obsidian_operate": "Метод обработки",
"topics.export.obsidian_operate_append": "Добавить в конец",
"topics.export.obsidian_operate_new_or_overwrite": "Создать новый (перезаписать, если уже существует)",
@ -199,6 +202,13 @@
"topics.export.obsidian_title": "Заголовок",
"topics.export.obsidian_title_placeholder": "Пожалуйста, введите заголовок",
"topics.export.obsidian_title_required": "Заголовок не может быть пустым",
"topics.export.obsidian_no_vaults": "Хранилища Obsidian не найдены",
"topics.export.obsidian_loading": "Загрузка...",
"topics.export.obsidian_fetch_error": "Не удалось получить хранилища Obsidian",
"topics.export.obsidian_fetch_folders_error": "Не удалось получить структуру папок",
"topics.export.obsidian_no_vault_selected": "Пожалуйста, сначала выберите хранилище",
"topics.export.obsidian_select_vault_first": "Пожалуйста, сначала выберите хранилище",
"topics.export.obsidian_root_directory": "Корневая директория",
"topics.export.title": "Экспорт",
"topics.export.word": "Экспорт как Word",
"topics.export.yuque": "Экспорт в Yuque",
@ -796,15 +806,6 @@
"notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90",
"notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)",
"notion.title": "Настройки Notion",
"obsidian": {
"folder": "Папка",
"folder_placeholder": "Пожалуйста, введите имя папки",
"tags": "Глобальные Теги",
"tags_placeholder": "Пожалуйста, введите имена тегов. Разделяйте несколько тегов запятыми на английском языке. В Obsidian нельзя использовать только цифры.",
"title": "Конфигурация Obsidian",
"vault": "Хранилище",
"vault_placeholder": "Пожалуйста, введите имя хранилища"
},
"title": "Настройки данных",
"webdav": {
"autoSync": "Автоматическое резервное копирование",
@ -850,6 +851,15 @@
"token": "Токен Yuque",
"token_placeholder": "Введите токен Yuque"
},
"obsidian": {
"title": "Настройки Obsidian",
"default_vault": "Хранилище Obsidian по умолчанию",
"default_vault_placeholder": "Выберите хранилище Obsidian по умолчанию",
"default_vault_loading": "Получение хранилищ Obsidian...",
"default_vault_no_vaults": "Хранилища Obsidian не найдены",
"default_vault_fetch_error": "Не удалось получить хранилища Obsidian",
"default_vault_export_failed": "Ошибка экспорта"
},
"siyuan": {
"title": "Конфигурация SiYuan Note",
"api_url": "API адрес",

View File

@ -180,13 +180,16 @@
"topics.export.md": "导出为 Markdown",
"topics.export.notion": "导出到 Notion",
"topics.export.obsidian": "导出到 Obsidian",
"topics.export.obsidian_vault": "保管库",
"topics.export.obsidian_vault_placeholder": "请选择保管库名称",
"topics.export.obsidian_path": "路径",
"topics.export.obsidian_path_placeholder": "请选择路径",
"topics.export.obsidian_atributes": "配置笔记属性",
"topics.export.obsidian_btn": "确定",
"topics.export.obsidian_created": "创建时间",
"topics.export.obsidian_created_placeholder": "请选择创建时间",
"topics.export.obsidian_export_failed": "导出失败",
"topics.export.obsidian_export_success": "导出成功",
"topics.export.obsidian_not_configured": "Obsidian 未配置",
"topics.export.obsidian_export_failed": "导出到Obsidian失败",
"topics.export.obsidian_export_success": "导出到Obsidian成功",
"topics.export.obsidian_operate": "处理方式",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)",
@ -199,6 +202,13 @@
"topics.export.obsidian_title": "标题",
"topics.export.obsidian_title_placeholder": "请输入标题",
"topics.export.obsidian_title_required": "标题不能为空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管库",
"topics.export.obsidian_loading": "加载中...",
"topics.export.obsidian_fetch_error": "获取Obsidian保管库失败",
"topics.export.obsidian_fetch_folders_error": "获取文件夹结构失败",
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
"topics.export.obsidian_select_vault_first": "请先选择保管库",
"topics.export.obsidian_root_directory": "根目录",
"topics.export.title": "导出",
"topics.export.word": "导出为 Word",
"topics.export.yuque": "导出到语雀",
@ -796,15 +806,6 @@
"notion.split_size_help": "Notion免费版用户建议设置为90高级版用户建议设置为24990默认值为90",
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
"notion.title": "Notion 配置",
"obsidian": {
"folder": "文件夹",
"folder_placeholder": "请输入文件夹名称",
"tags": "全局标签",
"tags_placeholder": "请输入标签名称, 多个标签用英文逗号分隔",
"title": "Obsidian 配置",
"vault": "保管库",
"vault_placeholder": "请输入保管库名称"
},
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",
@ -850,6 +851,15 @@
"token": "语雀 Token",
"token_placeholder": "请输入语雀Token"
},
"obsidian": {
"title": "Obsidian 配置",
"default_vault": "默认 Obsidian 仓库",
"default_vault_placeholder": "请选择默认 Obsidian 仓库",
"default_vault_loading": "正在获取 Obsidian 仓库...",
"default_vault_no_vaults": "未找到 Obsidian 仓库",
"default_vault_fetch_error": "获取 Obsidian 仓库失败",
"default_vault_export_failed": "导出失败"
},
"siyuan": {
"title": "思源笔记配置",
"api_url": "API地址",

View File

@ -180,13 +180,16 @@
"topics.export.md": "匯出為 Markdown",
"topics.export.notion": "匯出到 Notion",
"topics.export.obsidian": "匯出到 Obsidian",
"topics.export.obsidian_vault": "保管庫",
"topics.export.obsidian_vault_placeholder": "請選擇保管庫名稱",
"topics.export.obsidian_path": "路徑",
"topics.export.obsidian_path_placeholder": "請選擇路徑",
"topics.export.obsidian_atributes": "配置筆記屬性",
"topics.export.obsidian_btn": "確定",
"topics.export.obsidian_created": "建立時間",
"topics.export.obsidian_created_placeholder": "請選擇建立時間",
"topics.export.obsidian_export_failed": "匯出失敗",
"topics.export.obsidian_export_success": "匯出成功",
"topics.export.obsidian_not_configured": "Obsidian 未配置",
"topics.export.obsidian_operate": "處理方式",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆蓋)",
@ -199,6 +202,13 @@
"topics.export.obsidian_title": "標題",
"topics.export.obsidian_title_placeholder": "請輸入標題",
"topics.export.obsidian_title_required": "標題不能為空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管庫",
"topics.export.obsidian_loading": "加載中...",
"topics.export.obsidian_fetch_error": "獲取Obsidian保管庫失敗",
"topics.export.obsidian_fetch_folders_error": "獲取文件夾結構失敗",
"topics.export.obsidian_no_vault_selected": "請先選擇一個保管庫",
"topics.export.obsidian_select_vault_first": "請先選擇保管庫",
"topics.export.obsidian_root_directory": "根目錄",
"topics.export.title": "匯出",
"topics.export.word": "匯出為 Word",
"topics.export.yuque": "匯出到語雀",
@ -796,15 +806,6 @@
"notion.split_size_help": "Notion 免費版使用者建議設定為 90進階版使用者建議設定為 24990預設值為 90",
"notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)",
"notion.title": "Notion 設定",
"obsidian": {
"folder": "資料夾",
"folder_placeholder": "請輸入資料夾名稱",
"tags": "全域標籤",
"tags_placeholder": "請輸入標籤名稱多個標籤用英文逗號分隔。Obsidian 不可用純數字。",
"title": "Obsidian 設定",
"vault": "保險庫",
"vault_placeholder": "請輸入保險庫名稱"
},
"title": "資料設定",
"webdav": {
"autoSync": "自動備份",
@ -850,6 +851,15 @@
"token": "語雀 Token",
"token_placeholder": "請輸入語雀 Token"
},
"obsidian": {
"title": "Obsidian 設定",
"default_vault": "預設 Obsidian 倉庫",
"default_vault_placeholder": "請選擇預設 Obsidian 倉庫",
"default_vault_loading": "正在獲取 Obsidian 倉庫...",
"default_vault_no_vaults": "未找到 Obsidian 倉庫",
"default_vault_fetch_error": "獲取 Obsidian 倉庫失敗",
"default_vault_export_failed": "匯出失敗"
},
"siyuan": {
"title": "思源筆記配置",
"api_url": "API地址",

View File

@ -37,7 +37,7 @@ const DataSettings: FC = () => {
const [appInfo, setAppInfo] = useState<AppInfo>()
const { size, removeAllFiles } = useKnowledgeFiles()
const { theme } = useTheme()
const [menu, setMenu] = useState<string>('data')
const [menu, setMenu] = useState<string>('common')
//joplin icon needs to be updated into iconfont
const JoplinIcon = () => (
@ -77,17 +77,17 @@ const DataSettings: FC = () => {
title: 'settings.data.yuque.title',
icon: <YuqueOutlined style={{ fontSize: 16 }} />
},
{
key: 'obsidian',
title: 'settings.data.obsidian.title',
icon: <i className="iconfont icon-obsidian" />
},
{
key: 'joplin',
title: 'settings.data.joplin.title',
//joplin icon needs to be updated into iconfont
icon: <JoplinIcon />
},
{
key: 'obsidian',
title: 'settings.data.obsidian.title',
icon: <i className="iconfont icon-obsidian" />
},
{
key: 'siyuan',
title: 'settings.data.siyuan.title',
@ -230,8 +230,8 @@ const DataSettings: FC = () => {
{menu === 'markdown_export' && <MarkdownExportSettings />}
{menu === 'notion' && <NotionSettings />}
{menu === 'yuque' && <YuqueSettings />}
{menu === 'obsidian' && <ObsidianSettings />}
{menu === 'joplin' && <JoplinSettings />}
{menu === 'obsidian' && <ObsidianSettings />}
{menu === 'siyuan' && <SiyuanSettings />}
</SettingContainer>
</Container>

View File

@ -1,95 +1,90 @@
import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { RootState, useAppDispatch } from '@renderer/store'
import { setObsidianFolder, setObsidianTages, setObsidianValut } from '@renderer/store/settings'
import Input from 'antd/es/input/Input'
import { FC } from 'react'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setDefaultObsidianVault } from '@renderer/store/settings'
import { Empty, Select, Spin } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const { Option } = Select
const ObsidianSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { defaultObsidianVault } = useSettings()
const dispatch = useAppDispatch()
// const obsidianApiKey = useSelector((state: RootState) => state.settings.obsidianApiKey)
// const obsidianUrl = useSelector((state: RootState) => state.settings.obsidianUrl)
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const obsidianVault = useSelector((state: RootState) => state.settings.obsidianValut)
const obsidianFolder = useSelector((state: RootState) => state.settings.obsidianFolder)
const obsidianTags = useSelector((state: RootState) => state.settings.obsidianTages)
// 组件加载时获取Vault列表
useEffect(() => {
const fetchVaults = async () => {
try {
setLoading(true)
setError(null)
const vaultsData = await window.obsidian.getVaults()
const handleObsidianVaultChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setObsidianValut(e.target.value))
if (vaultsData.length === 0) {
setError(t('settings.data.obsidian.default_vault_no_vaults'))
setLoading(false)
return
}
const handleObsidianFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setObsidianFolder(e.target.value))
setVaults(vaultsData)
// 如果没有设置默认vault则选择第一个
if (!defaultObsidianVault && vaultsData.length > 0) {
dispatch(setDefaultObsidianVault(vaultsData[0].name))
}
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
setError(t('settings.data.obsidian.default_vault_fetch_error'))
} finally {
setLoading(false)
}
}
const handleObsidianVaultBlur = (e: React.FocusEvent<HTMLInputElement>) => {
dispatch(setObsidianValut(e.target.value))
}
fetchVaults()
}, [dispatch, defaultObsidianVault, t])
const handleObsidianFolderBlur = (e: React.FocusEvent<HTMLInputElement>) => {
dispatch(setObsidianFolder(e.target.value))
}
const handleObsidianTagsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setObsidianTages(e.target.value))
}
const handleObsidianTagsBlur = (e: React.FocusEvent<HTMLInputElement>) => {
dispatch(setObsidianTages(e.target.value))
const handleChange = (value: string) => {
dispatch(setDefaultObsidianVault(value))
}
return (
<SettingGroup theme={theme}>
<SettingGroup>
<SettingTitle>{t('settings.data.obsidian.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.obsidian.vault')}</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
type="text"
value={obsidianVault || ''}
onChange={handleObsidianVaultChange}
onBlur={handleObsidianVaultBlur}
style={{ width: 315 }}
placeholder={t('settings.data.obsidian.vault_placeholder')}
/>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.obsidian.folder')}</span>
</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
value={obsidianFolder || ''}
onChange={handleObsidianFolderChange}
onBlur={handleObsidianFolderBlur}
style={{ width: 315 }}
placeholder={t('settings.data.obsidian.folder_placeholder')}
/>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.obsidian.tags')}</span>
</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
value={obsidianTags || ''}
onChange={handleObsidianTagsChange}
onBlur={handleObsidianTagsBlur}
style={{ width: 315 }}
placeholder={t('settings.data.obsidian.tags_placeholder')}
<SettingRowTitle>{t('settings.data.obsidian.default_vault')}</SettingRowTitle>
<HStack gap="5px">
<Spin spinning={loading} size="small">
{vaults.length > 0 ? (
<Select
value={defaultObsidianVault || undefined}
onChange={handleChange}
placeholder={t('settings.data.obsidian.default_vault_placeholder')}
style={{ width: 300 }}>
{vaults.map((vault) => (
<Option key={vault.name} value={vault.name}>
{vault.name}
</Option>
))}
</Select>
) : (
<Empty
description={
loading
? t('settings.data.obsidian.default_vault_loading')
: error || t('settings.data.obsidian.default_vault_no_vaults')
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</HStack>
</SettingRow>
</SettingGroup>

View File

@ -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<string>) => {
state.yuqueUrl = action.payload
},
setObsidianValut: (state, action: PayloadAction<string>) => {
state.obsidianValut = action.payload
},
setObsidianFolder: (state, action: PayloadAction<string>) => {
state.obsidianFolder = action.payload
},
setObsidianTages: (state, action: PayloadAction<string>) => {
state.obsidianTages = action.payload
},
setJoplinToken: (state, action: PayloadAction<string>) => {
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<string>) => {
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

9
src/renderer/src/types/electron.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
interface ObsidianAPI {
getVaults: () => Promise<Array<{ path: string; name: string }>>
getFiles: (vaultName: string) => Promise<Array<{ path: string; type: 'folder' | 'markdown'; name: string }>>
getFolders: (vaultName: string) => Promise<Array<{ path: string; type: 'folder' | 'markdown'; name: string }>>
}
interface Window {
obsidian: ObsidianAPI
}

View File

@ -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
}
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(path + fileName)}&vault=${encodeURIComponent(obsidianValut)}&clipboard`
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) {