Improved treeview; added custom directory and message boxes

This commit is contained in:
Thomas Wilczynski 2025-09-05 13:48:05 -07:00
commit 380212e2be
4 changed files with 143 additions and 50 deletions

109
app.py
View file

@ -1,4 +1,4 @@
"""Download utility app.""" """File sorter utility app."""
__name__ = "__main__" __name__ = "__main__"
__author__ = "Gull" __author__ = "Gull"
@ -8,9 +8,11 @@ import file_manager as fm
import logging as log import logging as log
import utils as ut import utils as ut
from tkinter import ttk from tkinter import ttk
from tkinter.messagebox import showinfo from tkinter import messagebox as mb
from tkinter import filedialog as fd
debug_mode = False debug_mode = False
custom_rules_path = "%/Temp/file_rules_custom.json"
def set_debug_mode(value): def set_debug_mode(value):
global debug_mode global debug_mode
@ -34,12 +36,13 @@ def setup_log():
class App(tk.Tk): class App(tk.Tk):
def __init__(self, width=640, height=480): def __init__(self, width=640, height=480):
super().__init__() super().__init__()
self.title("Download Sorter") self.title("Gull's File Sorter")
self.current_mode = "all" self.current_mode = "all"
self.option_add('*tearOff', tk.FALSE) self.option_add('*tearOff', tk.FALSE)
self.fm = fm.Manager(True) self.fm = fm.Manager(True)
self.log = list() self.log = list()
self.gui() self.gui()
self.load_config()
self.prep() self.prep()
screen_width = self.winfo_screenwidth() screen_width = self.winfo_screenwidth()
@ -50,27 +53,53 @@ class App(tk.Tk):
self.geometry(f"{width}x{height}+{center_x}+{center_y}") self.geometry(f"{width}x{height}+{center_x}+{center_y}")
def update_fileview(self, tree=True): def get_filemode_files(self, key):
self.fm.set_directory(self.fm.get_directory()) if key == "all": return
files = dict()
for k, v in self.fm.filedata.items():
if v["mode"] == key: files[k] = v
return files if len(files) > 0 else None
def update_fileview(self):
tree = self.v_tree.get()
self.fm.update_file_data() self.fm.update_file_data()
log.info(f"Processed file view with {len(self.fm.filedata)} items") 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:
self.fileview.delete(self.fileview.get_children()[-1]) self.fileview.delete(self.fileview.get_children()[-1])
for k, v in self.fm.filedata.items(): if tree:
values = (k, str(ut.format_date(v["time"])), for k, v in self.fm.filemodes.items():
str(ut.format_bytes(v["size"]))) files = self.get_filemode_files(k)
self.fileview.insert("", tk.END, k, values=values) if files != None:
id = self.fileview.insert("", tk.END, text=v["name"],
open=True)
def load_config(self, filepath="%/Temp/file_rules_custom.json"): for k, v in files.items():
data = ut.load_json_file(ut.parse_dir(filepath)) values = (str(ut.format_date(v["time"])),
self.fm.setup_file_rules(data, True) str(ut.format_bytes(v["size"])))
log.info(f"Loaded custom file rules") self.fileview.insert(id, tk.END, text=k, values=values)
def save_config(self, filepath="%/Temp/file_rules_custom.json"): else:
ut.save_json_file(ut.parse_dir(filepath), self.fm.filemodes) for k, v in self.fm.filedata.items():
log.info(f"Saved custom file rules") values = (str(ut.format_date(v["time"])),
str(ut.format_bytes(v["size"])))
self.fileview.insert("", tk.END, text=k, values=values)
def load_config(self, filepath=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=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): def reset_config(self):
data = ut.load_json_file(self.fm.config_path) data = ut.load_json_file(self.fm.config_path)
@ -106,16 +135,40 @@ class App(tk.Tk):
self.set_rules_variables(key) self.set_rules_variables(key)
log.debug(f"Selected file mode: {key}") log.debug(f"Selected file mode: {key}")
def set_directory(self, dir):
self.fm.set_directory(dir)
self.update_fileview()
def ask_directory(self):
dir = fd.askdirectory(initialdir = self.fm.get_directory(""),
title = "Select directory")
self.set_directory(dir)
def prep(self): def prep(self):
self.update_fileview() self.update_fileview()
if self.current_mode in self.fm.filemodes: if self.current_mode in self.fm.filemodes:
self.set_rules_variables(self.current_mode) self.set_rules_variables(self.current_mode)
def run_task(self): def run_task(self):
result = mb.askyesno("Task", "Ready to sort all files?")
if not result: return
log.info("File organization started") log.info("File organization started")
self.fm.run_task() self.fm.run_task()
result = self.fm.work
stats = {"move": 0, "copy": 0, "delete": 0}
for v in result: stats[v[0]] += 1
mb.showinfo("Task",
f"Successfully processed {len(result)} files in total.\n"
+ f"Moved {stats["move"]} files.\n"
+ f"Copied {stats["copy"]} files.\n"
+ f"Deleted {stats["delete"]} files.\n")
def run_backup(self): def run_backup(self):
result = mb.askyesno("Backup", "Ready to back up all files?")
if not result: return
log.info("File backup started") log.info("File backup started")
def gui(self): def gui(self):
@ -132,6 +185,8 @@ class App(tk.Tk):
self.config(menu=a_menu) self.config(menu=a_menu)
a_menu_file = tk.Menu(a_menu) a_menu_file = tk.Menu(a_menu)
a_menu_file.add_command(label="Set Directory",
command=self.ask_directory)
a_menu_file.add_command(label="Exit", command=self.destroy) a_menu_file.add_command(label="Exit", command=self.destroy)
a_menu.add_cascade( a_menu.add_cascade(
@ -154,13 +209,21 @@ class App(tk.Tk):
button = ttk.Button(box, text="Backup", command=self.run_backup) button = ttk.Button(box, text="Backup", command=self.run_backup)
button.grid(column=2, row=0, pady=5) button.grid(column=2, 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=3, row=0, padx=5, pady=5)
box.pack(side=tk.TOP, fill=tk.X) box.pack(side=tk.TOP, fill=tk.X)
self.fileview_columns = ("Name", "Last Modified", "Size") self.fileview_columns = ("Last Modified", "Size")
self.fileview = ttk.Treeview(frame, columns=self.fileview_columns, self.fileview = ttk.Treeview(frame, columns=self.fileview_columns)
show="headings") # Disabled "show=headings"
for v in self.fileview_columns: 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.heading(v, text=v)
self.fileview.column(v, width=100, anchor=tk.W) self.fileview.column(v, width=100, anchor=tk.W)
@ -210,7 +273,7 @@ class App(tk.Tk):
radio = ttk.Radiobutton(i_frame, text=str, value=s, radio = ttk.Radiobutton(i_frame, text=str, value=s,
variable=self.v_radio, command = f_radio) variable=self.v_radio, command = f_radio)
radio.pack(side=tk.LEFT) radio.pack(side=tk.LEFT)
i_frame.pack(side=tk.TOP, anchor=tk.W) i_frame.pack(side=tk.TOP, anchor=tk.W, pady=5)
i_frame = ttk.Labelframe(details, text="Target Directory") i_frame = ttk.Labelframe(details, text="Target Directory")
@ -225,7 +288,7 @@ class App(tk.Tk):
entry.bind('<Return>', f_targetdir) entry.bind('<Return>', f_targetdir)
entry.pack(side=tk.TOP, fill=tk.X) entry.pack(side=tk.TOP, fill=tk.X)
i_frame.pack(side=tk.TOP, fill=tk.X) i_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
i_frame = ttk.Labelframe(details, text="File Extensions") i_frame = ttk.Labelframe(details, text="File Extensions")
@ -239,7 +302,7 @@ class App(tk.Tk):
entry.bind('<Return>', f_extstr) entry.bind('<Return>', f_extstr)
entry.pack(side=tk.TOP, fill=tk.X) entry.pack(side=tk.TOP, fill=tk.X)
i_frame.pack(side=tk.TOP, fill=tk.X) i_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
details.grid(row=0, column=1, sticky=tk.NSEW) details.grid(row=0, column=1, sticky=tk.NSEW)

View file

@ -1,7 +1,7 @@
{ {
"filemodes": { "filemodes": {
"all": { "all": {
"action": "delete", "action": "ignore",
"active": true, "active": true,
"destination": "~/Downloads/", "destination": "~/Downloads/",
"extensions": ["*"], "extensions": ["*"],

View file

@ -11,9 +11,11 @@ import send2trash as s2t
debug_mode = True debug_mode = True
class Manager: class Manager:
"""File manager that can store and sort files in a directory."""
def __init__(self, auto_config=False): def __init__(self, auto_config=False):
self.dir = os.path.expanduser("~") # Default directory self.dir = os.path.expanduser("~") # Default directory
self.filedata = dict() self.work = list() # Work history of last task
self.filedata = dict() # Dictionary of files in directory
self.filemodes = dict() # Contains file mode data self.filemodes = dict() # Contains file mode data
self.filetypes = dict() # Maps file extensions to modes self.filetypes = dict() # Maps file extensions to modes
self.config_path = "data/file_rules_default.json" self.config_path = "data/file_rules_default.json"
@ -23,17 +25,20 @@ class Manager:
self.setup_file_rules(ut.load_json_file(self.config_path)) self.setup_file_rules(ut.load_json_file(self.config_path))
def get_directory(self, target="Downloads"): def get_directory(self, target="Downloads"):
"""Returns a file path, appending the target string."""
dir = os.path.join(os.path.expanduser("~"), target) dir = os.path.join(os.path.expanduser("~"), target)
if os.path.exists(dir): # Needs error handling if os.path.exists(dir): # Needs error handling
return dir return dir
def set_directory(self, dir): def set_directory(self, dir):
"""Sets the working directory of the file manager."""
if os.path.exists(dir): if os.path.exists(dir):
self.dir = dir self.dir = dir
def setup_file_rules(self, data, reset=False): def setup_file_rules(self, data, reset=False):
if reset: self.filemodes.clear() """Sets or resets file rules from the given data."""
if not "filemodes" in data: return if not "filemodes" in data: return
if reset: self.filemodes.clear()
filemodes = data["filemodes"] filemodes = data["filemodes"]
for k, v in filemodes.items(): for k, v in filemodes.items():
@ -42,59 +47,75 @@ class Manager:
self.match_file_types() self.match_file_types()
def match_file_types(self): def match_file_types(self):
"""Organizes the file types dict into modes."""
self.filetypes.clear() self.filetypes.clear()
for k, v in self.filemodes.items(): for k, v in self.filemodes.items():
if "extensions" in v: if "extensions" in v:
for ext in v["extensions"]: for ext in v["extensions"]:
self.filetypes[ext] = k self.filetypes[ext] = k
def modify_type_list(self, key, values): def set_type_list(self, key, values):
"""Sets the list of extensions for the given file type."""
if key in self.filetypes: if key in self.filetypes:
self.filetypes[key] = list(values) self.filetypes[key] = list(values)
def get_file_properties(self, short_filename):
"""Returns the time, size, mode, and full path of a single file."""
fullpath = os.path.join(self.dir, short_filename)
# Maybe check if fullpath exists (os.path.exists(fullpath))
_, ext = os.path.splitext(fullpath)
ext = ext[1:].lower()
mode = self.filetypes[ext] if ext in self.filetypes else "other"
stat = os.stat(fullpath)
return {"time": stat.st_mtime, "size": stat.st_size, "mode": mode,
"fullpath": fullpath}
def update_file_data(self): def update_file_data(self):
"""Updates the data of all files in the active directory."""
self.filedata.clear() self.filedata.clear()
self.force_update = False
with os.scandir(self.dir) as scan: with os.scandir(self.dir) as scan:
for entry in scan: for entry in scan:
if entry.is_file(): if entry.is_file():
st = entry.stat() data = self.get_file_properties(entry.name)
data = {"time": st.st_mtime, "size": st.st_size}
self.filedata[entry.name] = data self.filedata[entry.name] = data
def run_task(self): def run_task(self):
"""Sorts all files in the active directory, following file rules."""
self.work.clear()
self.match_file_types() # Updates file types dict self.match_file_types() # Updates file types dict
log.info(f"Now processing {len(self.filedata)} files") log.info(f"Now processing {len(self.filedata)} files")
if debug_mode: if debug_mode:
log.warning("Debug mode is enabled; file actions will be ignored") log.warning("Debug mode is enabled; file actions will be ignored")
for k, v in self.filedata.items(): if self.force_update:
fullpath = os.path.join(self.dir, k) self.update_file_data()
if os.path.exists(fullpath):
_, ext = os.path.splitext(fullpath)
ext = ext[1:].lower()
mode = self.filetypes[ext] if ext in self.filetypes else "other"
v["mode"] = mode
v["fullpath"] = fullpath
for k, v in self.filedata.items(): for v in self.filedata.values():
rule = self.filemodes[v["mode"]] rule = self.filemodes[v["mode"]]
if rule["action"] == "move": action = rule["action"]
move(v["fullpath"], rule["destination"]) if action == "move":
elif rule["action"] == "copy": src, dst = move(v["fullpath"], rule["destination"])
copy(v["fullpath"], rule["destination"]) self.work.append((action, src, dst))
elif rule["action"] == "delete": elif action == "copy":
delete(v["fullpath"]) src, dst = copy(v["fullpath"], rule["destination"])
self.work.append((action, src, dst))
elif action == "delete":
src = delete(v["fullpath"])
self.work.append((action, src, "(trash)"))
def move(src, dst): def move(src, dst):
src, dst = ut.parse_dir(src), ut.parse_dir(dst) src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Moved file: {src} > {dst}") log.info(f"Moved file: {src} > {dst}")
if not debug_mode: if not debug_mode:
shutil.move(src, dst) shutil.move(src, dst)
return src, dst
def copy(src, dst): def copy(src, dst):
src, dst = ut.parse_dir(src), ut.parse_dir(dst) src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Copied file: {src} > {dst}") log.info(f"Copied file: {src} > {dst}")
if not debug_mode: if not debug_mode:
shutil.copy2(src, dst) shutil.copy2(src, dst)
return src, dst
def delete(src): def delete(src):
src = ut.parse_dir(src) src = ut.parse_dir(src)
@ -103,4 +124,5 @@ def delete(src):
try: try:
s2t.send2trash(src) s2t.send2trash(src)
except s2t.TrashPermissionError: except s2t.TrashPermissionError:
pass pass
return src

View file

@ -36,14 +36,22 @@ def format_date(timestamp):
def load_json_file(filepath): def load_json_file(filepath):
"""Opens a JSON file, and returns JSON data as a dict.""" """Opens a JSON file, and returns JSON data as a dict."""
with open(filepath, "r", encoding="utf-8") as f: try: # Needs improvement!
data = json.load(f) with open(filepath, "r", encoding="utf-8") as f:
return data data = json.load(f)
return data
except:
return None
def save_json_file(filepath, data, raw=False): def save_json_file(filepath, data, raw=False):
"""Saves a JSON file, given a dict.""" """Saves a JSON file, given a dict."""
with open(filepath, "w", encoding="utf-8") as f: try:
json.dump(data, f, sort_keys=True, indent=None if raw else 2) with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, sort_keys=True, indent=None if raw else 2)
except:
return False
else:
return True
def get_user_dir(dir=""): def get_user_dir(dir=""):
"""Returns the joined user directory.""" """Returns the joined user directory."""