1010from PyQt6 .QtWidgets import (
1111 QDialog ,
1212 QDialogButtonBox ,
13+ QHBoxLayout ,
1314 QLabel ,
1415 QListWidget ,
1516 QListWidgetItem ,
1617 QMainWindow ,
18+ QPushButton ,
1719 QTabWidget ,
1820 QVBoxLayout ,
1921 QWidget ,
@@ -37,8 +39,6 @@ class Content(IntEnum):
3739
3840
3941class BatmanLegacyModDataContent (mobase .ModDataContent ):
40- content : list [int ] = []
41-
4242 GAMECONTENTS : list [tuple [Content , str , str , bool ] | tuple [Content , str , str ]] = [
4343 (Content .UCAS , "UCAS" , ":/MO/gui/content/geometries" ),
4444 (Content .UTOC , "UTOC" , ":/MO/gui/content/inifile" ),
@@ -80,7 +80,7 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]:
8080
8181
8282class ModDetectionCandidate (TypedDict ):
83- tree : mobase .IFileTree | mobase .FileTreeEntry
83+ trees : list [ mobase .IFileTree | mobase .FileTreeEntry ]
8484 name : str
8585 display : str
8686 destination : str
@@ -92,6 +92,8 @@ def __init__(self, organizer: mobase.IOrganizer):
9292 super ().__init__ ()
9393 self .organizer : mobase .IOrganizer = organizer
9494 self .modDetectionCandidates : list [ModDetectionCandidate ] = []
95+ self .processedBasenames : set [str ] = set () # Track already-grouped files
96+ self .category_groups : dict [str , list [mobase .FileTreeEntry ]] = {}
9597
9698 def moveOverwriteMerge (self , source : str , destination : str ):
9799 if not os .path .exists (destination ):
@@ -113,14 +115,30 @@ def sanitizeFolderName(self, name: str) -> str:
113115 name = "" .join (c for c in name if ord (c ) >= 32 )
114116 name = name .rstrip (". " )
115117 if not name :
116- name = "FOLDERNAME "
118+ name = "Mod "
117119 return name
118120
121+ def groupRelatedFiles (
122+ self ,
123+ entries : list [mobase .FileTreeEntry ],
124+ ) -> list [list [mobase .FileTreeEntry ]]:
125+ """Group files that belong together (e.g., .pak, .utoc, .ucas with same base name)."""
126+ grouped : dict [str , list [mobase .FileTreeEntry ]] = {}
127+
128+ for entry in entries :
129+ # Get base name without extension
130+ name_without_ext = os .path .splitext (entry .name ())[0 ]
131+ if name_without_ext not in grouped :
132+ grouped [name_without_ext ] = []
133+ grouped [name_without_ext ].append (entry )
134+
135+ return list (grouped .values ())
136+
119137 def dataLooksValid (
120138 self , filetree : mobase .IFileTree
121139 ) -> mobase .ModDataChecker .CheckReturn :
122- GameDataUE4SSMods = (
123- getattr ( self .organizer .managedGame (), "GameDataUE4SSRoot" , "" ) + "/Mods "
140+ GameDataUE4SSMods = getattr (
141+ self .organizer .managedGame (), "GameDataUE4SSRoot" , ""
124142 )
125143 GameDataPakMods = getattr (self .organizer .managedGame (), "GameDataPakMods" , "" )
126144 GameDataMovieMods = getattr (
@@ -137,41 +155,42 @@ def dataLooksValid(
137155 def moveTreeContent (
138156 self ,
139157 installtype : str ,
140- entry : mobase .IFileTree | mobase .FileTreeEntry ,
158+ entries : list [ mobase .IFileTree | mobase .FileTreeEntry ] ,
141159 targettree : mobase .IFileTree ,
142160 destination : str ,
143161 ) -> None :
144162 if installtype == "virtual" :
145- targettree .move (entry , destination , mobase .IFileTree .MERGE )
163+ for entry in entries :
164+ targettree .move (entry , destination , mobase .IFileTree .MERGE )
146165 elif installtype == "os" :
147- if isinstance ( entry , mobase . IFileTree ):
148- for element in entry :
149- mod_file = element .name ()
150- mod_name = entry .name ()
151- mod_path = os .path .join (self .organizer .modsPath (), mod_name )
152- insideMods = os .path .join (mod_path , destination )
153- os .makedirs (insideMods , exist_ok = True )
154- src = os .path .join (mod_path , mod_file )
155- dst = os .path .join (mod_path , destination , mod_file )
156- shutil .move (
157- src ,
158- dst ,
159- )
166+ entry = entries [ 0 ]
167+ for subentry in entry :
168+ mod_file = subentry .name ()
169+ mod_name_val = entry .name ()
170+ mod_path = os .path .join (self .organizer .modsPath (), mod_name_val )
171+ insideMods = os .path .join (mod_path , destination )
172+ os .makedirs (insideMods , exist_ok = True )
173+ src = os .path .join (mod_path , mod_file )
174+ dst = os .path .join (mod_path , destination , mod_file )
175+ shutil .move (
176+ src ,
177+ dst ,
178+ )
160179 return None
161180
162181 def addModDetectionCandidate (
163182 self ,
164- tree : mobase .IFileTree | mobase .FileTreeEntry ,
183+ trees : list [ mobase .IFileTree | mobase .FileTreeEntry ] ,
165184 name : str ,
166185 category : str ,
167186 destination : str ,
168187 installtype : str ,
169188 ) -> None :
170- tree_name = tree . name ()
189+ tree_name = self . sanitizeFolderName ( trees [ 0 ]. name () if trees else "Unknown" )
171190
172191 self .modDetectionCandidates .append (
173192 {
174- "tree " : tree ,
193+ "trees " : trees ,
175194 "name" : tree_name ,
176195 "display" : f"{ name } ({ category } )" ,
177196 "destination" : destination ,
@@ -199,6 +218,19 @@ def showModDetectionDialog(self) -> set[int] | None:
199218
200219 layout .addWidget (listWidget )
201220
221+ selectButtons = QHBoxLayout ()
222+ selectAllButton = QPushButton ("Select All" )
223+ selectNoneButton = QPushButton ("Select None" )
224+ selectAllButton .clicked .connect (
225+ lambda : self .setDialogSelection (listWidget , True )
226+ ) # type: ignore
227+ selectNoneButton .clicked .connect (
228+ lambda : self .setDialogSelection (listWidget , False )
229+ ) # type: ignore
230+ selectButtons .addWidget (selectAllButton )
231+ selectButtons .addWidget (selectNoneButton )
232+ layout .addLayout (selectButtons )
233+
202234 buttonBox = QDialogButtonBox (
203235 QDialogButtonBox .StandardButton .Ok | QDialogButtonBox .StandardButton .Cancel
204236 )
@@ -220,14 +252,22 @@ def showModDetectionDialog(self) -> set[int] | None:
220252
221253 return selectedIndexes
222254
255+ def setDialogSelection (self , listWidget : QListWidget , select : bool ) -> None :
256+ state = Qt .CheckState .Checked if select else Qt .CheckState .Unchecked
257+ for index in range (listWidget .count ()):
258+ item = listWidget .item (index )
259+ if isinstance (item , QListWidgetItem ):
260+ item .setCheckState (state )
261+
223262 def collectModCandidates (
224263 self ,
225- tree : mobase .IFileTree | mobase .FileTreeEntry ,
264+ path : str ,
265+ entry : mobase .FileTreeEntry ,
226266 installtype : str = "virtual" ,
227- ) -> bool :
228- sanitized_name = self .sanitizeFolderName (tree .name ())
267+ ):
229268 category = None
230269 entryext = "None"
270+ basename = "Unknown"
231271 GameDataUE4SSRootDir = getattr (
232272 self .organizer .managedGame (), "GameDataUE4SSRoot" , ""
233273 )
@@ -239,22 +279,24 @@ def collectModCandidates(
239279 self .organizer .managedGame (), "GameDataMovieMods" , ""
240280 )
241281
242- if installtype == "os" and isinstance (tree , mobase .IFileTree ):
243- for entry in tree :
244- entryext = os .path .splitext (entry .name ())[1 ].removeprefix ("." )
282+ if installtype == "os" and isinstance (entry , mobase .IFileTree ):
283+ for subentry in entry :
284+ entryext = os .path .splitext (subentry .name ())[1 ].removeprefix ("." )
285+ basename = os .path .splitext (subentry .name ())[0 ]
245286 else :
246- entryext = tree .suffix ().casefold ()
287+ entryext = entry .suffix ().casefold ()
288+ basename = os .path .splitext (entry .name ())[0 ]
247289
248- if isinstance (tree , mobase .IFileTree ) and tree .isDir ():
249- if tree .exists ("ue4ss.dll" , mobase .IFileTree .FILE ) or tree .exists (
290+ if isinstance (entry , mobase .IFileTree ) and entry .isDir ():
291+ if entry .exists ("ue4ss.dll" , mobase .IFileTree .FILE ) or entry .exists (
250292 "dsound.dll" , mobase .IFileTree .FILE
251293 ):
252294 category = "Root"
253- elif tree . exists ( "Scripts" , mobase . IFileTree . DIRECTORY ) and not tree .exists (
254- "ue4ss.dll " , mobase .IFileTree .FILE
255- ):
295+ elif entry .exists (
296+ "Scripts " , mobase .IFileTree .DIRECTORY
297+ ) and not entry . exists ( "ue4ss.dll" , mobase . IFileTree . FILE ) :
256298 disallowedFolders = {"mods" }
257- tree_path = tree .path ()
299+ tree_path = entry .path ()
258300 tree_path_lower = tree_path .replace ("\\ " , "/" ).casefold ()
259301 if not disallowedFolders & set (tree_path_lower .split ("/" )):
260302 category = "UE4SS"
@@ -268,60 +310,76 @@ def collectModCandidates(
268310 case _:
269311 pass
270312
271- if category is None :
272- return False
273-
274- if category == "UE4SS" :
275- destination = GameDataUE4SSModsDir + "/"
276- elif category == "Root" :
277- destination = GameDataUE4SSRootDir + "/"
278- elif category == "Paks" :
279- destination = GameDataPakModsDir + "/"
280- elif category == "Movie" :
281- destination = GameDataMovieModsDir + "/"
282- else :
283- destination = "/"
284-
285- self .addModDetectionCandidate (
286- tree ,
287- sanitized_name ,
288- f"{ category } Mod" ,
289- destination ,
290- installtype ,
291- )
292- return True
313+ if category is not None :
314+ basename = basename + " " + category
315+
316+ if basename not in self .category_groups :
317+ self .category_groups [basename ] = []
318+
319+ self .category_groups [basename ].append (entry )
320+
321+ # Add grouped entries as candidates
322+ for basename , entries in self .category_groups .items ():
323+ if basename not in self .processedBasenames :
324+ self .processedBasenames .add (basename )
325+ sanitized_name = self .sanitizeFolderName (entries [0 ].name ())
326+
327+ if category == "UE4SS" :
328+ destination = GameDataUE4SSModsDir + "/"
329+ elif category == "Root" :
330+ destination = GameDataUE4SSRootDir + "/"
331+ elif category == "Paks" :
332+ destination = GameDataPakModsDir + "/"
333+ elif category == "Movie" :
334+ destination = GameDataMovieModsDir + "/"
335+ else :
336+ destination = "/"
337+
338+ if installtype == "os" :
339+ # Single file/entry
340+ self .addModDetectionCandidate (
341+ [entry ],
342+ sanitized_name ,
343+ f"{ category } Mod" ,
344+ destination ,
345+ installtype ,
346+ )
347+ else :
348+ candidate_entries = entries
349+
350+ if category == "Root" :
351+ candidate_entries = []
352+ for root_entry in entries :
353+ if root_entry .isDir ():
354+ candidate_entries .extend (list (root_entry ))
355+ else :
356+ candidate_entries .append (root_entry )
357+
358+ self .addModDetectionCandidate (
359+ candidate_entries ,
360+ sanitized_name ,
361+ f"{ category } Mod" ,
362+ destination ,
363+ installtype ,
364+ )
293365
294- def walkEntry (self , path : str , entry : mobase .FileTreeEntry ):
295- self .collectModCandidates (entry )
296366 return mobase .IFileTree .WalkReturn .CONTINUE
297367
298- def fileExistsInNextSubDir (self , filetree : mobase .IFileTree , name : str ):
299- for branch in filetree :
300- if isinstance (branch , mobase .IFileTree ):
301- for e in branch :
302- if e .name () == name :
303- return True
304- return False
305-
306- def allMoveTo (self , filetree : mobase .IFileTree , toMoveTo : str ):
307- entriesToMove : list [mobase .FileTreeEntry ] = []
308- retVal = 0
309- for e in filetree :
310- entriesToMove .append (e )
311- for e in entriesToMove :
312- filetree .move (e , toMoveTo , mobase .IFileTree .MERGE )
313- retVal = 1
314- return retVal
315-
316368 def fix (self , filetree : mobase .IFileTree ) -> mobase .IFileTree | None :
317369 self .modDetectionCandidates = []
370+ self .processedBasenames = set ()
371+ self .category_groups = {}
372+ UnZippedInstallation = False
318373 newtree = filetree .createOrphanTree ("Fixed Tree" )
319- # Check for Non Zipped Mod
374+
320375 if filetree .name () != "" :
321- self .collectModCandidates (filetree , installtype = "os" )
376+ # Initial Check on Main Directory
377+ self .collectModCandidates ("/" , filetree , installtype = "os" )
378+ UnZippedInstallation = True
322379 else :
323- self .collectModCandidates (filetree )
324- filetree .walk (self .walkEntry , "/" )
380+ # Initial Check on Main Directory
381+ self .collectModCandidates ("/" , filetree )
382+ filetree .walk (self .collectModCandidates , "/" )
325383
326384 if len (self .modDetectionCandidates ) == 1 :
327385 selectedIndexes = {0 }
@@ -330,19 +388,19 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None:
330388 if selectedIndexes is None :
331389 return None
332390
391+ if not UnZippedInstallation :
392+ filetree = newtree
393+
333394 for index in selectedIndexes :
334395 candidate = self .modDetectionCandidates [index ]
335396 self .moveTreeContent (
336397 candidate ["installtype" ],
337- candidate ["tree " ],
338- newtree ,
398+ candidate ["trees " ],
399+ filetree ,
339400 candidate ["destination" ],
340401 )
341402
342- if newtree :
343- return newtree
344- else :
345- return filetree
403+ return filetree
346404
347405
348406class BatmanLegacyGame (BasicGame ):
0 commit comments