22import json
33import re
44import shutil
5+ import tempfile
6+ import textwrap
57from collections import Counter
68from collections .abc import Iterable
79from dataclasses import dataclass
810from pathlib import Path
911from typing import Any , Literal , TypeVar
1012
1113import mobase
12- from PyQt6 .QtCore import QDateTime , QDir , qCritical , qInfo , qWarning
14+ from PyQt6 .QtCore import QDateTime , QDir , Qt , qCritical , qInfo , qWarning
15+ from PyQt6 .QtWidgets import (
16+ QCheckBox ,
17+ QMainWindow ,
18+ QMessageBox ,
19+ QProgressDialog ,
20+ QWidget ,
21+ )
1322
1423from ..basic_features import BasicLocalSavegames , BasicModDataChecker , GlobPatterns
1524from ..basic_features .basic_save_game_info import (
@@ -207,6 +216,9 @@ class Cyberpunk2077Game(BasicGame):
207216 _redmod_deploy_args = "deploy -reportProgress"
208217 """Deploy arguments for `redmod.exe`, -modlist=... is added."""
209218
219+ _parentWidget : QWidget
220+ """Set with `_organizer.onUserInterfaceInitialized()`"""
221+
210222 def init (self , organizer : mobase .IOrganizer ) -> bool :
211223 super ().init (organizer )
212224 self ._register_feature (BasicLocalSavegames (self .savesDirectory ()))
@@ -234,7 +246,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool:
234246 organizer .onAboutToRun (self ._onAboutToRun )
235247 organizer .onPluginSettingChanged (self ._on_settings_changed )
236248 organizer .modList ().onModInstalled (self ._check_disable_crashreporter )
237- organizer .onUserInterfaceInitialized (self ._check_disable_crashreporter )
249+ organizer .onUserInterfaceInitialized (self ._on_user_interface_initialized )
238250 return True
239251
240252 def _on_settings_changed (
@@ -244,11 +256,27 @@ def _on_settings_changed(
244256 old : mobase .MoVariant ,
245257 new : mobase .MoVariant ,
246258 ):
247- if self .name () == plugin_name :
248- if setting == "reverse_archive_load_order" :
259+ if self .name () != plugin_name :
260+ return
261+ match setting :
262+ case "reverse_archive_load_order" :
249263 self ._modlist_files ["archive" ].reversed_priority = bool (new )
250- elif setting == "reverse_remod_load_order" :
264+ case "reverse_remod_load_order" :
251265 self ._modlist_files ["redmod" ].reversed_priority = bool (new )
266+ case "show_rootbuilder_conversion" :
267+ if new and (dialog := self ._get_rootbuilder_conversion_dialog ()):
268+ dialog .open () # type: ignore
269+ case _:
270+ return
271+
272+ def _on_user_interface_initialized (self , window : QMainWindow ):
273+ self ._parentWidget = window
274+ if not self .isActive ():
275+ return
276+ if dialog := self ._get_rootbuilder_conversion_dialog (window ):
277+ dialog .open () # type: ignore
278+ else :
279+ self ._check_disable_crashreporter ()
252280
253281 def _check_disable_crashreporter (
254282 self , mod : mobase .IModInterface | None | Any = None
@@ -388,6 +416,14 @@ def settings(self) -> list[mobase.PluginSetting]:
388416 ),
389417 True ,
390418 ),
419+ mobase .PluginSetting (
420+ "show_rootbuilder_conversion" ,
421+ (
422+ "Shows a dialog to convert legacy RootBuilder mods to native MO mods,"
423+ " using force load libraries"
424+ ),
425+ True ,
426+ ),
391427 ]
392428
393429 def _get_setting (self , key : str ) -> mobase .MoVariant :
@@ -587,3 +623,125 @@ def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]:
587623 yield Path (file ).absolute ().relative_to (data_path )
588624 except ValueError :
589625 continue
626+
627+ def _get_rootbuilder_conversion_dialog (
628+ self , parent_widget : QWidget | None = None
629+ ) -> QMessageBox | None :
630+ """
631+ Dialog to convert any mods with `root` folder, if applicable.
632+ CET and RED4ext work with forced load libraries since ~ Cyberpunk v2.12,
633+ making RootBuilder unnecessary.
634+ """
635+ setting = "show_rootbuilder_conversion"
636+ if not self .isActive () or not self ._get_setting (setting ):
637+ return None
638+ if not (
639+ (root_folder := self ._organizer .virtualFileTree ().find ("root" ))
640+ and root_folder .isDir ()
641+ ):
642+ return None
643+ parent_widget = parent_widget or self ._parentWidget
644+ message_box = QMessageBox (
645+ QMessageBox .Icon .Question ,
646+ "RootBuilder obsolete" ,
647+ textwrap .dedent (
648+ """
649+ Mod Organizer now supports Cyberpunk Engine Tweaks (CET) and
650+ RED4ext native via forced load libraries, making RootBuilder
651+ unnecessary.
652+
653+ Do you want to convert all mods with a `root` folder now?
654+ <br/>This usually only affects CET, RED4ext and overwrite.
655+
656+ You can disable RootBuilder afterwards.
657+ """
658+ ),
659+ QMessageBox .StandardButton .Yes | QMessageBox .StandardButton .No ,
660+ parent_widget ,
661+ )
662+ message_box .setTextFormat (Qt .TextFormat .MarkdownText )
663+ checkbox = QCheckBox ("&Do not show again*" , parent_widget )
664+ checkbox .setChecked (True )
665+ checkbox .setToolTip (f"Settings/Plugins/{ self .name ()} /{ setting } " )
666+ message_box .setCheckBox (checkbox )
667+
668+ def accept_callback ():
669+ if unfolded_mods := unfold_root_folders (self ._organizer , parent_widget ):
670+ n_mods = len (unfolded_mods )
671+ info = QMessageBox (
672+ QMessageBox .Icon .Information ,
673+ "Root mods converted" ,
674+ (
675+ f"{ n_mods } mod{ 's' if n_mods > 1 else '' } converted."
676+ "You can disable RootBuilder in the settings now."
677+ ),
678+ parent = parent_widget ,
679+ )
680+ info .setDetailedText (f"Converted mods:\n { '\n ' .join (unfolded_mods )} " )
681+ info .open () # type: ignore
682+
683+ message_box .accepted .connect (accept_callback ) # type: ignore
684+
685+ def finished_callback ():
686+ if checkbox .isChecked ():
687+ self ._set_setting (setting , False )
688+ self ._check_disable_crashreporter ()
689+
690+ message_box .finished .connect (finished_callback ) # type: ignore
691+ return message_box
692+
693+
694+ def unfold_root_folders (
695+ organizer : mobase .IOrganizer , parent_widget : QWidget | None = None
696+ ) -> list [str ]:
697+ """Unfolds (RootBuilders) root folders of all mods (excluding backups)."""
698+ mods = organizer .modList ().allMods ()
699+ progress = None
700+ unfolded_mods : list [str ] = []
701+ if parent_widget :
702+ progress = QProgressDialog (
703+ "Merging/unfolding root folders..." ,
704+ "Abort" ,
705+ 0 ,
706+ len (mods ),
707+ parent_widget ,
708+ )
709+ progress .setWindowModality (Qt .WindowModality .WindowModal )
710+ for i , mod_name in enumerate (mods ):
711+ if progress :
712+ if progress .wasCanceled ():
713+ break
714+ progress .setValue (i )
715+ if mod_name == "data" :
716+ continue
717+ mod = organizer .modList ().getMod (mod_name )
718+ if mod .isBackup () or mod .isSeparator () or mod .isForeign ():
719+ continue
720+ root_folder = mod .fileTree ().find ("root" )
721+ if root_folder is None or not root_folder .isDir ():
722+ continue
723+ qInfo (f"Merging root folder of { mod_name } " )
724+ mod_path = Path (mod .absolutePath ())
725+ root_folder_path = mod_path / "root"
726+ unfold_folder (root_folder_path )
727+ unfolded_mods .append (mod_name )
728+ if progress :
729+ progress .setValue (len (mods ))
730+ organizer .refresh ()
731+ return unfolded_mods
732+
733+
734+ def unfold_folder (src_path : Path ):
735+ """
736+ Unfolds a folder (`parent/src/* -> parent/*`), overwriting existing files/folders.
737+ Preserves a subfolder with same name (`parent/src/src -> parent/src`).
738+ """
739+ parent = src_path .parent
740+ if (src_path / src_path .name ).exists ():
741+ # Contains a file/folder with same name
742+ with tempfile .TemporaryDirectory (dir = parent ) as temp_folder :
743+ src_path = src_path .rename (parent / temp_folder / src_path .name )
744+ shutil .copytree (src_path , parent , symlinks = True , dirs_exist_ok = True )
745+ else :
746+ shutil .copytree (src_path , parent , symlinks = True , dirs_exist_ok = True )
747+ shutil .rmtree (src_path )
0 commit comments