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