diff --git a/core/i18n.py b/core/i18n.py index 9a8ae97..c0d7f4e 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -396,6 +396,10 @@ _EN = { "s3_folder_failed": "Failed to create folder", "s3_delete_folder_confirm": "Delete folder \"{folder}\" and all its contents?", "s3_deleted_n": "Deleted {count} objects", + "s3_download_folder_title": "Save folder to...", + "s3_downloading_n": "Downloading {count} files...", + "s3_downloaded_n": "Downloaded {count} files", + "s3_download_partial": "Downloaded {ok}/{total} files", "s3_copy_link_48h": "Copy Link (48h)", "s3_copy_link_permanent": "Copy Direct Link", "s3_generating_link": "Generating link...", @@ -909,6 +913,10 @@ _RU = { "s3_folder_failed": "Ошибка создания папки", "s3_delete_folder_confirm": "Удалить папку \"{folder}\" со всем содержимым?", "s3_deleted_n": "Удалено {count} объектов", + "s3_download_folder_title": "Сохранить папку в...", + "s3_downloading_n": "Скачивание {count} файлов...", + "s3_downloaded_n": "Скачано {count} файлов", + "s3_download_partial": "Скачано {ok}/{total} файлов", "s3_copy_link_48h": "Ссылка (48ч)", "s3_copy_link_permanent": "Прямая ссылка", "s3_generating_link": "Генерация ссылки...", @@ -1422,6 +1430,10 @@ _ZH = { "s3_folder_failed": "创建文件夹失败", "s3_delete_folder_confirm": "删除文件夹 \"{folder}\" 及其所有内容?", "s3_deleted_n": "已删除 {count} 个对象", + "s3_download_folder_title": "保存文件夹到...", + "s3_downloading_n": "正在下载 {count} 个文件...", + "s3_downloaded_n": "已下载 {count} 个文件", + "s3_download_partial": "已下载 {ok}/{total} 个文件", "s3_copy_link_48h": "复制链接 (48小时)", "s3_copy_link_permanent": "复制直接链接", "s3_generating_link": "生成链接中...", diff --git a/core/s3_client.py b/core/s3_client.py index a8f1497..3efc01e 100644 --- a/core/s3_client.py +++ b/core/s3_client.py @@ -306,6 +306,30 @@ class S3Client: log.error("S3 presigned URL failed: %s", exc) return None + def list_all_objects(self, bucket: str, prefix: str = "") -> list[dict]: + """List ALL objects under prefix recursively (no delimiter). + Returns list of {'Key', 'Size', 'LastModified'}. + """ + if not self._ensure_connected(): + return [] + try: + objects = [] + paginator = self._client.get_paginator("list_objects_v2") + kwargs = {"Bucket": bucket} + if prefix: + kwargs["Prefix"] = prefix + for page in paginator.paginate(**kwargs): + for obj in page.get("Contents", []): + # Skip "folder" markers (zero-byte keys ending with /) + if obj["Key"].endswith("/") and obj.get("Size", 0) == 0: + continue + objects.append(obj) + self._last_ok = time.time() + return objects + except Exception as exc: + log.error("S3 list_all_objects failed: %s", exc) + return [] + def delete_prefix(self, bucket: str, prefix: str) -> int: """Recursively delete all objects under a prefix. Returns count deleted.""" if not self._ensure_connected(): diff --git a/gui/tabs/s3_tab.py b/gui/tabs/s3_tab.py index 4618f34..f72b566 100644 --- a/gui/tabs/s3_tab.py +++ b/gui/tabs/s3_tab.py @@ -547,7 +547,7 @@ class S3Tab(ctk.CTkFrame): file_state = "normal" if not is_folder else "disabled" self._ctx_menu.entryconfigure(0, state=file_state) # Link 48h self._ctx_menu.entryconfigure(1, state=file_state) # Link permanent - self._ctx_menu.entryconfigure(3, state=file_state) # Download + self._ctx_menu.entryconfigure(3, state="normal") # Download (files + folders) # 5 = New Folder — always enabled # 7 = Delete — enabled for both files and folders self._ctx_menu.tk_popup(event.x_root, event.y_root) @@ -644,21 +644,25 @@ class S3Tab(ctk.CTkFrame): def _download(self): sel = self._tree.selection() - if not sel: + if not sel or not self._client or not self._current_bucket: return item = self._tree.item(sel[0]) name = item["values"][0] if item["values"] else "" if not isinstance(name, str): name = str(name) - if name.endswith("/"): - return # Can't download a folder + if name.endswith("/"): + self._download_folder(name) + else: + self._download_file(name) + + def _download_file(self, name: str): + """Download a single file.""" key = self._current_prefix + name save_path = filedialog.asksaveasfilename(initialfile=name) if not save_path: return - # Get file size for progress total_bytes = self._client.get_object_size(self._current_bucket, key) label = t("s3_downloading") self._status_label.configure(text=label) @@ -669,13 +673,61 @@ class S3Tab(ctk.CTkFrame): self._current_bucket, key, save_path, progress_cb=self._on_progress, status_cb=self._on_transfer_status) - if ok: - self.after(0, lambda: self._on_download_done(True)) - else: - self.after(0, lambda: self._on_download_done(False)) + self.after(0, lambda: self._on_download_done(ok)) threading.Thread(target=_do, daemon=True).start() + def _download_folder(self, display_name: str): + """Download all objects under a prefix, preserving directory structure.""" + clean = display_name.replace("\U0001f4c1 ", "").strip() + prefix = self._current_prefix + clean + + # Ask user to pick a local directory + dest_dir = filedialog.askdirectory(title=t("s3_download_folder_title")) + if not dest_dir: + return + + self._status_label.configure(text=t("s3_downloading")) + + def _do(): + # List all objects recursively under this prefix + objects = self._client.list_all_objects(self._current_bucket, prefix) + if not objects: + self.after(0, lambda: self._status_label.configure( + text=t("s3_download_failed"))) + return + + total_bytes = sum(o.get("Size", 0) for o in objects) + self.after(0, lambda: self._show_progress( + t("s3_downloading_n").format(count=len(objects)), total_bytes)) + + ok_count = 0 + for obj in objects: + obj_key = obj["Key"] + # Relative path from the selected folder + rel = obj_key[len(self._current_prefix):] + local_path = os.path.join(dest_dir, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + if self._client.download_file( + self._current_bucket, obj_key, local_path, + progress_cb=self._on_progress, + status_cb=self._on_transfer_status): + ok_count += 1 + + total = len(objects) + self.after(0, lambda: self._on_folder_download_done(ok_count, total)) + + threading.Thread(target=_do, daemon=True).start() + + def _on_folder_download_done(self, ok_count: int, total: int): + self._hide_progress() + if ok_count == total: + self._status_label.configure( + text=t("s3_downloaded_n").format(count=ok_count)) + else: + self._status_label.configure( + text=t("s3_download_partial").format(ok=ok_count, total=total)) + def _on_download_done(self, success: bool): self._hide_progress() if success: diff --git a/releases/ServerManager-v1.9.7-win-x64.exe b/releases/ServerManager-v1.9.7-win-x64.exe new file mode 100644 index 0000000..16d33be Binary files /dev/null and b/releases/ServerManager-v1.9.7-win-x64.exe differ diff --git a/version.py b/version.py index f652fdb..28ac8c3 100755 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.9.6" +__version__ = "1.9.7" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"