Skip to content

Commit 641020f

Browse files
authored
Merge pull request #1 from Furglitch/nightly
Beta 1.0.0
2 parents b8c2d65 + dfc935e commit 641020f

File tree

9 files changed

+2477
-1
lines changed

9 files changed

+2477
-1
lines changed

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,64 @@
11
# ModOrganizer-SeparatorGenerator
2-
A tool for creating separators for categorization within Mod Organizer 2
2+
3+
A tool for creating separators for categorization within Mod Organizer 2, making it easier to manage large collections of mods.
4+
5+
This is my first major work in Python, teaching myself the language by converting a Powershell script I made into a Python script (with an easy UI!). And feedback is welcome!
6+
7+
## Features
8+
9+
- Create and manage categories and subcategories for mod organization.
10+
- Customize category headers and appearance.
11+
- Apply themes and color accents to the interface.
12+
- Generate output files for use in Mod Organizer 2.
13+
- Load and save configurations in JSON format.
14+
- Example configurations for popular games like Skyrim SE, Fallout 4, and Cyberpunk 2077.
15+
- Settings customizations are saveable and loaded at launch!
16+
17+
## Installation
18+
19+
1. Clone the repository:
20+
```sh
21+
git clone https://github.com/Furglitch/ModOrganizer-SeparatorGenerator.git
22+
```
23+
2. Navigate to the project directory:
24+
```sh
25+
cd ModOrganizer-SeparatorGenerator
26+
```
27+
28+
## Usage
29+
30+
### Running the Script
31+
32+
1. Ensure you have Python installed on your system.
33+
2. Run the application:
34+
```sh
35+
python interface.py
36+
```
37+
38+
### Building to an Executable with PyInstaller
39+
40+
1. Install PyInstaller if you haven't already:
41+
```sh
42+
pip install pyinstaller
43+
```
44+
2. Build the executable:
45+
```sh
46+
pyinstaller -n 'Mod Organizer Separator Generator' --onefile -w interface.py backend.py --add-data "resources;resources" -i 'resources/icon.ico
47+
```
48+
3. The executable will be located in the `dist` directory.
49+
50+
## Settings
51+
52+
Any changes you make in the settings menu can be saved with the click of a button. This file is automatically created at launch and updated in the `%APPDATA%/Furglitch/MO2SE/MO2SE.json` directory.
53+
54+
## Logging
55+
56+
The application logs almost everything to files located in the `%APPDATA%/Furglitch/MO2SE/logs` directory. These logs can be used for troubleshooting and debugging. Please include when reporting an issue.
57+
58+
## Contributing
59+
60+
Contributions, critiques, and bug reports are welcome! If you have any suggestions or improvements, please create a pull request or open an issue.
61+
62+
## License
63+
64+
This project is licensed under the GNU General Public License v3.0.

