mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 11:19:10 +08:00
refactor(notes): improve notes management with local state and file handling
- Replace UUID-based IDs with SHA1 hash of file paths for better consistency - Remove database storage for notes tree, use local state management instead - Add localStorage persistence for starred and expanded states - Improve cross-platform path normalization (replace backslashes with forward slashes) - Refactor tree operations to use optimized in-memory operations - Enhance file watcher integration for better sync performance - Simplify notes service with direct file system operations - Remove database dependencies from notes tree management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
52a980f751
commit
4428f511b0
@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import * as fs from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
|
||||
if (entry.isDirectory() && options.includeDirectories) {
|
||||
const stats = await fs.promises.stat(entryPath)
|
||||
const externalDirPath = entryPath.replace(/\\/g, '/')
|
||||
const dirTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalDirPath).digest('hex'),
|
||||
name: entry.name,
|
||||
treePath: treePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalDirPath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'folder',
|
||||
@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
|
||||
: `/${nameWithoutExt}`
|
||||
|
||||
const externalFilePath = entryPath.replace(/\\/g, '/')
|
||||
const fileTreeNode: NotesTreeNode = {
|
||||
id: uuidv4(),
|
||||
id: createHash('sha1').update(externalFilePath).digest('hex'),
|
||||
name: name,
|
||||
treePath: fileTreePath,
|
||||
externalPath: entryPath,
|
||||
externalPath: externalFilePath,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
type: 'file'
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
} from '@renderer/types'
|
||||
// Import necessary types for blocks and new message structure
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||
@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', {
|
||||
quick_phrases: EntityTable<QuickPhrase, 'id'>
|
||||
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
|
||||
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
|
||||
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
@ -118,8 +116,7 @@ db.version(10).stores({
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
translate_languages: '&id, langCode',
|
||||
quick_phrases: 'id',
|
||||
message_blocks: 'id, messageId, file.id',
|
||||
notes_tree: '&id'
|
||||
message_blocks: 'id, messageId, file.id'
|
||||
})
|
||||
|
||||
export default db
|
||||
|
||||
@ -5,8 +5,7 @@ import { HStack } from '@renderer/components/Layout'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { NotesTreeNode } from '@types'
|
||||
import { findNode } from '@renderer/services/NotesTreeService'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
||||
@ -17,7 +16,7 @@ import { menuItems } from './MenuConfig'
|
||||
|
||||
const logger = loggerService.withContext('HeaderNavbar')
|
||||
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath }) => {
|
||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||
@ -52,37 +51,12 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}, [getCurrentNoteContent])
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
async (item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && notesTree) {
|
||||
try {
|
||||
// 获取从根目录到点击目录的所有路径片段
|
||||
const pathParts = item.treePath.split('/').filter(Boolean)
|
||||
const expandPromises: Promise<NotesTreeNode>[] = []
|
||||
|
||||
// 逐级展开从根到目标路径的所有文件夹
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
|
||||
const folderNode = findNodeByPath(notesTree, currentPath)
|
||||
|
||||
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
|
||||
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// 并行执行所有展开操作
|
||||
if (expandPromises.length > 0) {
|
||||
await Promise.all(expandPromises)
|
||||
logger.info('Expanded folder path from breadcrumb:', {
|
||||
targetPath: item.treePath,
|
||||
expandedCount: expandPromises.length
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
|
||||
}
|
||||
(item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && onExpandPath) {
|
||||
onExpandPath(item.treePath)
|
||||
}
|
||||
},
|
||||
[notesTree]
|
||||
[onExpandPath]
|
||||
)
|
||||
|
||||
const buildMenuItem = (item: any) => {
|
||||
@ -139,7 +113,7 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
setBreadcrumbItems([])
|
||||
return
|
||||
}
|
||||
const node = findNodeInTree(notesTree, activeNode.id)
|
||||
const node = findNode(notesTree, activeNode.id)
|
||||
if (!node) return
|
||||
|
||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||
|
||||
@ -4,25 +4,15 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import {
|
||||
createFolder,
|
||||
createNote,
|
||||
deleteNode,
|
||||
initWorkSpace,
|
||||
moveNode,
|
||||
renameNode,
|
||||
sortAllLevels,
|
||||
uploadFiles
|
||||
} from '@renderer/services/NotesService'
|
||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { addDir, addNote, delNode, loadTree, renameNode as renameEntry, sortTree, uploadNotes } from '@renderer/services/NotesService'
|
||||
import { findNode, findNodeByPath, findParent } from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -30,8 +20,187 @@ import HeaderNavbar from './HeaderNavbar'
|
||||
import NotesEditor from './NotesEditor'
|
||||
import NotesSidebar from './NotesSidebar'
|
||||
|
||||
const STAR_STORAGE_KEY = 'notes:starred'
|
||||
const EXPAND_STORAGE_KEY = 'notes:expanded'
|
||||
|
||||
const logger = loggerService.withContext('NotesPage')
|
||||
|
||||
function normalizePathValue(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function readStoredPaths(key: string): string[] {
|
||||
if (typeof window === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => normalizePathValue(String(item)))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read stored paths from localStorage', error as Error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function writeStoredPaths(key: string, paths: string[]): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(paths))
|
||||
} catch (error) {
|
||||
logger.warn('Failed to write stored paths to localStorage', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
function addUniquePath(list: string[], path: string): string[] {
|
||||
const normalized = normalizePathValue(path)
|
||||
return list.includes(normalized) ? list : [...list, normalized]
|
||||
}
|
||||
|
||||
function removePathEntries(list: string[], path: string, deep: boolean): string[] {
|
||||
const normalized = normalizePathValue(path)
|
||||
const prefix = `${normalized}/`
|
||||
return list.filter((item) => {
|
||||
if (item === normalized) {
|
||||
return false
|
||||
}
|
||||
if (deep && item.startsWith(prefix)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] {
|
||||
const oldNormalized = normalizePathValue(oldPath)
|
||||
const newNormalized = normalizePathValue(newPath)
|
||||
const prefix = `${oldNormalized}/`
|
||||
return list.map((item) => {
|
||||
if (item === oldNormalized) {
|
||||
return newNormalized
|
||||
}
|
||||
if (deep && item.startsWith(prefix)) {
|
||||
return `${newNormalized}${item.slice(oldNormalized.length)}`
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function updateStoredPaths(
|
||||
setter: Dispatch<SetStateAction<string[]>>,
|
||||
key: string,
|
||||
updater: (list: string[]) => string[]
|
||||
) {
|
||||
setter((prev) => {
|
||||
const next = updater(prev)
|
||||
writeStoredPaths(key, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateTreeNode(
|
||||
nodes: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
updater: (node: NotesTreeNode) => NotesTreeNode
|
||||
): NotesTreeNode[] {
|
||||
let changed = false
|
||||
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
changed = true
|
||||
const updated = updater(node)
|
||||
if (updated.type === 'folder' && !updated.children) {
|
||||
return { ...updated, children: [] }
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const updatedChildren = updateTreeNode(node.children, nodeId, updater)
|
||||
if (updatedChildren !== node.children) {
|
||||
changed = true
|
||||
return { ...node, children: updatedChildren }
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return changed ? nextNodes : nodes
|
||||
}
|
||||
|
||||
function reorderTreeNodes(
|
||||
nodes: NotesTreeNode[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after'
|
||||
): NotesTreeNode[] {
|
||||
const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position)
|
||||
if (moved) {
|
||||
return updatedNodes
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return node
|
||||
}
|
||||
|
||||
const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position)
|
||||
if (reorderedChildren !== node.children) {
|
||||
changed = true
|
||||
return { ...node, children: reorderedChildren }
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
return changed ? nextNodes : nodes
|
||||
}
|
||||
|
||||
function reorderSiblings(
|
||||
nodes: NotesTreeNode[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after'
|
||||
): [NotesTreeNode[], boolean] {
|
||||
const sourceIndex = nodes.findIndex((node) => node.id === sourceId)
|
||||
const targetIndex = nodes.findIndex((node) => node.id === targetId)
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
return [nodes, false]
|
||||
}
|
||||
|
||||
const updated = [...nodes]
|
||||
const [sourceNode] = updated.splice(sourceIndex, 1)
|
||||
|
||||
let insertIndex = targetIndex
|
||||
if (sourceIndex < targetIndex) {
|
||||
insertIndex -= 1
|
||||
}
|
||||
if (position === 'after') {
|
||||
insertIndex += 1
|
||||
}
|
||||
|
||||
if (insertIndex < 0) {
|
||||
insertIndex = 0
|
||||
}
|
||||
if (insertIndex > updated.length) {
|
||||
insertIndex = updated.length
|
||||
}
|
||||
|
||||
updated.splice(insertIndex, 0, sourceNode)
|
||||
return [updated, true]
|
||||
}
|
||||
|
||||
const NotesPage: FC = () => {
|
||||
const editorRef = useRef<RichEditorRef>(null)
|
||||
const { t } = useTranslation()
|
||||
@ -42,8 +211,11 @@ const NotesPage: FC = () => {
|
||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||
|
||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||
const notesTreeQuery = useLiveQuery(() => getNotesTree(), [])
|
||||
const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery])
|
||||
const [notesTree, setNotesTree] = useState<NotesTreeNode[]>([])
|
||||
const [starredPaths, setStarredPaths] = useState<string[]>(() => readStoredPaths('notes:starred'))
|
||||
const [expandedPaths, setExpandedPaths] = useState<string[]>(() => readStoredPaths('notes:expanded'))
|
||||
const starredSet = useMemo(() => new Set(starredPaths), [starredPaths])
|
||||
const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths])
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const { invalidateFileContent } = useFileContentSync()
|
||||
const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath)
|
||||
@ -51,13 +223,47 @@ const NotesPage: FC = () => {
|
||||
const [tokenCount, setTokenCount] = useState(0)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const watcherRef = useRef<(() => void) | null>(null)
|
||||
const isSyncingTreeRef = useRef(false)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isInitialSortApplied = useRef(false)
|
||||
const isRenamingRef = useRef(false)
|
||||
const isCreatingNoteRef = useRef(false)
|
||||
|
||||
const mergeTreeState = useCallback(
|
||||
(nodes: NotesTreeNode[]): NotesTreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const normalizedPath = normalizePathValue(node.externalPath)
|
||||
const merged: NotesTreeNode = {
|
||||
...node,
|
||||
externalPath: normalizedPath,
|
||||
isStarred: starredSet.has(normalizedPath)
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
merged.expanded = expandedSet.has(normalizedPath)
|
||||
merged.children = node.children ? mergeTreeState(node.children) : []
|
||||
}
|
||||
|
||||
return merged
|
||||
})
|
||||
},
|
||||
[starredSet, expandedSet]
|
||||
)
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!notesPath) {
|
||||
setNotesTree([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rawTree = await loadTree(notesPath)
|
||||
const sortedTree = sortTree(rawTree, sortType)
|
||||
setNotesTree(mergeTreeState(sortedTree))
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh notes tree:', error as Error)
|
||||
}
|
||||
}, [mergeTreeState, notesPath, sortType])
|
||||
|
||||
useEffect(() => {
|
||||
const updateCharCount = () => {
|
||||
const textContent = editorRef.current?.getContent() || currentContent
|
||||
@ -67,19 +273,9 @@ const NotesPage: FC = () => {
|
||||
updateCharCount()
|
||||
}, [currentContent])
|
||||
|
||||
// 查找树节点 by ID
|
||||
const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => {
|
||||
for (const node of tree) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, nodeId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
refreshTree()
|
||||
}, [refreshTree])
|
||||
|
||||
// 保存当前笔记内容
|
||||
const saveCurrentNote = useCallback(
|
||||
@ -133,29 +329,13 @@ const NotesPage: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [notesPath])
|
||||
|
||||
// 应用初始排序
|
||||
useEffect(() => {
|
||||
async function applyInitialSort() {
|
||||
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
||||
try {
|
||||
await sortAllLevels(sortType)
|
||||
isInitialSortApplied.current = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply initial sorting:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyInitialSort()
|
||||
}, [notesTree.length, sortType])
|
||||
|
||||
// 处理树同步时的状态管理
|
||||
useEffect(() => {
|
||||
if (notesTree.length === 0) return
|
||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||
const shouldClearPath =
|
||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
|
||||
if (shouldClearPath) {
|
||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||
@ -167,7 +347,7 @@ const NotesPage: FC = () => {
|
||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notesPath || notesTree.length === 0) return
|
||||
if (!notesPath) return
|
||||
|
||||
async function startFileWatcher() {
|
||||
// 清理之前的监控
|
||||
@ -181,31 +361,13 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
if (!notesPath) return
|
||||
const { eventType, filePath } = data
|
||||
const normalizedEventPath = normalizePathValue(filePath)
|
||||
|
||||
switch (eventType) {
|
||||
case 'change': {
|
||||
// 处理文件内容变化 - 只有内容真正改变时才触发更新
|
||||
if (activeFilePath === filePath) {
|
||||
try {
|
||||
// 读取文件最新内容
|
||||
// const newFileContent = await window.api.file.readExternal(filePath)
|
||||
// // 获取当前编辑器/缓存中的内容
|
||||
// const currentEditorContent = editorRef.current?.getMarkdown()
|
||||
// // 如果编辑器还未初始化完成,忽略FileWatcher事件
|
||||
// if (!isEditorInitialized.current) {
|
||||
// return
|
||||
// }
|
||||
// // 比较内容是否真正发生变化
|
||||
// if (newFileContent.trim() !== currentEditorContent?.trim()) {
|
||||
// invalidateFileContent(filePath)
|
||||
// }
|
||||
} catch (error) {
|
||||
logger.error('Failed to read file for content comparison:', error as Error)
|
||||
// 读取失败时,还是执行原来的逻辑
|
||||
invalidateFileContent(filePath)
|
||||
}
|
||||
} else {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
if (activeFilePath && normalizePathValue(activeFilePath) === normalizedEventPath) {
|
||||
invalidateFileContent(normalizedEventPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -215,21 +377,16 @@ const NotesPage: FC = () => {
|
||||
case 'unlink':
|
||||
case 'unlinkDir': {
|
||||
// 如果删除的是当前活动文件,清空选择
|
||||
if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) {
|
||||
if (
|
||||
(eventType === 'unlink' || eventType === 'unlinkDir') &&
|
||||
activeFilePath &&
|
||||
normalizePathValue(activeFilePath) === normalizedEventPath
|
||||
) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
// 设置同步标志,避免竞态条件
|
||||
isSyncingTreeRef.current = true
|
||||
|
||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
||||
try {
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync database:', error as Error)
|
||||
} finally {
|
||||
isSyncingTreeRef.current = false
|
||||
}
|
||||
await refreshTree()
|
||||
break
|
||||
}
|
||||
|
||||
@ -272,14 +429,13 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
}, [
|
||||
notesPath,
|
||||
notesTree.length,
|
||||
activeFilePath,
|
||||
invalidateFileContent,
|
||||
dispatch,
|
||||
currentContent,
|
||||
debouncedSave,
|
||||
saveCurrentNote,
|
||||
sortType
|
||||
refreshTree
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -316,13 +472,13 @@ const NotesPage: FC = () => {
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
const getTargetFolderPath = useCallback(() => {
|
||||
if (selectedFolderId) {
|
||||
const selectedNode = findNodeById(notesTree, selectedFolderId)
|
||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
}
|
||||
}
|
||||
return notesPath // 默认返回根目录
|
||||
}, [selectedFolderId, notesTree, notesPath, findNodeById])
|
||||
}, [selectedFolderId, notesTree, notesPath])
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = useCallback(
|
||||
@ -332,12 +488,16 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
await createFolder(name, targetPath)
|
||||
await addDir(name, targetPath)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
addUniquePath(prev, normalizePathValue(targetPath))
|
||||
)
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error as Error)
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath]
|
||||
[getTargetFolderPath, refreshTree]
|
||||
)
|
||||
|
||||
// 创建笔记
|
||||
@ -350,11 +510,13 @@ const NotesPage: FC = () => {
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
const newNote = await createNote(name, '', targetPath)
|
||||
dispatch(setActiveFilePath(newNote.externalPath))
|
||||
const { path: notePath } = await addNote(name, '', targetPath)
|
||||
const normalizedParent = normalizePathValue(targetPath)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) => addUniquePath(prev, normalizedParent))
|
||||
dispatch(setActiveFilePath(notePath))
|
||||
setSelectedFolderId(null)
|
||||
|
||||
await sortAllLevels(sortType)
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create note:', error as Error)
|
||||
} finally {
|
||||
@ -364,73 +526,43 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[dispatch, getTargetFolderPath, sortType]
|
||||
)
|
||||
|
||||
// 切换展开状态
|
||||
const toggleNodeExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'folder') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
expanded: !node.expanded
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[dispatch, getTargetFolderPath, refreshTree]
|
||||
)
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleNodeExpanded(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle expanded:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const targetNode = findNode(notesTree, nodeId)
|
||||
if (!targetNode || targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpanded = !targetNode.expanded
|
||||
setNotesTree((prev) => updateTreeNode(prev, nodeId, (node) => ({ ...node, expanded: nextExpanded })))
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
nextExpanded
|
||||
? addUniquePath(prev, targetNode.externalPath)
|
||||
: removePathEntries(prev, targetNode.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleNodeExpanded]
|
||||
)
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleStarred = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.type === 'file') {
|
||||
await updateNodeInTree(tree, nodeId, {
|
||||
isStarred: !node.isStarred
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[notesTree]
|
||||
)
|
||||
|
||||
const handleToggleStar = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
await toggleStarred(nodeId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle star:', error as Error)
|
||||
(nodeId: string) => {
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStarred = !node.isStarred
|
||||
setNotesTree((prev) => updateTreeNode(prev, nodeId, (current) => ({ ...current, isStarred: nextStarred })))
|
||||
updateStoredPaths(setStarredPaths, STAR_STORAGE_KEY, (prev) =>
|
||||
nextStarred
|
||||
? addUniquePath(prev, node.externalPath)
|
||||
: removePathEntries(prev, node.externalPath, false)
|
||||
)
|
||||
},
|
||||
[toggleStarred]
|
||||
[notesTree]
|
||||
)
|
||||
|
||||
// 选择节点
|
||||
@ -447,7 +579,7 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
} else if (node.type === 'folder') {
|
||||
setSelectedFolderId(node.id)
|
||||
await handleToggleExpanded(node.id)
|
||||
handleToggleExpanded(node.id)
|
||||
}
|
||||
},
|
||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||
@ -457,28 +589,37 @@ const NotesPage: FC = () => {
|
||||
const handleDeleteNode = useCallback(
|
||||
async (nodeId: string) => {
|
||||
try {
|
||||
const nodeToDelete = findNodeById(notesTree, nodeId)
|
||||
const nodeToDelete = findNode(notesTree, nodeId)
|
||||
if (!nodeToDelete) return
|
||||
|
||||
const isActiveNodeOrParent =
|
||||
activeFilePath &&
|
||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
||||
await delNode(nodeToDelete)
|
||||
|
||||
await deleteNode(nodeId)
|
||||
await sortAllLevels(sortType)
|
||||
updateStoredPaths(setStarredPaths, STAR_STORAGE_KEY, (prev) =>
|
||||
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||
)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')
|
||||
)
|
||||
|
||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
||||
if (isActiveNodeOrParent) {
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath)
|
||||
const isActiveNode = normalizedActivePath === normalizedDeletePath
|
||||
const isActiveDescendant =
|
||||
nodeToDelete.type === 'folder' &&
|
||||
normalizedActivePath &&
|
||||
normalizedActivePath.startsWith(`${normalizedDeletePath}/`)
|
||||
|
||||
if (isActiveNode || isActiveDescendant) {
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
if (editorRef.current) {
|
||||
editorRef.current.clear()
|
||||
}
|
||||
editorRef.current?.clear()
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete node:', error as Error)
|
||||
}
|
||||
},
|
||||
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
||||
[notesTree, activeFilePath, dispatch, refreshTree]
|
||||
)
|
||||
|
||||
// 重命名节点
|
||||
@ -487,29 +628,29 @@ const NotesPage: FC = () => {
|
||||
try {
|
||||
isRenamingRef.current = true
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.name !== newName) {
|
||||
const oldExternalPath = node.externalPath
|
||||
const renamedNode = await renameNode(nodeId, newName)
|
||||
|
||||
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
|
||||
dispatch(setActiveFilePath(renamedNode.externalPath))
|
||||
} else if (
|
||||
renamedNode.type === 'folder' &&
|
||||
activeFilePath &&
|
||||
activeFilePath.startsWith(oldExternalPath + '/')
|
||||
) {
|
||||
const relativePath = activeFilePath.substring(oldExternalPath.length)
|
||||
const newFilePath = renamedNode.externalPath + relativePath
|
||||
dispatch(setActiveFilePath(newFilePath))
|
||||
}
|
||||
await sortAllLevels(sortType)
|
||||
if (renamedNode.name !== newName) {
|
||||
window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
|
||||
}
|
||||
const node = findNode(notesTree, nodeId)
|
||||
if (!node || node.name === newName) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldPath = node.externalPath
|
||||
const renamed = await renameEntry(node, newName)
|
||||
|
||||
if (node.type === 'file' && activeFilePath === oldPath) {
|
||||
dispatch(setActiveFilePath(renamed.path))
|
||||
} else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) {
|
||||
const suffix = activeFilePath.slice(oldPath.length)
|
||||
dispatch(setActiveFilePath(`${renamed.path}${suffix}`))
|
||||
}
|
||||
|
||||
updateStoredPaths(setStarredPaths, STAR_STORAGE_KEY, (prev) =>
|
||||
replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')
|
||||
)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')
|
||||
)
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename node:', error as Error)
|
||||
} finally {
|
||||
@ -518,7 +659,7 @@ const NotesPage: FC = () => {
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[activeFilePath, dispatch, findNodeById, sortType, t]
|
||||
[activeFilePath, dispatch, notesTree, refreshTree]
|
||||
)
|
||||
|
||||
// 处理文件上传
|
||||
@ -535,7 +676,7 @@ const NotesPage: FC = () => {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
const result = await uploadFiles(files, targetFolderPath)
|
||||
const result = await uploadNotes(files, targetFolderPath)
|
||||
|
||||
// 检查上传结果
|
||||
if (result.fileCount === 0) {
|
||||
@ -544,7 +685,10 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
|
||||
// 排序并显示成功信息
|
||||
await sortAllLevels(sortType)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
addUniquePath(prev, normalizePathValue(targetFolderPath))
|
||||
)
|
||||
await refreshTree()
|
||||
|
||||
const successMessage = t('notes.upload_success')
|
||||
|
||||
@ -554,37 +698,146 @@ const NotesPage: FC = () => {
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
}
|
||||
},
|
||||
[getTargetFolderPath, sortType, t]
|
||||
[getTargetFolderPath, refreshTree, t]
|
||||
)
|
||||
|
||||
// 处理节点移动
|
||||
const handleMoveNode = useCallback(
|
||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||
if (!notesPath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await moveNode(sourceNodeId, targetNodeId, position)
|
||||
if (result.success && result.type !== 'manual_reorder') {
|
||||
await sortAllLevels(sortType)
|
||||
const sourceNode = findNode(notesTree, sourceNodeId)
|
||||
const targetNode = findNode(notesTree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (position === 'inside' && targetNode.type !== 'folder') {
|
||||
return
|
||||
}
|
||||
|
||||
const rootPath = normalizePathValue(notesPath)
|
||||
const sourceParentNode = findParent(notesTree, sourceNodeId)
|
||||
const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId)
|
||||
|
||||
const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath
|
||||
const targetParentPath = position === 'inside'
|
||||
? targetNode.externalPath
|
||||
: targetParentNode
|
||||
? targetParentNode.externalPath
|
||||
: rootPath
|
||||
|
||||
const normalizedSourceParent = normalizePathValue(sourceParentPath)
|
||||
const normalizedTargetParent = normalizePathValue(targetParentPath)
|
||||
|
||||
const isManualReorder =
|
||||
position !== 'inside' && normalizedSourceParent === normalizedTargetParent
|
||||
|
||||
if (isManualReorder) {
|
||||
setNotesTree((prev) => reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after'))
|
||||
return
|
||||
}
|
||||
|
||||
const { safeName } = await window.api.file.checkFileName(
|
||||
normalizedTargetParent,
|
||||
sourceNode.name,
|
||||
sourceNode.type === 'file'
|
||||
)
|
||||
|
||||
const destinationPath =
|
||||
sourceNode.type === 'file'
|
||||
? `${normalizedTargetParent}/${safeName}.md`
|
||||
: `${normalizedTargetParent}/${safeName}`
|
||||
|
||||
if (destinationPath === sourceNode.externalPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceNode.type === 'file') {
|
||||
await window.api.file.move(sourceNode.externalPath, destinationPath)
|
||||
} else {
|
||||
await window.api.file.moveDir(sourceNode.externalPath, destinationPath)
|
||||
}
|
||||
|
||||
updateStoredPaths(setStarredPaths, STAR_STORAGE_KEY, (prev) =>
|
||||
replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder')
|
||||
)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) =>
|
||||
addUniquePath(prev, normalizedTargetParent)
|
||||
)
|
||||
|
||||
const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined
|
||||
if (normalizedActivePath) {
|
||||
if (normalizedActivePath === sourceNode.externalPath) {
|
||||
dispatch(setActiveFilePath(destinationPath))
|
||||
} else if (
|
||||
sourceNode.type === 'folder' &&
|
||||
normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)
|
||||
) {
|
||||
const suffix = normalizedActivePath.slice(sourceNode.externalPath.length)
|
||||
dispatch(setActiveFilePath(`${destinationPath}${suffix}`))
|
||||
}
|
||||
}
|
||||
|
||||
await refreshTree()
|
||||
} catch (error) {
|
||||
logger.error('Failed to move nodes:', error as Error)
|
||||
}
|
||||
},
|
||||
[sortType]
|
||||
[activeFilePath, dispatch, notesPath, notesTree, refreshTree]
|
||||
)
|
||||
|
||||
// 处理节点排序
|
||||
const handleSortNodes = useCallback(
|
||||
async (newSortType: NotesSortType) => {
|
||||
try {
|
||||
// 更新Redux中的排序类型
|
||||
dispatch(setSortType(newSortType))
|
||||
await sortAllLevels(newSortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort notes:', error as Error)
|
||||
throw error
|
||||
dispatch(setSortType(newSortType))
|
||||
setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType)))
|
||||
},
|
||||
[dispatch, mergeTreeState]
|
||||
)
|
||||
|
||||
const handleExpandPath = useCallback(
|
||||
(treePath: string) => {
|
||||
if (!treePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const segments = treePath.split('/').filter(Boolean)
|
||||
if (segments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextTree = notesTree
|
||||
const pathsToAdd: string[] = []
|
||||
|
||||
segments.forEach((_, index) => {
|
||||
const currentPath = '/' + segments.slice(0, index + 1).join('/')
|
||||
const node = findNodeByPath(nextTree, currentPath)
|
||||
if (node && node.type === 'folder' && !node.expanded) {
|
||||
pathsToAdd.push(node.externalPath)
|
||||
nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true }))
|
||||
}
|
||||
})
|
||||
|
||||
if (pathsToAdd.length > 0) {
|
||||
setNotesTree(nextTree)
|
||||
updateStoredPaths(setExpandedPaths, EXPAND_STORAGE_KEY, (prev) => {
|
||||
let updated = prev
|
||||
pathsToAdd.forEach((path) => {
|
||||
updated = addUniquePath(updated, path)
|
||||
})
|
||||
return updated
|
||||
})
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[notesTree]
|
||||
)
|
||||
|
||||
const getCurrentNoteContent = useCallback(() => {
|
||||
@ -631,6 +884,7 @@ const NotesPage: FC = () => {
|
||||
notesTree={notesTree}
|
||||
getCurrentNoteContent={getCurrentNoteContent}
|
||||
onToggleStar={handleToggleStar}
|
||||
onExpandPath={handleExpandPath}
|
||||
/>
|
||||
<NotesEditor
|
||||
activeNodeId={activeNode?.id}
|
||||
|
||||
@ -2,7 +2,6 @@ import { loggerService } from '@logger'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { initWorkSpace } from '@renderer/services/NotesService'
|
||||
import { EditorView } from '@renderer/types'
|
||||
import { Button, Input, message, Slider, Switch } from 'antd'
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
@ -70,7 +69,6 @@ const NotesSettings: FC = () => {
|
||||
}
|
||||
|
||||
updateNotesPath(tempPath)
|
||||
initWorkSpace(tempPath, 'sort_a2z')
|
||||
window.toast.success(t('notes.settings.data.path_updated'))
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply notes path:', error as Error)
|
||||
@ -83,7 +81,6 @@ const NotesSettings: FC = () => {
|
||||
const info = await window.api.getAppInfo()
|
||||
setTempPath(info.notesPath)
|
||||
updateNotesPath(info.notesPath)
|
||||
initWorkSpace(info.notesPath, 'sort_a2z')
|
||||
window.toast.success(t('notes.settings.data.reset_to_default'))
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset to default:', error as Error)
|
||||
|
||||
@ -1,100 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import db from '@renderer/databases'
|
||||
import {
|
||||
findNodeInTree,
|
||||
findParentNode,
|
||||
getNotesTree,
|
||||
insertNodeIntoTree,
|
||||
isParentNode,
|
||||
moveNodeInTree,
|
||||
removeNodeFromTree,
|
||||
renameNodeFromTree
|
||||
} from '@renderer/services/NotesTreeService'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { getFileDirectory } from '@renderer/utils'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
const MARKDOWN_EXT = '.md'
|
||||
const NOTES_TREE_ID = 'notes-tree-structure'
|
||||
|
||||
const logger = loggerService.withContext('NotesService')
|
||||
|
||||
export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' }
|
||||
|
||||
/**
|
||||
* 初始化/同步笔记树结构
|
||||
*/
|
||||
export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise<void> {
|
||||
const tree = await window.api.file.getDirectoryStructure(folderPath)
|
||||
await sortAllLevels(sortType, tree)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新文件夹
|
||||
*/
|
||||
export async function createFolder(name: string, folderPath: string): Promise<NotesTreeNode> {
|
||||
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false)
|
||||
if (exists) {
|
||||
logger.warn(`Folder already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const folderId = uuidv4()
|
||||
|
||||
const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`)
|
||||
|
||||
// 查找父节点ID
|
||||
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
|
||||
|
||||
const folder: NotesTreeNode = {
|
||||
id: folderId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: targetPath,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
expanded: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
insertNodeIntoTree(tree, folder, parentNode?.id)
|
||||
|
||||
return folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新笔记文件
|
||||
*/
|
||||
export async function createNote(name: string, content: string = '', folderPath: string): Promise<NotesTreeNode> {
|
||||
const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const noteId = uuidv4()
|
||||
const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
await window.api.file.write(notePath, content)
|
||||
|
||||
// 查找父节点ID
|
||||
const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath)
|
||||
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
insertNodeIntoTree(tree, note, parentNode?.id)
|
||||
|
||||
return note
|
||||
}
|
||||
const MARKDOWN_EXT = '.md'
|
||||
|
||||
export interface UploadResult {
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
@ -104,641 +14,202 @@ export interface UploadResult {
|
||||
folderCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件或文件夹,支持单个或批量上传,保持文件夹结构
|
||||
*/
|
||||
export async function uploadFiles(files: File[], targetFolderPath: string): Promise<UploadResult> {
|
||||
const tree = await getNotesTree()
|
||||
const uploadedNodes: NotesTreeNode[] = []
|
||||
let skippedFiles = 0
|
||||
|
||||
const markdownFiles = filterMarkdownFiles(files)
|
||||
skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
return createEmptyUploadResult(files.length, skippedFiles)
|
||||
}
|
||||
|
||||
// 处理重复的根文件夹名称
|
||||
const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath)
|
||||
|
||||
const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath)
|
||||
|
||||
const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes)
|
||||
|
||||
await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes)
|
||||
|
||||
const fileCount = uploadedNodes.filter((node) => node.type === 'file').length
|
||||
const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length
|
||||
|
||||
return {
|
||||
uploadedNodes,
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount
|
||||
}
|
||||
export async function loadTree(rootPath: string): Promise<NotesTreeNode[]> {
|
||||
return window.api.file.getDirectoryStructure(normalizePath(rootPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除笔记或文件夹
|
||||
*/
|
||||
export async function deleteNode(nodeId: string): Promise<void> {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] {
|
||||
const cloned = nodes.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? sortTree(node.children, sortType) : undefined
|
||||
}))
|
||||
|
||||
const sorter = getSorter(sortType)
|
||||
|
||||
cloned.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return sorter(a, b)
|
||||
}
|
||||
return a.type === 'folder' ? -1 : 1
|
||||
})
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> {
|
||||
const basePath = normalizePath(parentPath)
|
||||
const { safeName } = await window.api.file.checkFileName(basePath, name, false)
|
||||
const fullPath = `${basePath}/${safeName}`
|
||||
await window.api.file.mkdir(fullPath)
|
||||
return { path: fullPath, name: safeName }
|
||||
}
|
||||
|
||||
export async function addNote(
|
||||
name: string,
|
||||
content: string = '',
|
||||
parentPath: string
|
||||
): Promise<{ path: string; name: string }> {
|
||||
const basePath = normalizePath(parentPath)
|
||||
const { safeName } = await window.api.file.checkFileName(basePath, name, true)
|
||||
const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}`
|
||||
await window.api.file.write(notePath, content)
|
||||
return { path: notePath, name: safeName }
|
||||
}
|
||||
|
||||
export async function delNode(node: NotesTreeNode): Promise<void> {
|
||||
if (node.type === 'folder') {
|
||||
await window.api.file.deleteExternalDir(node.externalPath)
|
||||
} else if (node.type === 'file') {
|
||||
} else {
|
||||
await window.api.file.deleteExternalFile(node.externalPath)
|
||||
}
|
||||
|
||||
await removeNodeFromTree(tree, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名笔记或文件夹
|
||||
*/
|
||||
export async function renameNode(nodeId: string, newName: string): Promise<NotesTreeNode> {
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
const dirPath = getFileDirectory(node.externalPath)
|
||||
const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file')
|
||||
export async function renameNode(
|
||||
node: NotesTreeNode,
|
||||
newName: string
|
||||
): Promise<{ path: string; name: string }> {
|
||||
const isFile = node.type === 'file'
|
||||
const parentDir = normalizePath(getFileDirectory(node.externalPath))
|
||||
const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile)
|
||||
|
||||
if (exists) {
|
||||
logger.warn(`Target name already exists: ${safeName}`)
|
||||
throw new Error(`Target name already exists: ${safeName}`)
|
||||
}
|
||||
|
||||
if (node.type === 'file') {
|
||||
if (isFile) {
|
||||
await window.api.file.rename(node.externalPath, safeName)
|
||||
} else if (node.type === 'folder') {
|
||||
await window.api.file.renameDir(node.externalPath, safeName)
|
||||
return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName }
|
||||
}
|
||||
return renameNodeFromTree(tree, nodeId, safeName)
|
||||
|
||||
await window.api.file.renameDir(node.externalPath, safeName)
|
||||
return { path: `${parentDir}/${safeName}`, name: safeName }
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动节点
|
||||
*/
|
||||
export async function moveNode(
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
position: 'before' | 'after' | 'inside'
|
||||
): Promise<MoveNodeResult> {
|
||||
try {
|
||||
const tree = await getNotesTree()
|
||||
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const markdownFiles = filterMarkdown(files)
|
||||
const skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
// 找到源节点和目标节点
|
||||
const sourceNode = findNodeInTree(tree, sourceNodeId)
|
||||
const targetNode = findNodeInTree(tree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
||||
return { success: false }
|
||||
if (markdownFiles.length === 0) {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 不允许文件夹被放入文件中
|
||||
if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') {
|
||||
logger.error('Move nodes failed: cannot move a folder inside a file')
|
||||
return { success: false }
|
||||
const folders = collectFolders(markdownFiles, basePath)
|
||||
await createFolders(folders)
|
||||
|
||||
let fileCount = 0
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const { dir, name } = resolveFileTarget(file, basePath)
|
||||
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
await window.api.file.write(finalPath, content)
|
||||
fileCount += 1
|
||||
} catch (error) {
|
||||
logger.error('Failed to write uploaded file:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 不允许将节点移动到自身内部
|
||||
if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) {
|
||||
logger.error('Move nodes failed: cannot move a node inside itself or its descendants')
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
let targetPath: string = ''
|
||||
|
||||
if (position === 'inside') {
|
||||
// 目标是文件夹内部
|
||||
if (targetNode.type === 'folder') {
|
||||
targetPath = targetNode.externalPath
|
||||
} else {
|
||||
logger.error('Cannot move node inside a file node')
|
||||
return { success: false }
|
||||
}
|
||||
} else {
|
||||
const targetParent = findParentNode(tree, targetNodeId)
|
||||
if (targetParent) {
|
||||
targetPath = targetParent.externalPath
|
||||
} else {
|
||||
targetPath = getFileDirectory(targetNode.externalPath!)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为同级拖动排序
|
||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||
const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!)
|
||||
|
||||
const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath
|
||||
|
||||
if (isSameLevelReorder) {
|
||||
// 同级拖动排序:跳过文件系统操作,只更新树结构
|
||||
logger.debug(`Same level reorder detected, skipping file system operations`)
|
||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||
// 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序
|
||||
return success ? { success: true, type: 'manual_reorder' } : { success: false }
|
||||
}
|
||||
|
||||
// 构建新的文件路径
|
||||
const sourceName = sourceNode.externalPath!.split('/').pop()!
|
||||
const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '')
|
||||
|
||||
const { safeName } = await window.api.file.checkFileName(
|
||||
targetPath,
|
||||
sourceNameWithoutExt,
|
||||
sourceNode.type === 'file'
|
||||
)
|
||||
|
||||
const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '')
|
||||
const newPath = `${targetPath}/${baseName}`
|
||||
|
||||
if (sourceNode.externalPath !== newPath) {
|
||||
try {
|
||||
if (sourceNode.type === 'folder') {
|
||||
await window.api.file.moveDir(sourceNode.externalPath, newPath)
|
||||
} else {
|
||||
await window.api.file.move(sourceNode.externalPath, newPath)
|
||||
}
|
||||
sourceNode.externalPath = newPath
|
||||
logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to move external ${sourceNode.type}:`, error as Error)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position)
|
||||
return success ? { success: true, type: 'file_system_move' } : { success: false }
|
||||
} catch (error) {
|
||||
logger.error('Move nodes failed:', error as Error)
|
||||
return { success: false }
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount: folders.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对节点数组进行排序
|
||||
*/
|
||||
function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void {
|
||||
// 首先分离文件夹和文件
|
||||
const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder')
|
||||
const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file')
|
||||
|
||||
// 根据排序类型对文件夹和文件分别进行排序
|
||||
const sortFunction = getSortFunction(sortType)
|
||||
folders.sort(sortFunction)
|
||||
files.sort(sortFunction)
|
||||
|
||||
// 清空原数组并重新填入排序后的节点
|
||||
nodes.length = 0
|
||||
nodes.push(...folders, ...files)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据排序类型获取相应的排序函数
|
||||
*/
|
||||
function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||
function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number {
|
||||
switch (sortType) {
|
||||
case 'sort_a2z':
|
||||
return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' })
|
||||
|
||||
case 'sort_z2a':
|
||||
return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' })
|
||||
|
||||
case 'sort_updated_desc':
|
||||
return (a, b) => {
|
||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
return timeB - timeA
|
||||
}
|
||||
|
||||
return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt)
|
||||
case 'sort_updated_asc':
|
||||
return (a, b) => {
|
||||
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||
return timeA - timeB
|
||||
}
|
||||
|
||||
return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt)
|
||||
case 'sort_created_desc':
|
||||
return (a, b) => {
|
||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return timeB - timeA
|
||||
}
|
||||
|
||||
return (a, b) => getTime(b.createdAt) - getTime(a.createdAt)
|
||||
case 'sort_created_asc':
|
||||
return (a, b) => {
|
||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return timeA - timeB
|
||||
}
|
||||
|
||||
return (a, b) => getTime(a.createdAt) - getTime(b.createdAt)
|
||||
default:
|
||||
return (a, b) => a.name.localeCompare(b.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归排序笔记树中的所有层级
|
||||
*/
|
||||
export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise<void> {
|
||||
try {
|
||||
if (!tree) {
|
||||
tree = await getNotesTree()
|
||||
}
|
||||
sortNodesArray(tree, sortType)
|
||||
recursiveSortNodes(tree, sortType)
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
logger.info(`Sorted all levels of notes successfully: ${sortType}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort all levels of notes:', error as Error)
|
||||
throw error
|
||||
}
|
||||
function getTime(value?: string): number {
|
||||
return value ? new Date(value).getTime() : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归对节点中的子节点进行排序
|
||||
*/
|
||||
function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder' && node.children && node.children.length > 0) {
|
||||
sortNodesArray(node.children, sortType)
|
||||
recursiveSortNodes(node.children, sortType)
|
||||
}
|
||||
}
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据外部路径查找节点(递归查找)
|
||||
*/
|
||||
function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.externalPath === externalPath) {
|
||||
return node
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeByExternalPath(node.children, externalPath)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
function filterMarkdown(files: File[]): File[] {
|
||||
return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT))
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 Markdown 文件
|
||||
*/
|
||||
function filterMarkdownFiles(files: File[]): File[] {
|
||||
return Array.from(files).filter((file) => {
|
||||
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
||||
return true
|
||||
function collectFolders(files: File[], basePath: string): Set<string> {
|
||||
const folders = new Set<string>()
|
||||
|
||||
files.forEach((file) => {
|
||||
const relativePath = file.webkitRelativePath || ''
|
||||
if (!relativePath.includes('/')) {
|
||||
return
|
||||
}
|
||||
|
||||
const parts = relativePath.split('/')
|
||||
parts.pop()
|
||||
|
||||
let current = basePath
|
||||
for (const part of parts) {
|
||||
current = `${current}/${part}`
|
||||
folders.add(current)
|
||||
}
|
||||
logger.warn(`Skipping non-markdown file: ${file.name}`)
|
||||
return false
|
||||
})
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的上传结果
|
||||
*/
|
||||
function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath
|
||||
*/
|
||||
async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise<File[]> {
|
||||
// 按根文件夹名称分组文件
|
||||
const filesByRootFolder = new Map<string, File[]>()
|
||||
const processedFiles: File[] = []
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
|
||||
if (filePath.includes('/')) {
|
||||
const rootFolderName = filePath.substring(0, filePath.indexOf('/'))
|
||||
if (!filesByRootFolder.has(rootFolderName)) {
|
||||
filesByRootFolder.set(rootFolderName, [])
|
||||
}
|
||||
filesByRootFolder.get(rootFolderName)!.push(file)
|
||||
} else {
|
||||
// 单个文件,直接添加
|
||||
processedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个根文件夹组生成唯一的文件夹名称
|
||||
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
|
||||
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
|
||||
|
||||
for (const file of files) {
|
||||
// 创建一个新的 File 对象,并修改 webkitRelativePath
|
||||
const originalPath = file.webkitRelativePath || file.name
|
||||
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
|
||||
const newPath = `${safeName}/${relativePath}`
|
||||
|
||||
const newFile = new File([file], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
|
||||
Object.defineProperty(newFile, 'webkitRelativePath', {
|
||||
value: newPath,
|
||||
writable: false
|
||||
})
|
||||
|
||||
processedFiles.push(newFile)
|
||||
}
|
||||
}
|
||||
|
||||
return processedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路径分组文件并收集需要创建的文件夹
|
||||
*/
|
||||
function groupFilesByPath(
|
||||
markdownFiles: File[],
|
||||
targetFolderPath: string
|
||||
): { filesByPath: Map<string, File[]>; foldersToCreate: Set<string> } {
|
||||
const filesByPath = new Map<string, File[]>()
|
||||
const foldersToCreate = new Set<string>()
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''
|
||||
const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath
|
||||
|
||||
if (relativeDirPath) {
|
||||
const pathParts = relativeDirPath.split('/')
|
||||
|
||||
let currentPath = targetFolderPath
|
||||
for (const part of pathParts) {
|
||||
currentPath = `${currentPath}/${part}`
|
||||
foldersToCreate.add(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!filesByPath.has(fullDirPath)) {
|
||||
filesByPath.set(fullDirPath, [])
|
||||
}
|
||||
filesByPath.get(fullDirPath)!.push(file)
|
||||
}
|
||||
|
||||
return { filesByPath, foldersToCreate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序创建文件夹(避免竞争条件)
|
||||
*/
|
||||
async function createFoldersSequentially(
|
||||
foldersToCreate: Set<string>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<Map<string, NotesTreeNode>> {
|
||||
const createdFolders = new Map<string, NotesTreeNode>()
|
||||
const sortedFolders = Array.from(foldersToCreate).sort()
|
||||
const folderCreationLock = new Set<string>()
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
if (folderCreationLock.has(folderPath)) {
|
||||
continue
|
||||
}
|
||||
folderCreationLock.add(folderPath)
|
||||
async function createFolders(folders: Set<string>): Promise<void> {
|
||||
const ordered = Array.from(folders).sort((a, b) => a.length - b.length)
|
||||
|
||||
for (const folder of ordered) {
|
||||
try {
|
||||
const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders)
|
||||
if (result) {
|
||||
createdFolders.set(folderPath, result)
|
||||
if (result.externalPath !== folderPath) {
|
||||
createdFolders.set(result.externalPath, result)
|
||||
}
|
||||
uploadedNodes.push(result)
|
||||
logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`)
|
||||
}
|
||||
await window.api.file.mkdir(folder)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
|
||||
} finally {
|
||||
folderCreationLock.delete(folderPath)
|
||||
logger.debug('Skip existing folder while uploading notes', {
|
||||
folder,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return createdFolders
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个文件夹
|
||||
*/
|
||||
async function createSingleFolder(
|
||||
folderPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const existingNode = findNodeByExternalPath(tree, folderPath)
|
||||
if (existingNode) {
|
||||
return existingNode
|
||||
}
|
||||
|
||||
const relativePath = folderPath.replace(targetFolderPath + '/', '')
|
||||
const originalFolderName = relativePath.split('/').pop()!
|
||||
const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'))
|
||||
|
||||
const { safeName: safeFolderName, exists } = await window.api.file.checkFileName(
|
||||
parentFolderPath,
|
||||
originalFolderName,
|
||||
false
|
||||
)
|
||||
|
||||
const actualFolderPath = `${parentFolderPath}/${safeFolderName}`
|
||||
|
||||
if (exists) {
|
||||
logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.file.mkdir(actualFolderPath)
|
||||
} catch (error) {
|
||||
logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error)
|
||||
}
|
||||
|
||||
let parentNode: NotesTreeNode | null
|
||||
if (parentFolderPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
} else {
|
||||
parentNode = createdFolders.get(parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, parentFolderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const folderId = uuidv4()
|
||||
const folder: NotesTreeNode = {
|
||||
id: folderId,
|
||||
name: safeFolderName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`,
|
||||
externalPath: actualFolderPath,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
expanded: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
await insertNodeIntoTree(tree, folder, parentNode?.id)
|
||||
return folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(支持大文件处理)
|
||||
*/
|
||||
async function readFileContent(file: File): Promise<string> {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await file.text()
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read file content for ${file.name}:`, error as Error)
|
||||
throw new Error(`Failed to read file content: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传所有文件
|
||||
*/
|
||||
async function uploadAllFiles(
|
||||
filesByPath: Map<string, File[]>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>,
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<void> {
|
||||
const uploadPromises: Promise<NotesTreeNode | null>[] = []
|
||||
|
||||
for (const [dirPath, dirFiles] of filesByPath.entries()) {
|
||||
for (const file of dirFiles) {
|
||||
const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
logger.debug(`Uploaded file: ${result.externalPath}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`Failed to upload file ${file.name}:`, error as Error)
|
||||
return null
|
||||
})
|
||||
|
||||
uploadPromises.push(uploadPromise)
|
||||
}
|
||||
function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } {
|
||||
if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) {
|
||||
const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT)
|
||||
? file.name.slice(0, -MARKDOWN_EXT.length)
|
||||
: file.name
|
||||
return { dir: basePath, name: nameWithoutExt }
|
||||
}
|
||||
|
||||
const results = await Promise.all(uploadPromises)
|
||||
const parts = file.webkitRelativePath.split('/')
|
||||
const fileName = parts.pop() || file.name
|
||||
const dirPath = `${basePath}/${parts.join('/')}`
|
||||
const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT)
|
||||
? fileName.slice(0, -MARKDOWN_EXT.length)
|
||||
: fileName
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result) {
|
||||
uploadedNodes.push(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点
|
||||
*/
|
||||
async function uploadSingleFile(
|
||||
file: File,
|
||||
originalDirPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const fileName = (file.webkitRelativePath || file.name).split('/').pop()!
|
||||
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
|
||||
|
||||
let actualDirPath = originalDirPath
|
||||
let parentNode: NotesTreeNode | null = null
|
||||
|
||||
if (originalDirPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
|
||||
if (!parentNode) {
|
||||
logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`)
|
||||
}
|
||||
} else {
|
||||
parentNode = createdFolders.get(originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, originalDirPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
for (const [originalPath, createdNode] of createdFolders.entries()) {
|
||||
if (originalPath === originalDirPath) {
|
||||
parentNode = createdNode
|
||||
actualDirPath = createdNode.externalPath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists, will be overwritten: ${safeName}`)
|
||||
}
|
||||
|
||||
const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const noteId = uuidv4()
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const content = await readFileContent(file)
|
||||
await window.api.file.write(notePath, content)
|
||||
await insertNodeIntoTree(tree, note, parentNode?.id)
|
||||
|
||||
return note
|
||||
return { dir: dirPath, name: nameWithoutExt }
|
||||
}
|
||||
|
||||
@ -1,217 +1,12 @@
|
||||
import { loggerService } from '@logger'
|
||||
import db from '@renderer/databases'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
|
||||
const MARKDOWN_EXT = '.md'
|
||||
const NOTES_TREE_ID = 'notes-tree-structure'
|
||||
|
||||
const logger = loggerService.withContext('NotesTreeService')
|
||||
|
||||
/**
|
||||
* 获取树结构
|
||||
*/
|
||||
export const getNotesTree = async (): Promise<NotesTreeNode[]> => {
|
||||
const record = await db.notes_tree.get(NOTES_TREE_ID)
|
||||
return record?.tree || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中插入节点
|
||||
*/
|
||||
export async function insertNodeIntoTree(
|
||||
tree: NotesTreeNode[],
|
||||
node: NotesTreeNode,
|
||||
parentId?: string
|
||||
): Promise<NotesTreeNode[]> {
|
||||
try {
|
||||
if (!parentId) {
|
||||
tree.push(node)
|
||||
} else {
|
||||
const parent = findNodeInTree(tree, parentId)
|
||||
if (parent && parent.type === 'folder') {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
return tree
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert node into tree:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树中删除节点
|
||||
*/
|
||||
export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise<boolean> {
|
||||
const removed = removeNodeFromTreeInMemory(tree, nodeId)
|
||||
if (removed) {
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树中删除节点(仅在内存中操作,不保存数据库)
|
||||
*/
|
||||
function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean {
|
||||
for (let i = 0; i < tree.length; i++) {
|
||||
if (tree[i].id === nodeId) {
|
||||
tree.splice(i, 1)
|
||||
return true
|
||||
}
|
||||
if (tree[i].children) {
|
||||
const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId)
|
||||
if (removed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function moveNodeInTree(
|
||||
tree: NotesTreeNode[],
|
||||
sourceNodeId: string,
|
||||
targetNodeId: string,
|
||||
position: 'before' | 'after' | 'inside'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sourceNode = findNodeInTree(tree, sourceNodeId)
|
||||
const targetNode = findNodeInTree(tree, targetNodeId)
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序
|
||||
const sourceParent = findParentNode(tree, sourceNodeId)
|
||||
const targetParent = findParentNode(tree, targetNodeId)
|
||||
|
||||
// 从原位置移除节点(不保存数据库,只在内存中操作)
|
||||
const removed = removeNodeFromTreeInMemory(tree, sourceNodeId)
|
||||
if (!removed) {
|
||||
logger.error('Move nodes in tree failed: could not remove source node')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据位置进行放置
|
||||
if (position === 'inside' && targetNode.type === 'folder') {
|
||||
if (!targetNode.children) {
|
||||
targetNode.children = []
|
||||
}
|
||||
targetNode.children.push(sourceNode)
|
||||
targetNode.expanded = true
|
||||
|
||||
sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}`
|
||||
} else {
|
||||
const targetList = targetParent ? targetParent.children! : tree
|
||||
const targetIndex = targetList.findIndex((node) => node.id === targetNodeId)
|
||||
|
||||
if (targetIndex === -1) {
|
||||
logger.error('Move nodes in tree failed: target position not found')
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据position确定插入位置
|
||||
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1
|
||||
targetList.splice(insertIndex, 0, sourceNode)
|
||||
|
||||
// 检查是否为同级排序,如果是则保持原有的 treePath
|
||||
const isSameLevelReorder = sourceParent === targetParent
|
||||
|
||||
// 只有在跨级移动时才更新节点路径
|
||||
if (!isSameLevelReorder) {
|
||||
if (targetParent) {
|
||||
sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}`
|
||||
} else {
|
||||
sourceNode.treePath = `/${sourceNode.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新修改时间
|
||||
sourceNode.updatedAt = new Date().toISOString()
|
||||
|
||||
// 只有在所有操作成功后才保存到数据库
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error)
|
||||
// 如果放置失败,尝试恢复原始节点到原位置
|
||||
// 这里需要重新实现恢复逻辑,暂时返回false
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Move nodes in tree failed:', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名节点
|
||||
*/
|
||||
export async function renameNodeFromTree(
|
||||
tree: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
newName: string
|
||||
): Promise<NotesTreeNode> {
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
node.name = newName
|
||||
|
||||
const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1)
|
||||
node.treePath = dirPath + newName
|
||||
|
||||
const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1)
|
||||
node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName
|
||||
|
||||
node.updatedAt = new Date().toISOString()
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改节点键值
|
||||
*/
|
||||
export async function updateNodeInTree(
|
||||
tree: NotesTreeNode[],
|
||||
nodeId: string,
|
||||
updates: Partial<NotesTreeNode>
|
||||
): Promise<NotesTreeNode> {
|
||||
const node = findNodeInTree(tree, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
Object.assign(node, updates)
|
||||
node.updatedAt = new Date().toISOString()
|
||||
await db.notes_tree.put({ id: NOTES_TREE_ID, tree })
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点
|
||||
*/
|
||||
export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.id === nodeId) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeInTree(node.children, nodeId)
|
||||
const found = findNode(node.children, nodeId)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
@ -220,16 +15,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径查找节点
|
||||
*/
|
||||
export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null {
|
||||
export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.treePath === path) {
|
||||
if (node.treePath === targetPath || node.externalPath === targetPath) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path)
|
||||
const found = findNodeByPath(node.children, targetPath)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
@ -238,53 +30,18 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo
|
||||
return null
|
||||
}
|
||||
|
||||
// ---
|
||||
// 辅助函数
|
||||
// ---
|
||||
|
||||
/**
|
||||
* 查找节点的父节点
|
||||
*/
|
||||
export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null {
|
||||
export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null {
|
||||
for (const node of tree) {
|
||||
if (node.children) {
|
||||
const isDirectChild = node.children.some((child) => child.id === targetNodeId)
|
||||
if (isDirectChild) {
|
||||
return node
|
||||
}
|
||||
|
||||
const parent = findParentNode(node.children, targetNodeId)
|
||||
if (parent) {
|
||||
return parent
|
||||
}
|
||||
if (!node.children) {
|
||||
continue
|
||||
}
|
||||
if (node.children.some((child) => child.id === nodeId)) {
|
||||
return node
|
||||
}
|
||||
const found = findParent(node.children, nodeId)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断节点是否为另一个节点的父节点
|
||||
*/
|
||||
export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean {
|
||||
const childNode = findNodeInTree(tree, childId)
|
||||
if (!childNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parentNode = findNodeInTree(tree, parentId)
|
||||
if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (parentNode.children.some((child) => child.id === childId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const child of parentNode.children) {
|
||||
if (isParentNode(tree, child.id, childId)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@ -3,12 +3,11 @@ import { Client } from '@notionhq/client'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { getMessageTitle } from '@renderer/services/MessagesService'
|
||||
import { createNote } from '@renderer/services/NotesService'
|
||||
import { addNote } from '@renderer/services/NotesService'
|
||||
import store from '@renderer/store'
|
||||
import { setExportState } from '@renderer/store/runtime'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { NotesTreeNode } from '@renderer/types/note'
|
||||
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
||||
import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
|
||||
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
@ -1056,14 +1055,12 @@ export const exportMessageToNotes = async (
|
||||
title: string,
|
||||
content: string,
|
||||
folderPath: string
|
||||
): Promise<NotesTreeNode> => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '')
|
||||
const note = await createNote(title, cleanedContent, folderPath)
|
||||
await addNote(title, cleanedContent, folderPath)
|
||||
|
||||
window.toast.success(i18n.t('message.success.notes.export'))
|
||||
|
||||
return note
|
||||
} catch (error) {
|
||||
logger.error('导出到笔记失败:', error as Error)
|
||||
window.toast.error(i18n.t('message.error.notes.export'))
|
||||
@ -1077,14 +1074,12 @@ export const exportMessageToNotes = async (
|
||||
* @param folderPath
|
||||
* @returns 创建的笔记节点
|
||||
*/
|
||||
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<NotesTreeNode> => {
|
||||
export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise<void> => {
|
||||
try {
|
||||
const content = await topicToMarkdown(topic)
|
||||
const note = await createNote(topic.name, content, folderPath)
|
||||
await addNote(topic.name, content, folderPath)
|
||||
|
||||
window.toast.success(i18n.t('message.success.notes.export'))
|
||||
|
||||
return note
|
||||
} catch (error) {
|
||||
logger.error('导出到笔记失败:', error as Error)
|
||||
window.toast.error(i18n.t('message.error.notes.export'))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user