From 4714442d6e15b966923912a5db02b04b669e9504 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Thu, 11 Sep 2025 16:59:39 +0800 Subject: [PATCH] feat: add note folder upload (#9996) * feat: add note folder upload * Update NotesService.ts * Update NotesService.ts * Update NotesService.ts * fix: window message warning * test: ci auto i18n * fix(i18n): Auto update translations for PR #9996 * fix: ci i18n error * fix: i18n --------- Co-authored-by: GitHub Action --- src/renderer/src/i18n/locales/en-us.json | 5 +- src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 5 +- src/renderer/src/i18n/translate/el-gr.json | 7 +- src/renderer/src/i18n/translate/es-es.json | 5 +- src/renderer/src/i18n/translate/fr-fr.json | 5 +- src/renderer/src/i18n/translate/ja-jp.json | 5 +- src/renderer/src/i18n/translate/pt-pt.json | 5 +- src/renderer/src/i18n/translate/ru-ru.json | 5 +- src/renderer/src/pages/notes/NotesPage.tsx | 38 +- src/renderer/src/pages/notes/NotesSidebar.tsx | 52 ++- src/renderer/src/services/NotesService.ts | 409 ++++++++++++++++-- 12 files changed, 465 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 32fb1fd7e5..8ccab4db9d 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1708,7 +1708,7 @@ "delete_confirm": "Are you sure you want to delete this {{type}}?", "delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?", "delete_note_confirm": "Are you sure you want to delete the note \"{{name}}\"?", - "drop_markdown_hint": "Drop markdown files here to import", + "drop_markdown_hint": "Drop markdown files or folders here to import", "empty": "No notes available yet", "expand": "unfold", "export_failed": "Failed to export to knowledge base", @@ -1719,8 +1719,7 @@ "new_note": "Create a new note", "no_content_to_copy": "No content to copy", "no_file_selected": "Please select the file to upload", - "only_markdown": "Only Markdown files are supported", - "only_one_file_allowed": "Only one file can be uploaded", + "no_valid_files": "No valid file was uploaded", "open_folder": "Open an external folder", "open_outside": "Open from external", "rename": "Rename", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b432dac525..f43a689c3d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1708,7 +1708,7 @@ "delete_confirm": "确定要删除这个{{type}}吗?", "delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?", "delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?", - "drop_markdown_hint": "拖拽 Markdown 文件到此处导入", + "drop_markdown_hint": "拖拽 Markdown 文件或目录到此处导入", "empty": "暂无笔记", "expand": "展开", "export_failed": "导出到知识库失败", @@ -1719,8 +1719,7 @@ "new_note": "新建笔记", "no_content_to_copy": "没有内容可复制", "no_file_selected": "请选择要上传的文件", - "only_markdown": "仅支持 Markdown 格式", - "only_one_file_allowed": "只能上传一个文件", + "no_valid_files": "没有上传有效的文件", "open_folder": "打开外部文件夹", "open_outside": "从外部打开", "rename": "重命名", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 73288e916f..28fb04eea4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1708,7 +1708,7 @@ "delete_confirm": "確定要刪除此 {{type}} 嗎?", "delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?", "delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?", - "drop_markdown_hint": "拖拽 Markdown 文件到此處導入", + "drop_markdown_hint": "拖拽 Markdown 文件或資料夾到此處導入", "empty": "暫無筆記", "expand": "展開", "export_failed": "匯出至知識庫失敗", @@ -1719,8 +1719,7 @@ "new_note": "新建筆記", "no_content_to_copy": "沒有內容可複制", "no_file_selected": "請選擇要上傳的文件", - "only_markdown": "僅支援 Markdown 格式", - "only_one_file_allowed": "只能上傳一個文件", + "no_valid_files": "沒有上傳有效的檔案", "open_folder": "打開外部文件夾", "open_outside": "從外部打開", "rename": "重命名", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 2403c487b5..909a610784 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1704,7 +1704,7 @@ "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};", "delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;", "delete_note_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη σημείωση \"{{name}}\";;", - "drop_markdown_hint": "Σύρετε το αρχείο Markdown εδώ για εισαγωγή", + "drop_markdown_hint": "Σύρετε και αποθέστε αρχεία ή φακέλους Markdown εδώ για εισαγωγή", "empty": "δεν υπάρχει σημείωση για τώρα", "expand": "να ανοίξει", "export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε", @@ -1715,8 +1715,7 @@ "new_note": "Δημιουργία νέας σημείωσης", "no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή", "no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση", - "only_markdown": "Υποστηρίζεται μόνο η μορφή Markdown", - "only_one_file_allowed": "Μπορείτε να ανεβάσετε μόνο ένα αρχείο", + "no_valid_files": "Δεν ανέβηκε έγκυρο αρχείο", "open_folder": "Άνοιγμα εξωτερικού φακέλου", "open_outside": "Από το εξωτερικό", "rename": "μετονομασία", @@ -2235,7 +2234,7 @@ "changeType": "Αλλαγή τύπου", "deleteProperty": "διαγραφή χαρακτηριστικού", "editValue": "Επεξεργασία τιμής", - "empty": "\n空\n", + "empty": "Κενό", "moreActions": "Περισσότερες ενέργειες", "propertyName": "όνομα χαρακτηριστικού" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index f25b2be8f5..353fcf35b4 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1704,7 +1704,7 @@ "delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?", "delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?", "delete_note_confirm": "¿Está seguro de que desea eliminar la nota \"{{name}}\"?", - "drop_markdown_hint": "Arrastra archivos Markdown aquí para importar", + "drop_markdown_hint": "Arrastre y suelte archivos o carpetas de Markdown aquí para importar", "empty": "Sin notas por el momento", "expand": "expandir", "export_failed": "Exportación a la base de conocimientos fallida", @@ -1715,8 +1715,7 @@ "new_note": "Crear nota nueva", "no_content_to_copy": "No hay contenido para copiar", "no_file_selected": "Por favor, seleccione el archivo a subir", - "only_markdown": "Solo se admite el formato Markdown", - "only_one_file_allowed": "solo se puede subir un archivo", + "no_valid_files": "No se ha cargado un archivo válido", "open_folder": "abrir carpeta externa", "open_outside": "Abrir desde el exterior", "rename": "renombrar", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 36f9cc192b..6b17f9ebac 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1704,7 +1704,7 @@ "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?", "delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?", "delete_note_confirm": "Êtes-vous sûr de vouloir supprimer la note \"{{name}}\" ?", - "drop_markdown_hint": "Glissez-déposez le fichier Markdown ici pour l'importer", + "drop_markdown_hint": "Déposez ici des fichiers ou dossiers Markdown pour les importer", "empty": "Aucune note pour le moment", "expand": "développer", "export_failed": "Échec de l'exportation vers la base de connaissances", @@ -1715,8 +1715,7 @@ "new_note": "Nouvelle note", "no_content_to_copy": "Aucun contenu à copier", "no_file_selected": "Veuillez sélectionner le fichier à télécharger", - "only_markdown": "uniquement le format Markdown est pris en charge", - "only_one_file_allowed": "On ne peut télécharger qu'un seul fichier", + "no_valid_files": "Aucun fichier valide n’a été téléversé", "open_folder": "ouvrir le dossier externe", "open_outside": "Ouvrir depuis l'extérieur", "rename": "renommer", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 2635cf6500..a47670bafc 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1704,7 +1704,7 @@ "delete_confirm": "この{{type}}を本当に削除しますか?", "delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?", "delete_note_confirm": "メモ \"{{name}}\" を削除してもよろしいですか?", - "drop_markdown_hint": "マークダウンファイルをドラッグアンドドロップしてここにインポートします", + "drop_markdown_hint": "Markdown ファイルまたはディレクトリをここにドラッグ&ドロップしてインポートしてください", "empty": "暫無ノート", "expand": "展開", "export_failed": "知識ベースへのエクスポートに失敗しました", @@ -1715,8 +1715,7 @@ "new_note": "新規ノート作成", "no_content_to_copy": "コピーするコンテンツはありません", "no_file_selected": "アップロードするファイルを選択してください", - "only_markdown": "Markdown ファイルのみをアップロードできます", - "only_one_file_allowed": "アップロードできるファイルは1つだけです", + "no_valid_files": "有効なファイルがアップロードされていません", "open_folder": "外部フォルダーを開きます", "open_outside": "外部から開く", "rename": "名前の変更", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 227f5b3c4b..24716c2726 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1704,7 +1704,7 @@ "delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?", "delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?", "delete_note_confirm": "Tem a certeza de que deseja eliminar a nota \"{{name}}\"?", - "drop_markdown_hint": "Arraste o arquivo Markdown para aqui e importe", + "drop_markdown_hint": "Arraste e solte arquivos ou pastas Markdown aqui para importar", "empty": "Ainda não existem notas", "expand": "expandir", "export_failed": "Falha ao exportar para a base de conhecimento", @@ -1715,8 +1715,7 @@ "new_note": "Nova nota", "no_content_to_copy": "Não há conteúdo para copiar", "no_file_selected": "Selecione o arquivo a ser enviado", - "only_markdown": "Apenas o formato Markdown é suportado", - "only_one_file_allowed": "só é possível enviar um arquivo", + "no_valid_files": "Nenhum arquivo válido foi carregado", "open_folder": "Abrir pasta externa", "open_outside": "Abrir externamente", "rename": "renomear", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 903d809a13..9394503d6e 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1704,7 +1704,7 @@ "delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?", "delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?", "delete_note_confirm": "Вы действительно хотите удалить заметку \"{{name}}\"?", - "drop_markdown_hint": "Перетаскивать файл разметки, чтобы импортировать его здесь", + "drop_markdown_hint": "Перетащите сюда файлы или папки Markdown для импорта", "empty": "заметок пока нет", "expand": "развернуть", "export_failed": "Экспорт в базу знаний не выполнен", @@ -1715,8 +1715,7 @@ "new_note": "Создать заметку", "no_content_to_copy": "Нет контента для копирования", "no_file_selected": "Пожалуйста, выберите файл для загрузки", - "only_markdown": "Только Markdown", - "only_one_file_allowed": "Можно загрузить только один файл", + "no_valid_files": "Не загружен действительный файл", "open_folder": "Откройте внешнюю папку", "open_outside": "открыть снаружи", "rename": "переименовать", diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index e1976fcd20..c85793e781 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -12,7 +12,7 @@ import { moveNode, renameNode, sortAllLevels, - uploadNote + uploadFiles } from '@renderer/services/NotesService' import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService' import { useAppDispatch, useAppSelector } from '@renderer/store' @@ -525,38 +525,36 @@ const NotesPage: FC = () => { const handleUploadFiles = useCallback( async (files: File[]) => { try { - const fileToUpload = files[0] - - if (!fileToUpload) { + if (!files || files.length === 0) { window.toast.warning(t('notes.no_file_selected')) return } - // 暂时这么处理 - if (files.length > 1) { - window.toast.warning(t('notes.only_one_file_allowed')) + + const targetFolderPath = getTargetFolderPath() + if (!targetFolderPath) { + throw new Error('No folder path selected') } - if (!fileToUpload.name.toLowerCase().endsWith('.md')) { - window.toast.warning(t('notes.only_markdown')) + const result = await uploadFiles(files, targetFolderPath) + + // 检查上传结果 + if (result.fileCount === 0) { + window.toast.warning(t('notes.no_valid_files')) return } - try { - if (!notesPath) { - throw new Error('No folder path selected') - } - await uploadNote(fileToUpload, notesPath) - window.toast.success(t('notes.upload_success', { count: 1 })) - } catch (error) { - logger.error(`Failed to upload note file ${fileToUpload.name}:`, error as Error) - window.toast.error(t('notes.upload_failed', { name: fileToUpload.name })) - } + // 排序并显示成功信息 + await sortAllLevels(sortType) + + const successMessage = t('notes.upload_success') + + window.toast.success(successMessage) } catch (error) { logger.error('Failed to handle file upload:', error as Error) window.toast.error(t('notes.upload_failed')) } }, - [notesPath, t] + [getTargetFolderPath, sortType, t] ) // 处理节点移动 diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 045697857b..1de9ff8a28 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -461,14 +461,58 @@ const NotesSidebar: FC = ({ ) const handleDropFiles = useCallback( - (e: React.DragEvent) => { + async (e: React.DragEvent) => { e.preventDefault() setIsDragOverSidebar(false) - const files = Array.from(e.dataTransfer.files) + // 处理文件夹拖拽:从 dataTransfer.items 获取完整的文件路径信息 + const items = Array.from(e.dataTransfer.items) + const files: File[] = [] - if (files.length > 0) { - onUploadFiles(files) + const processEntry = async (entry: FileSystemEntry, path: string = '') => { + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry + return new Promise((resolve) => { + fileEntry.file((file) => { + // 手动设置 webkitRelativePath 以保持文件夹结构 + Object.defineProperty(file, 'webkitRelativePath', { + value: path + file.name, + writable: false + }) + files.push(file) + resolve() + }) + }) + } else if (entry.isDirectory) { + const dirEntry = entry as FileSystemDirectoryEntry + const reader = dirEntry.createReader() + return new Promise((resolve) => { + reader.readEntries(async (entries) => { + const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/')) + await Promise.all(promises) + resolve() + }) + }) + } + } + + // 如果支持 DataTransferItem API(文件夹拖拽) + if (items.length > 0 && items[0].webkitGetAsEntry()) { + const promises = items.map((item) => { + const entry = item.webkitGetAsEntry() + return entry ? processEntry(entry) : Promise.resolve() + }) + + await Promise.all(promises) + + if (files.length > 0) { + onUploadFiles(files) + } + } else { + const regularFiles = Array.from(e.dataTransfer.files) + if (regularFiles.length > 0) { + onUploadFiles(regularFiles) + } } }, [onUploadFiles] diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 4e27a41366..e850f30cc3 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -96,41 +96,48 @@ export async function createNote(name: string, content: string = '', folderPath: return note } +export interface UploadResult { + uploadedNodes: NotesTreeNode[] + totalFiles: number + skippedFiles: number + fileCount: number + folderCount: number +} + /** - * 上传笔记 + * 上传文件或文件夹,支持单个或批量上传,保持文件夹结构 */ -export async function uploadNote(file: File, folderPath: string): Promise { +export async function uploadFiles(files: File[], targetFolderPath: string): Promise { const tree = await getNotesTree() - const fileName = file.name.toLowerCase() - if (!fileName.endsWith(MARKDOWN_EXT)) { - throw new Error('Only markdown files are allowed') + 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 noteId = uuidv4() - const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') + // 处理重复的根文件夹名称 + const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath) - const { safeName, exists } = await window.api.file.checkFileName(folderPath, nameWithoutExt, true) - if (exists) { - logger.warn(`Note already exists: ${safeName}`) + 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 } - - const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` - - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - const content = await file.text() - await window.api.file.write(notePath, content) - insertNodeIntoTree(tree, note) - - return note } /** @@ -148,7 +155,7 @@ export async function deleteNode(nodeId: string): Promise { await window.api.file.deleteExternalFile(node.externalPath) } - removeNodeFromTree(tree, nodeId) + await removeNodeFromTree(tree, nodeId) } /** @@ -387,3 +394,347 @@ function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): N } return null } + +/** + * 过滤出 Markdown 文件 + */ +function filterMarkdownFiles(files: File[]): File[] { + return Array.from(files).filter((file) => { + if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) { + return true + } + logger.warn(`Skipping non-markdown file: ${file.name}`) + return false + }) +} + +/** + * 创建空的上传结果 + */ +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) + + 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}`) + } + } catch (error) { + logger.error(`Failed to create folder ${folderPath}:`, error as Error) + } finally { + folderCreationLock.delete(folderPath) + } + } + + 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) + } + } + + const results = await Promise.all(uploadPromises) + + 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 + if (originalDirPath === targetFolderPath) { + parentNode = + tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) + } else { + parentNode = createdFolders.get(originalDirPath) || null + if (!parentNode) { + parentNode = tree.find((node) => node.externalPath === originalDirPath) || null + if (!parentNode) { + parentNode = findNodeByExternalPath(tree, originalDirPath) + } + } + } + + // 如果找不到父节点,尝试通过 createdFolders 找到实际路径 + if (!parentNode && originalDirPath !== targetFolderPath) { + 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 +}