backend.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import json, os, sys, logging as log
2+
from datetime import datetime as dt
3+
from tkinter import messagebox as msg, filedialog as prompt
4+
5+
rootDir = os.path.dirname(os.path.abspath(__file__))
6+
initDir = os.path.dirname(os.path.abspath(sys.argv[0]))
7+
appdataDir = os.path.join(os.getenv('APPDATA'), "Furglitch", "MO2SE")
8+
logDir = os.path.join(appdataDir, 'logs', f'{dt.now().strftime('%Y-%m-%d %H%M%S')}.log')
9+
resourceDir = os.path.join(rootDir, "resources")
10+
iconDir = os.path.join(resourceDir, "icon.ico")
11+
12+
saved = True
13+
categories = {}
14+
examples = {}
15+
startColor = "#000000"
16+
endColor = "#ffffff"
17+
gradient = []
18+
header = 'Bracket'
19+
theme = 'Nord'
20+
themeAccent = 'Blue'
21+
catCasing = 'Unchanged'
22+
subCasing = 'Unchanged'
23+
24+
# Logging
25+
if not os.path.exists(os.path.join(appdataDir, 'logs')): os.makedirs(os.path.join(appdataDir, 'logs'))
26+
open(logDir, "w").close()
27+
log.basicConfig(
28+
filename=logDir,
29+
level=log.DEBUG,
30+
format='%(asctime)s - %(levelname)s - %(message)s',
31+
datefmt='%Y-%m-%d %H:%M:%S'
32+
)
33+
log.info("MO2SE Started")
34+
35+
# Menu Functions
36+
def fileNew():
37+
global startColor, endColor
38+
if msg.askyesno("Confirm", "Are you sure you want to create a new file? This will clear all current data."):
39+
categories.clear()
40+
startColor = "#000000"
41+
endColor = "#ffffff"
42+
log.info("Data cleared for new file")
43+
global saved; saved = True
44+
45+
def fileSave():
46+
global categories, startColor, endColor
47+
path = prompt.asksaveasfilename(initialdir=initDir, filetypes=[("JSON", "*.json")], defaultextension=".json")
48+
if path:
49+
data = {"categories": categories, "gradient": {"startColor": startColor, "endColor": endColor}}
50+
with open(path, "w") as f: json.dump(data, f, indent=4)
51+
global saved; saved = True
52+
log.info(f"File Saved: {path}")
53+
54+
def fileOpen(path=None):
55+
global categories, startColor, endColor, saved
56+
if not saved:
57+
if not msg.askyesno("Changes Not Saved", "You've adjusted your separator list but haven't saved!\nAre you sure you want to open this file?"):
58+
return
59+
if path is None: path = prompt.askopenfilename(initialdir=initDir, filetypes=[("JSON", "*.json")], defaultextension=".json")
60+
if path:
61+
with open(path, "r") as f:
62+
data = json.load(f)
63+
categories.clear()
64+
categories.update(data["categories"])
65+
startColor = data["gradient"]["startColor"]
66+
endColor = data["gradient"]["endColor"]
67+
saved = True
68+
log.info(f"File Opened: {path}")
69+
else: return
70+
71+
def exampleGet(bar, list, subBox, startIndicator, endIndicator, startLabel, endLabel):
72+
global examples
73+
path = os.path.join(resourceDir, "examples")
74+
for file in os.listdir(path):
75+
filepath = os.path.join(path,file)
76+
if file.endswith('.json') and os.path.isfile(filepath):
77+
bar.add_command(label=file.removesuffix(".json"), command=lambda filepath=filepath: exampleOpen(filepath, list, subBox, startIndicator, endIndicator, startLabel, endLabel))
78+
log.info(f"Example Found: {file.removesuffix('.json')}")
79+
80+
def exampleOpen(path, tree, subBox, startIndicator, endIndicator, startLabel, endLabel):
81+
global startColor, endColor, categories, saved
82+
if not saved:
83+
if not msg.askyesno("Unsaved Changes", "You have unsaved changes. Are you sure you want to open an example?"):
84+
return
85+
saved = True
86+
log.info(f"Example Selected: {os.path.basename(path).removesuffix('.json')}")
87+
fileOpen(path)
88+
expanded_categories = set()
89+
for category_id in tree.get_children():
90+
if tree.item(category_id, 'open'): expanded_categories.add(tree.item(category_id, 'values')[0].strip())
91+
tree.delete(*tree.get_children())
92+
for category, details in categories.items():
93+
categoryID = tree.insert("", "end", text="", values=(category,))
94+
categories[category]["id"] = categoryID
95+
for subcategory in details["sub"]:
96+
subcategoryID = tree.insert(categoryID, "end", text="", values=(f"\u00A0\u00A0\u00A0\u00A0{subcategory}",))
97+
categories[category]["sub"][subcategory]["id"] = subcategoryID
98+
if category in expanded_categories: tree.item(categoryID, open=True)
99+
subBox.config(values=list(categories.keys()))
100+
startIndicator.config(bg=startColor)
101+
startLabel.config(text=f"Start Color: {startColor}")
102+
endIndicator.config(bg=endColor)
103+
endLabel.config(text=f"End Color: {endColor}")
104+
105+
# Button Functions
106+
def sepAdd(type, name, parent_category=None):
107+
if type == "cat":
108+
if name not in categories:
109+
categories[name] = {"id": None, "sub": {}}
110+
log.info(f"Category Added: {name}")
111+
else: warn(1, name)
112+
elif type == "sub" and parent_category is not None:
113+
if parent_category in categories:
114+
if name not in categories[parent_category]["sub"]:
115+
categories[parent_category]["sub"][name] = {"id": None}
116+
log.info(f"Subcategory Added: {name} in Category {parent_category}")
117+
else: warn(2, name, parent_category)
118+
else: warn(3, parent_category)
119+
else: error(1, type)
120+
global saved; saved = False
121+
122+
def sepRemove(type, name, children, parent):
123+
if type == "cat":
124+
if children: warn(4, name)
125+
else:
126+
if name in categories:
127+
del categories[name]
128+
log.info(f"Category Removed: {name}")
129+
else: warn(3, name)
130+
elif type == "sub":
131+
if parent in categories and name in categories[parent]["sub"]:
132+
del categories[parent]["sub"][name]
133+
log.info(f"Subcategory Removed: {name} in Category {parent}")
134+
else: warn(3, parent)
135+
else: error(1, type)
136+
global saved; saved = False
137+
138+
def outputGen():
139+
global gradient
140+
gradientGet()
141+
path = prompt.askdirectory()
142+
profilePath = os.path.join(path, 'profiles', 'default')
143+
modsPath = os.path.join(path, 'mods')
144+
if not os.path.exists(profilePath): os.makedirs(profilePath)
145+
f = open(profilePath + '/modlist.txt', "w")
146+
f.close()
147+
if not os.path.exists(modsPath): os.makedirs(modsPath)
148+
with open(os.path.join(profilePath + '/modlist.txt'), "r+") as l:
149+
lines = l.readlines()
150+
l.seek(0, 0)
151+
i = 0; j=0
152+
for category, details in categories.items():
153+
catSep = headerGet("start", header) + applyCasing("cat", category) + headerGet("end", header) + '_separator'
154+
os.mkdir(os.path.join(modsPath, catSep))
155+
with open(os.path.join(modsPath, catSep, 'meta.ini'), 'w') as meta:
156+
meta.write(f"[General]\ncolor={gradient[j]}")
157+
lines.insert(0, "+"+catSep+'\n')
158+
j += 1
159+
for subcategory in details["sub"]:
160+
i += 1
161+
subSep = str(i)+'. ' + applyCasing("sub", subcategory) + '_separator'
162+
os.mkdir(os.path.join(modsPath, subSep))
163+
lines.insert(0, "+"+subSep+'\n')
164+
l.writelines(lines)
165+
log.info(f"Output Generated at {path}")
166+
167+
168+
# Settings Functions
169+
def themeGet(type, theme=None):
170+
output = ''
171+
with open(os.path.join(resourceDir, 'themes.json')) as f:
172+
data = json.load(f)
173+
if type == "name":
174+
output = list(data.keys())
175+
log.info(f"Themes Loaded: {output}")
176+
elif type == "theme":
177+
output = data[theme]
178+
log.info(f"Theme {theme} info loaded: {output}")
179+
elif type == "color":
180+
output = data[theme][type]
181+
log.info(f"Theme {theme} color loaded: {type} - {output}")
182+
return output
183+
184+
def headerGet(type, header=None):
185+
output = ''
186+
with open(os.path.join(resourceDir, 'headers.json')) as f:
187+
data = json.load(f)
188+
if type == "name":
189+
output = list(data.keys())
190+
log.info(f"Headers Loaded: {output}")
191+
elif type == "start" or type == "end":
192+
output = data[header][type]
193+
log.info(f"Headers {header} loaded: {type} - {output}")
194+
return output
195+
196+
def applyCasing(type, text):
197+
global catCasing, subCasing
198+
if type == "cat":
199+
if catCasing == "Capitalize": return text.title()
200+
elif catCasing == "UPPER": return text.upper()
201+
elif catCasing == "lower": return text.lower()
202+
else: return text
203+
if type == "sub":
204+
if subCasing == "Capitalize": return text.title()
205+
elif subCasing == "UPPER": return text.upper()
206+
elif subCasing == "lower": return text.lower()
207+
else: return text
208+
209+
def casingSet(type, case):
210+
global catCasing, subCasing
211+
if type == "cat":
212+
catCasing = case
213+
log.info(f"Category Casing Set: {case}")
214+
if type == "sub":
215+
subCasing = case
216+
log.info(f"Subcategory Casing Set: {case}")
217+
218+
def settingsGet():
219+
global theme, themeAccent, header, catCasing, subCasing
220+
if not os.path.exists(os.path.join(appdataDir, 'MO2SE.json')):
221+
data = {"theme": {"name": theme, "accent": themeAccent}, "header": header, "casing": {"cat": catCasing, "sub": subCasing}}
222+
f = open(os.path.join(appdataDir, 'MO2SE.json'), "w")
223+
json.dump(data, f, indent=4)
224+
f.close()
225+
log.info("Default Settings File Created")
226+
else:
227+
with open(os.path.join(appdataDir, 'MO2SE.json'), "r") as f:
228+
data = json.load(f)
229+
if data["theme"]["name"] in themeGet("name"): theme = data["theme"]["name"]
230+
else: theme = "Nord"
231+
if data["theme"]["accent"] in ['Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Purple']: themeAccent = data["theme"]["accent"]
232+
else: themeAccent = "Blue"
233+
if data["header"] in headerGet("name"): header = data["header"]
234+
else: header = "Bracket"
235+
if data["casing"]["cat"] in ['Unchanged', 'Capitalize', 'UPPER', 'lower']: catCasing = data["casing"]["cat"]
236+
else: catCasing = "Unchanged"
237+
if data["casing"]["sub"] in ['Unchanged', 'Capitalize', 'UPPER', 'lower']: subCasing = data["casing"]["sub"]
238+
else: subCasing = "Unchanged"
239+
log.info(f"Settings Loaded: Theme {theme}, ThemeAccent {themeAccent}, Header {header}, Category Casing {catCasing}, Subcategory Casing {subCasing}")
240+
241+
def settingsCheck():
242+
global theme, themeAccent, header, catCasing, subCasing
243+
with open(os.path.join(appdataDir, 'MO2SE.json'), "r") as f:
244+
data = json.load(f)
245+
if theme == data["theme"]["name"] and themeAccent == data["theme"]["accent"] and header == data["header"] and catCasing == data["casing"]["cat"] and subCasing == data["casing"]["sub"]:
246+
log.info("Settings match saved settings")
247+
return True
248+
else:
249+
log.info("Settings do not match saved settings")
250+
return False
251+
252+
def settingsSave():
253+
global theme, header, themeAccent, casing
254+
with open(os.path.join(appdataDir + '/MO2SE.json'), "w") as f:
255+
data = {"theme": {"name": theme, "accent": themeAccent}, "header": header, "casing": {"cat": catCasing, "sub": subCasing}}
256+
json.dump(data, f, sort_keys=True, indent=4)
257+
log.info(f"Settings Saved: Theme {theme}, Theme Accent {themeAccent}, Header {header}, Category Casing {catCasing}, Subcategory Casing {subCasing}")
258+
259+
# Gradient Processing
260+
def hexRGB(hex):
261+
hex = hex.lstrip('#')
262+
log.info(f"Hex Converted to RGB: {hex}")
263+
return tuple(int(hex[i:i+2], 16) for i in (0, 2, 4))
264+
265+
def stepRGB(start, end, steps):
266+
if steps == 1: return [start]
267+
gradient = []
268+
for i in range(steps):
269+
t = i / (steps - 1)
270+
r = int(start[0] + (end[0] - start[0]) * t)
271+
g = int(start[1] + (end[1] - start[1]) * t)
272+
b = int(start[2] + (end[2] - start[2]) * t)
273+
gradient.append((r, g, b))
274+
log.info(f"Generated RGB Gradient from {startColor} to {endColor} with {steps} steps")
275+
return gradient
276+
277+
def rgbHex(rgb):
278+
log.info(f"RGB Converted to Hex: {rgb}")
279+
return '#{:02x}{:02x}{:02x}'.format(*rgb)
280+
281+
def gradientGet():
282+
global startColor, endColor, gradient
283+
if len(categories) == 0: return
284+
startRGB = hexRGB(startColor)
285+
endRGB = hexRGB(endColor)
286+
gradRGB = stepRGB(startRGB, endRGB, len(categories))
287+
288+
gradient = [rgbHex(rgb) for rgb in gradRGB]
289+
log.info(f"Gradient Generated: {gradient}")
290+
291+
# Error Handling
292+
def warn(code, text, text2=None):
293+
match code:
294+
case 1:
295+
msg.showwarning("Warning", f"Category already exists: {text}")
296+
log.warning(f"Category Already Exists: {text}")
297+
case 2:
298+
msg.showwarning("Warning", f"Subcategory already exists: {text} in Category {text2}")
299+
log.warning(f"Subcategory Already Exists: {text} in Category {text2}")
300+
case 3:
301+
msg.showwarning("Warning", f"Category {text} does not exist")
302+
log.warning(f"Category Does Not Exist: {text}")
303+
case 4:
304+
msg.showwarning("Warning", f"Cannot remove Category with existing subcategories: {text}")
305+
log.warning(f"Cannot Remove Category with Existing Subcategories: {text}")
306+
307+
def error(code, text=None):
308+
match code:
309+
case 1:
310+
msg.showerror("Error", "Invalid Type\n\nYou shouldn't be seeing this. Please submit an issue on GitHub.")
311+
log.error(f"Invalid Type: {text}")
312+
313+
def settingsPrompt():
314+
log.info("Prompt: Settings Not Saved")
315+
return msg.askyesno("Settings Not Saved", "You've adjusted your settings but haven't saved! Are you sure you want to exit?")
316+
317+
def filePrompt():
318+
log.info("Prompt: Changes Not Saved")
319+
return msg.askyesno("Changes Not Saved", "You've adjusted your separator list but haven't saved! Are you sure you want to exit?")

0 commit comments

Comments
 (0)