44from enum import IntEnum , auto
55from functools import cached_property
66from pathlib import Path
7+ from typing import TypedDict
78
8- from PyQt6 .QtCore import QDir , QFileInfo
9- from PyQt6 .QtWidgets import QMainWindow , QTabWidget , QWidget
9+ from PyQt6 .QtCore import QDir , QFileInfo , Qt
10+ from PyQt6 .QtWidgets import (
11+ QDialog ,
12+ QDialogButtonBox ,
13+ QLabel ,
14+ QListWidget ,
15+ QListWidgetItem ,
16+ QMainWindow ,
17+ QTabWidget ,
18+ QVBoxLayout ,
19+ QWidget ,
20+ )
1021
1122import mobase
1223
@@ -67,12 +78,21 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]:
6778 return list (self .contents )
6879
6980
81+ class ModDetectionCandidate (TypedDict ):
82+ tree : mobase .IFileTree | mobase .FileTreeEntry
83+ name : str
84+ display : str
85+ destination : str
86+ installtype : str
87+
88+
7089class CrimeBossModDataChecker (mobase .ModDataChecker ):
7190 def __init__ (self , organizer : mobase .IOrganizer ):
7291 super ().__init__ ()
7392 self .organizer : mobase .IOrganizer = organizer
7493 self .organizer .modList ().onModInstalled (self .fixInstalledMod )
7594 self .needsNameFix = False
95+ self .modDetectionCandidates : list [ModDetectionCandidate ] = []
7696
7797 def moveOverwriteMerge (self , source : str , destination : str ):
7898 if not os .path .exists (destination ):
@@ -87,6 +107,16 @@ def moveOverwriteMerge(self, source: str, destination: str):
87107 self .moveOverwriteMerge (s_item , d_item )
88108 os .rmdir (source )
89109
110+ def sanitizeFolderName (self , name : str ) -> str :
111+ invalid_chars = '+&<>:"|?*\\ /'
112+ for char in invalid_chars :
113+ name = name .replace (char , "" )
114+ name = "" .join (c for c in name if ord (c ) >= 32 )
115+ name = name .rstrip (". " )
116+ if not name :
117+ name = "FOLDERNAME"
118+ return name
119+
90120 def fixInstalledMod (self , mod : mobase .IModInterface ):
91121 if not self .needsNameFix :
92122 return
@@ -133,6 +163,175 @@ def dataLooksValid(
133163 return mobase .ModDataChecker .VALID
134164 return mobase .ModDataChecker .FIXABLE
135165
166+ def moveTreeContent (
167+ self ,
168+ installtype : str ,
169+ entry : mobase .IFileTree | mobase .FileTreeEntry ,
170+ targettree : mobase .IFileTree ,
171+ destination : str ,
172+ ) -> None :
173+ if installtype == "virtual" :
174+ targettree .move (entry , destination , mobase .IFileTree .MERGE )
175+ return None
176+ if installtype == "os" and isinstance (entry , mobase .IFileTree ):
177+ for element in entry :
178+ mod_file = element .name ()
179+ mod_name = entry .name ()
180+ mod_path = os .path .join (self .organizer .modsPath (), mod_name )
181+ inside_mods = os .path .join (mod_path , destination )
182+ os .makedirs (inside_mods , exist_ok = True )
183+ src = os .path .join (mod_path , mod_file )
184+ dst = os .path .join (mod_path , destination , mod_file )
185+ shutil .move (src , dst )
186+ return None
187+
188+ def addModDetectionCandidate (
189+ self ,
190+ tree : mobase .IFileTree | mobase .FileTreeEntry ,
191+ name : str ,
192+ category : str ,
193+ destination : str ,
194+ installtype : str = "virtual" ,
195+ ) -> None :
196+ self .modDetectionCandidates .append (
197+ {
198+ "tree" : tree ,
199+ "name" : tree .name (),
200+ "display" : f"{ name } ({ category } )" ,
201+ "destination" : destination ,
202+ "installtype" : installtype ,
203+ }
204+ )
205+
206+ def showModDetectionDialog (self ) -> set [int ] | None :
207+ if not self .modDetectionCandidates :
208+ return set ()
209+
210+ dialog = QDialog ()
211+ dialog .setWindowTitle ("Found Mods" )
212+
213+ layout = QVBoxLayout (dialog )
214+ layout .addWidget (QLabel ("Select the mods to install:" ))
215+
216+ listWidget = QListWidget ()
217+ listWidget .setSelectionMode (QListWidget .SelectionMode .NoSelection )
218+ for candidate in self .modDetectionCandidates :
219+ item = QListWidgetItem (candidate ["display" ])
220+ item .setFlags (item .flags () | Qt .ItemFlag .ItemIsUserCheckable )
221+ item .setCheckState (Qt .CheckState .Checked )
222+ listWidget .addItem (item )
223+
224+ layout .addWidget (listWidget )
225+
226+ buttonBox = QDialogButtonBox (
227+ QDialogButtonBox .StandardButton .Ok | QDialogButtonBox .StandardButton .Cancel
228+ )
229+ buttonBox .accepted .connect (lambda : dialog .accept ()) # type: ignore
230+ buttonBox .rejected .connect (lambda : dialog .reject ()) # type: ignore
231+ layout .addWidget (buttonBox )
232+
233+ if dialog .exec () != QDialog .DialogCode .Accepted :
234+ return None
235+
236+ selectedIndexes : set [int ] = set ()
237+ for index in range (listWidget .count ()):
238+ item = listWidget .item (index )
239+ if (
240+ isinstance (item , QListWidgetItem )
241+ and item .checkState () == Qt .CheckState .Checked
242+ ):
243+ selectedIndexes .add (index )
244+
245+ return selectedIndexes
246+
247+ def collectModCandidates (
248+ self ,
249+ tree : mobase .IFileTree | mobase .FileTreeEntry ,
250+ installtype : str = "virtual" ,
251+ ) -> bool :
252+ if not isinstance (tree , mobase .IFileTree ) or not tree .name ():
253+ return False
254+
255+ sanitized_name = self .sanitizeFolderName (tree .name ())
256+ category = None
257+ entryext = "None"
258+
259+ if installtype == "os" and isinstance (tree , mobase .IFileTree ):
260+ for entry in tree :
261+ entryext = os .path .splitext (entry .name ())[1 ].removeprefix ("." )
262+ else :
263+ entryext = tree .suffix ().casefold ()
264+
265+ if (
266+ tree .exists ("UE4SS.dll" , mobase .IFileTree .FILE )
267+ or tree .exists ("Scripts" , mobase .IFileTree .DIRECTORY )
268+ or tree .exists ("dlls" , mobase .IFileTree .DIRECTORY )
269+ or tree .exists ("Binaries" , mobase .IFileTree .DIRECTORY )
270+ ):
271+ category = "UE4SS"
272+ elif tree .exists ("Content/Paks" , mobase .IFileTree .DIRECTORY ):
273+ category = "Paks"
274+ elif tree .exists ("Content/Movies" , mobase .IFileTree .DIRECTORY ):
275+ category = "Movie"
276+ elif tree .exists ("Content" , mobase .IFileTree .DIRECTORY ):
277+ category = "Native"
278+ else :
279+ for entry in tree :
280+ if entry .isFile ():
281+ suffix = entry .suffix ().casefold ()
282+ if suffix in {"pak" , "utoc" , "ucas" }:
283+ category = "Paks"
284+ break
285+ if suffix == "bk2" :
286+ category = "Movie"
287+ break
288+ if suffix in {"dll" , "lua" }:
289+ category = "UE4SS"
290+ break
291+
292+ if category is None :
293+ match entryext :
294+ case "pak" | "utoc" | "ucas" :
295+ category = "Paks"
296+ case "bk2" :
297+ category = "Movie"
298+ case _:
299+ pass
300+
301+ if category is None :
302+ return False
303+
304+ if category == "UE4SS" :
305+ destination = (
306+ getattr (self .organizer .managedGame (), "GameDataUE4SSMods" , "" ) + "/"
307+ )
308+ elif category == "Paks" :
309+ destination = (
310+ getattr (self .organizer .managedGame (), "GameDataPakMods" , "" ) + "/"
311+ )
312+ elif category == "Movie" :
313+ destination = (
314+ getattr (self .organizer .managedGame (), "GameDataMovies" , "" ) + "/"
315+ )
316+ else :
317+ destination = (
318+ getattr (self .organizer .managedGame (), "GameDataNativeMods" , "" ) + "/"
319+ )
320+
321+ self .addModDetectionCandidate (
322+ tree ,
323+ sanitized_name ,
324+ category ,
325+ destination ,
326+ installtype ,
327+ )
328+ return True
329+
330+ def walkEntry (self , path : str , entry : mobase .FileTreeEntry ):
331+ if entry .isDir () and isinstance (entry , mobase .IFileTree ):
332+ self .collectModCandidates (entry )
333+ return mobase .IFileTree .WalkReturn .CONTINUE
334+
136335 def fileExistsInNextSubDir (self , filetree : mobase .IFileTree , name : str ):
137336 for branch in filetree :
138337 if isinstance (branch , mobase .IFileTree ):
@@ -152,6 +351,34 @@ def allMoveTo(self, filetree: mobase.IFileTree, toMoveTo: str):
152351 return retVal
153352
154353 def fix (self , filetree : mobase .IFileTree ) -> mobase .IFileTree | None :
354+ self .modDetectionCandidates = []
355+ newtree = filetree .createOrphanTree ("Fixed Tree" )
356+
357+ if filetree .name () != "" :
358+ self .collectModCandidates (filetree , installtype = "os" )
359+ else :
360+ self .collectModCandidates (filetree )
361+ filetree .walk (self .walkEntry , "/" )
362+
363+ if len (self .modDetectionCandidates ) == 1 :
364+ selectedIndexes = {0 }
365+ else :
366+ selectedIndexes = self .showModDetectionDialog ()
367+ if selectedIndexes is None :
368+ return None
369+
370+ for index in selectedIndexes :
371+ candidate = self .modDetectionCandidates [index ]
372+ self .moveTreeContent (
373+ candidate ["installtype" ],
374+ candidate ["tree" ],
375+ newtree ,
376+ candidate ["destination" ],
377+ )
378+
379+ if len (newtree ) > 0 :
380+ return newtree
381+
155382 GameDataUE4SSMods = (
156383 getattr (self .organizer .managedGame (), "GameDataUE4SSMods" , "" ) + "/"
157384 )
@@ -249,7 +476,7 @@ class CrimeBossGame(BasicGame):
249476 GameSteamId = 2933080
250477 GameBinary = "CrimeBoss/Binaries/Win64/CrimeBoss-Win64-Shipping.exe"
251478 GameDataPath = "CrimeBoss"
252- GameDataUE4SSMods = "Binaries/Win64/Mods "
479+ GameDataUE4SSRoot = "Binaries/Win64"
253480 GameDataNativeMods = "Mods"
254481 GameDataPakMods = "Content/Paks/~Mods"
255482 GameDocumentsDirectory = (
@@ -327,7 +554,9 @@ def paksDirectory(self) -> QDir:
327554 return QDir (self .dataDirectory ().absolutePath () + "/" + self .GameDataPakMods )
328555
329556 def ue4ssDirectory (self ) -> QDir :
330- return QDir (self .dataDirectory ().absolutePath () + "/" + self .GameDataUE4SSMods )
557+ return QDir (
558+ self .dataDirectory ().absolutePath () + "/" + self .GameDataUE4SSRoot + "/Mods"
559+ )
331560
332561 def nativeDirectory (self ) -> QDir :
333562 return QDir (self .dataDirectory ().absolutePath () + "/" + self .GameDataNativeMods )
0 commit comments