diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 90ba38c..86576a4 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -474,6 +474,31 @@ export class UnifiedClient { throw new Error("Use uploadFileList for web folder uploads"); } + async selectFolders(): Promise { + await this.initialize(); + + if (this._environment === "wails") { + const client = await getWailsClient(); + const paths = await client.App.SelectFolders(); + return paths ?? []; + } + + // In web mode, folder selection is handled via HTML input with webkitdirectory + return []; + } + + async uploadFolders(folderPaths: string[]): Promise { + await this.initialize(); + + if (this._environment === "wails") { + const client = await getWailsClient(); + return client.App.UploadFolders(folderPaths); + } + + // In web mode, folder upload is handled via uploadFileList with webkitdirectory files + throw new Error("Use uploadFileList for web folder uploads"); + } + // Logs async getLogs(): Promise { await this.initialize(); diff --git a/frontend/src/lib/components/dashboard/DashboardHeader.svelte b/frontend/src/lib/components/dashboard/DashboardHeader.svelte index 4c45a8b..0cd05bd 100644 --- a/frontend/src/lib/components/dashboard/DashboardHeader.svelte +++ b/frontend/src/lib/components/dashboard/DashboardHeader.svelte @@ -124,17 +124,27 @@ function closeFileExplorer() { async function handleFolderUpload() { try { if (apiClient.environment === "wails") { - // Desktop mode: use native folder picker - const folderPath = await apiClient.selectFolder(); - if (folderPath) { - await apiClient.uploadFolder(folderPath); + // Desktop mode: use native multi-folder picker + const folderPaths = await apiClient.selectFolders(); + if (folderPaths.length === 0) return; + + await apiClient.uploadFolders(folderPaths); + + if (folderPaths.length === 1) { toastStore.success( $t("dashboard.header.folder_added"), $t("dashboard.header.folder_added_description") ); + } else { + toastStore.success( + $t("dashboard.header.folders_added"), + $t("dashboard.header.folders_added_description", { count: folderPaths.length }) + ); } } else { // Web mode: use hidden input with webkitdirectory attribute + // Web mode already supports selecting one folder at a time via the browser's native picker; + // multiple folder uploads require repeated clicks (browser limitation). const input = document.createElement("input"); input.type = "file"; // @ts-ignore - webkitdirectory is not in the type definitions @@ -145,11 +155,7 @@ async function handleFolderUpload() { input.onchange = async () => { if (input.files && input.files.length > 0) { - // In web mode, we need to send the files to the server - const fileList = input.files; - - // Use the existing file upload mechanism - uploadActions.startUpload(fileList); + uploadActions.startUpload(input.files); try { await apiClient.uploadFileList( diff --git a/frontend/src/lib/locales/en/dashboard.json b/frontend/src/lib/locales/en/dashboard.json index 6763462..d179e04 100644 --- a/frontend/src/lib/locales/en/dashboard.json +++ b/frontend/src/lib/locales/en/dashboard.json @@ -8,9 +8,11 @@ "description": "Manage your file uploads and monitor progress", "add_files": "Add Files", "add_folder": "Add Folder", - "add_folder_tooltip": "Upload an entire folder as a single NZB", + "add_folder_tooltip": "Upload one or more folders, each as a single NZB", "folder_added": "Folder Added", "folder_added_description": "Folder has been added to the upload queue", + "folders_added": "Folders Added", + "folders_added_description": "{{count}} folders have been added to the upload queue", "import_files": "Import Files", "import_files_tooltip": "Browse and import files from the remote server", "clear_completed": "Clear Completed" diff --git a/frontend/src/lib/locales/es/dashboard.json b/frontend/src/lib/locales/es/dashboard.json index 7ab4827..1db3477 100644 --- a/frontend/src/lib/locales/es/dashboard.json +++ b/frontend/src/lib/locales/es/dashboard.json @@ -8,9 +8,11 @@ "description": "Gestiona tus cargas de archivos y monitorea el progreso", "add_files": "Agregar Archivos", "add_folder": "Agregar Carpeta", - "add_folder_tooltip": "Subir una carpeta entera como un solo NZB", + "add_folder_tooltip": "Subir una o más carpetas, cada una como un solo NZB", "folder_added": "Carpeta Agregada", "folder_added_description": "La carpeta ha sido agregada a la cola de carga", + "folders_added": "Carpetas Agregadas", + "folders_added_description": "{{count}} carpetas han sido agregadas a la cola de carga", "import_files": "Importar Archivos", "import_files_tooltip": "Explorar e importar archivos desde el servidor remoto", "clear_completed": "Limpiar Completados" diff --git a/frontend/src/lib/locales/fr/dashboard.json b/frontend/src/lib/locales/fr/dashboard.json index 9b84ecf..83918c9 100644 --- a/frontend/src/lib/locales/fr/dashboard.json +++ b/frontend/src/lib/locales/fr/dashboard.json @@ -8,9 +8,11 @@ "description": "Gérez vos téléchargements de fichiers et surveillez le progrès", "add_files": "Ajouter des Fichiers", "add_folder": "Ajouter un Dossier", - "add_folder_tooltip": "Télécharger un dossier entier comme un seul NZB", + "add_folder_tooltip": "Télécharger un ou plusieurs dossiers, chacun comme un seul NZB", "folder_added": "Dossier Ajouté", "folder_added_description": "Le dossier a été ajouté à la file d'attente de téléchargement", + "folders_added": "Dossiers Ajoutés", + "folders_added_description": "{{count}} dossiers ont été ajoutés à la file d'attente de téléchargement", "import_files": "Importer des Fichiers", "import_files_tooltip": "Explorer et importer des fichiers depuis le serveur distant", "clear_completed": "Vider les Terminés" diff --git a/frontend/src/lib/locales/tr/dashboard.json b/frontend/src/lib/locales/tr/dashboard.json index 5ab74b6..f5fa7f0 100644 --- a/frontend/src/lib/locales/tr/dashboard.json +++ b/frontend/src/lib/locales/tr/dashboard.json @@ -8,9 +8,11 @@ "description": "Dosya yüklemelerinizi yönetin ve ilerlemeyi izleyin", "add_files": "Dosya Ekle", "add_folder": "Klasör Ekle", - "add_folder_tooltip": "Tüm klasörü tek bir NZB olarak yükle", + "add_folder_tooltip": "Bir veya daha fazla klasörü her biri tek bir NZB olarak yükle", "folder_added": "Klasör Eklendi", "folder_added_description": "Klasör yükleme kuyruğuna eklendi", + "folders_added": "Klasörler Eklendi", + "folders_added_description": "{{count}} klasör yükleme kuyruğuna eklendi", "import_files": "Dosyaları İçe Aktar", "import_files_tooltip": "Uzak sunucudan dosyalara göz atın ve içe aktarın", "clear_completed": "Tamamlananları Temizle" diff --git a/frontend/src/lib/wailsjs/go/backend/App.d.ts b/frontend/src/lib/wailsjs/go/backend/App.d.ts index 47c29aa..22e40e8 100755 --- a/frontend/src/lib/wailsjs/go/backend/App.d.ts +++ b/frontend/src/lib/wailsjs/go/backend/App.d.ts @@ -106,6 +106,8 @@ export function SelectConfigFile():Promise; export function SelectFolder():Promise; +export function SelectFolders():Promise>; + export function SelectOutputDirectory():Promise; export function SelectTempDirectory():Promise; @@ -132,4 +134,6 @@ export function UploadFiles():Promise; export function UploadFolder(arg1:string):Promise; +export function UploadFolders(arg1:Array):Promise; + export function ValidateNNTPServer(arg1:backend.ServerData):Promise; diff --git a/frontend/src/lib/wailsjs/go/backend/App.js b/frontend/src/lib/wailsjs/go/backend/App.js index ac2cd6b..9fb1bf8 100755 --- a/frontend/src/lib/wailsjs/go/backend/App.js +++ b/frontend/src/lib/wailsjs/go/backend/App.js @@ -202,6 +202,10 @@ export function SelectFolder() { return window['go']['backend']['App']['SelectFolder'](); } +export function SelectFolders() { + return window['go']['backend']['App']['SelectFolders'](); +} + export function SelectOutputDirectory() { return window['go']['backend']['App']['SelectOutputDirectory'](); } @@ -254,6 +258,10 @@ export function UploadFolder(arg1) { return window['go']['backend']['App']['UploadFolder'](arg1); } +export function UploadFolders(arg1) { + return window['go']['backend']['App']['UploadFolders'](arg1); +} + export function ValidateNNTPServer(arg1) { return window['go']['backend']['App']['ValidateNNTPServer'](arg1); } diff --git a/internal/backend/upload.go b/internal/backend/upload.go index aced5ce..6a11ca7 100644 --- a/internal/backend/upload.go +++ b/internal/backend/upload.go @@ -148,6 +148,34 @@ func (a *App) SelectFolder() (string, error) { return folderPath, nil } +// SelectFolders opens a native multi-select dialog and returns paths of all selected folders. +// Returns nil slice (not an error) when the user cancels. +func (a *App) SelectFolders() ([]string, error) { + defer a.recoverPanic("SelectFolders") + + selected, err := runtime.OpenMultipleFilesDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Select folders to upload", + }) + if err != nil { + return nil, fmt.Errorf("error opening folder dialog: %w", err) + } + + // Filter to directories only so files accidentally selected are ignored + var dirs []string + for _, p := range selected { + info, err := os.Stat(p) + if err != nil { + slog.Warn("Could not stat selected path, skipping", "path", p, "error", err) + continue + } + if info.IsDir() { + dirs = append(dirs, p) + } + } + + return dirs, nil +} + // UploadFolder uploads all files from a folder as a single NZB // The folder structure will be preserved in the article subjects func (a *App) UploadFolder(folderPath string) error { @@ -217,3 +245,80 @@ func (a *App) UploadFolder(folderPath string) error { return nil } + +// UploadFolders queues multiple folders for upload as separate NZBs. +func (a *App) UploadFolders(folderPaths []string) error { + defer a.recoverPanic("UploadFolders") + + if len(folderPaths) == 0 { + return fmt.Errorf("no folder paths provided") + } + + // Check configuration once before processing all folders + status := a.GetAppStatus() + if status.NeedsConfiguration { + return fmt.Errorf("configuration required: Please configure at least one server in the Settings page before uploading files") + } + + if a.queue == nil { + return fmt.Errorf("queue not initialized") + } + + addedCount := 0 + for _, folderPath := range folderPaths { + info, err := os.Stat(folderPath) + if err != nil { + slog.Warn("Could not access folder, skipping", "path", folderPath, "error", err) + continue + } + if !info.IsDir() { + slog.Warn("Path is not a folder, skipping", "path", folderPath) + continue + } + + filesByFolder, sizeByFolder, err := processDirectoryRecursively(folderPath) + if err != nil { + slog.Error("Error processing directory, skipping", "path", folderPath, "error", err) + continue + } + + var totalFiles int + var totalSize int64 + for _, files := range filesByFolder { + totalFiles += len(files) + } + for _, size := range sizeByFolder { + totalSize += size + } + + if totalFiles == 0 { + slog.Warn("Folder contains no files, skipping", "path", folderPath) + continue + } + + folderName := filepath.Base(folderPath) + folderQueuePath := "FOLDER:" + folderPath + if err := a.queue.AddFile(context.Background(), folderQueuePath, totalSize); err != nil { + slog.Warn("Could not add folder to queue, skipping", "folder", folderName, "error", err) + continue + } + + addedCount++ + slog.Info("Folder added to queue", "folder", folderName, "files", totalFiles, "size", totalSize) + } + + if addedCount == 0 { + return fmt.Errorf("no valid folders could be added to queue") + } + + slog.Info("Added folders to queue", "added", addedCount, "total", len(folderPaths)) + + // Emit a single event after all folders are queued + if !a.isWebMode { + runtime.EventsEmit(a.ctx, "queue-updated") + } else if a.webEventEmitter != nil { + a.webEventEmitter("queue-updated", nil) + } + + return nil +}