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:
chrome-storm-c442
2026-03-03 08:19:07 -05:00
parent 2a56ececd1
commit f445953a82
5 changed files with 98 additions and 10 deletions

View File

@@ -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: