import os import shutil import datetime import asyncio import aiofiles from collections import defaultdict import tkinter as tk from tkinter import filedialog, ttk, messagebox import threading import time class AdvancedFileOrganizer: def __init__(self, master): self.master = master self.master.title("Advanced File Organizer") self.master.geometry("800x600") self.search_dir = tk.StringVar() self.output_dir = tk.StringVar() self.dry_run = tk.BooleanVar(value=True) self.log_text = tk.StringVar() self.create_widgets() self.extension_mapping = { # Existing mappings '.jpg': 'Images', '.jpeg': 'Images', '.png': 'Images', '.gif': 'Images', '.bmp': 'Images', '.tiff': 'Images', '.eps': 'Images', '.psd': 'Images', '.webp': 'Images', '.svg': 'Images', '.ico': 'Images', '.heic': 'Images', '.raw': 'Images', '.psd': 'Images', '.xcf': 'Images', '.lnk': 'Shortcuts', '.pdf': 'Documents', '.txt': 'Documents', '.md': 'Documents', '.rtf': 'Documents', '.doc': 'Documents', '.docx': 'Documents', '.odt': 'Documents', '.csv': 'Spreadsheets', '.xlsx': 'Spreadsheets', '.xls': 'Spreadsheets', '.ods': 'Spreadsheets', '.wav': 'Audio', '.mp3': 'Audio', '.aiff': 'Audio', '.aac': 'Audio', '.wma': 'Audio', '.flac': 'Audio', '.mid': 'Audio', '.midi': 'Audio', '.m4a': 'Audio', '.ogg': 'Audio', '.m4p': 'Audio', '.mp4': 'Videos', '.avi': 'Videos', '.mov': 'Videos', '.wmv': 'Videos', '.flv': 'Videos', '.mkv': 'Videos', '.webm': 'Videos', '.mpg': 'Videos', '.mpeg': 'Videos', '.aep': 'Videos', '.prproj': 'Videos', '.stl': '3D Prints', '.ply': '3D Prints', '.3mf': '3D Prints', '.gcode': '3D Prints', '.obj': '3D Prints', '.ttc': 'Fonts', '.otf': 'Fonts', '.eot': 'Fonts', '.fon': 'Fonts', '.ttf': 'Fonts', '.woff': 'Fonts', '.woff2': 'Fonts', '.zip': 'Compressed', '.rar': 'Compressed', '.7z': 'Compressed', '.tar': 'Compressed', '.gz': 'Compressed', '.exe': 'Executables', '.msi': 'Executables', '.app': 'Executables', '.py': 'Code', '.java': 'Code', '.cpp': 'Code', '.h': 'Code', '.js': 'Code', '.html': 'Code', '.css': 'Code', '.php': 'Code', '.sh': 'Code', '.bat': 'Code', '.ppt': 'Presentations', '.pptx': 'Presentations', '.key': 'Presentations', '.odp': 'Presentations', '.epub': 'eBooks', '.mobi': 'eBooks', '.azw': 'eBooks', '.db': 'Databases', '.sqlite': 'Databases', '.sql': 'Databases', '.vdi': 'Virtual Machines', '.vmdk': 'Virtual Machines', '.ova': 'Virtual Machines', '.indd': 'Design', '.qxd': 'Design', '.ai': 'Design', '.cdr': 'Design', '.dwg': 'Design', '.dxf': 'Design', '.iso': 'Disk Images', '.dmg': 'Disk Images', '.json': 'Data Files', '.xml': 'Data Files', '.yaml': 'Data Files', # New mappings '.step': 'Design', '.stp': 'Design', '.iges': 'Design', '.igs': 'Design', '.eps': 'Design', '.svg': 'Design', '.bak': 'Backups', '.old': 'Backups', '.tmp': 'Backups', '.ini': 'ConfigFiles', '.cfg': 'ConfigFiles', '.conf': 'ConfigFiles', '.config': 'ConfigFiles', '.ps1': 'Code', '.vbs': 'Code', '.bash': 'Code', '.log': 'Logs', '.syslog': 'Logs' } def create_widgets(self): # Search directory selection ttk.Label(self.master, text="Search Directory:").grid(row=0, column=0, padx=5, pady=5, sticky="w") ttk.Entry(self.master, textvariable=self.search_dir, width=50).grid(row=0, column=1, padx=5, pady=5) ttk.Button(self.master, text="Browse", command=self.browse_search_dir).grid(row=0, column=2, padx=5, pady=5) # Output directory selection ttk.Label(self.master, text="Output Directory:").grid(row=1, column=0, padx=5, pady=5, sticky="w") ttk.Entry(self.master, textvariable=self.output_dir, width=50).grid(row=1, column=1, padx=5, pady=5) ttk.Button(self.master, text="Browse", command=self.browse_output_dir).grid(row=1, column=2, padx=5, pady=5) # Dry run checkbox ttk.Checkbutton(self.master, text="Dry Run", variable=self.dry_run).grid(row=2, column=0, padx=5, pady=5, sticky="w") # Run button ttk.Button(self.master, text="Run", command=self.run_organizer).grid(row=2, column=1, padx=5, pady=5) # Log display self.log_display = tk.Text(self.master, wrap=tk.WORD, width=80, height=20) self.log_display.grid(row=3, column=0, columnspan=3, padx=5, pady=5) # Scrollbar for log display scrollbar = ttk.Scrollbar(self.master, orient="vertical", command=self.log_display.yview) scrollbar.grid(row=3, column=3, sticky="ns") self.log_display.configure(yscrollcommand=scrollbar.set) # Commit changes button (initially disabled) self.commit_button = ttk.Button(self.master, text="Commit Changes", command=self.run_commit_changes, state="disabled") self.commit_button.grid(row=4, column=1, padx=5, pady=5) # Progress bar self.progress_var = tk.DoubleVar() self.progress_bar = ttk.Progressbar(self.master, variable=self.progress_var, maximum=100) self.progress_bar.grid(row=5, column=0, columnspan=3, padx=5, pady=5, sticky="ew") def browse_search_dir(self): directory = filedialog.askdirectory() self.search_dir.set(directory) def browse_output_dir(self): directory = filedialog.askdirectory() self.output_dir.set(directory) def run_organizer(self): self.log_display.delete(1.0, tk.END) self.commit_button.config(state="disabled") if not self.search_dir.get() or not self.output_dir.get(): self.log("Please select both search and output directories.") return # Run the organizer in a separate thread to keep the UI responsive threading.Thread(target=self._run_organizer_thread, daemon=True).start() def _run_organizer_thread(self): search_directory = self.search_dir.get() output_directory = self.output_dir.get() is_dry_run = self.dry_run.get() date_code = datetime.datetime.now().strftime("%m%d%Y") self.log(f"Searching directory: {search_directory}") self.log(f"Output directory: {output_directory}") self.log(f"Dry run: {'Yes' if is_dry_run else 'No'}") categorized_files = defaultdict(list) # Scan the directory and categorize files total_files = 0 for root, _, files in os.walk(search_directory): for file in files: total_files += 1 file_path = os.path.join(root, file) _, ext = os.path.splitext(file) category = self.extension_mapping.get(ext.lower(), 'Misc') categorized_files[category].append(file_path) # Process files self.changes = [] processed_files = 0 for category, files in categorized_files.items(): if files: target_folder = os.path.join(output_directory, f'{category}_{date_code}') self.log(f"Category: {category}") for file_path in files: target_path = os.path.join(target_folder, os.path.basename(file_path)) self.changes.append((file_path, target_path)) # Extract the filename from the file path filename = os.path.basename(file_path) # Extract the target folder name from the target path target_folder_name = os.path.basename(target_folder) # Format the log message with aligned filenames and target folders self.log(f" {filename:<30} → {target_folder_name}") processed_files += 1 self.update_progress(processed_files, total_files) if not is_dry_run: self.run_commit_changes() else: self.log("\nDry run completed. Review the log and click 'Commit Changes' to apply.") self.master.after(0, lambda: self.commit_button.config(state="normal")) def update_progress(self, current, total): progress = (current / total) * 100 self.progress_var.set(progress) self.master.update_idletasks() def run_commit_changes(self): threading.Thread(target=self._commit_changes_thread, daemon=True).start() def _commit_changes_thread(self): asyncio.run(self.commit_changes()) async def commit_changes(self): if not self.changes: self.log("No changes to commit.") return total_files = len(self.changes) self.progress_var.set(0) self.progress_bar["maximum"] = total_files async def process_batch(batch): tasks = [self.async_move_file(source, target) for source, target in batch] results = await asyncio.gather(*tasks) for result in results: self.log(result) self.progress_var.set(self.progress_var.get() + 1) self.master.update_idletasks() # Process files in batches of 10 batch_size = 10 for i in range(0, len(self.changes), batch_size): batch = self.changes[i:i+batch_size] await process_batch(batch) self.log("All changes committed.") self.changes = [] self.commit_button.config(state="disabled") async def async_move_file(self, source, target): try: # Create target directory if it doesn't exist os.makedirs(os.path.dirname(target), exist_ok=True) # Check if the file already exists in the target location if os.path.exists(target): base, extension = os.path.splitext(target) counter = 1 while os.path.exists(f"{base}_{counter}{extension}"): counter += 1 target = f"{base}_{counter}{extension}" # Check file size file_size = os.path.getsize(source) if file_size > 100 * 1024 * 1024: # 100 MB # For large files, use shutil.move with a callback for progress await asyncio.to_thread(self.move_large_file, source, target, file_size) else: # For smaller files, use aiofiles async with aiofiles.open(source, 'rb') as src, aiofiles.open(target, 'wb') as dst: await dst.write(await src.read()) await asyncio.to_thread(os.remove, source) return f"Moved: '{source}' to '{target}'" except PermissionError: return f"Permission denied: Unable to move '{source}' to '{target}'" except FileNotFoundError: return f"File not found: '{source}'" except OSError as e: if "on a different device" in str(e): # File is on a different device (e.g., network drive), use shutil.copy2 and then remove original await asyncio.to_thread(shutil.copy2, source, target) await asyncio.to_thread(os.remove, source) return f"Copied and removed: '{source}' to '{target}' (different device)" else: return f"Error moving '{source}' to '{target}': {str(e)}" except Exception as e: return f"Unexpected error moving '{source}' to '{target}': {str(e)}" def move_large_file(self, source, target, total_size): with open(source, 'rb') as src, open(target, 'wb') as dst: copied = 0 while True: buf = src.read(8388608) # 8MB chunks if not buf: break dst.write(buf) copied += len(buf) progress = (copied / total_size) * 100 self.master.after(0, lambda: self.progress_var.set(progress)) os.remove(source) def log(self, message): self.log_display.insert(tk.END, message + "\n") self.log_display.see(tk.END) if __name__ == "__main__": root = tk.Tk() app = AdvancedFileOrganizer(root) root.mainloop() Buy me a coffee Don’t be shy; if you found this useful, say Hi!