Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """
- Advanced File Scanner & Manager (Final)
- - Window: 1150x720 (kept as requested)
- - Multi-extension scanning
- - Include hidden/subfolders option
- - Checkbox per file + Select All/Unselect All
- - Image preview, Video thumbnail (ffmpeg if available), Text preview
- - Zip selected files, Delete, Move, Export CSV/TXT
- - Find duplicates (MD5)
- - Progress bar for operations (scan/zip/hash/delete/move)
- - Threaded heavy tasks to avoid GUI freeze
- - Both vertical and horizontal scrollbars for results area
- Author: Najeeb Shah Khan Edition (final)
- """
- import os
- import sys
- import threading
- import zipfile
- import hashlib
- import csv
- import shutil
- import time
- import subprocess
- from pathlib import Path
- from queue import Queue, Empty
- import tempfile
- import tkinter as tk
- from tkinter import filedialog, messagebox, simpledialog, StringVar, IntVar
- # Optional nicer theme
- try:
- import ttkbootstrap as tb
- from ttkbootstrap.constants import *
- THEME_BACKEND = "ttkbootstrap"
- except Exception:
- THEME_BACKEND = "tkinter.ttk"
- import tkinter.ttk as ttk
- from PIL import Image, ImageTk, UnidentifiedImageError
- # ---------------- Helpers ----------------
- def format_size(size):
- if size < 1024:
- return f"{size} B"
- for unit in ["KB", "MB", "GB", "TB"]:
- size /= 1024.0
- if size < 1024.0:
- return f"{size:.2f} {unit}"
- return f"{size:.2f} PB"
- def normalize_ext_list(ext_input):
- if not ext_input:
- return []
- parts = [p.strip().lower() for p in ext_input.split(",") if p.strip()]
- norm = []
- for p in parts:
- p = p.lstrip("*").lstrip(".")
- if p:
- norm.append("." + p)
- return norm
- def is_video_by_ext(path):
- video_exts = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
- return Path(path).suffix.lower() in video_exts
- def md5_for_file(path, chunk_size=4 * 1024 * 1024):
- # large-chunk MD5 for speed
- h = hashlib.md5()
- try:
- with open(path, "rb") as f:
- while True:
- chunk = f.read(chunk_size)
- if not chunk:
- break
- h.update(chunk)
- except Exception:
- return None
- return h.hexdigest()
- def ffmpeg_available():
- return shutil.which("ffmpeg") is not None
- # ---------------- Main App ----------------
- class FileManagerApp:
- def __init__(self, root):
- self.root = root
- self.root.title("Najeeb Advanced File Scanner & Manager - Final")
- self.root.geometry("1150x720") # as requested - keep this
- self.root.minsize(1024, 640)
- # style
- if THEME_BACKEND == "ttkbootstrap":
- self.style = tb.Style(theme="darkly")
- self.container = tb.Frame(self.root)
- self.container.pack(fill="both", expand=True)
- self.Button = tb.Button
- self.Label = tb.Label
- self.Entry = tb.Entry
- self.Frame = tb.Frame
- self.LabelFrame = tb.Labelframe if hasattr(tb, "Labelframe") else tb.Frame
- self.Progressbar = tb.Progressbar
- self.Checkbutton = tb.Checkbutton
- else:
- self.style = None
- import tkinter.ttk as ttk
- self.container = ttk.Frame(self.root)
- self.container.pack(fill="both", expand=True)
- self.Button = ttk.Button
- self.Label = ttk.Label
- self.Entry = ttk.Entry
- self.Frame = ttk.Frame
- self.LabelFrame = ttk.Labelframe
- self.Progressbar = ttk.Progressbar
- # fallback checkbutton
- from tkinter import Checkbutton as TkCheck
- self.Checkbutton = TkCheck
- # Data structures
- self.file_items = [] # list of dicts: {path, size, var(IntVar), md5}
- self.filtered = []
- self.image_cache = {} # to prevent GC of PhotoImage references
- self.scan_thread = None
- self.op_lock = threading.Lock() # prevent multiple ops at same time
- self.queue = Queue() # for thread->UI messages
- self._build_ui()
- self._periodic_process_queue()
- # ---------------- UI BUILD ----------------
- def _build_ui(self):
- top = self.Frame(self.container)
- top.pack(fill="x", padx=8, pady=6)
- # Drive / Folder
- drives = [f"{chr(d)}:\\" for d in range(65, 91) if os.path.exists(f"{chr(d)}:\\")]
- start_path = drives[0] if drives else os.path.expanduser("~")
- self.drive_entry = self.Entry(top, width=40)
- self.drive_entry.pack(side="left", padx=(2,6))
- self.drive_entry.insert(0, start_path)
- self.Button(top, text="Browse...", command=self.browse_drive).pack(side="left", padx=6)
- # Extensions
- self.Label(top, text="Extensions:").pack(side="left", padx=(8,2))
- self.ext_entry = self.Entry(top, width=24)
- self.ext_entry.pack(side="left")
- self.ext_entry.insert(0, ".jpg,.png,.mp4")
- # include hidden
- self.include_hidden = IntVar(value=1)
- chk = self.Checkbutton(top, text="Include hidden/subfolders", variable=self.include_hidden)
- chk.pack(side="left", padx=8)
- # Scan button
- self.scan_btn = self.Button(top, text="Scan", command=self.start_scan)
- self.scan_btn.pack(side="left", padx=6)
- # Toggle theme (ttkbootstrap only)
- if THEME_BACKEND == "ttkbootstrap":
- self.Button(top, text="Toggle Dark/Light", command=self.toggle_theme).pack(side="right", padx=6)
- # Middle frames
- mid = self.Frame(self.container)
- mid.pack(fill="both", expand=True, padx=8, pady=6)
- # Left: list + controls
- left = self.Frame(mid)
- left.pack(side="left", fill="both", expand=True)
- # Search
- sframe = self.Frame(left)
- sframe.pack(fill="x")
- self.Label(sframe, text="Search:").pack(side="left", padx=4)
- self.search_var = StringVar()
- self.search_entry = self.Entry(sframe, textvariable=self.search_var, width=40)
- self.search_entry.pack(side="left", padx=4)
- self.search_var.trace_add("write", lambda *a: self.apply_filter())
- # Control buttons
- ctrl = self.Frame(left)
- ctrl.pack(fill="x", pady=4)
- self.Button(ctrl, text="Select All", command=self.select_all).pack(side="left", padx=3)
- self.Button(ctrl, text="Unselect All", command=self.unselect_all).pack(side="left", padx=3)
- self.Button(ctrl, text="Zip Selected", command=self.zip_selected).pack(side="left", padx=3)
- self.Button(ctrl, text="Export List", command=self.export_list_dialog).pack(side="left", padx=3)
- self.Button(ctrl, text="Find Duplicates", command=self.find_duplicates_thread).pack(side="left", padx=3)
- # Canvas area with both scrollbars
- canvas_container = self.Frame(left)
- canvas_container.pack(fill="both", expand=True)
- # Horizontal and vertical scrollbars
- self.h_scroll = tk.Scrollbar(canvas_container, orient="horizontal")
- self.v_scroll = tk.Scrollbar(canvas_container, orient="vertical")
- self.canvas = tk.Canvas(canvas_container, bd=0, highlightthickness=0,
- xscrollcommand=self.h_scroll.set, yscrollcommand=self.v_scroll.set)
- self.h_scroll.config(command=self.canvas.xview)
- self.v_scroll.config(command=self.canvas.yview)
- self.h_scroll.pack(side="bottom", fill="x")
- self.v_scroll.pack(side="right", fill="y")
- self.canvas.pack(side="left", fill="both", expand=True)
- # frame inside canvas
- if THEME_BACKEND == "ttkbootstrap":
- self.list_frame = tb.Frame(self.canvas)
- else:
- self.list_frame = tk.Frame(self.canvas)
- self.canvas_window = self.canvas.create_window((0,0), window=self.list_frame, anchor="nw")
- self.list_frame.bind("<Configure>", lambda e: self._on_list_frame_configure())
- self.canvas.bind('<Configure>', lambda e: self._on_canvas_configure())
- # Right: preview + info + actions
- right = self.Frame(mid, width=360)
- right.pack(side="right", fill="y", padx=(8,0))
- # Preview area (image OR text)
- preview_box = self.LabelFrame(right, text="Preview", width=360, height=360)
- preview_box.pack(pady=4)
- preview_box.pack_propagate(False)
- self.preview_inner = tk.Frame(preview_box)
- self.preview_inner.pack(fill="both", expand=True)
- # Image label (for images/video thumbnail)
- self.preview_label = tk.Label(self.preview_inner, text="Select a file to preview", anchor="center", justify="center", wraplength=320)
- self.preview_label.pack(fill="both", expand=True)
- # Text preview (hidden by default)
- self.preview_textbox = tk.Text(self.preview_inner, wrap="word", state="disabled")
- # do not pack yet; pack when needed
- # Info area
- info_box = self.LabelFrame(right, text="File Info")
- info_box.pack(fill="x", pady=6)
- self.info_text = tk.Text(info_box, height=8, width=44, wrap="word")
- self.info_text.pack(fill="both", expand=True)
- # Actions
- actions = self.Frame(right)
- actions.pack(fill="x", pady=6)
- self.Button(actions, text="Delete Selected", command=self.delete_selected).pack(fill="x", pady=4)
- self.Button(actions, text="Move Selected", command=self.move_selected).pack(fill="x", pady=4)
- self.Button(actions, text="Play Selected (Video)", command=self.play_selected).pack(fill="x", pady=4)
- # Bottom status & progress
- bottom = self.Frame(self.container)
- bottom.pack(fill="x", padx=8, pady=6)
- self.status_var = StringVar(value="Idle")
- self.Label(bottom, textvariable=self.status_var).pack(side="left")
- self.progress = self.Progressbar(bottom, mode="determinate", maximum=100)
- self.progress.pack(side="right", fill="x", expand=True)
- # ---------------- Canvas helpers ----------------
- def _on_list_frame_configure(self):
- self.canvas.configure(scrollregion=self.canvas.bbox("all"))
- def _on_canvas_configure(self):
- # keep width of inner frame to canvas width (helps horizontal)
- canvas_width = self.canvas.winfo_width()
- self.canvas.itemconfig(self.canvas_window, width=canvas_width)
- # ---------------- Utility ----------------
- def browse_drive(self):
- p = filedialog.askdirectory()
- if p:
- self.drive_entry.delete(0, "end")
- self.drive_entry.insert(0, p)
- def toggle_theme(self):
- if THEME_BACKEND != "ttkbootstrap":
- messagebox.showinfo("Not available", "Theme toggle requires 'ttkbootstrap'. Install via: pip install ttkbootstrap")
- return
- current = self.style.theme.name
- new = "flatly" if "dark" in current else "darkly"
- self.style.theme_use(new)
- def _periodic_process_queue(self):
- # called via after to process messages from background threads
- try:
- while True:
- func, args = self.queue.get_nowait()
- try:
- func(*args)
- except Exception as e:
- print("Queue callback error:", e)
- except Empty:
- pass
- self.root.after(100, self._periodic_process_queue)
- def _safe_ui_call(self, func, *args):
- # background threads should use this to schedule UI updates
- self.queue.put((func, args))
- # ---------------- Scanning ----------------
- def start_scan(self):
- if self.op_lock.locked():
- messagebox.showwarning("Busy", "Another operation is in progress. Wait or cancel.")
- return
- path = self.drive_entry.get().strip()
- if not path or not os.path.exists(path):
- messagebox.showerror("Error", "Select a valid drive/folder path.")
- return
- self.scan_btn.config(state="disabled")
- self._clear_list_ui()
- t = threading.Thread(target=self.scan_files_thread, args=(path,), daemon=True)
- t.start()
- def scan_files_thread(self, root_path):
- with self.op_lock:
- self._safe_ui_call(self._set_status, "Scanning...")
- self._safe_ui_call(self._set_progress_mode_indeterminate)
- exts = normalize_ext_list(self.ext_entry.get().strip())
- include_hidden = bool(self.include_hidden.get())
- found = []
- count = 0
- # Walk and add incrementally to UI for fast results
- for dirpath, dirnames, filenames in os.walk(root_path):
- # filter hidden dirs if requested
- if not include_hidden:
- dirnames[:] = [d for d in dirnames if not d.startswith(".")]
- for f in filenames:
- full = os.path.join(dirpath, f)
- try:
- if not include_hidden:
- if os.name == "nt":
- try:
- import ctypes
- attrs = ctypes.windll.kernel32.GetFileAttributesW(full)
- if attrs != -1 and bool(attrs & 2): # hidden
- continue
- except Exception:
- pass
- if f.startswith("."):
- continue
- if not exts or Path(full).suffix.lower() in exts:
- size = os.path.getsize(full)
- item = {"path": full, "size": size, "var": IntVar(value=0), "md5": None}
- found.append(item)
- count += 1
- # update UI every 50 found to keep responsive
- if count % 50 == 0:
- self._safe_ui_call(self._extend_file_items, found.copy())
- found.clear()
- except Exception:
- continue
- # push remaining
- if found:
- self._safe_ui_call(self._extend_file_items, found.copy())
- # finalize
- self._safe_ui_call(self._set_status, f"Scan complete: {len(self.file_items)} files")
- self._safe_ui_call(self._set_progress_value, 0)
- self._safe_ui_call(self.scan_btn.config, {'state': 'normal'})
- def _extend_file_items(self, new_items):
- # Called in UI thread via queue
- for it in new_items:
- self.file_items.append(it)
- self.filtered = list(self.file_items)
- self._populate_list_ui()
- def _clear_list_ui(self):
- for w in self.list_frame.winfo_children():
- w.destroy()
- self.file_items.clear()
- self.filtered.clear()
- self.image_cache.clear()
- self._update_status_line()
- def _populate_list_ui(self):
- for widget in self.list_frame.winfo_children():
- widget.destroy()
- for idx, item in enumerate(self.filtered):
- frame = tk.Frame(self.list_frame)
- frame.pack(fill="x", padx=2, pady=1)
- cb = tk.Checkbutton(frame, text=os.path.basename(item["path"]) + " — " + format_size(item["size"]),
- variable=item["var"], anchor="w", justify="left", wraplength=700)
- cb.pack(side="left", anchor="w", fill="x", expand=True)
- btn = self.Button(frame, text="Preview", width=9, command=lambda p=item["path"]: self.preview_file(p))
- btn.pack(side="right", padx=6)
- self._on_list_frame_configure()
- self._update_status_line()
- def apply_filter(self):
- q = self.search_var.get().lower().strip()
- if not q:
- self.filtered = list(self.file_items)
- else:
- self.filtered = [it for it in self.file_items if q in it["path"].lower() or q in os.path.basename(it["path"]).lower()]
- self._populate_list_ui()
- def select_all(self):
- for it in self.filtered:
- it["var"].set(1)
- self._update_status_line()
- def unselect_all(self):
- for it in self.filtered:
- it["var"].set(0)
- self._update_status_line()
- def get_selected_items(self):
- return [it for it in self.file_items if it["var"].get()]
- # ---------------- Status / Progress helpers ----------------
- def _set_status(self, text):
- self.status_var.set(text)
- self._update_status_line()
- def _update_status_line(self):
- total = len(self.file_items)
- selected = len(self.get_selected_items())
- size_total = sum(it["size"] for it in self.file_items)
- self.status_var.set(f"Total: {total} | Selected: {selected} | Size: {format_size(size_total)}")
- def _set_progress_mode_indeterminate(self):
- try:
- self.progress.config(mode="indeterminate")
- self.progress.start(10)
- except Exception:
- pass
- def _set_progress_mode_determinate(self, maximum=100):
- try:
- self.progress.stop()
- self.progress.config(mode="determinate", maximum=maximum)
- self.progress["value"] = 0
- except Exception:
- pass
- def _set_progress_value(self, val):
- try:
- self.progress.config(mode="determinate")
- self.progress["value"] = val
- except Exception:
- pass
- # ---------------- Preview (image/video/text) ----------------
- def preview_file(self, path):
- # schedule preview in background to avoid blocking (especially reading large files)
- threading.Thread(target=self._preview_file_thread, args=(path,), daemon=True).start()
- def _preview_file_thread(self, path):
- # send file info first
- try:
- size = os.path.getsize(path)
- mtime = time.ctime(os.path.getmtime(path))
- info = f"Path: {path}\nSize: {format_size(size)}\nExtension: {Path(path).suffix}\nModified: {mtime}\n"
- except Exception as e:
- info = f"Error reading file info: {e}\n"
- self._safe_ui_call(self._show_info_text, info)
- ext = Path(path).suffix.lower()
- # image preview
- if ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp"):
- def load_image():
- try:
- img = Image.open(path)
- img.thumbnail((340, 340))
- img_tk = ImageTk.PhotoImage(img)
- # keep reference
- self.image_cache["preview"] = img_tk
- self._safe_ui_call(self._show_image_preview, img_tk)
- except UnidentifiedImageError:
- self._safe_ui_call(self._show_text_preview_message, "Image preview failed (unsupported format).")
- except Exception as e:
- self._safe_ui_call(self._show_text_preview_message, f"Image preview error: {e}")
- threading.Thread(target=load_image, daemon=True).start()
- return
- # text preview
- if ext in (".txt", ".log", ".ini", ".cfg", ".py", ".json", ".csv"):
- def load_text():
- try:
- with open(path, "r", encoding="utf-8", errors="ignore") as f:
- data = f.read(1024 * 200) # first 200 KB
- if not data:
- data = "[empty file]"
- self._safe_ui_call(self._show_text_preview, data)
- except Exception as e:
- self._safe_ui_call(self._show_text_preview_message, f"Text preview error: {e}")
- threading.Thread(target=load_text, daemon=True).start()
- return
- # video preview - try ffmpeg thumbnail
- if is_video_by_ext(path):
- if ffmpeg_available():
- tmp_dir = tempfile.gettempdir()
- thumb_path = os.path.join(tmp_dir, f"thumb_{abs(hash(path)) % 1000000}.jpg")
- cmd = [
- "ffmpeg", "-y", "-i", path,
- "-vf", "select=eq(n\\,0)", "-frames:v", "1", "-q:v", "2", thumb_path
- ]
- # run and then load
- try:
- subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20)
- if os.path.exists(thumb_path):
- try:
- img = Image.open(thumb_path)
- img.thumbnail((340, 340))
- img_tk = ImageTk.PhotoImage(img)
- self.image_cache["preview"] = img_tk
- self._safe_ui_call(self._show_image_preview, img_tk)
- # remove thumb file after some time
- # schedule deletion
- def clean():
- try:
- os.remove(thumb_path)
- except Exception:
- pass
- threading.Timer(15.0, clean).start()
- return
- except Exception:
- pass
- except Exception:
- pass
- # fallback - no ffmpeg or failed
- self._safe_ui_call(self._show_text_preview_message, "Video preview not available (install ffmpeg for thumbnails).")
- return
- # unknown type
- self._safe_ui_call(self._show_text_preview_message, "Preview not available for this file type.")
- def _show_info_text(self, txt):
- self.info_text.delete("1.0", "end")
- self.info_text.insert("end", txt)
- def _show_image_preview(self, img_tk):
- # hide text preview if visible
- try:
- self.preview_textbox.pack_forget()
- except Exception:
- pass
- self.preview_label.config(image=img_tk, text="")
- self.preview_label.image = img_tk
- def _show_text_preview(self, text):
- # hide image label content
- self.preview_label.config(image="", text="")
- # show text box
- try:
- self.preview_textbox.pack(fill="both", expand=True)
- except Exception:
- pass
- self.preview_textbox.config(state="normal")
- self.preview_textbox.delete("1.0", "end")
- self.preview_textbox.insert("end", text)
- self.preview_textbox.config(state="disabled")
- def _show_text_preview_message(self, msg):
- try:
- self.preview_textbox.pack_forget()
- except Exception:
- pass
- self.preview_label.config(image="", text=msg)
- # ---------------- Play Selected (open default player) ----------------
- def play_selected(self):
- sel = self.get_selected_items()
- if not sel:
- messagebox.showwarning("No selection", "Select at least one file to play (video).")
- return
- for it in sel:
- p = it["path"]
- if is_video_by_ext(p):
- try:
- if sys.platform.startswith("win"):
- os.startfile(p)
- elif sys.platform.startswith("darwin"):
- subprocess.call(["open", p])
- else:
- subprocess.call(["xdg-open", p])
- return
- except Exception as e:
- messagebox.showerror("Error", f"Could not open file: {e}")
- return
- messagebox.showinfo("No video found", "Selected files do not include a recognized video file.")
- # ---------------- Zip / Export / Delete / Move ----------------
- def zip_selected(self):
- sel = self.get_selected_items()
- if not sel:
- messagebox.showwarning("No files", "No files selected to zip.")
- return
- save_path = filedialog.asksaveasfilename(defaultextension=".zip", filetypes=[("Zip files", "*.zip")])
- if not save_path:
- return
- t = threading.Thread(target=self._zip_thread, args=(sel, save_path), daemon=True)
- t.start()
- def _zip_thread(self, items, zip_path):
- with self.op_lock:
- self._safe_ui_call(self._set_status, "Zipping...")
- self._safe_ui_call(self._set_progress_mode_determinate, len(items))
- try:
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
- for idx, it in enumerate(items, 1):
- z.write(it["path"], arcname=os.path.basename(it["path"]))
- self._safe_ui_call(self._set_progress_value, idx)
- self._safe_ui_call(self._set_status, f"Zip saved: {zip_path}")
- self._safe_ui_call(messagebox.showinfo, "Done", f"Zip saved: {zip_path}")
- except Exception as e:
- self._safe_ui_call(messagebox.showerror, "Error", f"Failed to create zip: {e}")
- finally:
- self._safe_ui_call(self._set_progress_value, 0)
- self._safe_ui_call(self._update_status_line)
- def export_list_dialog(self):
- if not self.file_items:
- messagebox.showwarning("Empty", "No files to export.")
- return
- typ = messagebox.askquestion("Export CSV?", "Export to CSV? (Yes => CSV, No => TXT)")
- if typ == "yes":
- self.export_csv()
- else:
- self.export_txt()
- def export_txt(self):
- path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt")])
- if not path:
- return
- try:
- with open(path, "w", encoding="utf-8") as f:
- for it in self.file_items:
- f.write(f"{it['path']}\t{it['size']}\n")
- messagebox.showinfo("Exported", f"List exported to {path}")
- except Exception as e:
- messagebox.showerror("Error", str(e))
- def export_csv(self):
- path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv")])
- if not path:
- return
- try:
- with open(path, "w", newline="", encoding="utf-8") as csvfile:
- writer = csv.writer(csvfile)
- writer.writerow(["path", "size_bytes"])
- for it in self.file_items:
- writer.writerow([it["path"], it["size"]])
- messagebox.showinfo("Exported", f"CSV exported to {path}")
- except Exception as e:
- messagebox.showerror("Error", str(e))
- def delete_selected(self):
- sel = self.get_selected_items()
- if not sel:
- messagebox.showwarning("No selection", "Select files to delete.")
- return
- ok = messagebox.askyesno("Confirm Delete", f"Delete {len(sel)} files permanently? This cannot be undone.")
- if not ok:
- return
- t = threading.Thread(target=self._delete_thread, args=(sel,), daemon=True)
- t.start()
- def _delete_thread(self, items):
- with self.op_lock:
- self._safe_ui_call(self._set_status, "Deleting...")
- self._safe_ui_call(self._set_progress_mode_determinate, len(items))
- failures = []
- for idx, it in enumerate(items, 1):
- try:
- os.remove(it["path"])
- except Exception as e:
- failures.append((it["path"], str(e)))
- self._safe_ui_call(self._set_progress_value, idx)
- # remove successfully deleted from file_items
- self.file_items = [it for it in self.file_items if not (it in items and not os.path.exists(it["path"]))]
- self._safe_ui_call(self.apply_filter)
- if failures:
- self._safe_ui_call(messagebox.showwarning, "Some failed", f"{len(failures)} files failed to delete.")
- else:
- self._safe_ui_call(messagebox.showinfo, "Deleted", "Selected files deleted.")
- self._safe_ui_call(self._set_progress_value, 0)
- self._safe_ui_call(self._update_status_line)
- def move_selected(self):
- sel = self.get_selected_items()
- if not sel:
- messagebox.showwarning("No selection", "Select files to move.")
- return
- dest = filedialog.askdirectory()
- if not dest:
- return
- t = threading.Thread(target=self._move_thread, args=(sel, dest), daemon=True)
- t.start()
- def _move_thread(self, items, dest):
- with self.op_lock:
- self._safe_ui_call(self._set_status, "Moving files...")
- self._safe_ui_call(self._set_progress_mode_determinate, len(items))
- failures = []
- for idx, it in enumerate(items, 1):
- try:
- shutil.move(it["path"], dest)
- except Exception as e:
- failures.append((it["path"], str(e)))
- self._safe_ui_call(self._set_progress_value, idx)
- # remove moved ones from list
- self.file_items = [it for it in self.file_items if not it["var"].get()]
- self._safe_ui_call(self.apply_filter)
- if failures:
- self._safe_ui_call(messagebox.showwarning, "Some failed", f"{len(failures)} failed to move.")
- else:
- self._safe_ui_call(messagebox.showinfo, "Moved", f"Moved {len(items)} files to {dest}")
- self._safe_ui_call(self._set_progress_value, 0)
- self._safe_ui_call(self._update_status_line)
- # ---------------- Duplicate Detection ----------------
- def find_duplicates_thread(self):
- if not self.file_items:
- messagebox.showwarning("Empty", "No files to check.")
- return
- t = threading.Thread(target=self.find_duplicates, daemon=True)
- t.start()
- def find_duplicates(self):
- with self.op_lock:
- self._safe_ui_call(self._set_status, "Finding duplicates (hashing)...")
- paths = [it["path"] for it in self.file_items]
- total = len(paths)
- if total == 0:
- self._safe_ui_call(self._set_status, "No files to check.")
- return
- self._safe_ui_call(self._set_progress_mode_determinate, total)
- hash_map = {}
- for idx, p in enumerate(paths, 1):
- h = md5_for_file(p)
- if h is None:
- continue
- hash_map.setdefault(h, []).append(p)
- self._safe_ui_call(self._set_progress_value, idx)
- self._safe_ui_call(self._set_status, f"Hashing... {idx}/{total}")
- groups = [v for v in hash_map.values() if len(v) > 1]
- if not groups:
- self._safe_ui_call(messagebox.showinfo, "Duplicates", "No duplicate files found.")
- self._safe_ui_call(self._set_status, "No duplicates found.")
- self._safe_ui_call(self._set_progress_value, 0)
- return
- # present groups window (UI thread)
- out = "Duplicate groups found:\n\n"
- for gi, g in enumerate(groups, 1):
- out += f"Group {gi} ({len(g)} files):\n"
- for p in g:
- out += f" {p}\n"
- out += "\n"
- def show_and_mark():
- win = tk.Toplevel(self.root)
- win.title("Duplicate Groups")
- txt = tk.Text(win, width=100, height=30)
- txt.pack(fill="both", expand=True)
- txt.insert("1.0", out)
- def mark_all_duplicates():
- for g in groups:
- for p in g[1:]:
- for it in self.file_items:
- if it["path"] == p:
- it["var"].set(1)
- messagebox.showinfo("Marked", "Marked duplicate files (except first of each group).")
- win.destroy()
- self.apply_filter()
- b = self.Button(win, text="Mark duplicates (select for deletion/move/zip)", command=mark_all_duplicates)
- b.pack(pady=6)
- self._safe_ui_call(show_and_mark)
- self._safe_ui_call(self._set_progress_value, 0)
- self._safe_ui_call(self._set_status, f"Found {len(groups)} duplicate groups.")
- # ---------------- Run ----------------
- def main():
- root = None
- if THEME_BACKEND == "ttkbootstrap":
- root = tb.Window(themename="darkly")
- else:
- root = tk.Tk()
- app = FileManagerApp(root)
- root.mainloop()
- if __name__ == "__main__":
- main()
Add Comment
Please, Sign In to add comment