diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 5c197e8971..20305d1c9e 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -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' diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 05bda8661d..83ad6b663d 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -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 message_blocks: EntityTable // Correct type for message_blocks translate_languages: EntityTable - 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 diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index c9eb189302..fb153b580c 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -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[] = [] - - // 逐级展开从根到目标路径的所有文件夹 - 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) diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index c85793e781..2e6159a6c4 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -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>, + 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(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([]) + const [starredPaths, setStarredPaths] = useState(() => readStoredPaths('notes:starred')) + const [expandedPaths, setExpandedPaths] = useState(() => 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(null) const watcherRef = useRef<(() => void) | null>(null) - const isSyncingTreeRef = useRef(false) const lastContentRef = useRef('') const lastFilePathRef = useRef(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} /> { } 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) diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 45383344d4..c356df58f2 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -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 { - const tree = await window.api.file.getDirectoryStructure(folderPath) - await sortAllLevels(sortType, tree) -} - -/** - * 创建新文件夹 - */ -export async function createFolder(name: string, folderPath: string): Promise { - 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 { - 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 { - 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 { + return window.api.file.getDirectoryStructure(normalizePath(rootPath)) } -/** - * 删除笔记或文件夹 - */ -export async function deleteNode(nodeId: string): Promise { - 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 { 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 { - 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 { - try { - const tree = await getNotesTree() +export async function uploadNotes(files: File[], targetPath: string): Promise { + 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 { - 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 { + const folders = new Set() + + 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 { - // 按根文件夹名称分组文件 - const filesByRootFolder = new Map() - 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; foldersToCreate: Set } { - const filesByPath = new Map() - const foldersToCreate = new Set() - - 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, - targetFolderPath: string, - tree: NotesTreeNode[], - uploadedNodes: NotesTreeNode[] -): Promise> { - const createdFolders = new Map() - const sortedFolders = Array.from(foldersToCreate).sort() - const folderCreationLock = new Set() - - for (const folderPath of sortedFolders) { - if (folderCreationLock.has(folderPath)) { - continue - } - folderCreationLock.add(folderPath) +async function createFolders(folders: Set): Promise { + 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 -): Promise { - 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 { - 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, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map, - uploadedNodes: NotesTreeNode[] -): Promise { - const uploadPromises: Promise[] = [] - - 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 -): Promise { - 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 } } diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts index 4159948323..f94df87e80 100644 --- a/src/renderer/src/services/NotesTreeService.ts +++ b/src/renderer/src/services/NotesTreeService.ts @@ -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 => { - const record = await db.notes_tree.get(NOTES_TREE_ID) - return record?.tree || [] -} - -/** - * 在树中插入节点 - */ -export async function insertNodeIntoTree( - tree: NotesTreeNode[], - node: NotesTreeNode, - parentId?: string -): Promise { - 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 { - 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 { - 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 { - 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 -): Promise { - 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 -} diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index d4fed4d029..4f6852c28e 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -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 => { +): Promise => { 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 => { +export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { 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'))