├── GUI ├── __init__.py ├── viewRemove.py ├── viewAddRule.py ├── viewModifyRule.py └── viewMain.py ├── Tests ├── __init__.py └── testSorter.py ├── Utils ├── __init__.py ├── config.py └── sorter.py ├── requirements.txt ├── Assets ├── SNAKE.png ├── banner.png └── SNAKE.aseprite ├── app.py ├── .gitignore └── README.md /GUI/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Pypubsub 2 | wxPython 3 | -------------------------------------------------------------------------------- /Assets/SNAKE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehanix/TidyCobra/HEAD/Assets/SNAKE.png -------------------------------------------------------------------------------- /Assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehanix/TidyCobra/HEAD/Assets/banner.png -------------------------------------------------------------------------------- /Assets/SNAKE.aseprite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehanix/TidyCobra/HEAD/Assets/SNAKE.aseprite -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from GUI import viewMain 2 | 3 | if __name__ == "__main__": 4 | 5 | viewMain.renderGui() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # personal files 2 | config.json 3 | */__pycache__ 4 | .env 5 | .venv 6 | env/ 7 | venv/ 8 | ENV/ 9 | env.bak/ 10 | venv.bak/ 11 | 12 | # IDE files 13 | .idea/ 14 | *.iml 15 | out/ 16 | *.sublime-workspace 17 | *.sublime-project 18 | .vs/ 19 | *.user 20 | *.suo 21 | *.userosscache 22 | *.sln.docstates 23 | .vscode/ 24 | .history/ 25 | .venv 26 | venv 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

🐍TidyCobra is a Python-based utility that automates the sorting of your files into designated folders like Pictures, Music, Documents, etc., keeping your folders organized.

6 |

🐍 Includes configuration tool, as well as a script to be set to run, in order to periodically reroute your files to their respective folders.

7 |

🐍 Written in Python. GUI created using wxPython.

