Comments and cleanup; resuming this project

This commit is contained in:
Thomas Wilczynski 2025-11-04 13:14:16 -08:00
commit fafa69202b
5 changed files with 213 additions and 96 deletions

View file

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

228
app.py
View file

@ -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)
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():
for i, (k, v) in enumerate(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)
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(
@ -191,7 +281,22 @@ class App(tk.Tk):
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('<Return>', 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('<Return>', f_extstr)
entry.pack(side=tk.TOP, fill=tk.X)

View file

@ -3,7 +3,7 @@
"all": {
"action": "ignore",
"active": true,
"destination": "~/Downloads/",
"destination": "~/Downloads/FinchFiler/",
"extensions": ["*"],
"name": "All Files"
},

View file

@ -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
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"
class Manager:
"""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."""

View file

@ -6,34 +6,42 @@ 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)
run()