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__"
__author__ = "Gull"
@ -8,9 +8,11 @@ import file_manager as fm
import logging as log
import utils as ut
from tkinter import ttk
from tkinter.messagebox import showinfo
from tkinter import messagebox as mb
from tkinter import filedialog as fd
debug_mode = False
custom_rules_path = "%/Temp/file_rules_custom.json"
def set_debug_mode(value):
global debug_mode
@ -34,12 +36,13 @@ def setup_log():
class App(tk.Tk):
def __init__(self, width=640, height=480):
super().__init__()
self.title("Download Sorter")
self.title("Gull's File Sorter")
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()
screen_width = self.winfo_screenwidth()
@ -50,27 +53,53 @@ class App(tk.Tk):
self.geometry(f"{width}x{height}+{center_x}+{center_y}")
def update_fileview(self, tree=True):
self.fm.set_directory(self.fm.get_directory())
def get_filemode_files(self, key):
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()
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:
self.fileview.delete(self.fileview.get_children()[-1])
for k, v in self.fm.filedata.items():
values = (k, str(ut.format_date(v["time"])),
str(ut.format_bytes(v["size"])))
self.fileview.insert("", tk.END, k, values=values)
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)
def load_config(self, filepath="%/Temp/file_rules_custom.json"):
data = ut.load_json_file(ut.parse_dir(filepath))
self.fm.setup_file_rules(data, True)
log.info(f"Loaded custom file rules")
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)
def save_config(self, filepath="%/Temp/file_rules_custom.json"):
ut.save_json_file(ut.parse_dir(filepath), self.fm.filemodes)
log.info(f"Saved custom file rules")
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 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):
data = ut.load_json_file(self.fm.config_path)
@ -106,16 +135,40 @@ class App(tk.Tk):
self.set_rules_variables(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):
self.update_fileview()
if self.current_mode in self.fm.filemodes:
self.set_rules_variables(self.current_mode)
def run_task(self):
result = mb.askyesno("Task", "Ready to sort all files?")
if not result: return
log.info("File organization started")
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):
result = mb.askyesno("Backup", "Ready to back up all files?")
if not result: return
log.info("File backup started")
def gui(self):
@ -132,6 +185,8 @@ class App(tk.Tk):
self.config(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.add_cascade(
@ -154,13 +209,21 @@ class App(tk.Tk):
button = ttk.Button(box, text="Backup", command=self.run_backup)
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)
self.fileview_columns = ("Name", "Last Modified", "Size")
self.fileview = ttk.Treeview(frame, columns=self.fileview_columns,
show="headings")
self.fileview_columns = ("Last Modified", "Size")
self.fileview = ttk.Treeview(frame, columns=self.fileview_columns)
# 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.column(v, width=100, anchor=tk.W)
@ -210,7 +273,7 @@ class App(tk.Tk):
radio = ttk.Radiobutton(i_frame, text=str, value=s,
variable=self.v_radio, command = f_radio)
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")
@ -225,7 +288,7 @@ class App(tk.Tk):
entry.bind('<Return>', f_targetdir)
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")
@ -239,7 +302,7 @@ class App(tk.Tk):
entry.bind('<Return>', f_extstr)
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)

View file

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

View file

@ -11,9 +11,11 @@ import send2trash as s2t
debug_mode = True
class Manager:
"""File manager that can store and sort files in a directory."""
def __init__(self, auto_config=False):
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.filetypes = dict() # Maps file extensions to modes
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))
def get_directory(self, target="Downloads"):
"""Returns a file path, appending the target string."""
dir = os.path.join(os.path.expanduser("~"), target)
if os.path.exists(dir): # Needs error handling
return dir
def set_directory(self, dir):
"""Sets the working directory of the file manager."""
if os.path.exists(dir):
self.dir = dir
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 reset: self.filemodes.clear()
filemodes = data["filemodes"]
for k, v in filemodes.items():
@ -42,59 +47,75 @@ class Manager:
self.match_file_types()
def match_file_types(self):
"""Organizes the file types dict into modes."""
self.filetypes.clear()
for k, v in self.filemodes.items():
if "extensions" in v:
for ext in v["extensions"]:
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:
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):
"""Updates the data of all files in the active directory."""
self.filedata.clear()
self.force_update = False
with os.scandir(self.dir) as scan:
for entry in scan:
if entry.is_file():
st = entry.stat()
data = {"time": st.st_mtime, "size": st.st_size}
data = self.get_file_properties(entry.name)
self.filedata[entry.name] = data
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
log.info(f"Now processing {len(self.filedata)} files")
if debug_mode:
log.warning("Debug mode is enabled; file actions will be ignored")
for k, v in self.filedata.items():
fullpath = os.path.join(self.dir, k)
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
if self.force_update:
self.update_file_data()
for k, v in self.filedata.items():
for v in self.filedata.values():
rule = self.filemodes[v["mode"]]
if rule["action"] == "move":
move(v["fullpath"], rule["destination"])
elif rule["action"] == "copy":
copy(v["fullpath"], rule["destination"])
elif rule["action"] == "delete":
delete(v["fullpath"])
action = rule["action"]
if action == "move":
src, dst = move(v["fullpath"], rule["destination"])
self.work.append((action, src, dst))
elif action == "copy":
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):
src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Moved file: {src} > {dst}")
if not debug_mode:
shutil.move(src, dst)
return src, dst
def copy(src, dst):
src, dst = ut.parse_dir(src), ut.parse_dir(dst)
log.info(f"Copied file: {src} > {dst}")
if not debug_mode:
shutil.copy2(src, dst)
return src, dst
def delete(src):
src = ut.parse_dir(src)
@ -103,4 +124,5 @@ def delete(src):
try:
s2t.send2trash(src)
except s2t.TrashPermissionError:
pass
pass
return src

View file

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