v1.9.7: S3 folder download — recursive with preserved directory structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
core/i18n.py
12
core/i18n.py
@@ -396,6 +396,10 @@ _EN = {
|
|||||||
"s3_folder_failed": "Failed to create folder",
|
"s3_folder_failed": "Failed to create folder",
|
||||||
"s3_delete_folder_confirm": "Delete folder \"{folder}\" and all its contents?",
|
"s3_delete_folder_confirm": "Delete folder \"{folder}\" and all its contents?",
|
||||||
"s3_deleted_n": "Deleted {count} objects",
|
"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_48h": "Copy Link (48h)",
|
||||||
"s3_copy_link_permanent": "Copy Direct Link",
|
"s3_copy_link_permanent": "Copy Direct Link",
|
||||||
"s3_generating_link": "Generating link...",
|
"s3_generating_link": "Generating link...",
|
||||||
@@ -909,6 +913,10 @@ _RU = {
|
|||||||
"s3_folder_failed": "Ошибка создания папки",
|
"s3_folder_failed": "Ошибка создания папки",
|
||||||
"s3_delete_folder_confirm": "Удалить папку \"{folder}\" со всем содержимым?",
|
"s3_delete_folder_confirm": "Удалить папку \"{folder}\" со всем содержимым?",
|
||||||
"s3_deleted_n": "Удалено {count} объектов",
|
"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_48h": "Ссылка (48ч)",
|
||||||
"s3_copy_link_permanent": "Прямая ссылка",
|
"s3_copy_link_permanent": "Прямая ссылка",
|
||||||
"s3_generating_link": "Генерация ссылки...",
|
"s3_generating_link": "Генерация ссылки...",
|
||||||
@@ -1422,6 +1430,10 @@ _ZH = {
|
|||||||
"s3_folder_failed": "创建文件夹失败",
|
"s3_folder_failed": "创建文件夹失败",
|
||||||
"s3_delete_folder_confirm": "删除文件夹 \"{folder}\" 及其所有内容?",
|
"s3_delete_folder_confirm": "删除文件夹 \"{folder}\" 及其所有内容?",
|
||||||
"s3_deleted_n": "已删除 {count} 个对象",
|
"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_48h": "复制链接 (48小时)",
|
||||||
"s3_copy_link_permanent": "复制直接链接",
|
"s3_copy_link_permanent": "复制直接链接",
|
||||||
"s3_generating_link": "生成链接中...",
|
"s3_generating_link": "生成链接中...",
|
||||||
|
|||||||
@@ -306,6 +306,30 @@ class S3Client:
|
|||||||
log.error("S3 presigned URL failed: %s", exc)
|
log.error("S3 presigned URL failed: %s", exc)
|
||||||
return None
|
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:
|
def delete_prefix(self, bucket: str, prefix: str) -> int:
|
||||||
"""Recursively delete all objects under a prefix. Returns count deleted."""
|
"""Recursively delete all objects under a prefix. Returns count deleted."""
|
||||||
if not self._ensure_connected():
|
if not self._ensure_connected():
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
file_state = "normal" if not is_folder else "disabled"
|
file_state = "normal" if not is_folder else "disabled"
|
||||||
self._ctx_menu.entryconfigure(0, state=file_state) # Link 48h
|
self._ctx_menu.entryconfigure(0, state=file_state) # Link 48h
|
||||||
self._ctx_menu.entryconfigure(1, state=file_state) # Link permanent
|
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
|
# 5 = New Folder — always enabled
|
||||||
# 7 = Delete — enabled for both files and folders
|
# 7 = Delete — enabled for both files and folders
|
||||||
self._ctx_menu.tk_popup(event.x_root, event.y_root)
|
self._ctx_menu.tk_popup(event.x_root, event.y_root)
|
||||||
@@ -644,21 +644,25 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _download(self):
|
def _download(self):
|
||||||
sel = self._tree.selection()
|
sel = self._tree.selection()
|
||||||
if not sel:
|
if not sel or not self._client or not self._current_bucket:
|
||||||
return
|
return
|
||||||
item = self._tree.item(sel[0])
|
item = self._tree.item(sel[0])
|
||||||
name = item["values"][0] if item["values"] else ""
|
name = item["values"][0] if item["values"] else ""
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
name = str(name)
|
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
|
key = self._current_prefix + name
|
||||||
save_path = filedialog.asksaveasfilename(initialfile=name)
|
save_path = filedialog.asksaveasfilename(initialfile=name)
|
||||||
if not save_path:
|
if not save_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get file size for progress
|
|
||||||
total_bytes = self._client.get_object_size(self._current_bucket, key)
|
total_bytes = self._client.get_object_size(self._current_bucket, key)
|
||||||
label = t("s3_downloading")
|
label = t("s3_downloading")
|
||||||
self._status_label.configure(text=label)
|
self._status_label.configure(text=label)
|
||||||
@@ -669,13 +673,61 @@ class S3Tab(ctk.CTkFrame):
|
|||||||
self._current_bucket, key, save_path,
|
self._current_bucket, key, save_path,
|
||||||
progress_cb=self._on_progress,
|
progress_cb=self._on_progress,
|
||||||
status_cb=self._on_transfer_status)
|
status_cb=self._on_transfer_status)
|
||||||
if ok:
|
self.after(0, lambda: self._on_download_done(ok))
|
||||||
self.after(0, lambda: self._on_download_done(True))
|
|
||||||
else:
|
|
||||||
self.after(0, lambda: self._on_download_done(False))
|
|
||||||
|
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
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):
|
def _on_download_done(self, success: bool):
|
||||||
self._hide_progress()
|
self._hide_progress()
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
BIN
releases/ServerManager-v1.9.7-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.9.7-win-x64.exe
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
"""Version info for ServerManager."""
|
"""Version info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.9.6"
|
__version__ = "1.9.7"
|
||||||
__app_name__ = "ServerManager"
|
__app_name__ = "ServerManager"
|
||||||
__author__ = "aibot777"
|
__author__ = "aibot777"
|
||||||
__description__ = "Desktop GUI for managing remote servers"
|
__description__ = "Desktop GUI for managing remote servers"
|
||||||
|
|||||||
Reference in New Issue
Block a user