├── 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 |
--------------------------------------------------------------------------------