8 | 9 | ## Features 10 | 11 | - **Automatic Sorting**: Redirect files to specific folders based on file type. 12 | - **Custom Rules**: Configure your own sorting criteria. 13 | 14 | ## Works on: 15 | 16 | - Linux 17 | - Windows 18 | 19 | ## Requirements 20 | 21 | - Python 3.x 22 | - wxPython (for GUI) 23 | - pypubsub 24 | 25 | ## Installation 26 | 27 | ```bash 28 | git clone https://github.com/mehanix/TidyCobra.git 29 | cd TidyCobra 30 | pip install -r requirements.txt 31 | ``` 32 | 33 | ## Usage 34 | 35 | To start TidyCobra, run: 36 | 37 | ```bash 38 | python app.py 39 | ``` 40 | 41 | Follow the GUI prompts to configure your sorting rules. 42 | 43 | ## Configuration 44 | 45 | Use the provided GUI to create rules for sorting your downloads. These rules determine the destination folder for each file type. 46 | 47 | ## Contributing 48 | 49 | Contributions are welcome! Feel free to fork the repository, make changes, and submit a pull request. For major changes, please open an issue first to discuss what you would like to change. 50 | 51 | ## Acknowledgments 52 | 53 | Thanks to all the contributors who have helped with this project. Special thanks to the wxPython project for the GUI toolkit. 54 | -------------------------------------------------------------------------------- /GUI/viewRemove.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from pubsub import pub 3 | 4 | class RemoveRule(wx.Frame): 5 | 6 | def onBtnConfirm(self,event) -> None: 7 | 8 | pub.sendMessage("removeRuleListener",id=self.id) 9 | self.Destroy() 10 | 11 | def onBtnCancel(self,event) -> None: 12 | 13 | self.Destroy() 14 | 15 | def __init__(self, id:int) -> None: 16 | wx.Frame.__init__(self, None, title="Remove rule", style=wx.DEFAULT_DIALOG_STYLE & ~wx.RESIZE_BORDER) 17 | self.panel = wx.Panel(self) 18 | self.id: int = id 19 | 20 | '''Sizers''' 21 | self.vbox = wx.BoxSizer(wx.VERTICAL) 22 | self.operationsBox = wx.BoxSizer(wx.HORIZONTAL) 23 | 24 | '''Labels''' 25 | self.message= wx.StaticText(self.panel, label="Are you sure you want to delete this role?") 26 | self.vbox.Add(self.message, flag=wx.LEFT|wx.RIGHT|wx.TOP, border=10) 27 | 28 | '''Buttons''' 29 | self.btnConfirm= wx.Button(self.panel, label="Yes") 30 | self.btnConfirm.Bind(wx.EVT_BUTTON, self.onBtnConfirm) 31 | self.btnCancel = wx.Button(self.panel, label="No") 32 | self.btnCancel.Bind(wx.EVT_BUTTON, self.onBtnCancel) 33 | self.operationsBox.Add(self.btnConfirm, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 34 | self.operationsBox.Add(self.btnCancel, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 35 | self.vbox.Add(self.operationsBox, flag=wx.CENTER|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 36 | 37 | self.panel.SetSizer(self.vbox) 38 | self.Center() 39 | self.SetSize(self.GetBestSize()) 40 | self.vbox.Fit(self) 41 | self.vbox.Layout() 42 | self.SetMinSize(self.GetSize()) 43 | self.SetMaxSize(self.GetSize()) 44 | 45 | 46 | 47 | self.Show(True) -------------------------------------------------------------------------------- /Utils/config.py: -------------------------------------------------------------------------------- 1 | from json import load, dump 2 | from os import path 3 | 4 | configTemplate: dict = { 5 | "startup": False, 6 | "isIntervalRunning": False, 7 | "interval": 15000, 8 | "rulesList": [ 9 | { 10 | "sourceFolder": path.join(path.expanduser("~"), "Downloads"), 11 | "destinationFolders": [ 12 | { 13 | "extensions": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"], 14 | "destinationPath": path.join(path.expanduser("~"), "Pictures") 15 | }, 16 | { 17 | "extensions": [".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"], 18 | "destinationPath": path.join(path.expanduser("~"), "Documents") 19 | }, 20 | { 21 | "extensions": [".mp3", ".wav", ".flac", ".aac"], 22 | "destinationPath": path.join(path.expanduser("~"), "Music") 23 | }, 24 | { 25 | "extensions": [".mp4", ".mkv", ".avi", ".mov", ".webm"], 26 | "destinationPath": path.join(path.expanduser("~"), "Videos") 27 | }, 28 | { 29 | "extensions": [".zip", ".rar", ".7z", ".tar", ".gz"], 30 | "destinationPath": path.join(path.expanduser("~"), "Documents") 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | 37 | 38 | class Config: 39 | 40 | def saveConfig(self) -> None: 41 | 42 | with open(self.configFilePath, 'w') as configFile: 43 | dump({ 44 | "isIntervalRunning": self.isIntervalRunning, 45 | "interval":self.interval, 46 | "startup": self.startup, 47 | "rulesList": self.rulesList 48 | }, configFile) 49 | 50 | 51 | def loadConfig(self,configFilePath:str) -> None: 52 | 53 | with open(configFilePath, 'r') as configFile: 54 | dummy:dict = load(configFile) 55 | 56 | if "rules" in dummy.keys(): 57 | # old config format support 58 | self.isIntervalRunning: bool = False 59 | self.interval: int = 15000 60 | self.startup: bool = False 61 | self.rulesList: list = [ 62 | { 63 | "sourceFolder": dummy["path_downloads"], 64 | "destinationFolders": [ 65 | { 66 | "destinationPath": rule[0], 67 | "extensions": rule[1].split(" ") 68 | } 69 | for rule in dummy["rules"] 70 | ] 71 | } 72 | ] 73 | else: 74 | self.isIntervalRunning: bool = dummy["isIntervalRunning"] 75 | self.interval: int = dummy["interval"] 76 | self.startup: bool = dummy["startup"] 77 | self.rulesList: list = dummy["rulesList"] 78 | 79 | 80 | def __init__(self, configFilePath: str = "./config.json") -> None: 81 | 82 | if not path.isfile(configFilePath): 83 | 84 | with open(configFilePath, 'w+') as configFile: 85 | dump(configTemplate, configFile) 86 | self.configFilePath = configFilePath 87 | self.loadConfig(self.configFilePath) -------------------------------------------------------------------------------- /GUI/viewAddRule.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from pubsub import pub 3 | 4 | class AddRuleWindow(wx.Frame): 5 | 6 | def onBtnBrowse(self, event): 7 | dlg = wx.DirDialog(self, "Choose a directory:", style=wx.DD_DEFAULT_STYLE) 8 | if dlg.ShowModal() == wx.ID_OK: 9 | self.textboxFolderPath.SetValue(dlg.GetPath()) 10 | dlg.Destroy() 11 | 12 | def onBtnCancel(self, event): 13 | self.Destroy() 14 | 15 | def onBtnSave(self, event): 16 | 17 | extensions = [ 18 | extension if extension.startswith(".") else "." + extension 19 | for extension in self.textboxExtensions.GetValue().split(" ") 20 | ] 21 | 22 | data = { 23 | "extensions": extensions, 24 | "destinationPath": self.textboxFolderPath.GetValue() 25 | } 26 | 27 | pub.sendMessage("addRuleListener", data=data) 28 | 29 | self.Destroy() 30 | 31 | def __init__(self) -> None: 32 | wx.Frame.__init__(self, None, title="Add rule", style=wx.DEFAULT_DIALOG_STYLE & ~wx.RESIZE_BORDER) 33 | self.panel = wx.Panel(self) 34 | 35 | '''Sizers''' 36 | self.vbox = wx.BoxSizer(wx.VERTICAL) 37 | self.hboxPath = wx.BoxSizer(wx.HORIZONTAL) 38 | self.operationsBox = wx.BoxSizer(wx.HORIZONTAL) 39 | 40 | '''Labels''' 41 | self.labelFolderPath = wx.StaticText(self.panel, label="Select folder path:") 42 | self.labelExtensions = wx.StaticText(self.panel, label="Specify file extensions to reroute: (separated by spaces)") 43 | 44 | '''Textboxes''' 45 | self.textboxFolderPath = wx.TextCtrl(self.panel, size=(300, -1)) 46 | self.textboxExtensions = wx.TextCtrl(self.panel) 47 | '''Buttons''' 48 | self.btnBrowseFolder = wx.Button(self.panel, label="Browse") 49 | self.btnBrowseFolder.Bind(wx.EVT_BUTTON, self.onBtnBrowse) 50 | self.btnSave = wx.Button(self.panel, label="Save") 51 | self.btnSave.Bind(wx.EVT_BUTTON, self.onBtnSave) 52 | self.btnCancel = wx.Button(self.panel, label="Back") 53 | self.btnCancel.Bind(wx.EVT_BUTTON, self.onBtnCancel) 54 | 55 | '''Layout''' 56 | # Select path 57 | self.vbox.Add(self.labelFolderPath, flag=wx.LEFT|wx.RIGHT|wx.TOP, border=10) 58 | 59 | self.hboxPath.Add(self.textboxFolderPath, proportion=1) 60 | self.hboxPath.Add(self.btnBrowseFolder, wx.SizerFlags().Border(wx.LEFT, 5)) 61 | self.vbox.Add(self.hboxPath, flag=wx.EXPAND|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 62 | 63 | # Set extensions 64 | self.vbox.Add(self.labelExtensions, flag=wx.LEFT|wx.RIGHT|wx.TOP, border=10) 65 | self.vbox.Add(self.textboxExtensions, flag=wx.EXPAND|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 66 | 67 | # Save 68 | self.operationsBox.Add(self.btnCancel, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 69 | self.operationsBox.Add(self.btnSave, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 70 | self.vbox.Add(self.operationsBox, flag=wx.CENTER|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 71 | 72 | self.panel.SetSizer(self.vbox) 73 | self.Center() 74 | self.SetSize(self.GetBestSize()) 75 | self.vbox.Fit(self) 76 | self.vbox.Layout() 77 | self.SetMinSize(self.GetSize()) 78 | self.SetMaxSize(self.GetSize()) 79 | 80 | self.Show(True) 81 | -------------------------------------------------------------------------------- /Utils/sorter.py: -------------------------------------------------------------------------------- 1 | from Utils.config import Config 2 | from glob import glob 3 | from shutil import move 4 | from os import path 5 | 6 | def sortFolderByExtensions(rule: dict) -> dict: 7 | 8 | successCount:int = 0 9 | failCount:int = 0 10 | message:str = "Success!" 11 | 12 | # rule format: 13 | # { 14 | # "sourceFolder": "", 15 | # "destinationFolders": [ 16 | # { 17 | # "extensions": [], 18 | # "destinationPath": "" 19 | # }, 20 | # ... 21 | # ] 22 | # }, ... 23 | 24 | sourceFolder:str = rule["sourceFolder"] 25 | if not sourceFolder or not path.isdir(sourceFolder): 26 | return { 27 | "successCount": successCount, 28 | "failCount": failCount, 29 | "message": f"Failed! The source folder ({sourceFolder}) does not exist." 30 | } 31 | 32 | extensionsMapping:dict = {} 33 | for folder in rule["destinationFolders"]: 34 | for extension in folder["extensions"]: 35 | 36 | if extension in extensionsMapping.keys(): 37 | continue 38 | 39 | if not path.isdir(folder["destinationPath"]): 40 | continue 41 | 42 | extensionsMapping[extension] = folder["destinationPath"] 43 | 44 | files:list = glob(f'{sourceFolder}/*') 45 | for file in files: 46 | fileExtension:str = "."+file.split('.')[-1] 47 | 48 | if not fileExtension in extensionsMapping.keys(): 49 | continue 50 | 51 | fileName:str = path.basename(file) 52 | newFilePath:str = f'{extensionsMapping[fileExtension]}/{fileName}' 53 | 54 | i:int = 1 55 | while path.isfile(newFilePath): 56 | newFilePath = f'{extensionsMapping[fileExtension]}/d{i}_{fileName}' 57 | i += 1 58 | 59 | try: 60 | move(file,newFilePath) 61 | successCount += 1 62 | except PermissionError: 63 | message = "Access denied for some files!" 64 | failCount += 1 65 | 66 | return { 67 | "successCount": successCount, 68 | "failCount": failCount, 69 | "message": message 70 | } 71 | 72 | class Sorter: 73 | 74 | def sortAll(self) -> dict: 75 | 76 | successCount:int = 0 77 | failCount:int = 0 78 | 79 | areAllEndSuccessfully:bool = True 80 | ruleFailMessage:str 81 | 82 | for rule in self.ruleList: 83 | result = sortFolderByExtensions(rule) 84 | successCount += result['successCount'] 85 | failCount += result['failCount'] 86 | if result['message'] != "Success!": 87 | areAllEndSuccessfully = False 88 | ruleFailMessage = result['message'] 89 | 90 | if areAllEndSuccessfully: 91 | return { 92 | "successCount": successCount, 93 | "failCount": failCount, 94 | "message": "Success!" 95 | } 96 | else: 97 | return { 98 | "successCount": successCount, 99 | "failCount": failCount, 100 | "message": ruleFailMessage 101 | } 102 | 103 | def __init__(self, config: Config) -> None: 104 | self.ruleList: list = config.rulesList 105 | -------------------------------------------------------------------------------- /GUI/viewModifyRule.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from pubsub import pub 3 | 4 | class ModifyRuleWindow(wx.Frame): 5 | 6 | def onBtnBrowse(self, event): 7 | dlg = wx.DirDialog(self, "Choose a directory:", style=wx.DD_DEFAULT_STYLE) 8 | if dlg.ShowModal() == wx.ID_OK: 9 | self.textboxFolderPath.SetValue(dlg.GetPath()) 10 | dlg.Destroy() 11 | 12 | def onBtnCancel(self, event): 13 | self.Destroy() 14 | 15 | def onBtnSave(self, event): 16 | 17 | extensions = [ 18 | extension if extension.startswith(".") else "." + extension 19 | for extension in self.textboxExtensions.GetValue().split(" ") 20 | ] 21 | 22 | data = { 23 | "id": self.id, 24 | "extensions": extensions, 25 | "destinationPath": self.textboxFolderPath.GetValue() 26 | } 27 | 28 | pub.sendMessage("modifyRuleListener", data=data) 29 | 30 | self.Destroy() 31 | 32 | def __init__(self, id:int, baseData:dict = {"destinationPath":"","extensions":[]}) -> None: 33 | wx.Frame.__init__(self, None, title="Modify rule", style=wx.DEFAULT_DIALOG_STYLE & ~wx.RESIZE_BORDER) 34 | self.panel = wx.Panel(self) 35 | 36 | self.id:int = id 37 | 38 | '''Sizers''' 39 | self.vbox = wx.BoxSizer(wx.VERTICAL) 40 | self.hboxPath = wx.BoxSizer(wx.HORIZONTAL) 41 | self.operationsBox = wx.BoxSizer(wx.HORIZONTAL) 42 | 43 | '''Labels''' 44 | self.labelFolderPath = wx.StaticText(self.panel, label="Select folder path:") 45 | self.labelExtensions = wx.StaticText(self.panel, label="Specify file extensions to reroute: (separated by spaces)") 46 | 47 | '''Textboxes''' 48 | self.textboxFolderPath = wx.TextCtrl(self.panel, size=(300, -1)) 49 | self.textboxExtensions = wx.TextCtrl(self.panel) 50 | self.textboxFolderPath.Value = baseData["destinationPath"] 51 | self.textboxExtensions.Value = " ".join(baseData["extensions"]) 52 | 53 | '''Buttons''' 54 | self.btnBrowseFolder = wx.Button(self.panel, label="Browse") 55 | self.btnBrowseFolder.Bind(wx.EVT_BUTTON, self.onBtnBrowse) 56 | self.btnSave = wx.Button(self.panel, label="Save") 57 | self.btnSave.Bind(wx.EVT_BUTTON, self.onBtnSave) 58 | self.btnCancel = wx.Button(self.panel, label="Back") 59 | self.btnCancel.Bind(wx.EVT_BUTTON, self.onBtnCancel) 60 | 61 | '''Layout''' 62 | # Select path 63 | self.vbox.Add(self.labelFolderPath, flag=wx.LEFT|wx.RIGHT|wx.TOP, border=10) 64 | 65 | self.hboxPath.Add(self.textboxFolderPath, proportion=1) 66 | self.hboxPath.Add(self.btnBrowseFolder, wx.SizerFlags().Border(wx.LEFT, 5)) 67 | self.vbox.Add(self.hboxPath, flag=wx.EXPAND|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 68 | 69 | # Set extensions 70 | self.vbox.Add(self.labelExtensions, flag=wx.LEFT|wx.RIGHT|wx.TOP, border=10) 71 | self.vbox.Add(self.textboxExtensions, flag=wx.EXPAND|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 72 | 73 | # Save 74 | self.operationsBox.Add(self.btnCancel, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 75 | self.operationsBox.Add(self.btnSave, flag=wx.RIGHT|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 76 | self.vbox.Add(self.operationsBox, flag=wx.CENTER|wx.LEFT|wx.RIGHT|wx.TOP|wx.BOTTOM, border=10) 77 | 78 | self.panel.SetSizer(self.vbox) 79 | self.Center() 80 | self.SetSize(self.GetBestSize()) 81 | self.vbox.Fit(self) 82 | self.vbox.Layout() 83 | self.SetMinSize(self.GetSize()) 84 | self.SetMaxSize(self.GetSize()) 85 | 86 | self.Show(True) -------------------------------------------------------------------------------- /Tests/testSorter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | from tempfile import mkdtemp 4 | from shutil import rmtree 5 | from Utils.sorter import sortFolderByExtensions 6 | from os import path, makedirs 7 | 8 | class TestSorter(unittest.TestCase): 9 | 10 | def setUp(self) -> None: 11 | 12 | self.tempDirectory:str = mkdtemp() 13 | self.sourceDirectory:str = path.join(self.tempDirectory,'source') 14 | self.firstDestinationDirectory:str = path.join(self.tempDirectory,'dest1') 15 | self.secondDestinationDirectory:str = path.join(self.tempDirectory,'dest2') 16 | 17 | makedirs(self.sourceDirectory) 18 | makedirs(self.firstDestinationDirectory) 19 | makedirs(self.secondDestinationDirectory) 20 | 21 | open(path.join(self.sourceDirectory, 'test1.txt'), 'w').close() 22 | open(path.join(self.sourceDirectory, 'test2.doc'), 'w').close() 23 | open(path.join(self.sourceDirectory, 'test3.jpg'), 'w').close() 24 | open(path.join(self.sourceDirectory, 'test4.pdf'), 'w').close() 25 | open(path.join(self.sourceDirectory, 'test5.xyz'), 'w').close() 26 | 27 | self.testRule:dict = { 28 | "sourceFolder": self.sourceDirectory, 29 | "destinationFolders": [ 30 | { 31 | "extensions": [".txt", ".doc"], 32 | "destinationPath": self.firstDestinationDirectory 33 | }, 34 | { 35 | "extensions": [".pdf", ".jpg"], 36 | "destinationPath": self.secondDestinationDirectory 37 | } 38 | ] 39 | } 40 | 41 | def tearDown(self) -> None: 42 | 43 | rmtree(self.tempDirectory) 44 | 45 | def test_SuccessfulSort(self) -> None: 46 | 47 | result:dict = sortFolderByExtensions(self.testRule) 48 | 49 | self.assertEqual(result['successCount'],4) 50 | self.assertEqual(result['failCount'], 0) 51 | self.assertEqual(result['message'], 'Success!') 52 | 53 | self.assertTrue(path.exists(path.join(self.firstDestinationDirectory, 'test1.txt'))) 54 | self.assertTrue(path.exists(path.join(self.firstDestinationDirectory, 'test2.doc'))) 55 | self.assertTrue(path.exists(path.join(self.secondDestinationDirectory, 'test3.jpg'))) 56 | self.assertTrue(path.exists(path.join(self.secondDestinationDirectory, 'test4.pdf'))) 57 | 58 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test5.xyz'))) 59 | 60 | def test_InvalidSource(self) -> None: 61 | 62 | invalidRule:dict = self.testRule.copy() 63 | invalidRule['sourceFolder'] = path.join(self.sourceDirectory,'nonExistingDirectory') 64 | 65 | result:dict = sortFolderByExtensions(invalidRule) 66 | 67 | self.assertEqual(result['successCount'], 0) 68 | self.assertEqual(result['failCount'], 0) 69 | self.assertTrue('does not exist' in result['message']) 70 | 71 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test1.txt'))) 72 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test2.doc'))) 73 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test3.jpg'))) 74 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test4.pdf'))) 75 | 76 | def test_DuplicateHandling(self) -> None: 77 | 78 | open(path.join(self.secondDestinationDirectory, 'test4.pdf'), 'w').close() 79 | 80 | sortFolderByExtensions(self.testRule) 81 | 82 | self.assertTrue(path.exists(path.join(self.secondDestinationDirectory, 'test4.pdf'))) 83 | self.assertTrue(path.exists(path.join(self.secondDestinationDirectory, 'd1_test4.pdf'))) 84 | 85 | @patch('Utils.sorter.move') 86 | def test_PermissionDenied(self,mockMove) -> None: 87 | 88 | mockMove.side_effect = PermissionError() 89 | 90 | result:dict = sortFolderByExtensions(self.testRule) 91 | 92 | 93 | self.assertEqual(result['successCount'], 0) 94 | self.assertEqual(result['failCount'], 4) 95 | self.assertEqual(result['message'], 'Access denied for some files!') 96 | 97 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test1.txt'))) 98 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test2.doc'))) 99 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test3.jpg'))) 100 | self.assertTrue(path.exists(path.join(self.sourceDirectory, 'test4.pdf'))) 101 | 102 | 103 | # to run this use: python -m unittest Tests.testSorter 104 | if __name__ == "__main__": 105 | 106 | unittest.main() -------------------------------------------------------------------------------- /GUI/viewMain.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import wx.dataview 3 | from pubsub import pub 4 | from Utils.config import Config 5 | from Utils.sorter import Sorter 6 | from GUI import viewModifyRule,viewRemove,viewAddRule 7 | 8 | class MainWindow(wx.Frame): 9 | 10 | def onBtnDownloadFolder(self, event) -> None: 11 | 12 | dlg = wx.DirDialog(self, "Choose a directory:", style=wx.DD_DEFAULT_STYLE) 13 | if dlg.ShowModal() == wx.ID_OK: 14 | self.textBoxDownloadFolder.SetValue(dlg.GetPath()) 15 | self.config.rulesList[0]["sourceFolder"] = dlg.GetPath() 16 | dlg.Destroy() 17 | 18 | def onBtnAddItem(self, event) -> None: 19 | 20 | addRuleWindow = viewAddRule.AddRuleWindow() 21 | addRuleWindow.Show() 22 | 23 | def onBtnRemoveItem(self, event) -> None: 24 | 25 | selectedItem:int = self.dataView.GetSelectedRow() 26 | selectedItem:int = self.dataView.GetSelectedRow() 27 | if 0 <= selectedItem <= len(self.config.rulesList[0]["destinationFolders"]): 28 | removeRuleWindow = viewRemove.RemoveRule(selectedItem) 29 | removeRuleWindow.Show() 30 | else: 31 | self.SetStatusText("No selected item.") 32 | 33 | def onBtnModifyItem(self, event) -> None: 34 | 35 | selectedItem:int = self.dataView.GetSelectedRow() 36 | if 0 <= selectedItem <= len(self.config.rulesList[0]["destinationFolders"]): 37 | modifyRuleWindow = viewModifyRule.ModifyRuleWindow(selectedItem,self.config.rulesList[0]["destinationFolders"][selectedItem]) 38 | modifyRuleWindow.Show() 39 | else: 40 | self.SetStatusText("No selected item.") 41 | 42 | def onBtnRunManual(self, event) -> None: 43 | 44 | self.sorter.ruleList = self.config.rulesList 45 | result = self.sorter.sortAll() 46 | 47 | self.SetStatusText(f's:{result["successCount"]} f:{result["failCount"]} {result["message"]}') 48 | 49 | def onBtnStartInterval(self,event) -> None: 50 | 51 | if self.config.isIntervalRunning: 52 | self.config.isIntervalRunning = False 53 | if hasattr(self, "timer"): 54 | self.timer.Stop() 55 | else: 56 | self.timer = wx.Timer(self) 57 | self.Bind(wx.EVT_TIMER, self.onInterval, self.timer) 58 | self.timer.Start(self.config.interval) 59 | self.config.isIntervalRunning = True 60 | 61 | self.updateStatusLabel() 62 | 63 | def onBtnLoadConfig(self,event) -> None: 64 | dlg = wx.FileDialog(self, "Choose a file:", style=wx.DD_DEFAULT_STYLE) 65 | if dlg.ShowModal() == wx.ID_OK: 66 | filePath = dlg.GetPath() 67 | try: 68 | self.config.loadConfig(filePath) 69 | self.SetStatusText(f"{filePath} config file loaded.") 70 | except: 71 | self.SetStatusText(f"Failed to load config.") 72 | self.render() 73 | else: 74 | self.SetStatusText(f"Failed to load config.") 75 | 76 | def onInterval(self,event) -> None: 77 | 78 | self.sorter.ruleList = self.config.rulesList 79 | result = self.sorter.sortAll() 80 | 81 | self.SetStatusText(f'Interval: s:{result["successCount"]} f:{result["failCount"]} {result["message"]}') 82 | 83 | def onClose(self,event) -> None: 84 | 85 | self.config.saveConfig() 86 | self.Destroy() 87 | 88 | def listenerAddRule(self, data) -> None: 89 | 90 | self.dataView.AppendItem([data["destinationPath"]," ".join(data["extensions"])]) 91 | self.config.rulesList[0]["destinationFolders"].append(data) 92 | 93 | self.SetStatusText('New element has been added.') 94 | 95 | def listenerModifyRule(self, data) -> None: 96 | 97 | self.dataView.SetValue(data["destinationPath"],data["id"],0) 98 | self.dataView.SetValue(" ".join(data["extensions"]),data["id"],1) 99 | self.config.rulesList[0]["destinationFolders"][data["id"]] = data 100 | 101 | self.SetStatusText(f'Item number {data['id']} has been changed.') 102 | 103 | def listenerRemoveRule(self, id) -> None: 104 | 105 | self.dataView.DeleteItem(id) 106 | self.config.rulesList[0]["destinationFolders"].pop(id) 107 | 108 | self.SetStatusText(f'Item number {id} has been removed.') 109 | 110 | def updateStatusLabel(self) -> None: 111 | if self.config.isIntervalRunning: 112 | self.lblStatus.SetLabel("Background: ON") 113 | self.lblStatus.SetForegroundColour(wx.Colour(0, 150, 0)) 114 | else: 115 | self.lblStatus.SetLabel("Background: OFF") 116 | self.lblStatus.SetForegroundColour(wx.Colour(200, 0, 0)) 117 | self.lblStatus.Refresh() 118 | 119 | def render(self) -> None: 120 | 121 | self.panel.DestroyChildren() 122 | if hasattr(self, "timer"): 123 | self.timer.Stop() 124 | 125 | ''' Text labels ''' 126 | self.textStep1 = wx.StaticText(self.panel, label="Step 1: Choose your Downloads folder") 127 | self.textStep2 = wx.StaticText(self.panel, label="Step 2: Set up destination folders and their extensions") 128 | self.textStep3 = wx.StaticText(self.panel, label="Step 3: Run") 129 | 130 | ''' Dividers ''' 131 | self.sizerMain = wx.BoxSizer(wx.VERTICAL) # main sizer 132 | 133 | ''' Horizontal boxes ''' 134 | self.hboxDownloads = wx.BoxSizer(wx.HORIZONTAL) 135 | self.hboxDataViewControls = wx.BoxSizer(wx.HORIZONTAL) 136 | self.hboxSaveControls = wx.BoxSizer(wx.HORIZONTAL) 137 | 138 | ''' Dialogs ''' 139 | self.dialogStep1 = wx.DirDialog(None, "Choose input directory", "", wx.DD_DIR_MUST_EXIST) 140 | 141 | ''' Buttons ''' 142 | self.btnDownloadFolder = wx.Button(self.panel, label="Browse") 143 | self.btnDownloadFolder.Bind(wx.EVT_BUTTON, self.onBtnDownloadFolder) 144 | 145 | self.btnAddItem = wx.Button(self.panel, label="Add") 146 | self.btnAddItem.Bind(wx.EVT_BUTTON, self.onBtnAddItem) 147 | 148 | self.btnRemoveItem = wx.Button(self.panel, label="Remove") 149 | self.btnRemoveItem.Bind(wx.EVT_BUTTON, self.onBtnRemoveItem) 150 | 151 | self.btnModifyItem = wx.Button(self.panel, label="Modify") 152 | self.btnModifyItem.Bind(wx.EVT_BUTTON, self.onBtnModifyItem) 153 | 154 | if self.config.isIntervalRunning: 155 | self.timer = wx.Timer(self) 156 | self.Bind(wx.EVT_TIMER, self.onInterval, self.timer) 157 | self.timer.Start(self.config.interval) 158 | 159 | self.btnInterval = wx.Button(self.panel, label="Toggle Background") 160 | self.btnInterval.Bind(wx.EVT_BUTTON, self.onBtnStartInterval) 161 | 162 | self.btnRunManual = wx.Button(self.panel, label="Run once") 163 | self.btnRunManual.Bind(wx.EVT_BUTTON, self.onBtnRunManual) 164 | 165 | self.btnLoadConfig = wx.Button(self.panel, label="Load Config") 166 | self.btnLoadConfig.Bind(wx.EVT_BUTTON, self.onBtnLoadConfig) 167 | 168 | ''' Textboxes ''' 169 | self.textBoxDownloadFolder = wx.TextCtrl(self.panel) 170 | self.textBoxDownloadFolder.SetValue(self.config.rulesList[0]["sourceFolder"]) 171 | 172 | ''' DataView ''' 173 | self.dataView = wx.dataview.DataViewListCtrl(self.panel, size=(400, 200)) 174 | self.dataView.AppendTextColumn("Folder Path", width=225) 175 | self.dataView.AppendTextColumn("Extensions") 176 | for destinationFolder in self.config.rulesList[0]["destinationFolders"]: 177 | self.dataView.AppendItem([destinationFolder["destinationPath"]," ".join(destinationFolder["extensions"])]) 178 | 179 | ''' Step 1 : Select download folder''' 180 | self.sizerMain.Add(self.textStep1, wx.SizerFlags().Border(wx.TOP | wx.LEFT, 10)) 181 | 182 | self.hboxDownloads.Add(self.textBoxDownloadFolder, proportion=1) 183 | self.hboxDownloads.Add(self.btnDownloadFolder, wx.SizerFlags().Border(wx.LEFT | wx.RIGHT, 5)) 184 | self.sizerMain.Add(self.hboxDownloads, flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP | wx.BOTTOM, border=10) 185 | 186 | ''' Step 2: Set up rules ''' 187 | self.sizerMain.Add(self.textStep2, wx.SizerFlags().Border(wx.TOP | wx.LEFT, 10)) 188 | self.sizerMain.Add(self.dataView, proportion=1, flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP | wx.BOTTOM, border=10) 189 | self.hboxDataViewControls.Add(self.btnAddItem, wx.SizerFlags().Border(wx.RIGHT, 2).Proportion(1)) 190 | self.hboxDataViewControls.Add(self.btnRemoveItem, wx.SizerFlags().Proportion(1).Border(wx.LEFT | wx.RIGHT, 2)) 191 | self.hboxDataViewControls.Add(self.btnModifyItem, wx.SizerFlags().Proportion(1).Border(wx.LEFT, 2)) 192 | self.sizerMain.Add(self.hboxDataViewControls, flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=10) 193 | 194 | ''' Step 3: Save/Run ''' 195 | self.hboxStep3 = wx.BoxSizer(wx.HORIZONTAL) 196 | self.hboxStep3.Add(self.textStep3, flag=wx.ALIGN_CENTER_VERTICAL) 197 | self.hboxStep3.AddStretchSpacer(1) 198 | self.lblStatus = wx.StaticText(self.panel, label="") 199 | font = self.lblStatus.GetFont() 200 | font.MakeBold() 201 | self.lblStatus.SetFont(font) 202 | self.hboxStep3.Add(self.lblStatus, flag=wx.ALIGN_CENTER_VERTICAL) 203 | self.sizerMain.Add(self.hboxStep3, flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=10) 204 | 205 | self.hboxSaveControls.Add(self.btnInterval, wx.SizerFlags().Border(wx.RIGHT, 2).Proportion(1)) 206 | self.hboxSaveControls.Add(self.btnRunManual, wx.SizerFlags().Proportion(1).Border(wx.LEFT | wx.RIGHT, 2)) 207 | self.hboxSaveControls.Add(self.btnLoadConfig, wx.SizerFlags().Proportion(1).Border(wx.LEFT, 2)) 208 | self.sizerMain.Add(self.hboxSaveControls, flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, border=10) 209 | 210 | self.panel.SetSizer(self.sizerMain) 211 | self.sizerMain.Fit(self) 212 | self.updateStatusLabel() 213 | self.Center() 214 | 215 | def __init__(self) -> None: 216 | wx.Frame.__init__(self, None, title="Tidy Cobra", style=wx.DEFAULT_FRAME_STYLE) 217 | 218 | self.SetMinSize((200,300)) 219 | 220 | self.config = Config() 221 | self.sorter = Sorter(self.config) 222 | 223 | pub.subscribe(self.listenerAddRule, "addRuleListener") 224 | pub.subscribe(self.listenerModifyRule, "modifyRuleListener") 225 | pub.subscribe(self.listenerRemoveRule, "removeRuleListener") 226 | 227 | self.panel = wx.Panel(self) 228 | self.CreateStatusBar() 229 | self.Bind(wx.EVT_CLOSE, self.onClose) 230 | self.SetStatusText("Ready!") 231 | 232 | self.render() 233 | 234 | self.Show(True) 235 | 236 | def renderGui(): 237 | app = wx.App() 238 | frame = MainWindow() 239 | app.MainLoop() 240 | 241 | if __name__ == "__main__": 242 | 243 | renderGui() 244 | --------------------------------------------------------------------------------