Improved treeview; added custom directory and message boxes
This commit is contained in:
parent
5a04dd37f1
commit
380212e2be
4 changed files with 143 additions and 50 deletions
105
app.py
105
app.py
|
|
@ -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():
|
||||||
|
files = self.get_filemode_files(k)
|
||||||
|
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"])))
|
str(ut.format_bytes(v["size"])))
|
||||||
self.fileview.insert("", tk.END, k, values=values)
|
self.fileview.insert(id, tk.END, text=k, values=values)
|
||||||
|
|
||||||
def load_config(self, filepath="%/Temp/file_rules_custom.json"):
|
else:
|
||||||
data = ut.load_json_file(ut.parse_dir(filepath))
|
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)
|
self.fm.setup_file_rules(data, True)
|
||||||
log.info(f"Loaded custom file rules")
|
log.info(f"Loaded custom file rules: {path}")
|
||||||
|
|
||||||
def save_config(self, filepath="%/Temp/file_rules_custom.json"):
|
def save_config(self, filepath=custom_rules_path):
|
||||||
ut.save_json_file(ut.parse_dir(filepath), self.fm.filemodes)
|
path = ut.parse_dir(filepath)
|
||||||
log.info(f"Saved custom file rules")
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"filemodes": {
|
"filemodes": {
|
||||||
"all": {
|
"all": {
|
||||||
"action": "delete",
|
"action": "ignore",
|
||||||
"active": true,
|
"active": true,
|
||||||
"destination": "~/Downloads/",
|
"destination": "~/Downloads/",
|
||||||
"extensions": ["*"],
|
"extensions": ["*"],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -104,3 +125,4 @@ def delete(src):
|
||||||
s2t.send2trash(src)
|
s2t.send2trash(src)
|
||||||
except s2t.TrashPermissionError:
|
except s2t.TrashPermissionError:
|
||||||
pass
|
pass
|
||||||
|
return src
|
||||||
8
utils.py
8
utils.py
|
|
@ -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."""
|
||||||
|
try: # Needs improvement!
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data
|
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."""
|
||||||
|
try:
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, sort_keys=True, indent=None if raw else 2)
|
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."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue