From fafa69202b9ded51510604304d4471b34b43065e Mon Sep 17 00:00:00 2001 From: Thomas Wilczynski <47839545+Gamergull@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:14:16 -0800 Subject: [PATCH] Comments and cleanup; resuming this project --- README.md | 12 +- app.py | 238 ++++++++++++++++++++++++----------- data/file_rules_default.json | 2 +- file_manager.py | 15 ++- script.py | 42 ++++--- 5 files changed, 213 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 6284f94..dbb603f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,17 @@ ## Description -A simple file sorter app, built with the Python TKinter library. This is my first real Python project, after many useless scripts and experiments. +A simple file sorter app, built with the Python TKinter library. It can automatically move, copy, or delete files from a given directory, based on file types. For example, maybe your Downloads folder is cluttered with a bunch of useless files, but you want to keep the image files by moving them into your Pictures folder. This app can help you move the images and delete the junk, all in one click (after a bit of setup). + +Other use cases: + +- Moving all video files out of your camera roll + +- Copying only certain types of documents into a backup folder + +- TODO: Grouping all of your photos into separate folders, by year + +This is my first real Python project, after many unimportant scripts and experiments. I really needed a project like this in my portfolio, even if it's not truly amazing. ## Installation diff --git a/app.py b/app.py index 1be0128..f4cdb52 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,17 @@ """ Frontend GUI that displays a list of files from a folder. -Also, there are various settings for different generic file types. +File actions (move, copy, or delete) can be set for generic file types. """ +# TODO: +# Add comments +# Add some error handling +# Enable loading custom configuration json +# Improve user experience of rules tab + __name__ = "__main__" -__version__ = "0.8" +__version__ = "0.9" __author__ = "Gull" import tkinter as tk @@ -16,19 +22,12 @@ from tkinter import ttk from tkinter import messagebox as mb from tkinter import filedialog as fd -debug_mode = False APP_NAME = "Finch Filer" LOG_PATH = "%/Gull/app.log" RULES_PATH = "%/Gull/file_rules_custom.json" -def set_debug_mode(value): - global debug_mode - if type(value) == None: - debug_mode = not debug_mode # Toggles the mode - else: - debug_mode = value - def setup_log(): + """Initializes the logging system.""" from sys import stdout log.basicConfig(**ut.get_log_config(LOG_PATH, log.DEBUG)) @@ -36,27 +35,31 @@ def setup_log(): log.info("App started") class App(tk.Tk): + """TKinter GUI and related methods.""" def __init__(self, width=640, height=480): super().__init__() self.title(APP_NAME) - self.current_mode = "all" self.option_add('*tearOff', tk.FALSE) - self.fm = fm.Manager(True) - self.log = list() - self.gui() - self.load_config() - self.prep() + self.fm = fm.Manager(True) # Main file manager object + self.log = list() # Running list of all logs + self.temp = {"column": "", "reverse": False} # Temporary data + self.current_mode = "all" + self.custom_rules_path = RULES_PATH + self.gui() # User interface + self.load_config() # File rules configuration + self.prep() # Ready state + # Centers the app on the screen screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() - center_x = int(screen_width / 2 - width / 2) center_y = int(screen_height / 2 - height / 2) - self.geometry(f"{width}x{height}+{center_x}+{center_y}") - def get_filemode_files(self, key): - if key == "all": return + def get_filemode_files(self, key, ignore_all=True): + """Returns a dict of files that match the given file mode key.""" + if key == "all": # Special key representing all file types + return self.fm.filedata if not ignore_all else None files = dict() for k, v in self.fm.filedata.items(): @@ -65,66 +68,106 @@ class App(tk.Tk): return files if len(files) > 0 else None def update_fileview(self): + """Updates the treeview widget in the app.""" tree = self.v_tree.get() self.fm.update_file_data() log.info(f"Processed file view with {len(self.fm.filedata)} items") log.info(f"Show as tree: {str(tree)}") - while len(self.fileview.get_children()) > 0: + while len(self.fileview.get_children()) > 0: # Clears all children self.fileview.delete(self.fileview.get_children()[-1]) - if tree: - for k, v in self.fm.filemodes.items(): - files = self.get_filemode_files(k) - if files != None: - id = self.fileview.insert("", tk.END, text=v["name"], - open=True) + filemodes = self.fm.filemodes + if not tree: filemodes = {"all": {"name": "All Files"}} # Fallback + for k, v in filemodes.items(): + files = self.get_filemode_files(k, tree) + if files != None: + id = self.fileview.insert("", tk.END, text=v["name"], + open=True) - for k, v in files.items(): - values = (str(ut.format_date(v["time"])), - str(ut.format_bytes(v["size"]))) - self.fileview.insert(id, tk.END, text=k, values=values) + for i, (k, v) in enumerate(files.items()): + values = (str(ut.format_date(v["time"])), + str(ut.format_bytes(v["size"]))) + nid = self.fileview.insert(id, tk.END, text=k, + values=values) + self.fm.filedata[k]["_treeid"] = nid + self.fm.filedata[k]["_order"] = i # Used for sorting - else: - for k, v in self.fm.filedata.items(): - values = (str(ut.format_date(v["time"])), - str(ut.format_bytes(v["size"]))) - self.fileview.insert("", tk.END, text=k, values=values) + def sort_fileview_items(self, column="name"): + """Sorts treeview items by the given column type.""" + columns = ("name", "time", "size") + c_index = columns.index(column) if column in columns else -1 + if c_index == -1: return - def load_config(self, filepath=RULES_PATH): + reverse = False + if self.temp["column"] == column: + self.temp["reverse"] = not self.temp["reverse"] # Toggles reverse + reverse = self.temp["reverse"] + + self.temp["column"] = column + + files = self.fm.filedata + temp = list() # Stores file data in a temporary list, for sorting + for c1 in self.fileview.get_children(): + for c2 in self.fileview.get_children(c1): + key = self.fileview.item(c2)["text"] + temp.append((key, files[key]["time"], files[key]["size"])) + temp.sort(key=lambda v: v[c_index], reverse=reverse) + + for i, v in enumerate(temp): + files[v[0]]["_order"] = i + + for index, (k, _, _) in enumerate(temp): + id = files[k]["_treeid"] + index = files[k]["_order"] + self.fileview.move(id, self.fileview.parent(id), index) + + temp.clear() # Clears list after every loop + + def load_config(self, filepath=""): + """Loads the file sorting rules from the given file path.""" + if filepath == "": filepath = self.custom_rules_path path = ut.parse_dir(filepath) data = ut.load_json_file(path) if data != None: self.fm.setup_file_rules(data, True) log.info(f"Loaded custom file rules: {path}") - def save_config(self, filepath=RULES_PATH): + def save_config(self, filepath=""): + """Saves the file sorting rules to the given file path.""" + if filepath == "": filepath = self.custom_rules_path path = ut.parse_dir(filepath) ut.save_json_file(path, {"filemodes": self.fm.filemodes}) log.info(f"Saved custom file rules: {path}") def reset_config(self): - data = ut.load_json_file(self.fm.config_path) + """Resets the file sorting rules back to default.""" + data = ut.load_json_file(fm.Manager.RULES_PATH) self.fm.setup_file_rules(data, True) self.prep() log.info(f"Reloaded default file rules") def update_mode_data(self, mode, key, value): + """Updates file mode data (key and value).""" + if mode == "": mode = self.current_mode if not mode in self.fm.filemodes: return self.fm.filemodes[mode].update({key: value}) log.info(f"Set properties for file mode: {key} = {value}") def set_rules_variables(self, key): + """Updates the internal variables of the file rules (key is mode).""" if not key in self.fm.filemodes: return data = self.fm.filemodes[key] self.v_radio.set(data["action"]) self.v_targetdir.set(data["destination"]) + # TODO: Change string parsing if type(data["extensions"]) == list: # Needs improvement self.v_extstr.set(", ".join(data["extensions"])) else: self.v_extstr.set(data["extensions"]) def select_mode_from_list(self, event): + """Selects the current file mode to use; also updates variables.""" key = "" sel = self.w_list.curselection() if len(sel) == 0: return @@ -134,26 +177,59 @@ class App(tk.Tk): key = k break - if len(key) > 0: + if len(key) > 0: # Valid key was found self.current_mode = key self.set_rules_variables(key) log.debug(f"Selected file mode: {key}") + def ask_sort_directory(self): + """Prompts which file directory to process files for the given mode.""" + if not self.current_mode in self.fm.filemodes: return + data = self.fm.filemodes[self.current_mode] + dir = fd.askdirectory(initialdir = ut.parse_dir(data["destination"]), + title = "Select target directory") + self.v_targetdir.set(dir) + self.update_mode_data("", "destination", dir) + def set_directory(self, dir): + """Sets the file directory for the app to use.""" self.fm.set_directory(dir) + log.debug(f"Set custom file directory: {dir}") self.update_fileview() def ask_directory(self): + """Prompts which file directory for the app to use.""" dir = fd.askdirectory(initialdir = ut.parse_dir(self.fm.dir), title = "Select directory") self.set_directory(dir) + def ask_load_rules(self): + """Prompts which file rules config for the app to use.""" + initdir = ut.parse_dir(self.fm.config_path) + filetypes = (("Config files", "*.json"), ("All files", "*.*")) + dir = fd.askopenfilename(initialdir = initdir, + filetypes = filetypes, + title = "Load config file") + self.load_config(dir) + + def ask_save_rules(self): + """Prompts which file name to use to save the file rules config.""" + initdir = ut.parse_dir(self.custom_rules_path) + filetypes = (("Config files", "*.json")) + dir = fd.asksaveasfilename(initialdir = initdir, + filetypes = filetypes, + title = "Save config file") + self.custom_rules_path = dir + self.save_config(dir) + def prep(self): + """Makes the app ready to use after initialization.""" self.update_fileview() if self.current_mode in self.fm.filemodes: self.set_rules_variables(self.current_mode) def run_task(self): + """Runs the main file sorting task.""" result = mb.askyesno("Task", "Ready to sort all files?") if not result: return @@ -168,6 +244,7 @@ class App(tk.Tk): + f"Deleted {stats['delete']} files.\n") def gui(self): + """Main user interface of the app.""" self.gui_menu() self.book = ttk.Notebook(self) @@ -177,12 +254,25 @@ class App(tk.Tk): self.book.pack(expand=True, fill=tk.BOTH) def gui_menu(self): - a_menu = tk.Menu(self) # First arg is always the parent + """File bar of the app.""" + a_menu = tk.Menu(self) self.config(menu=a_menu) a_menu_file = tk.Menu(a_menu) - a_menu_file.add_command(label="Set Directory", + a_menu_file.add_command(label="Open Directory", command=self.ask_directory) + a_menu_file.add_separator() + + a_menu_file.add_command(label="Load Rules", + command=self.ask_load_rules) + a_menu_file.add_command(label="Save Rules", + command=self.save_config) + a_menu_file.add_command(label="Save Rules As...", + command=self.ask_save_rules) + a_menu_file.add_command(label="Reset Rules", + command=self.reset_config) + a_menu_file.add_separator() + a_menu_file.add_command(label="Exit", command=self.destroy) a_menu.add_cascade( @@ -190,8 +280,23 @@ class App(tk.Tk): menu=a_menu_file, underline=0 ) + + a_menu_settings = tk.Menu(a_menu) + + self.v_tree = tk.BooleanVar(value=True) + a_menu_settings.add_checkbutton(label="Show Categories", + variable=self.v_tree, + command=self.update_fileview) + + a_menu.add_cascade( + label="Settings", + menu=a_menu_settings, + underline=0 + ) + def gui_files(self): + """Files tab of the app.""" frame = ttk.Frame(self.book) box = ttk.Frame(frame) @@ -202,23 +307,24 @@ class App(tk.Tk): button = ttk.Button(box, text="Refresh", command=self.update_fileview) button.grid(column=1, row=0, pady=5) - self.v_tree = tk.BooleanVar(value=False) - f_check = lambda: self.update_fileview() - check = ttk.Checkbutton(box, text="Show As Tree", variable=self.v_tree, - command=f_check) - check.grid(column=2, row=0, padx=5, pady=5) - box.pack(side=tk.TOP, fill=tk.X) self.fileview_columns = ("Last Modified", "Size") self.fileview = ttk.Treeview(frame, columns=self.fileview_columns) # Disabled "show=headings" - self.fileview.heading("#0", text="Name") # First (key) column - self.fileview.column("#0", width=200, anchor=tk.W) - for v in self.fileview_columns: # Value columns - self.fileview.heading(v, text=v) - self.fileview.column(v, width=100, anchor=tk.W) + temp_columns = (("name", "Name", + lambda: self.sort_fileview_items("name")), + ("time", "Last Modified", + lambda: self.sort_fileview_items("time")), + ("size", "Size", + lambda: self.sort_fileview_items("size"))) + + for i, v in enumerate(temp_columns): + id = "#0" if i == 0 else v[1] + width = 200 if i == 0 else 100 + self.fileview.heading(id, text=v[1], command=v[2]) + self.fileview.column(id, width=width, anchor=tk.W) sbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.fileview.yview) @@ -230,19 +336,10 @@ class App(tk.Tk): return frame def gui_rules(self): + """Rules tab of the app.""" um = self.update_mode_data frame = ttk.Frame(self.book) - box = ttk.Frame(frame) - - button = ttk.Button(box, text="Save Config", command=self.save_config) - button.grid(column=0, row=0, pady=5) - - button = ttk.Button(box, text="Reset", command=self.reset_config) - button.grid(column=1, row=0, pady=5) - - box.pack(side=tk.TOP, fill=tk.X) - main = ttk.Frame(frame) main.columnconfigure(0, weight=1, uniform="column") main.columnconfigure(1, weight=3, uniform="column") @@ -260,7 +357,7 @@ class App(tk.Tk): self.v_radio = tk.StringVar() self.v_actions = ("move", "copy", "delete", "ignore") - f_radio = lambda: um(self.current_mode, "action", self.v_radio.get()) + f_radio = lambda: um("", "action", self.v_radio.get()) for s in self.v_actions: str = f"{s.capitalize()} " radio = ttk.Radiobutton(i_frame, text=str, value=s, @@ -270,17 +367,18 @@ class App(tk.Tk): i_frame = ttk.Labelframe(details, text="Target Directory") - label = ttk.Label(i_frame, - text="Start with \"~/\" " - "for your home (user) directory.") - label.pack(side=tk.TOP, anchor=tk.W) + # text="Start with \"~/\" for your home (user) directory.") self.v_targetdir = tk.StringVar() - f_targetdir = lambda e: um(self.current_mode, "destination", self.v_targetdir.get()) + f_targetdir = lambda e: um("", "destination", self.v_targetdir.get()) entry = ttk.Entry(i_frame, textvariable=self.v_targetdir) entry.bind('', f_targetdir) entry.pack(side=tk.TOP, fill=tk.X) + f_userdir = lambda: self.ask_sort_directory() + button = ttk.Button(i_frame, text="Browse...", command=f_userdir) + button.pack(side=tk.TOP, anchor=tk.W) + i_frame.pack(side=tk.TOP, fill=tk.X, pady=5) i_frame = ttk.Labelframe(details, text="File Extensions") @@ -290,7 +388,7 @@ class App(tk.Tk): label.pack(side=tk.TOP, anchor=tk.W) self.v_extstr = tk.StringVar() - f_extstr = lambda e: um(self.current_mode, "extensions", self.v_extstr.get()) + f_extstr = lambda e: um("", "extensions", self.v_extstr.get()) entry = ttk.Entry(i_frame, textvariable=self.v_extstr) entry.bind('', f_extstr) entry.pack(side=tk.TOP, fill=tk.X) diff --git a/data/file_rules_default.json b/data/file_rules_default.json index 5565831..cfc66e0 100644 --- a/data/file_rules_default.json +++ b/data/file_rules_default.json @@ -3,7 +3,7 @@ "all": { "action": "ignore", "active": true, - "destination": "~/Downloads/", + "destination": "~/Downloads/FinchFiler/", "extensions": ["*"], "name": "All Files" }, diff --git a/file_manager.py b/file_manager.py index 210570d..69df361 100644 --- a/file_manager.py +++ b/file_manager.py @@ -1,4 +1,4 @@ -"""File manager and organizer for the app.""" +"""File manager and organizer module.""" __author__ = "Gull" @@ -9,14 +9,17 @@ import utils as ut import send2trash as s2t debug_mode = True -SOURCE_PATH = "~/Downloads/" -RULES_PATH = "data/file_rules_default.json" class Manager: + """File manager and organizer system.""" + # These constants are defined here, for cross module access + SOURCE_PATH = "~/Downloads/" + RULES_PATH = "data/file_rules_default.json" + """File manager that can store and sort files in a directory.""" def __init__(self, auto_config=False): - self.dir = SOURCE_PATH # Default directory - self.config_path = RULES_PATH # Default file rules + self.dir = ut.parse_dir(Manager.SOURCE_PATH) # Current directory + self.config_path = Manager.RULES_PATH # Current file rules self.filedata = dict() # Dictionary of files in directory self.filemodes = dict() # Contains file mode data self.filetypes = dict() # Maps file extensions to modes @@ -83,7 +86,7 @@ class Manager: for entry in scan: if entry.is_file(): data = self.get_file_properties(entry.name) - self.filedata[entry.name] = data + self.filedata.update({entry.name: data}) def run_task(self): """Sorts all files in the active directory, following file rules.""" diff --git a/script.py b/script.py index 745867e..409ccec 100644 --- a/script.py +++ b/script.py @@ -2,38 +2,46 @@ __author__ = "Gull" -import sys +import sys import logging as log import utils as ut import file_manager as fm +from argparse import ArgumentParser as ap LOG_PATH = "%/Gull/script.log" SOURCE_PATH = "~/Downloads" RULES_PATH = "%/Gull/file_rules_custom.json" -MODES = { - "-a": "default", - "-m": "move", - "-c": "copy", - "-d": "delete", - "-i": "ignore" -} def setup_log(): log.basicConfig(**ut.get_log_config(LOG_PATH, log.DEBUG)) log.getLogger().addHandler(log.StreamHandler(sys.stdout)) # Console log.info("Script started") -def run(source=SOURCE_PATH, rules=RULES_PATH, mode="default", debug=None): - if source == "-d": source = SOURCE_PATH - if rules == "-d": rules = RULES_PATH - if mode in MODES: mode = MODES[mode] - if debug == "true" or debug == True: fm.set_debug_mode(True) +def run(): + parser = ap(prog="Finch Filer", + description="Moves, copies, or deletes files from a source " + "directory.", + add_help=True) + parser.add_argument("-s", "--source", default=SOURCE_PATH, + help="source directory (default: User Downloads)") + parser.add_argument("-r", "--rules", default=RULES_PATH, + help="config file (default: Same as app setting)") + parser.add_argument("-m", "--mode", default="default", + help="action for all files (move, copy, delete, " + "ignore)") + parser.add_argument("-d", "--debug", action="store_true", + help="enables debug mode and ignores file actions") - source = ut.parse_dir(source, True) - rules = ut.parse_dir(rules, True) + args = parser.parse_args() + source = ut.parse_dir(args.source, True) + rules = ut.parse_dir(args.rules, True) + mode = args.mode + if args.debug: fm.set_debug_mode(True) setup_log() + fm.set_debug_mode(True) # Temporary + mgr = fm.Manager() mgr.set_directory(source) mgr.setup_file_rules(ut.load_json_file(rules)) @@ -44,6 +52,4 @@ def run(source=SOURCE_PATH, rules=RULES_PATH, mode="default", debug=None): mgr.update_file_data() mgr.run_task() -args = sys.argv.copy() -args.pop(0) -run(*args) \ No newline at end of file +run() \ No newline at end of file