mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 17:59:09 +08:00
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:
parent
66115ca306
commit
4714442d6e
@ -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",
|
||||
|
||||
@ -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": "重命名",
|
||||
|
||||
@ -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": "重命名",
|
||||
|
||||
@ -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": "όνομα χαρακτηριστικού"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "名前の変更",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "переименовать",
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
// 处理节点移动
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user