diff --git a/crop_image.py b/crop_image.py new file mode 100644 index 0000000..777b2c3 --- /dev/null +++ b/crop_image.py @@ -0,0 +1,368 @@ +""" +######################################## +# # +# Crop Image UI # +# # +# Version : v1.00 # +# Author : github.com/Nenotriple # +# # +######################################## + +Description: +------------- +Crop an image to a square. + +More info here: https://github.com/Nenotriple/img-txt_viewer + +""" + +#endregion +################################################################################################################################################ +#region - Imports + + +import os +import sys +import tkinter as tk +from tkinter import * +from tkinter import messagebox +from PIL import Image, ImageTk + + +#endregion +################################################################################################################################################ +#region - Class Setup + + +class Crop: + def __init__(self, root, filepath): + self.root = root + self.top = Toplevel(self.root) + self.image_path = filepath + self.rect = None + self.state = None + self.start_x = None + self.start_y = None + self.rect_center = None + self.prompt_small_crop_var = True + self.rect_size = 0 + self.free_crop_var = BooleanVar(value=False) + self.set_icon() + self.create_image() + self.create_canvas() + self.create_context_menu() + self.setup_window() + + + self.top.bind('', self.crop_image) + + +#endregion +################################################################################################################################################ +#region - Interface Setup + + + def set_icon(self): + if getattr(sys, 'frozen', False): + application_path = sys._MEIPASS + elif __file__: + application_path = os.path.dirname(__file__) + icon_path = os.path.join(application_path, "icon.ico") + try: + self.top.iconbitmap(icon_path) + except TclError: pass + + + def create_image(self): + self.original_image = Image.open(self.image_path) + self.image = self.original_image.copy() + self.max_size = (1024, 1024) + self.scale_factor = max(1, max([round(original_dim / max_dim) for original_dim, max_dim in zip(self.original_image.size, self.max_size)])) + self.image.thumbnail((self.original_image.size[0] // self.scale_factor, self.original_image.size[1] // self.scale_factor), Image.LANCZOS) + self.tk_image = ImageTk.PhotoImage(self.image) + + + def create_canvas(self): + self.canvas = tk.Canvas(self.top, width=self.tk_image.width(), height=self.tk_image.height()) + self.canvas.pack() + self.canvas.create_image(0, 0, anchor='nw', image=self.tk_image) + self.canvas.bind("", self.on_button_press) + self.canvas.bind("", self.on_move_press) + self.canvas.bind("", self.on_double_click) + self.canvas.bind("", self.on_mouse_wheel) + + + def create_context_menu(self, event=None): + def show_menu(event): + self.context_menu.tk_popup(event.x_root, event.y_root) + self.canvas.bind("", show_menu) + self.context_menu = tk.Menu(self.top, tearoff=0) + self.context_menu.add_command(label="Crop", accelerator="(Spacebar)", command=self.crop_image) + self.context_menu.add_separator() + self.context_menu.add_command(label="Clear", command=self.destroy_rectangle) + self.context_menu.add_separator() + self.context_menu.add_command(label="Open Directory...", command=lambda: self.open_directory(os.path.dirname(self.image_path))) + self.context_menu.add_checkbutton(label="Freeform Crop", variable=self.free_crop_var) + + + def setup_window(self): + self.top.resizable(False, False) + self.top.update_idletasks() + x = (self.top.winfo_screenwidth() - self.tk_image.width()) / 2 + y = (self.top.winfo_screenheight() - self.tk_image.height()) / 2 + self.top.geometry("+%d+%d" % (x, y)) + self.top.title(f"Crop Image | {os.path.basename(self.image_path)} | (0 x 0)") + + + def update_title(self): + if self.rect: + rect_coords = self.canvas.coords(self.rect) + x_scale = self.original_image.width / self.tk_image.width() + y_scale = self.original_image.height / self.tk_image.height() + width = abs(rect_coords[2] - rect_coords[0]) * x_scale + height = abs(rect_coords[3] - rect_coords[1]) * y_scale + if self.free_crop_var: + self.rect_size = (width, height) + self.top.title(f"Crop Image | {os.path.basename(self.image_path)} | ({int(width)} x {int(height)})") + else: + self.rect_size = min(width, height) + self.top.title(f"Crop Image | {os.path.basename(self.image_path)} | ({int(self.rect_size)} x {int(self.rect_size)})") + + +#endregion +################################################################################################################################################ +#region - Handle rectangle creation and positioning + + +####### Input setup ################################################## + + + def on_button_press(self, event): + if self.rect and self.is_within_rectangle(event.x, event.y): + self.start_dragging(event) + else: + self.start_drawing(event) + + + def on_move_press(self, event): + if self.state == "is_dragging": + self.drag(event) + elif self.state == "is_drawing": + self.draw(event) + self.update_title() + + + def on_double_click(self, event): + self.create_rectangle_based_on_image_size() + + + def is_within_rectangle(self, x, y): + rect_coords = self.canvas.coords(self.rect) + return rect_coords[0] < x < rect_coords[2] and rect_coords[1] < y < rect_coords[3] + + + def on_mouse_wheel(self, event): + if self.rect and self.is_within_rectangle(event.x, event.y): + rect_coords = self.canvas.coords(self.rect) + if self.scale_factor == 1: + new_size = max(512, rect_coords[2] - rect_coords[0] + event.delta//15) + else: + new_size = max(256, rect_coords[2] - rect_coords[0] + event.delta//15) + center_x = (rect_coords[2] + rect_coords[0]) // 2 + center_y = (rect_coords[3] + rect_coords[1]) // 2 + new_coords = [center_x - new_size // 2, center_y - new_size // 2, center_x + new_size // 2, center_y + new_size // 2] + self.resize_rectangle(new_coords, new_size, center_x, center_y) + + +####### Drawing ################################################## + + + def start_drawing(self, event): + self.destroy_rectangle() + self.state = "is_drawing" + self.start_x = event.x + self.start_y = event.y + + + def draw(self, event): + if not self.rect: + self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, 1, 1, outline='red', width=4) + self.canvas.itemconfig(self.rect, fill='black', stipple='gray12') + else: + cur_width = event.x - self.start_x + cur_height = event.y - self.start_y + if self.free_crop_var.get() == True: + curX = min(max(0, self.start_x + cur_width), self.tk_image.width()) + curY = min(max(0, self.start_y + cur_height), self.tk_image.height()) + else: + cur_size = min(abs(cur_width), abs(cur_height)) + cur_size = self.snap_to_points(cur_size) + curX = min(max(0, self.start_x + (cur_size if cur_width >= 0 else -cur_size)), self.tk_image.width()) + curY = min(max(0, self.start_y + (cur_size if cur_height >= 0 else -cur_size)), self.tk_image.height()) + if curX <= 0 or curY <= 0 or curX >= self.tk_image.width() or curY >= self.tk_image.height(): + return + self.canvas.coords(self.rect, self.start_x, self.start_y, curX, curY) + + + def snap_to_points(self, cur_size): + snap_points = [1024, 768, 512, 256] + snap_distance = 16 + for snap_point in snap_points: + if snap_point - snap_distance < cur_size < snap_point + snap_distance: + return snap_point + elif cur_size > snap_point + snap_distance: + break + return cur_size + + +####### Dragging ################################################## + + + def start_dragging(self, event): + self.state = "is_dragging" + rect_coords = self.canvas.coords(self.rect) + self.rect_center = ((rect_coords[2] + rect_coords[0]) / 2, (rect_coords[3] + rect_coords[1]) / 2) + self.start_x = event.x - self.rect_center[0] + self.start_y = event.y - self.rect_center[1] + + + def drag(self, event): + dx = event.x - self.start_x - self.rect_center[0] + dy = event.y - self.start_y - self.rect_center[1] + rect_coords = self.canvas.coords(self.rect) + new_coords = [rect_coords[0] + dx, rect_coords[1] + dy, rect_coords[2] + dx, rect_coords[3] + dy] + new_coords[0] = max(min(new_coords[0], self.tk_image.width() - (rect_coords[2] - rect_coords[0])), 0) + new_coords[1] = max(min(new_coords[1], self.tk_image.height() - (rect_coords[3] - rect_coords[1])), 0) + new_coords[2] = min(max(new_coords[2], rect_coords[2] - rect_coords[0]), self.tk_image.width()) + new_coords[3] = min(max(new_coords[3], rect_coords[3] - rect_coords[1]), self.tk_image.height()) + dx = new_coords[0] - rect_coords[0] + dy = new_coords[1] - rect_coords[1] + self.canvas.move(self.rect, dx, dy) + self.rect_center = (self.rect_center[0] + dx, self.rect_center[1] + dy) + + +####### Resizing ################################################## + + + def resize_rectangle(self, new_coords, new_size, center_x, center_y): + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + if (new_coords[0] >= 0 and new_coords[2] <= canvas_width and new_coords[1] >= 0 and new_coords[3] <= canvas_height): + self.canvas.coords(self.rect, *new_coords) + else: + new_coords = [max(0, min(canvas_width - new_size, center_x - new_size // 2)), + max(0, min(canvas_height - new_size, center_y - new_size // 2)), + min(canvas_width, max(new_size, center_x + new_size // 2)), + min(canvas_height, max(new_size, center_y + new_size // 2))] + if new_coords[0] == 0 and new_coords[2] == canvas_width or new_coords[1] == 0 and new_coords[3] == canvas_height: + return + self.canvas.coords(self.rect, *new_coords) + self.update_title() + + +####### Create ################################################## + + + def create_rectangle_based_on_image_size(self): + self.destroy_rectangle() + original_width, original_height = self.original_image.size + if original_width <= 511 or original_height <= 511: + return + size = 256 if self.scale_factor == 2 else 512 + if original_width >= 1024 and original_height >= 1024: + size = 512 if self.scale_factor == 2 else 1024 + self.create_rectangle(size) + + + def create_rectangle(self, size): + rect_size = size + canvas_width = self.tk_image.width() + canvas_height = self.tk_image.height() + rect_x1 = max(0, (canvas_width - rect_size) // 2) + rect_y1 = max(0, (canvas_height - rect_size) // 2) + rect_x2 = min(canvas_width, rect_x1 + rect_size) + rect_y2 = min(canvas_height, rect_y1 + rect_size) + self.rect = self.canvas.create_rectangle(rect_x1, rect_y1, rect_x2, rect_y2, outline='red', width=4) + self.canvas.itemconfig(self.rect, fill='black', stipple='gray12') + self.update_title() + + +####### Flash Color ################################################## + + + def flash_rectangle(self): + def reset_color(): + if self.rect: + self.canvas.itemconfig(self.rect, outline='red', width=4) + self.canvas.itemconfig(self.rect, outline='teal', width=8) + self.canvas.after(100, reset_color) + + +####### Destroy ################################################## + + + def destroy_rectangle(self): + if self.rect: + self.canvas.delete(self.rect) + self.rect = None + self.top.title(f"Crop Image | {os.path.basename(self.image_path)} | (0 x 0)") + + +#endregion +################################################################################################################################################ +#region - Misc + + + def open_directory(self, directory): + try: + if os.path.isdir(directory): + os.startfile(directory) + except Exception: pass + + +#endregion +################################################################################################################################################ +#region - Crop and Save + + + + def crop_image(self, event=None): + if self.rect: + rect_coords = self.canvas.coords(self.rect) + x_scale = self.original_image.width / self.tk_image.width() + y_scale = self.original_image.height / self.tk_image.height() + rect_coords = [int(coord * x_scale) if i % 2 == 0 else int(coord * y_scale) for i, coord in enumerate(rect_coords)] + width = abs(rect_coords[2] - rect_coords[0]) + height = abs(rect_coords[3] - rect_coords[1]) + size = min(width, height) + if self.check_crop_size(size, width, height) is False: + return + if self.free_crop_var.get(): + cropped_image = self.original_image.crop((rect_coords[0], rect_coords[1], rect_coords[2], rect_coords[3])) + else: + cropped_image = self.original_image.crop((rect_coords[0], rect_coords[1], rect_coords[0] + size, rect_coords[1] + size)) + self.save_image(cropped_image) + self.flash_rectangle() + + + def check_crop_size(self, size, width, height): + if size <= 511 and self.prompt_small_crop_var: + result = messagebox.askyesno("Confirm small crop size", f"You are about to create a small crop: ({width}x{height}).\nDo you want to continue?") + if result is True: + self.prompt_small_crop_var = False + return True + else: + return False + + + def save_image(self, cropped_image): + directory, filename = os.path.split(self.image_path) + filename, ext = os.path.splitext(filename) + matching_files = [f for f in os.listdir(directory) if f.startswith(filename + "_crop") and f.endswith(ext)] + count = len(matching_files) + 1 + new_filename = f"{filename}_crop{count:02d}{ext}" + cropped_image.save(os.path.join(directory, new_filename), "JPEG", quality=100) + self.top.title("Crop Image | Image Saved!") + + +#endregion \ No newline at end of file diff --git a/find_dupe_file.py b/find_dupe_file.py new file mode 100644 index 0000000..a5f14f1 --- /dev/null +++ b/find_dupe_file.py @@ -0,0 +1,787 @@ +################################################################################################################################################ +#region - Description + + +""" +######################################## +# # +# Find Dupe files # +# # +# Version : v1.00 # +# Author : github.com/Nenotriple # +# # +######################################## + +Description: +------------- +Scan a folder for duplicate images and/or all files by comparing thier MD5 or SHA-256 hash. + +Tested on Windows 10. + +Requirements: +------------- +Pillow: + Install by: pip install pillow + + +""" + + +#endregion +################################################################################################################################################ +#region - Imports + + +import os +import sys +import time +import shutil +import ctypes +import hashlib +import argparse +import threading +from PIL import Image +from tkinter import Tk, ttk, Menu, Text, Label, StringVar, simpledialog, BooleanVar, filedialog, messagebox, Toplevel, TclError + + +#endregion +################################################################################################################################################ +#region - ToolTips + + +''' Example ToolTip: ToolTip.create(WIDGET, "TOOLTIP TEXT", delay=0, x_offset=0, y_offset=0) ''' + +class ToolTip: + def __init__(self, widget, x_offset=0, y_offset=0): + self.widget = widget + self.tip_window = None + self.x_offset = x_offset + self.y_offset = y_offset + self.id = None + self.hide_id = None + self.hide_time = 0 + + def show_tip(self, tip_text, x, y): + if self.tip_window or not tip_text: + return + x += self.x_offset + y += self.y_offset + self.tip_window = tw = Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + tw.wm_attributes("-topmost", True) + tw.wm_attributes("-disabled", True) + label = Label(tw, text=tip_text, background="#ffffee", relief="ridge", borderwidth=1, justify="left", padx=4, pady=4) + label.pack() + self.hide_id = self.widget.after(15000, self.hide_tip) + + def hide_tip(self): + tw = self.tip_window + self.tip_window = None + if tw: + tw.destroy() + self.hide_time = time.time() + + @staticmethod + def create(widget, text, delay=0, x_offset=0, y_offset=0): + tool_tip = ToolTip(widget, x_offset, y_offset) + def enter(event): + if tool_tip.id: + widget.after_cancel(tool_tip.id) + if time.time() - tool_tip.hide_time > 0.1: + tool_tip.id = widget.after(delay, lambda: tool_tip.show_tip(text, widget.winfo_pointerx(), widget.winfo_pointery())) + def leave(event): + if tool_tip.id: + widget.after_cancel(tool_tip.id) + tool_tip.hide_tip() + def motion(event): + if tool_tip.id: + widget.after_cancel(tool_tip.id) + tool_tip.id = widget.after(delay, lambda: tool_tip.show_tip(text, widget.winfo_pointerx(), widget.winfo_pointery())) + widget.bind('', enter) + widget.bind('', leave) + widget.bind('', motion) + + + +#endregion +################################################################################################################################################ +#region - Duplicate Image Finder - Main class setup + + +class DuplicateFinder: + def __init__(self, master, path=None): + self.master = master + self.set_appid() + root.title("Duplicate File Finder") + self.create_window() + self.set_icon() + self.folder_path = "" + + + self.master.protocol("WM_DELETE_WINDOW", self.on_close) + + self.is_closing = False + self.duplicates_count = 0 + self.total_duplicates = 0 + self.total_images_checked = 0 + self.max_scan_size = 2048 # in MB + self.scanned_files = None + self.startup = True + + + self.filetypes_to_scan = ['All'] + + self.process_stopped = BooleanVar(value=True) + self.process_stopped.trace_add('write', self.toggle_widgets) + + + self.process_mode = StringVar(value="md5") + self.dupe_handling_mode = StringVar(value="Single") + self.scanning_mode = StringVar(value="Images") + self.recursive_mode = BooleanVar(value=False) + + + self.create_menubar(master) + self.create_widgets(master) + + self.all_widgets = [ + self.folder_entry, + self.browse_button, + #self.open_button, + self.clear_button, + self.radio_single, + self.radio_both, + self.undo_button, + self.run_button, + #self.stop_button, + self.radio_images, + self.radio_all_files, + self.recursive_checkbutton, + #self.textlog, + #tray_label_status, + #self.tray_label_duplicates, + #self.progress, + ] + if path: + self.folder_entry.insert(0, path) + + +#endregion +################################################################################################################################################ +#region - Menubar + + + def create_menubar(self, master): + # Create a Menu Bar + self.menubar = Menu(master) + self.master.config(menu=self.menubar) + + # File Menu + self.file_menu = Menu(self.menubar, tearoff=0) + self.menubar.add_cascade(label="File", menu=self.file_menu) + self.file_menu.add_command(label="Select Folder...", command=self.select_folder) + self.file_menu.add_command(label="Open Folder...", command=self.open_folder) + self.file_menu.add_separator() + self.file_menu.add_command(label="Restore Moved Duplicates", command=self.undo_file_move) + self.file_menu.add_command(label="Move All Duplicates Upfront", command=self.move_all_duplicates_to_root) + self.file_menu.add_separator() + self.file_menu.add_command(label="Delete All Duplicates", command=self.delete_all_duplicates) + + # Options Menu + self.options_menu = Menu(self.menubar, tearoff=0) + self.menubar.add_cascade(label="Options", menu=self.options_menu) + + # Process Mode Menu + self.process_mode_menu = Menu(self.options_menu, tearoff=0) + self.options_menu.add_cascade(label="Process Mode", menu=self.process_mode_menu) + self.process_mode_menu.add_radiobutton(label="MD5 - Fast", variable=self.process_mode, value="md5") + self.process_mode_menu.add_radiobutton(label="SHA-256 - Slow", variable=self.process_mode, value="sha-256") + + # Scanning Options Menu + self.scan_options_menu = Menu(self.options_menu, tearoff=0) + self.options_menu.add_cascade(label="Scanning Options", menu=self.scan_options_menu) + self.scan_options_menu.add_command(label="Set Max Scan Size...", command=self.open_max_scan_size_dialog) + self.scan_options_menu.add_command(label="Set Filetypes to Scan...", command=self.open_filetypes_dialog) + self.scan_options_menu.add_separator() + self.scan_options_menu.add_checkbutton(label="Use Recursive Scanning", variable=self.recursive_mode) + self.scan_options_menu.add_separator() + + # Scanning Mode Menu + self.scanning_mode_menu = Menu(self.scan_options_menu, tearoff=0) + self.scan_options_menu.add_cascade(label="Set Scanning Mode", menu=self.scanning_mode_menu) + self.scanning_mode_menu.add_radiobutton(label="Images", variable=self.scanning_mode, value="Images") + self.scanning_mode_menu.add_radiobutton(label="All Files", variable=self.scanning_mode, value="All Files") + + # Duplication Handling Menu + self.duplication_handling_menu = Menu(self.options_menu, tearoff=0) + self.options_menu.add_cascade(label="Duplication Handling", menu=self.duplication_handling_menu) + self.duplication_handling_menu.add_radiobutton(label="Single", variable=self.dupe_handling_mode, value="Single") + self.duplication_handling_menu.add_radiobutton(label="Both", variable=self.dupe_handling_mode, value="Both") + + +#endregion +################################################################################################################################################ +#region - Widgets + + + def create_widgets(self, master): + # Frame - Widget Frame + self.widget_frame = ttk.Frame(master) + self.widget_frame.pack(side="top", fill="both", padx=4, pady=4) + + + # Frame - Folder Frame + self.folder_frame = ttk.Frame(self.widget_frame) + self.folder_frame.pack(fill="both", expand=True) + + # Entry - Folder Entry + self.folder_entry = ttk.Entry(self.folder_frame) + self.folder_entry.pack(side="left", fill="x", expand=True) + + # Button - Browse + self.browse_button = ttk.Button(self.folder_frame, text="Browse...", command=self.select_folder) + ToolTip.create(self.browse_button, "Select a folder to analyze for duplicate files.", delay=150, x_offset=6, y_offset=6) + self.browse_button.pack(side="left") + + # Button - Open + self.open_button = ttk.Button(self.folder_frame, text="Open", command=self.open_folder) + ToolTip.create(self.open_button, "Open path from folder entry.", delay=150, x_offset=6, y_offset=6) + self.open_button.pack(side="left") + + # Button - Clear + self.clear_button = ttk.Button(self.folder_frame, text="X", width=2, command=lambda: self.folder_entry.delete(0, 'end')) + ToolTip.create(self.clear_button, "Clear folder entry.", delay=150, x_offset=6, y_offset=6) + self.clear_button.pack(side="left") + + + # Radio Buttons - Duplicate Handling Mode + self.radio_single = ttk.Radiobutton(self.widget_frame, text="Single", variable=self.dupe_handling_mode, value="Single") + ToolTip.create(self.radio_single, "Move extra duplicate files, leaving a copy behind.\nFiles will be stored in the '_Duplicate__Files' folder located in the parent folder.", delay=150, x_offset=6, y_offset=6) + self.radio_single.pack(side="left") + self.radio_both = ttk.Radiobutton(self.widget_frame, text="Both", variable=self.dupe_handling_mode, value="Both") + ToolTip.create(self.radio_both, "Move all duplicate files, leaving no copy behind.\nFiles will be stored in the '_Duplicate__Files' folder located in the parent folder.\nDuplicates will be grouped into subfolders.", delay=150, x_offset=6, y_offset=6) + self.radio_both.pack(side="left") + + # Button - Undo + self.undo_button = ttk.Button(self.widget_frame, text="Undo", width=6, command=self.undo_file_move) + ToolTip.create(self.undo_button, "Restore all files to their original path.\nEnable 'Recursive' To also restore all files within subfolders.", delay=150, x_offset=6, y_offset=6) + self.undo_button.pack(side="left") + + # Button - Run + self.run_button = ttk.Button(self.widget_frame, text="Find Duplicates", command=self.find_duplicates) + ToolTip.create(self.run_button, "Process the selected folder.", delay=1000, x_offset=6, y_offset=6) + self.run_button.pack(side="left", fill="x", expand=True) + + # Button - Stop + self.stop_button = ttk.Button(self.widget_frame, text="Stop!", width=6, command=self.stop_process) + ToolTip.create(self.stop_button, "Stop all running processes.", delay=150, x_offset=6, y_offset=6) + self.stop_button.pack(side="left", fill="x") + + # Radio Buttons - Scanning Mode + self.radio_images = ttk.Radiobutton(self.widget_frame, text="Images", variable=self.scanning_mode, value="Images") + ToolTip.create(self.radio_images, "Scan only image filetypes.", delay=150, x_offset=6, y_offset=6) + self.radio_images.pack(side="left") + self.radio_all_files = ttk.Radiobutton(self.widget_frame, text="All Files", variable=self.scanning_mode, value="All Files") + ToolTip.create(self.radio_all_files, "Scan all filetypes.", delay=150, x_offset=6, y_offset=6) + self.radio_all_files.pack(side="left") + + # Checkbutton - Subfolder Scanning + self.recursive_checkbutton = ttk.Checkbutton(self.widget_frame, text="Recursive", variable=self.recursive_mode, offvalue=False) + ToolTip.create(self.recursive_checkbutton, "Enable to scan subfolders.\nNOTE: This only compares files within the same subfolder, not across all scanned folders.", delay=150, x_offset=6, y_offset=6) + self.recursive_checkbutton.pack(side="left") + + + # Textlog + self.create_textlog() + self.startup_text = ("This tool will find any duplicate files by matching file hash.\n\nDuplicate files will be moved to a '_Duplicate__Files' folder within the scanned directory.") + self.insert_to_textlog(self.startup_text) + + + # Label - Tray - Status + self.tray_label_status = ttk.Label(self.master, width=15, relief="groove", text=" Idle...") + ToolTip.create(self.tray_label_status, "App status.", delay=150, x_offset=6, y_offset=6) + self.tray_label_status.pack(side="left", padx=2, ipadx=2, ipady=2) + + # Label - Tray - Total Duplicates + self.tray_label_duplicates = ttk.Label(self.master, width=15, relief="groove", text="Duplicates: 00000") + ToolTip.create(self.tray_label_duplicates, "Total number of duplicate files across all scanned folders.", delay=150, x_offset=6, y_offset=6) + self.tray_label_duplicates.pack(side="left", padx=2, ipadx=2, ipady=2) + + # Label - Tray - Total Images Checked + self.tray_label_total_files = ttk.Label(self.master, width=19, relief="groove", text="Files Checked: 000000") + ToolTip.create(self.tray_label_total_files, "Total number of files checked across all scanned folders.", delay=150, x_offset=6, y_offset=6) + self.tray_label_total_files.pack(side="left", padx=2, ipadx=2, ipady=2) + + # Progressbar + self.progress = ttk.Progressbar(self.master, length=100, mode='determinate') + ToolTip.create(self.progress, "Progressbar.", delay=150, x_offset=6, y_offset=6) + self.progress.pack(side="left", fill="x", padx=2, expand=True) + + +#endregion +################################################################################################################################################ +#region - Textlog + + + def create_textlog(self): + separator = ttk.Separator(self.master);separator.pack(fill="x") + text_frame = ttk.Frame(self.master) + text_frame.pack(side="top", expand="yes", fill="both", padx="2", pady="2") + vscrollbar = ttk.Scrollbar(text_frame, orient="vertical") + hscrollbar = ttk.Scrollbar(text_frame, orient="horizontal") + self.textlog = Text(text_frame, height=1, wrap="none", state='disabled', yscrollcommand=vscrollbar.set, xscrollcommand=hscrollbar.set, font=("Consolas", 8)) + vscrollbar.grid(row=0, column=1, sticky="ns") + hscrollbar.grid(row=1, column=0, sticky="we") + self.textlog.grid(row=0, column=0, sticky="nsew") + vscrollbar.config(command=self.textlog.yview) + hscrollbar.config(command=self.textlog.xview) + text_frame.grid_columnconfigure(0, weight=1) + text_frame.grid_rowconfigure(0, weight=1) + + + def insert_to_textlog(self, text): + try: + self.textlog.config(state='normal') + self.textlog.insert('end', text) + self.textlog.see('end') + self.textlog.config(state='disabled') + except RuntimeError: pass + + + def check_and_clear_textlog(self): + if self.startup == False: + return + textlog_content = self.textlog.get("1.0", 'end-1c') + if self.startup_text in textlog_content: + self.textlog.config(state='normal') + self.textlog.delete('1.0', 'end') + self.textlog.config(state='disabled') + self.startup = False + + +#endregion +################################################################################################################################################ +#region - Find Duplicates + + + def find_duplicates(self): + self.duplicates_count = 0 + self.total_duplicates = 0 + self.total_images_checked = 0 + self.tray_label_duplicates.config(text="Duplicates: 00000") + self.tray_label_total_files.config(text="Files Checked: 000000") + if not self.folder_entry.get(): + return + self.check_and_clear_textlog() + if self.process_stopped.get() == True: + self.process_stopped.set(False) + threading.Thread(target=self._find_duplicates).start() + + + def _find_duplicates(self): + folder_path = self.folder_entry.get() + if os.path.isdir(folder_path): + if self.recursive_mode.get(): + for root, dirs, files in os.walk(folder_path): + if self.process_stopped.get(): + break + self.scan_folder(root) + else: + self.scan_folder(folder_path) + self.process_stopped.set(True) + + + def scan_folder(self, folder_path): + hash_dict = {} + displayed_folder_path = folder_path.replace("\\", "/") + self.insert_to_textlog(f"\n\nScanning... \nFolder Path: {displayed_folder_path}") + self.tray_label_status.config(text=" Scanning...") + duplicates_folder = os.path.join(folder_path, '_Duplicate__Files') + duplicates_found = False + self.scanned_files = list(self.get_files(folder_path)) + self.insert_to_textlog(f"\nTotal files to check: {len(self.scanned_files)}") + self.update_total_images() + self.progress['maximum'] = len(self.scanned_files) + self.duplicates_count = 0 + for i, filename in enumerate(self.scanned_files, start=1): + if self.process_stopped.get(): + break + file_path = os.path.join(folder_path, filename) + if os.path.commonpath([file_path, duplicates_folder]) == duplicates_folder or filename.startswith('.'): + continue + try: + file_hash = self.get_file_hash(file_path) + if file_hash is None: + continue + if file_hash in hash_dict: + self.insert_to_textlog(f"\nDuplicate found: {os.path.basename(file_path)} == {os.path.basename(hash_dict[file_hash])}") + self.duplicates_count += 1 + if not duplicates_found: + os.makedirs(duplicates_folder, exist_ok=True) + duplicates_found = True + if self.dupe_handling_mode.get() == "Both": + group_folder = os.path.join(duplicates_folder, file_hash) + os.makedirs(group_folder, exist_ok=True) + shutil.move(file_path, group_folder) + if os.path.exists(hash_dict[file_hash]): + shutil.move(hash_dict[file_hash], group_folder) + else: + shutil.move(file_path, duplicates_folder) + else: + hash_dict[file_hash] = file_path + self.progress['value'] = i + self.master.update() + except Exception as e: + self.insert_to_textlog(f"\nERROR - find_duplicates: Exception: {e}") + self.update_total_duplicates() + self.status_check() + if self.process_stopped.get() == False: + self.insert_to_textlog(f"\nTotal duplicates found: {self.duplicates_count}") + + + def get_files(self, folder_path): + try: + self.tray_label_status.config(text=" Building Lists...") + duplicates_folder = os.path.join(folder_path, '_Duplicate__Files') + with os.scandir(folder_path) as entries: + for entry in entries: + if entry.is_file() and entry.stat().st_size <= self.max_scan_size * 1024 * 1024: + if os.path.commonpath([entry.path, duplicates_folder]) == duplicates_folder: + continue + if self.filetypes_to_scan == ['All'] or entry.name.endswith(tuple(self.filetypes_to_scan)): + if self.scanning_mode.get() == "Images" and self.is_image(entry.path): + yield entry.name + elif self.scanning_mode.get() == "All Files": + yield entry.name + except Exception as e: + self.insert_to_textlog(f"ERROR - get_files: Exception: {str(e)}") + + + def is_image(self, file_path): + try: + Image.open(file_path) + return True + except IOError: + return False + except Image.DecompressionBombError: + return False + + + def get_file_hash(self, file_path): + try: + self.tray_label_status.config(text=" Comparing...") + with open(file_path, 'rb') as f: + if self.process_mode.get() == "md5": + return hashlib.md5(f.read()).hexdigest() + elif self.process_mode.get() == "sha-256": + return hashlib.sha256(f.read()).hexdigest() + except IOError: + self.insert_to_textlog(f"\nERROR - get_file_hash: Cannot open file at {file_path}") + return None + + + def get_image_hash(self, image_path): + try: + self.tray_label_status.config(text=" Comparing...") + with Image.open(image_path) as img: + if self.process_mode.get() == "md5": + return hashlib.md5(img.tobytes()).hexdigest() + elif self.process_mode.get() == "sha-256": + return hashlib.sha256(img.tobytes()).hexdigest() + except IOError: + self.insert_to_textlog(f"\nERROR - get_image_hash: Cannot open image at {image_path}") + return None + + + def update_total_images(self): + self.total_images_checked += len(self.scanned_files) + self.tray_label_total_files.config(text=f"Files Checked: {self.total_images_checked:06d}") + + + def update_total_duplicates(self): + self.total_duplicates += self.duplicates_count + self.tray_label_duplicates.config(text=f"Duplicates: {self.total_duplicates:05d}") + + + def stop_process(self): + self.progress['value'] = 0 + if self.process_stopped.get() == False: + self.insert_to_textlog("\n\nStopping...\n") + self.process_stopped.set(True) + self.undo_file_move() + + +#endregion +################################################################################################################################################ +#region - Undo + + + def undo_file_move(self): + self.file_count = 0 + self.progress['value'] = 0 + self.total_files = self.count_total_files() + self.progress['maximum'] = self.total_files + try: + if self.recursive_mode.get(): + for root, dirs, files in os.walk(self.folder_entry.get()): + self.undo_folder(root) + else: + self.undo_folder(self.folder_entry.get()) + except Exception as e: + self.insert_to_textlog(f"\nERROR - get_files_to_undo: Exception: {e}") + finally: + if self.file_count != 0: + self.insert_to_textlog(f"\n\nRestoring {self.file_count} files") + self.status_check() + + + def count_total_files(self): + try: + total_files = 0 + duplicates_folder = os.path.join(self.folder_entry.get(), '_Duplicate__Files') + if os.path.exists(duplicates_folder): + for root, dirs, files in os.walk(duplicates_folder): + total_files += len(files) + return total_files + except Exception as e: + self.insert_to_textlog(f"ERROR - count_total_files: Exception: {str(e)}") + return None + + + def undo_folder(self, folder_path): + try: + duplicates_folder = os.path.join(folder_path, '_Duplicate__Files') + if not os.path.exists(duplicates_folder): + return + for filename in os.listdir(duplicates_folder): + if os.path.isfile(os.path.join(duplicates_folder, filename)): + shutil.move(os.path.join(duplicates_folder, filename), folder_path) + self.file_count += 1 + self.progress['value'] = self.file_count + elif os.path.isdir(os.path.join(duplicates_folder, filename)): + for sub_filename in os.listdir(os.path.join(duplicates_folder, filename)): + shutil.move(os.path.join(duplicates_folder, filename, sub_filename), folder_path) + self.file_count += 1 + self.progress['value'] = self.file_count + os.rmdir(os.path.join(duplicates_folder, filename)) + os.rmdir(duplicates_folder) + except Exception as e: + self.insert_to_textlog(f"\nERROR - undo_folder: Exception: {e}") + + +#endregion +################################################################################################################################################ +#region - Delete Duplicates + + + def delete_all_duplicates(self): + try: + if not messagebox.askokcancel("Warning", "CAUTION!\n\nAll folders with the name '_Duplicate__Files' will be deleted along with all file in those folders.\n\nTHIS CANNOT BE UNDONE!!!"): + return + if not messagebox.askokcancel("Final Warning", "Are you sure you want to delete all duplicates?"): + return + if self.recursive_mode.get(): + for root, dirs, files in os.walk(self.folder_entry.get()): + self.delete_folder(root) + else: + self.delete_folder(self.folder_entry.get()) + except Exception as e: + self.insert_to_textlog(f"\nERROR - delete_all_duplicates: Exception: {e}") + + + def delete_folder(self, folder_path): + try: + duplicates_folder = os.path.join(folder_path, '_Duplicate__Files') + if not os.path.exists(duplicates_folder): + return + self.insert_to_textlog(f"\n\nDeleting duplicates...\nDeleting: {os.path.normpath(duplicates_folder)}") + for filename in os.listdir(duplicates_folder): + file_path = os.path.join(duplicates_folder, filename) + if os.path.isfile(file_path): + os.remove(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + os.rmdir(duplicates_folder) + self.insert_to_textlog(f"\nAll files deleted successfully!") + except Exception as e: + self.insert_to_textlog(f"\nERROR - delete_folder: Exception: {e}") + + +#endregion +################################################################################################################################################ +#region - Move Duplicate Upfront + + + def move_all_duplicates_to_root(self): + try: + root_path = self.folder_entry.get() + root_duplicates_folder = os.path.join(root_path, '_Duplicate__Files') + if not os.path.exists(root_duplicates_folder): + os.makedirs(root_duplicates_folder) + if not messagebox.askyesno("Confirmation", "Are you sure you want to move all duplicates to the root '_Duplicate__Files' folder?\n\nYou cannot undo this action!"): + return + for folder_path, dirs, files in os.walk(root_path): + if folder_path == root_duplicates_folder: + continue + duplicates_folder = os.path.join(folder_path, '_Duplicate__Files') + if os.path.exists(duplicates_folder): + for filename in os.listdir(duplicates_folder): + file_path = os.path.join(duplicates_folder, filename) + new_file_path = os.path.join(root_duplicates_folder, filename) + if os.path.isfile(file_path): + counter = 1 + while os.path.exists(new_file_path): + base, ext = os.path.splitext(filename) + new_file_path = os.path.join(root_duplicates_folder, f"{base}_{counter}{ext}") + counter += 1 + shutil.move(file_path, new_file_path) + elif os.path.isdir(file_path): + for sub_filename in os.listdir(file_path): + sub_file_path = os.path.join(file_path, sub_filename) + new_sub_file_path = os.path.join(root_duplicates_folder, sub_filename) + counter = 1 + while os.path.exists(new_sub_file_path): + base, ext = os.path.splitext(sub_filename) + new_sub_file_path = os.path.join(root_duplicates_folder, f"{base}_{counter}{ext}") + counter += 1 + shutil.move(sub_file_path, new_sub_file_path) + if not os.listdir(file_path): + os.rmdir(file_path) + if not os.listdir(duplicates_folder): + os.rmdir(duplicates_folder) + except Exception as e: + self.insert_to_textlog(f"\nERROR - move_all_duplicates_to_root: Exception: {e}") + + +#endregion +################################################################################################################################################ +#region - Settings Dialog + + + # Set max image scanning size + def open_max_scan_size_dialog(self): + temp = simpledialog.askinteger("Input", f"Current Max Scan Size: {self.max_scan_size} MB\n\nEnter Max Scan Size in megabytes (MB)\nExample (1GB): 1024") + if temp is not None: + self.max_scan_size = temp + + + # Set filetypes to scan + def open_filetypes_dialog(self): + current_filetypes = ', '.join(self.filetypes_to_scan) + new_filetypes = simpledialog.askstring("Input", f"Current filetypes: {current_filetypes}\n\nEnter filetypes you want to scan. Unlisted filetypes will be skipped.\n\nEnter: 'All' to return to default.\n\nExample: .jpg, .png, .txt", parent=self.master) + if new_filetypes is not None and new_filetypes.strip() != '': + self.filetypes_to_scan = [ftype.strip() for ftype in new_filetypes.split(',')] + + +#endregion +################################################################################################################################################ +#region - Handle Widget States + + + def disable_all(self): + widgets_to_disable = self.all_widgets + for widgets in widgets_to_disable: + widgets.config(state="disabled") + for menu_item in ["File", "Options"]: + self.menubar.entryconfig(menu_item, state="disabled") + + + def enable_all(self): + widgets_to_disable = self.all_widgets + for widgets in widgets_to_disable: + widgets.config(state="normal") + for menu_item in ["File", "Options"]: + self.menubar.entryconfig(menu_item, state="normal") + + + def toggle_widgets(self, *args): + if self.process_stopped.get(): + self.enable_all() + else: + self.disable_all() + + +#endregion +################################################################################################################################################ +#region - Misc + + + def select_folder(self): + new_folder_path = filedialog.askdirectory() + if new_folder_path: + self.tray_label_duplicates.config(text="Duplicates: 00000") + self.tray_label_total_files.config(text="Files Checked: 000000") + self.progress['value'] = 0 + self.folder_path = new_folder_path + self.folder_entry.delete(0, 'end') + self.folder_entry.insert(0, self.folder_path) + + + def open_folder(self): + folder_path = self.folder_entry.get() + if os.path.exists(folder_path): + os.startfile(folder_path) + else: + messagebox.showinfo("Invalid Path", "The folder path is invalid or does not exist.") + + + def status_check(self): + if self.is_closing == False: + self.tray_label_status.config(text=" Idle...") + else: + self.tray_label_status.config(text=" Closing...") + + +#endregion +################################################################################################################################################ +#region - Framework + + + def on_close(self): + self.is_closing = True + self.stop_process() + self.status_check() + self.master.after(1000, self.master.destroy) + + + def set_icon(self): + if getattr(sys, 'frozen', False): + application_path = sys._MEIPASS + elif __file__: + application_path = os.path.dirname(__file__) + icon_path = os.path.join(application_path, "icon.ico") + try: + self.master.iconbitmap(icon_path) + except TclError: pass + + + def set_appid(self): + myappid = 'ImgTxtViewer.Nenotriple' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + + + def create_window(self): + window_width = 900 + window_height = 600 + root.geometry(f"{window_width}x{window_height}") + root.minsize(500,175) + + # Set the position of the window to the center of the screen + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + position_top = int(screen_height / 2 - window_height / 2) + position_right = int(screen_width / 2 - window_width / 2) + root.geometry(f"+{position_right}+{position_top}") + + +parser = argparse.ArgumentParser() +parser.add_argument("--path", help="The path to the directory") +args = parser.parse_args() + +root = Tk() +app = DuplicateFinder(root, args.path) +root.mainloop() + + +#endregion \ No newline at end of file