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 <action@github.com>
This commit is contained in:
Pleasure1234 2025-09-11 16:59:39 +08:00 committed by GitHub
parent 66115ca306
commit 4714442d6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 465 additions and 81 deletions

View File

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

View File

@ -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": "重命名",

View File

@ -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": "重命名",

View File

@ -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": "<translate_input>\n空\n</translate_input>",
"empty": "Κενό",
"moreActions": "Περισσότερες ενέργειες",
"propertyName": "όνομα χαρακτηριστικού"
},

View File

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

View File

@ -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 na été téléversé",
"open_folder": "ouvrir le dossier externe",
"open_outside": "Ouvrir depuis l'extérieur",
"rename": "renommer",

View File

@ -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": "名前の変更",

View File

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

View File

@ -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": "переименовать",

View File

@ -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]
)
// 处理节点移动

View File

@ -461,14 +461,58 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
)
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<void>((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<void>((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]

View File

@ -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<NotesTreeNode> {
export async function uploadFiles(files: File[], targetFolderPath: string): Promise<UploadResult> {
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<void> {
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<File[]> {
// 按根文件夹名称分组文件
const filesByRootFolder = new Map<string, File[]>()
const processedFiles: File[] = []
for (const file of markdownFiles) {
const filePath = file.webkitRelativePath || file.name
if (filePath.includes('/')) {
const rootFolderName = filePath.substring(0, filePath.indexOf('/'))
if (!filesByRootFolder.has(rootFolderName)) {
filesByRootFolder.set(rootFolderName, [])
}
filesByRootFolder.get(rootFolderName)!.push(file)
} else {
// 单个文件,直接添加
processedFiles.push(file)
}
}
// 为每个根文件夹组生成唯一的文件夹名称
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
for (const file of files) {
// 创建一个新的 File 对象,并修改 webkitRelativePath
const originalPath = file.webkitRelativePath || file.name
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
const newPath = `${safeName}/${relativePath}`
const newFile = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified
})
Object.defineProperty(newFile, 'webkitRelativePath', {
value: newPath,
writable: false
})
processedFiles.push(newFile)
}
}
return processedFiles
}
/**
*
*/
function groupFilesByPath(
markdownFiles: File[],
targetFolderPath: string
): { filesByPath: Map<string, File[]>; foldersToCreate: Set<string> } {
const filesByPath = new Map<string, File[]>()
const foldersToCreate = new Set<string>()
for (const file of markdownFiles) {
const filePath = file.webkitRelativePath || file.name
const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''
const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath
if (relativeDirPath) {
const pathParts = relativeDirPath.split('/')
let currentPath = targetFolderPath
for (const part of pathParts) {
currentPath = `${currentPath}/${part}`
foldersToCreate.add(currentPath)
}
}
if (!filesByPath.has(fullDirPath)) {
filesByPath.set(fullDirPath, [])
}
filesByPath.get(fullDirPath)!.push(file)
}
return { filesByPath, foldersToCreate }
}
/**
*
*/
async function createFoldersSequentially(
foldersToCreate: Set<string>,
targetFolderPath: string,
tree: NotesTreeNode[],
uploadedNodes: NotesTreeNode[]
): Promise<Map<string, NotesTreeNode>> {
const createdFolders = new Map<string, NotesTreeNode>()
const sortedFolders = Array.from(foldersToCreate).sort()
const folderCreationLock = new Set<string>()
for (const folderPath of sortedFolders) {
if (folderCreationLock.has(folderPath)) {
continue
}
folderCreationLock.add(folderPath)
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<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
const existingNode = findNodeByExternalPath(tree, folderPath)
if (existingNode) {
return existingNode
}
const relativePath = folderPath.replace(targetFolderPath + '/', '')
const originalFolderName = relativePath.split('/').pop()!
const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'))
const { safeName: safeFolderName, exists } = await window.api.file.checkFileName(
parentFolderPath,
originalFolderName,
false
)
const actualFolderPath = `${parentFolderPath}/${safeFolderName}`
if (exists) {
logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`)
}
try {
await window.api.file.mkdir(actualFolderPath)
} catch (error) {
logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error)
}
let parentNode: NotesTreeNode | null
if (parentFolderPath === targetFolderPath) {
parentNode =
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
} else {
parentNode = createdFolders.get(parentFolderPath) || null
if (!parentNode) {
parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null
if (!parentNode) {
parentNode = findNodeByExternalPath(tree, parentFolderPath)
}
}
}
const folderId = uuidv4()
const folder: NotesTreeNode = {
id: folderId,
name: safeFolderName,
treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`,
externalPath: actualFolderPath,
type: 'folder',
children: [],
expanded: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
await insertNodeIntoTree(tree, folder, parentNode?.id)
return folder
}
/**
*
*/
async function readFileContent(file: File): Promise<string> {
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
if (file.size > MAX_FILE_SIZE) {
logger.warn(
`Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.`
)
}
try {
return await file.text()
} catch (error) {
logger.error(`Failed to read file content for ${file.name}:`, error as Error)
throw new Error(`Failed to read file content: ${file.name}`)
}
}
/**
*
*/
async function uploadAllFiles(
filesByPath: Map<string, File[]>,
targetFolderPath: string,
tree: NotesTreeNode[],
createdFolders: Map<string, NotesTreeNode>,
uploadedNodes: NotesTreeNode[]
): Promise<void> {
const uploadPromises: Promise<NotesTreeNode | null>[] = []
for (const [dirPath, dirFiles] of filesByPath.entries()) {
for (const file of dirFiles) {
const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders)
.then((result) => {
if (result) {
logger.debug(`Uploaded file: ${result.externalPath}`)
}
return result
})
.catch((error) => {
logger.error(`Failed to upload file ${file.name}:`, error as Error)
return null
})
uploadPromises.push(uploadPromise)
}
}
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<string, NotesTreeNode>
): Promise<NotesTreeNode | null> {
const fileName = (file.webkitRelativePath || file.name).split('/').pop()!
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
let actualDirPath = originalDirPath
let parentNode: NotesTreeNode | null
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
}