diff --git a/.gitmodules b/.gitmodules index f516a26..9c6e9db 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,7 @@ path = core-res url = https://github.com/Cutleast/cutleast-core-res.git branch = master +[submodule "mod-manager-lib"] + path = mod-manager-lib + url = https://github.com/Cutleast/mod-manager-lib.git + branch = master diff --git a/core-lib b/core-lib index 22d9a6c..ffc3ab7 160000 --- a/core-lib +++ b/core-lib @@ -1 +1 @@ -Subproject commit 22d9a6ce7715f2d6fa2957491cc0e21fbf0f6a51 +Subproject commit ffc3ab7080ef9218033af51642b3ba57ec1e18d3 diff --git a/mod-manager-lib b/mod-manager-lib new file mode 160000 index 0000000..9d8dcde --- /dev/null +++ b/mod-manager-lib @@ -0,0 +1 @@ +Subproject commit 9d8dcdea704875a187826037dc70c66af7aeba3f diff --git a/pyproject.toml b/pyproject.toml index 0198551..1637c59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,7 @@ authors = [{ name = "Cutleast", email = "cutleast@gmail.com" }] license = { file = "LICENSE" } dependencies = [ "cutleast-core-lib", - "plyvel-ci", - "pyuac", + "mod-manager-lib", ] [dependency-groups] @@ -28,10 +27,12 @@ dev = [ [tool.uv.workspace] members = [ "core-lib", + "mod-manager-lib", ] [tool.uv.sources] cutleast-core-lib = { workspace = true } +mod-manager-lib = { workspace = true, editable = true } [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/res/loc/de.ts b/res/loc/de.ts index 345ddb5..e6d6e69 100644 --- a/res/loc/de.ts +++ b/res/loc/de.ts @@ -75,6 +75,14 @@ Die Akzentfarbe muss ein gültiger hexadezimaler Farbcode sein! + + Downloader + + + Downloading '{0}'... + '{0}' wird heruntergeladen... + + ErrorDialog @@ -125,38 +133,28 @@ InstanceCreatorWidget - - Mod Manager: + + Mod manager: Mod Manager: - - - Please select... - Bitte auswählen... - InstanceSelectorWidget - - Mod Manager: + + Mod manager: Mod Manager: - - - Please select... - Bitte auswählen... - InstanceWidget - + Modlist Modliste - + Tools Tools @@ -208,12 +206,12 @@ MainWidget - + Destination instance already exists! Zielinstanz existiert bereits! - + Are you sure you want to migrate to the existing destination instance? This feature is considered experimental and could cause issues. Continue at your own risk! @@ -222,12 +220,12 @@ Diese Funktion ist experimentell und könnte Probleme verursachen. Fortfahren auf eigene Gefahr! - + Migration completed with errors! Migration mit Fehlern abgeschlossen! - + Migration completed with errors! Click 'Ok' to open the report. @@ -236,12 +234,12 @@ Fortfahren auf eigene Gefahr! - + Migration Complete Migration abgeschlossen - + Migration completed successfully! @@ -367,96 +365,134 @@ Fortfahren auf eigene Gefahr! Migrator - - Migrating instance {0}... - Instanz {0} wird migriert... + + Preparing destination instance... + Ziel-Instanz wird vorbereitet... - + Migrating mods... Mods werden migriert... - + Migrating tools... Tools werden migriert... - + + Migrating INI files... + INI-Dateien werden migriert... + + + Failed to migrate INI files. Migration der INI-Dateien fehlgeschlagen. - + + Migrating additional files... + Zusätzliche Dateien werden migriert... + + + Failed to migrate additional files. Migration der zusätzlichen Dateien fehlgeschlagen. + + + The usage of root builder was enabled. +In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of your MO2 installation if not already installed. + Die Nutzung von Root Builder wurde aktiviert. +Damit die Root-Dateien korrekt deployed werden, muss das Root Builder Plugin von Nexus Mods heruntergeladen und in den "plugins"-Ordner deiner MO2 Installation entpackt werden, wenn nicht bereits installiert. + + + + The usage of root builder was enabled. +In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of the new MO2 installation. + Die Nutzung von Root Builder wurde aktiviert. +Damit die Root-Dateien korrekt deployed werden, muss das Root Builder Plugin von Nexus Mods heruntergeladen und in den "plugins"-Ordner der neuen MO2 Installation entpackt werden. + + + + At least one global instance was detected. +Global instances cause issues with portable instances and it is recommended to delete (or rename) the following folder: +{0} + Mindestens eine globale Instanz wurde gefunden. +Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfohlen, den folgenden Ordner zu löschen (oder umzubenennen): +{0} + + + + Vortex is currently deployed to the game folder. It is strongly recommended to purge the game directory before using the migrated instance. + Vortex ist zurzeit in das Spielverzeichnis deployed. Es wird empfohlen, das Spielverzeichnis vor der Nutzung der migrierten Instanz zu säubern. + MigratorWidget - - + + Load selected instance... Ausgewählte Instanz laden... - + Game: Spiel: - + Please select... Bitte auswählen... - + Choose the source instance: Quellinstanz auswählen: - + Configure the destination instance: Zielinstanz konfigurieren: - + Create new instance Neue Instanz erstellen - + Select existing instance Bestehende Instanz auswählen - + (Experimental) (Experimentell) - + Migrate... Migration starten... - + Could not find game directory! Spielverzeichnis konnte nicht gefunden werden! - + Unable to find game directory. Please select it manually. Das Spielverzeichnis konnte nicht gefunden werden. Bitte manuell auswählen. - + Select game directory Spielverzeichnis auswählen - + Instance loaded. Instanz geladen. @@ -464,127 +500,103 @@ Fortfahren auf eigene Gefahr! ModOrganizer - - - + + Loading mods from {0} > {1}... Mods von {0} > {1} werden geladen... - - + + Processing mod conflicts... Modkonflikte werden verarbeitet... - - + + Loading tools from {0} > {1}... Tools von {0} > {1} werden geladen... - + + Loading mods from {0} > {1}: {2}... + Mods von {0} > {1} werden geladen: {2}... + + + Processing single file conflicts... Einzelkonflikte werden verarbeitet... - + Downloading and installing ModOrganizer... ModOrganizer wird heruntergeladen und installiert... - - Downloading ModOrganizer... - ModOrganizer wird heruntergeladen... - - - + Extracting archive... Archiv wird entpackt... - - - The usage of root builder was enabled. -In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of your MO2 installation if not already installed. - Die Nutzung von Root Builder wurde aktiviert. -Damit die Root-Dateien korrekt deployed werden, muss das Root Builder Plugin von Nexus Mods heruntergeladen und in den "plugins"-Ordner deiner MO2 Installation entpackt werden, wenn nicht bereits installiert. - - - - The usage of root builder was enabled. -In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of the new MO2 installation. - Die Nutzung von Root Builder wurde aktiviert. -Damit die Root-Dateien korrekt deployed werden, muss das Root Builder Plugin von Nexus Mods heruntergeladen und in den "plugins"-Ordner der neuen MO2 Installation entpackt werden. - - - - At least one global instance was detected. -Global instances cause issues with portable instances and it is recommended to delete (or rename) the following folder: -{0} - Mindestens eine globale Instanz wurde gefunden. -Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfohlen, den folgenden Ordner zu löschen (oder umzubenennen): -{0} - ModOrganizerCreatorWidget - + Instance name: Name der Instanz: - + eg. My Migrated Instance z.B. Meine Migrierte Instanz - + Instance type: Typ der Instanz: - + Portable instance Portable Instanz - + Global instance Globale Instanz - + Instance path: Pfad zur Instanz: - + eg. C:\Modding\My Migrated Instance z.B. C:\Modding\Meine Migrierte Instanz - + Mods path: Pfad zu den Mods: - + eg. C:\Modding\My Migrated Instance\mods z.B. C:\Modding\Meine Migrierte Instanz\mods - + Install Mod Organizer 2: Mod Organizer 2 installieren: - + Use Root Builder plugin: Verwende Root Builder Plugin: - + If enabled, mod files for the game's root folder will be moved to a "Root" subfolder in their mod instead of copied to the game's root folder. Wenn aktiviert, werden Moddateien für das Rootverzeichnis des Spiels in einen "Root"-Unterordner innerhalb ihrer Mod verschoben, statt in das Spielverzeichnis kopiert zu werden. @@ -592,25 +604,17 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo ModOrganizerSelectorWidget - + Instance: Instanz: - - - - - Please select... - Bitte auswählen... - - - + Portable path: Pfad der portablen Instanz: - + Profile: Profil: @@ -618,32 +622,32 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo ModlistMenu - + Expand all Alle aufklappen - + Collapse all Alle einklappen - + Exclude selected mod(s) from migration Ausgewählte Mod(s) von der Migration ausschließen - + Include selected mod(s) in migration Ausgewählte Mod(s) in die Migration einschließen - + Open mod page on Nexus Mods... Modseite auf Nexus Mods öffnen... - + Open in Explorer... Im Explorer öffnen... @@ -735,27 +739,27 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo ProgressDialog - + Elapsed time: Vergangene Zeit: - + Cancel? Abbrechen? - + Are you sure you want to cancel? This may have unwanted consequences, depending on the current running process! Bist du sicher, dass du abbrechen möchtest? Je nach laufendem Vorgang kann das unbeabsichtigte Konsequenzen haben! - + No Nein - + Yes Ja @@ -882,32 +886,32 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo ToolsMenu - + Expand all Alle aufklappen - + Collapse all Alle einklappen - + Include selected tool(s) Ausgewählte(s) Tool(s) in Migration einschließen - + Exclude selected tool(s) Ausgewählte(s) Tool(s) von Migration ausschließen - + Open mod page on Nexus Mods... Modseite auf Nexus Mods öffnen... - + Open in Explorer... Im Explorer öffnen... @@ -915,32 +919,32 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo ToolsWidget - + Included Tools: Einbezogene Tools: - + Name Name - + Mod Mod - + Executable Path Pfad zur Executable - + Arguments Startargumente - + Working Directory Arbeitsverzeichnis @@ -1007,31 +1011,30 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo Vortex - + Loading profile {0}... Profil {0} wird geladen... - - + Loading mods from profile {0}... Mods von Profil {0} werden geladen... - - Loading tools from Vortex... - Tools werden von Vortex geladen... + + Loading mods from profile {0}: {1}... + Mods von Profil {0} werden geladen: {1}... - - Vortex is currently deployed to the game folder. It is strongly recommended to purge the game directory before using the migrated instance. - Vortex ist zurzeit in das Spielverzeichnis deployed. Es wird empfohlen, das Spielverzeichnis vor der Nutzung der migrierten Instanz zu säubern. + + Loading tools from Vortex... + Tools werden von Vortex geladen... VortexCreatorWidget - + Profile name: Profilname: @@ -1039,60 +1042,59 @@ Globale Instanzen verursachen Probleme mit portablen Instanzen und es wird empfo VortexSelectorWidget - + Profile: Profil: - - - - Please select... - Bitte auswählen... - exceptions - + A mod manager error occured! Ein Mod Manager-Fehler ist aufgetreten! - + The mod instance {0} could not be found! Die Modinstanz {0} konnte nicht gefunden werden! - + + The mod instance could not be created! + Die Modinstanz konnte nicht erstellt werden! + + + Not enough space ({2}) on the destination disk ({0})! Required space: {1} Nicht genug Speicher ({2}) auf dem Zieldatenträger ({0}) vorhanden! Benötigter Speicherplatz: {1} - + Source and destination must not be the same! Quelle und Ziel dürfen nicht übereinstimmen! - + Source and destination have the same mods location but different mod managers! Choose another location for the mods folder and try again. Quelle und Ziel haben denselben Speicherort für Mods, aber unterschiedliche Mod-Manager! Bitte einen anderen Speicherort für den Mods-Ordner wählen und erneut versuchen. - + The installation folder for the selected game could not be found! Das Installationsverzeichnis des ausgewählten Spiels konnte nicht gefunden werden! - + Vortex is running and blocking its database. Close Vortex and try again! Vortex ist offen und blockiert seine Datenbank. Vortex schließen und erneut versuchen! - + Migration cannot continue while Vortex is deployed! Open Vortex and purge the game directory. Then click 'Continue' to complete the migration process. @@ -1101,7 +1103,7 @@ Bitte Vortex öffnen und das Spielverzeichnis bereinigen. Dann auf "Fortfahren" klicken, um die Migration abzuschließen. - + Vortex is not installed or fully setup. Follow these steps and try again: 1. Install Vortex @@ -1114,19 +1116,19 @@ Bitte diesen Schritten folgen und erneut versuchen: 3. Profilverwaltung in den Vortex Einstellungen aktivieren. - + The overwrite folder of MO2 is not supported by Vortex! Please create a separate mod from the overwrite folder and restart the migration. Der Overwrite-Ordner von MO2 wird nicht von Vortex unterstützt! Bitte eine separate Mod aus dem Overwrite-Ordner erstellen und die Migration erneut ausführen. - + Invalid global instance path! The path must not be outside of the %LOCALAPPDATA%\ModOrganizer folder when choosing the global instance type! Ungültiger Pfad für eine global Instanz! Der Pfad darf nicht außerhalb von %LOCALAPPDATA%\ModOrganizer liegen, wenn "Global" als Instanztyp gewählt wird! - + Cannot install MO2 when a global instance is selected as destination! MO2 kann nicht installiert werden, wenn eine globale Instanz als Ziel gewählt ist! diff --git a/res/loc/pt_BR.ts b/res/loc/pt_BR.ts index c5792f4..fe080f3 100644 --- a/res/loc/pt_BR.ts +++ b/res/loc/pt_BR.ts @@ -75,6 +75,14 @@ + + Downloader + + + Downloading '{0}'... + + + ErrorDialog @@ -125,38 +133,28 @@ InstanceCreatorWidget - - Mod Manager: - Gerenciador de Mod: - - - - Please select... - Por favor selecione... + + Mod manager: + InstanceSelectorWidget - - Mod Manager: - Gerenciador de Mod: - - - - Please select... - Por favor selecione... + + Mod manager: + InstanceWidget - + Modlist Modlist - + Tools Ferramentas @@ -208,24 +206,24 @@ MainWidget - + Destination instance already exists! O destino da instância já existe! - + Are you sure you want to migrate to the existing destination instance? This feature is considered experimental and could cause issues. Continue at your own risk! Tem certeza de que deseja migrar para a instância de destino já existente? - + Migration completed with errors! Migração finalizou com erros! - + Migration completed with errors! Click 'Ok' to open the report. @@ -234,12 +232,12 @@ Continue at your own risk! - + Migration Complete Migração Finalizada - + Migration completed successfully! @@ -365,96 +363,134 @@ Continue at your own risk! Migrator - - Migrating instance {0}... - Migrando instância {0}... + + Preparing destination instance... + - + Migrating mods... Migrando mods... - + Migrating tools... Migrando ferramentas... - + + Migrating INI files... + + + + Failed to migrate INI files. Falha em migrar os arquivos INI. - + + Migrating additional files... + + + + Failed to migrate additional files. Falha ao migrar arquivos adicionais. + + + The usage of root builder was enabled. +In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of your MO2 installation if not already installed. + O uso do root builder foi ativado. +Para implantar corretamente os arquivos root, você deve baixar e extrair o plugin root builder do Nexus Mods para a pasta "plugins" da sua instalação do MO2, caso ainda não esteja instalado. + + + + The usage of root builder was enabled. +In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of the new MO2 installation. + O uso do root builder foi ativado. +Para implantar corretamente os arquivos root, você deve baixar e extrair o plugin root builder do Nexus Mods para a pasta "plugins" da nova instalação do MO2. + + + + At least one global instance was detected. +Global instances cause issues with portable instances and it is recommended to delete (or rename) the following folder: +{0} + Pelo menos uma instância global foi detectada. +Instâncias globais causam problemas com instâncias portáteis, e é recomendável excluir (ou renomear) a seguinte pasta: +{0} + + + + Vortex is currently deployed to the game folder. It is strongly recommended to purge the game directory before using the migrated instance. + O Vortex está atualmente instalado na pasta do jogo. É altamente recomendável limpar o diretório do jogo antes de usar a instância migrada. + MigratorWidget - - + + Load selected instance... Carregando instância selecionada... - + Game: Jogo: - + Please select... Por favor selecione... - + Choose the source instance: Escolha a fonte da instância: - + Configure the destination instance: Confihurar o destino da instância: - + Create new instance Criar nova instância - + Select existing instance Selecione isntância existente - + (Experimental) (Experimental) - + Migrate... Migrar... - + Could not find game directory! Não foi possível encontrar o diretório do jogo! - + Unable to find game directory. Please select it manually. Não foi possível encontrar o diretório do jogo. Por favor, selecione-o manualmente. - + Select game directory Selecione o diretório do jogo - + Instance loaded. Instância carregada. @@ -462,127 +498,103 @@ Continue at your own risk! ModOrganizer - - - + + Loading mods from {0} > {1}... Carregando mods de {0} > {1}... - - + + Loading tools from {0} > {1}... Carregando ferramentes de {0} > {1}... - - + + Loading mods from {0} > {1}: {2}... + + + + + Processing mod conflicts... Processando conflitos de mods... - + Processing single file conflicts... Processando conflitos de arquivo único... - + Downloading and installing ModOrganizer... Baixando e instalando ModOrganizer... - - Downloading ModOrganizer... - Baixando ModOrganizer... - - - + Extracting archive... Extraindo arquivo... - - - The usage of root builder was enabled. -In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of your MO2 installation if not already installed. - O uso do root builder foi ativado. -Para implantar corretamente os arquivos root, você deve baixar e extrair o plugin root builder do Nexus Mods para a pasta "plugins" da sua instalação do MO2, caso ainda não esteja instalado. - - - - The usage of root builder was enabled. -In order to correctly deploy the root files, you have to download and extract the root builder plugin from Nexus Mods to the "plugins" folder of the new MO2 installation. - O uso do root builder foi ativado. -Para implantar corretamente os arquivos root, você deve baixar e extrair o plugin root builder do Nexus Mods para a pasta "plugins" da nova instalação do MO2. - - - - At least one global instance was detected. -Global instances cause issues with portable instances and it is recommended to delete (or rename) the following folder: -{0} - Pelo menos uma instância global foi detectada. -Instâncias globais causam problemas com instâncias portáteis, e é recomendável excluir (ou renomear) a seguinte pasta: -{0} - ModOrganizerCreatorWidget - + Instance name: Nome da instância: - + eg. My Migrated Instance ex. Minha Migração de Instância - + Instance type: Tipo de instância: - + Portable instance Instáncia Portátil - + Global instance Instância Global - + Instance path: Caminho da instância: - + eg. C:\Modding\My Migrated Instance ex. C:\Modding\Minha Instância Migrada - + Mods path: Pasta dos Mods: - + eg. C:\Modding\My Migrated Instance\mods ex. C:\Modding\Minha Instância Migrada\mods - + Install Mod Organizer 2: Instalar Mod Organizer 2: - + Use Root Builder plugin: Usar plugin Root Builder: - + If enabled, mod files for the game's root folder will be moved to a "Root" subfolder in their mod instead of copied to the game's root folder. Se ativado, os arquivos de mod destinados à pasta raiz do jogo serão movidos para uma subpasta "Root" dentro do mod, em vez de serem copiados diretamente para a pasta raiz do jogo. @@ -590,25 +602,17 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá ModOrganizerSelectorWidget - + Instance: Instância: - - - - - Please select... - Por favor selecione... - - - + Portable path: Pasta do portátil: - + Profile: Perfil: @@ -616,32 +620,32 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá ModlistMenu - + Expand all Expandir tudo - + Collapse all Recolher tudo - + Exclude selected mod(s) from migration - + Include selected mod(s) in migration - + Open mod page on Nexus Mods... Abrir pagina do mod no Nexus Mods... - + Open in Explorer... Abrir no Explorador... @@ -733,27 +737,27 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá ProgressDialog - + Elapsed time: Tempo decorrido: - + Cancel? Cancelar? - + Are you sure you want to cancel? This may have unwanted consequences, depending on the current running process! Deseja mesmo cancelar? Isso pode ter consequências indesejadas, dependendo do processo em execução no momento! - + No Não - + Yes Sim @@ -880,32 +884,32 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá ToolsMenu - + Expand all Expandir tudo - + Collapse all Recolher tudo - + Exclude selected tool(s) Excluir ferramenta(s) selecionada(s) - + Include selected tool(s) Incluir ferramentas seleciona(s) - + Open mod page on Nexus Mods... Abrir pagina do mod no Nexus Mods... - + Open in Explorer... Abrir o Explorador... @@ -913,32 +917,32 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá ToolsWidget - + Included Tools: Ferramentas Incluídas: - + Name Nome - + Mod Mod - + Executable Path Caminho do executável - + Arguments Argumentos - + Working Directory Diretório de trabalho @@ -1005,31 +1009,30 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá Vortex - + Loading profile {0}... Carregando perfil{0}... - - + Loading mods from profile {0}... Carregando mods do perfil {0}... - - Loading tools from Vortex... - Carregando ferramentas do Vortex... + + Loading mods from profile {0}: {1}... + - - Vortex is currently deployed to the game folder. It is strongly recommended to purge the game directory before using the migrated instance. - O Vortex está atualmente instalado na pasta do jogo. É altamente recomendável limpar o diretório do jogo antes de usar a instância migrada. + + Loading tools from Vortex... + Carregando ferramentas do Vortex... VortexCreatorWidget - + Profile name: Nome do perfil: @@ -1037,51 +1040,50 @@ Instâncias globais causam problemas com instâncias portáteis, e é recomendá VortexSelectorWidget - + Profile: Perfil: - - - - Please select... - Por favor selecione... - exceptions - + The installation folder for the selected game could not be found! A pasta de instalação do jogo selecionado não pôde ser localizada! - + A mod manager error occured! Ocorreu um erro no gerenciador de mods! - + The mod instance {0} could not be found! A instância de mod {0} não pôde ser localizada! - + + The mod instance could not be created! + + + + Invalid global instance path! The path must not be outside of the %LOCALAPPDATA%\ModOrganizer folder when choosing the global instance type! Caminho de instância global inválido! O caminho não pode estar fora da pasta %LOCALAPPDATA%\ModOrganizer ao escolher o tipo de instância global! - + Cannot install MO2 when a global instance is selected as destination! Não é possível instalar o MO2 quando uma instância global está selecionada como destino! - + Vortex is running and blocking its database. Close Vortex and try again! O Vortex está em execução e bloqueando seu banco de dados. Feche o Vortex e tente novamente! - + Migration cannot continue while Vortex is deployed! Open Vortex and purge the game directory. Then click 'Continue' to complete the migration process. @@ -1090,7 +1092,7 @@ Abra o Vortex e limpe o diretório do jogo. Depois clique em 'Continuar' para finalizar o processo de migração. - + Vortex is not installed or fully setup. Follow these steps and try again: 1. Install Vortex @@ -1103,13 +1105,13 @@ Siga estas etapas e tente novamente: 3. Ative o gerenciamento de perfis nas configurações do Vortex. - + The overwrite folder of MO2 is not supported by Vortex! Please create a separate mod from the overwrite folder and restart the migration. A pasta overwrite do MO2 não é compatível com o Vortex! - + Not enough space ({2}) on the destination disk ({0})! Required space: {1} Crie um mod separado a partir da pasta overwrite e reinicie a migração. @@ -1117,12 +1119,12 @@ Espaço insuficiente ({2}) no disco de destino ({0})! Espaço necessário: {1} - + Source and destination must not be the same! A origem e o destino não podem ser os mesmos! - + Source and destination have the same mods location but different mod managers! Choose another location for the mods folder and try again. diff --git a/src/app.py b/src/app.py index 31c713f..a2a1c69 100644 --- a/src/app.py +++ b/src/app.py @@ -10,7 +10,9 @@ from cutleast_core_lib.base_app import BaseApp from cutleast_core_lib.core.config.app_config import AppConfig as BaseAppConfig from cutleast_core_lib.core.utilities.env_resolver import resolve +from cutleast_core_lib.core.utilities.qt_res_provider import read_resource from cutleast_core_lib.core.utilities.singleton import Singleton +from mod_manager_lib.core.game_service import GameService from PySide6.QtCore import QTranslator from PySide6.QtGui import QIcon from PySide6.QtWidgets import QMainWindow @@ -49,6 +51,8 @@ def _load_app_config(self) -> BaseAppConfig: def _init_main_window(self) -> QMainWindow: self.__load_translation() + GameService(read_resource(":/games.json")) + return MainWindow(cast(AppConfig, self.app_config)) def __load_translation(self) -> None: diff --git a/src/core/game/__init__.py b/src/core/game/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/src/core/game/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/src/core/game/exceptions.py b/src/core/game/exceptions.py deleted file mode 100644 index 5bc938b..0000000 --- a/src/core/game/exceptions.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import override - -from PySide6.QtWidgets import QApplication - -from core.utilities.exceptions import ExceptionBase - - -class GameNotFoundError(ExceptionBase): - """ - Exception when the installation folder for a game could not be found. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "The installation folder for the selected game could not be found!", - ) diff --git a/src/core/game/game.py b/src/core/game/game.py deleted file mode 100644 index d8675e0..0000000 --- a/src/core/game/game.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any, override - -from cutleast_core_lib.core.cache.function_cache import FunctionCache -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.core.utilities.qt_res_provider import load_json_resource -from pydantic import AfterValidator, BaseModel, Field - -from core.utilities.filesystem import get_documents_folder - - -class Game(BaseModel): - """ - Base class for general game specifications. - """ - - id: str - """ - Game identifier, should match the one used by Vortex (eg. "skyrimse"). - """ - - display_name: str - """ - Display name of the game (eg. "Skyrim Special Edition"). - """ - - short_name: str - """ - Short name of the game (eg. "SkyrimSE"). - """ - - nexus_id: str - """ - Name of the game's nexus page (eg. "skyrimspecialedition"). - """ - - inidir: Annotated[ - Path, - AfterValidator(lambda p: resolve(p, documents=str(get_documents_folder()))), - ] - """ - Path to the game's ini directory. - Variables like `%%DOCUMENTS%%` are automatically resolved. - """ - - inifiles: Annotated[ - list[Path], - AfterValidator( - lambda f: [resolve(f, documents=str(get_documents_folder())) for f in f] - ), - ] - """ - Paths to the game's ini files, relative to `inidir`. - Variables like `%%DOCUMENTS%%` are automatically resolved. - """ - - mods_folder: Path - """ - The game's default folder for mods, relative to its install directory. - """ - - additional_files: list[str] = Field(default_factory=list) - """ - List of additional files to include in the migration. - These filenames are relative to the respective mod manager's profiles folder. - """ - - @staticmethod - @FunctionCache.cache - def get_supported_games() -> list[Game]: - """ - Gets a list of supported games from the JSON resource. - - Returns: - list[Game]: List of supported games - """ - - data: list[dict[str, Any]] = load_json_resource(":/games.json") - - return [Game.model_validate(game) for game in data] - - @staticmethod - @FunctionCache.cache - def get_game_by_id(game_id: str) -> Game: - """ - Gets a game by its id. This method works case-insensitive. - - Args: - game_id (str): Game id - - Raises: - ValueError: when the game could not be found - - Returns: - Game: Game with specified id - """ - - games: dict[str, Game] = { - game.id.lower(): game for game in Game.get_supported_games() - } - - if game_id.lower() in games: - return games[game_id.lower()] - - raise ValueError(f"Game '{game_id}' not found!") - - @staticmethod - @FunctionCache.cache - def get_game_by_short_name(short_name: str) -> Game: - """ - Gets a game by its short name. This method works case-insensitive. - - Args: - short_name (str): Game short name - - Raises: - ValueError: when the game could not be found - - Returns: - Game: Game with specified short name - """ - - games: dict[str, Game] = { - game.short_name.lower(): game for game in Game.get_supported_games() - } - - if short_name.lower() in games: - return games[short_name.lower()] - - raise ValueError(f"Game '{short_name}' not found!") - - @staticmethod - @FunctionCache.cache - def get_game_by_nexus_id(nexus_id: str) -> Game: - """ - Gets a game by its nexus id. - - Args: - nexus_id (str): Game nexus id - - Raises: - ValueError: when the game could not be found - - Returns: - Game: Game with specified nexus id - """ - - games: dict[str, Game] = { - game.nexus_id: game for game in reversed(Game.get_supported_games()) - } - - if nexus_id in games: - return games[nexus_id] - - raise ValueError(f"Game '{nexus_id}' not found!") - - @override - def __hash__(self) -> int: - return hash((self.id, self.display_name, self.short_name, self.nexus_id)) diff --git a/src/core/instance/__init__.py b/src/core/instance/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/src/core/instance/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/src/core/instance/instance.py b/src/core/instance/instance.py deleted file mode 100644 index b5a2fa0..0000000 --- a/src/core/instance/instance.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -from core.utilities.filter import get_first_match - -from .mod import Mod -from .tool import Tool - - -@dataclass -class Instance: - """ - Class for representing an entire modinstance. - """ - - display_name: str - """ - The name that is visible to the user. - """ - - game_folder: Path - """ - The path to the instance's game folder. - """ - - mods: list[Mod] - """ - List of the instance's mods. - """ - - tools: list[Tool] - """ - List of the instance's tools. - """ - - last_tool: Optional[Tool] = None - """ - (Optional) last tool that was executed/selected in the instance. - """ - - order_matters: bool = False - """ - Whether the mods have a fixed order that matters. - """ - - separate_ini_files: bool = True - """ - Whether the instance has its own separate ini files. - """ - - separate_save_games: bool = True - """ - Whether the instance has its own separate save games. - """ - - def is_mod_installed(self, mod: Mod) -> bool: - """ - Checks if a mod is already installed in this instance. - - Args: - mod (Mod): The mod to check. - - Returns: - bool: `True` if the mod is installed, `False` otherwise - """ - - try: - self.get_installed_mod(mod) - return True - except ValueError: - return False - - def get_installed_mod(self, mod: Mod) -> Mod: - """ - Returns a mod from the instance matching the specified mod. - - Args: - mod (Mod): The mod to get. - - Raises: - ValueError: If the mod is not installed or cannot be found - - Returns: - Mod: The matching mod - """ - - return get_first_match( - self.mods, - lambda m: m == mod - or ( - ( - (m.display_name == mod.display_name and m.metadata == mod.metadata) - or ( - m.metadata.mod_id == mod.metadata.mod_id - and m.metadata.file_id == mod.metadata.file_id - and bool(m.metadata.mod_id) - and bool(m.metadata.file_id) - ) - ) - and m.mod_type == mod.mod_type - ), - ) - - @property - def loadorder(self) -> list[Mod]: - """ - List of mods sorted alphabetically and after their mod conflicts - (overwritten mods before overwriting mods). - """ - - return self.get_loadorder() - - def get_loadorder(self, order_matters: Optional[bool] = None) -> list[Mod]: - """ - Sorts the mods in this instance if `order_matters` is not `True`. - - Args: - order_matters (Optional[bool], optional): - Whether the mods have a fixed order. Defaults to the instance's default. - - Returns: - list[Mod]: The sorted list of mods - """ - - if order_matters is None: - order_matters = self.order_matters - - if order_matters: - return self.mods.copy() - - new_loadorder: list[Mod] = self.mods.copy() - new_loadorder.sort(key=lambda m: m.display_name) - - for mod in filter(lambda m: m.mod_conflicts, self.mods): - if mod.mod_conflicts: - old_index = index = new_loadorder.index(mod) - - # Get smallest index of all overwriting mods - overwriting_mods = [ - new_loadorder.index(self.get_installed_mod(overwriting_mod)) - for overwriting_mod in mod.mod_conflicts - if self.is_mod_installed(overwriting_mod) - ] - index = min(overwriting_mods, default=old_index) - - if old_index > index: - new_loadorder.insert(index, new_loadorder.pop(old_index)) - - return new_loadorder - - @property - def size(self) -> int: - """ - The total size of all mods in this instance. - """ - - return sum(mod.size for mod in self.mods) diff --git a/src/core/instance/metadata.py b/src/core/instance/metadata.py deleted file mode 100644 index 330e60d..0000000 --- a/src/core/instance/metadata.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from dataclasses import dataclass -from typing import Optional, override - - -@dataclass(frozen=True) -class Metadata: - """ - Class for holding mod metadata like mod id, file id, etc. - """ - - mod_id: Optional[int] - """ - Nexus Mods ID. - """ - - file_id: Optional[int] - """ - Nexus Mods File ID. - """ - - version: str - """ - Mod version. - """ - - file_name: Optional[str] - """ - Full file name of the downloaded archive (eg. from Nexus Mods) - or `None` if the installation is unknown or if there is none. - """ - - game_id: str - """ - Nexus Mods Game ID (eg. "skyrimspecialedition"). - """ - - @override - def __hash__(self) -> int: - return hash((self.mod_id, self.file_id, self.version, self.file_name)) diff --git a/src/core/instance/mod.py b/src/core/instance/mod.py deleted file mode 100644 index 691e508..0000000 --- a/src/core/instance/mod.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum, auto -from pathlib import Path -from typing import Optional, override - -from cutleast_core_lib.core.cache.function_cache import FunctionCache - -from .metadata import Metadata - - -@dataclass -class Mod: - """ - Class for representing a mod. - """ - - display_name: str - """ - Display name of the mod. - """ - - path: Path - """ - Path to source mod folder. - """ - - deploy_path: Optional[Path] - """ - Path where the mod should be deployed, relative to the game folder. - Defaults to the game's default mod folder if None. - """ - - metadata: Metadata - """ - Metadata of the mod. - """ - - installed: bool - """ - If the mod is installed. When migrating, this also indicates whether the mod is - included in it. - """ - - enabled: bool - """ - If the mod is enabled. - """ - - class Type(Enum): - """ - Type of the mod. - """ - - Regular = auto() - """ - The mod is a regular mod. - """ - - Separator = auto() - """ - The mod is a separator. - """ - - Overwrite = auto() - """ - The mod represents the overwrite folder of an MO2 instance. - """ - - mod_type: Type = Type.Regular - """ - Type of the mod. - """ - - mod_conflicts: list[Mod] = field(default_factory=list) - """ - List of mods that overwrite this mod. - These conflicts are used for creating a loadorder. - """ - - file_conflicts: dict[str, Mod] = field(default_factory=dict) - """ - Mapping of file names that are explicitly overwritten by other mods. - Each file is handled separately and has no impact on the loadorder. - """ - - @property - def files(self) -> list[Path]: - """ - List of files. - """ - - return Mod.__get_files(self.path) - - @staticmethod - @FunctionCache.cache - def __get_files(path: Path) -> list[Path]: - return [file.relative_to(path) for file in path.rglob("*") if file.is_file()] - - @staticmethod - def copy(mod: Mod) -> Mod: - """ - Creates a copy of the specified mod. - Resets the cache of the files and size properties. - - Args: - mod (Mod): Mod to copy - - Returns: - Mod: Copied mod instance - """ - - return Mod( - display_name=mod.display_name, - path=mod.path, - deploy_path=mod.deploy_path, - metadata=mod.metadata, - installed=mod.installed, - enabled=mod.enabled, - mod_type=mod.mod_type, - mod_conflicts=mod.mod_conflicts, - file_conflicts=mod.file_conflicts, - ) - - @property - def size(self) -> int: - """ - Total size of all files. - """ - - return Mod.__get_size(self.path) - - @staticmethod - @FunctionCache.cache - def __get_size(path: Path) -> int: - return sum((path / file).stat().st_size for file in Mod.__get_files(path)) - - def get_modpage_url(self, direct: bool = False) -> Optional[str]: - """ - Gets the modpage URL of the mod if it has one. - - Args: - direct (bool, optional): - If True, the modpage URL will include the file ID. - Defaults to False. - - Returns: - Optional[str]: The modpage URL of the mod if it has one, otherwise None. - """ - - base_url = "https://www.nexusmods.com" - - mod_id: Optional[int] = self.metadata.mod_id - file_id: Optional[int] = self.metadata.file_id - game_id: str = self.metadata.game_id - - if not mod_id: - return None - - url = f"{base_url}/{game_id}/mods/{mod_id}" - if file_id and direct: - url += f"?tab=files&file_id={file_id}" - - return url - - @override - def __hash__(self) -> int: - return hash((self.path, self.metadata)) - - @override - def __eq__(self, value: object) -> bool: - if not isinstance(value, Mod): - return False - - return hash(self) == hash(value) diff --git a/src/core/instance/tool.py b/src/core/instance/tool.py deleted file mode 100644 index 9372ab6..0000000 --- a/src/core/instance/tool.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path -from typing import Optional, override - -from pydantic.dataclasses import dataclass - -from .mod import Mod - - -@dataclass -class Tool: - """ - Class for representing modding tools. - """ - - display_name: str - """ - The name that is displayed in the mod manager's ui. - """ - - mod: Optional[Mod] - """ - The mod that contains the executable of this tool - or None if the tool is outside of the modinstance. - """ - - executable: Path - """ - The path to the executable, relative to the folder of its mod - or absolute if the tool is outside of the modinstance. - Relative to the game folder if `is_in_game_dir` is True. - """ - - commandline_args: list[str] - """ - The commandline arguments to pass to the executable. - """ - - working_dir: Optional[Path] - """ - The working directory, the tool should be executed in. - (Usually the game folder itself if the path is None) - """ - - is_in_game_dir: bool - """ - Whether the tool is in the game folder or not. - This controls whether the tool gets copied with the game folder, if selected by - the user (WIP). - """ - - def get_full_executable_path(self, game_folder: Optional[Path] = None) -> Path: - """ - Returns the full path to the executable. - - Args: - game_folder (Optional[Path], optional): - The game folder (required if `is_in_game_dir` is True). Defaults to None. - - Returns: - Path: The full path to the executable. - """ - - if self.mod is not None: - return self.mod.path / self.executable - elif self.is_in_game_dir: - if game_folder is None: - raise ValueError( - "Game folder not specified but tool is in game folder!" - ) - - return game_folder / self.executable - else: - return self.executable - - @override - def __hash__(self) -> int: - executable: Path = self.executable - - if self.mod is not None: - executable = self.mod.path / executable - - return hash((executable, " ".join(self.commandline_args), self.working_dir)) - - @override - def __eq__(self, value: object) -> bool: - if not isinstance(value, Tool): - return False - - return hash(self) == hash(value) diff --git a/src/core/utilities/exceptions.py b/src/core/migrator/exceptions.py similarity index 51% rename from src/core/utilities/exceptions.py rename to src/core/migrator/exceptions.py index 1de21f3..8f71131 100644 --- a/src/core/utilities/exceptions.py +++ b/src/core/migrator/exceptions.py @@ -2,53 +2,13 @@ Copyright (c) Cutleast """ -import traceback -from abc import abstractmethod -from typing import Any, override +from typing import override +from cutleast_core_lib.core.utilities.exceptions import LocalizedException from PySide6.QtWidgets import QApplication -def format_exception( - exception: BaseException, only_message_when_localized: bool = True -) -> str: - """ - Formats an exception to a string. - - Args: - exception (BaseException): The exception to format. - only_message_when_localized (bool): - Whether to only return the message when localized. - - Returns: - str: Formatted exception - """ - - if isinstance(exception, ExceptionBase) and only_message_when_localized: - return exception.getLocalizedMessage() - - return "".join(traceback.format_exception(exception)) - - -class ExceptionBase(Exception): - """ - Base Exception class for localized exceptions. - """ - - def __init__(self, *values: Any) -> None: - super().__init__(self.getLocalizedMessage().format(*values)) - - @abstractmethod - def getLocalizedMessage(self) -> str: - """ - Returns localized message - - Returns: - str: Localized message - """ - - -class NotEnoughSpaceError(ExceptionBase): +class NotEnoughSpaceError(LocalizedException): """ Exception when the destination disk has not enough space. """ @@ -64,7 +24,7 @@ def getLocalizedMessage(self) -> str: ) -class SameSourceDestinationError(ExceptionBase): +class SameSourceDestinationError(LocalizedException): """ Exception when the source and destination are the same. """ @@ -79,7 +39,7 @@ def getLocalizedMessage(self) -> str: ) -class SameModsLocationDiffManagerError(ExceptionBase): +class SameModsLocationDiffManagerError(LocalizedException): """ Exception when the source and destination have the same mods location but different mod managers. diff --git a/src/core/migrator/migration_report.py b/src/core/migrator/migration_report.py index 511afba..061b218 100644 --- a/src/core/migrator/migration_report.py +++ b/src/core/migrator/migration_report.py @@ -4,8 +4,8 @@ from dataclasses import dataclass, field -from core.instance.mod import Mod -from core.instance.tool import Tool +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.instance.tool import Tool @dataclass diff --git a/src/core/migrator/migrator.py b/src/core/migrator/migrator.py index 2a6b3cc..6fd0fa6 100644 --- a/src/core/migrator/migrator.py +++ b/src/core/migrator/migrator.py @@ -4,25 +4,33 @@ import logging from pathlib import Path -from typing import Optional +from typing import Optional, cast +from cutleast_core_lib.core.multithreading.progress import ProgressUpdate from cutleast_core_lib.core.utilities.logger import Logger from cutleast_core_lib.core.utilities.scale import scale_value -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog +from cutleast_core_lib.ui.widgets.progress_dialog import ProgressDialog +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.instance.tool import Tool +from mod_manager_lib.core.mod_manager.exceptions import InstanceNotFoundError +from mod_manager_lib.core.mod_manager.instance_info import InstanceInfo +from mod_manager_lib.core.mod_manager.mod_manager import ModManager +from mod_manager_lib.core.mod_manager.mod_manager_api import ModManagerApi +from mod_manager_lib.core.mod_manager.modorganizer.mo2_instance_info import ( + MO2InstanceInfo, +) +from mod_manager_lib.core.mod_manager.modorganizer.modorganizer import ModOrganizer +from mod_manager_lib.core.mod_manager.vortex.vortex import Vortex from PySide6.QtCore import QObject -from core.instance.instance import Instance, Mod -from core.instance.tool import Tool -from core.mod_manager.exceptions import InstanceNotFoundError -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager -from core.utilities.exceptions import ( +from core.utilities.filesystem import get_free_disk_space + +from .exceptions import ( NotEnoughSpaceError, SameModsLocationDiffManagerError, SameSourceDestinationError, ) -from core.utilities.filesystem import get_free_disk_space - from .file_blacklist import FileBlacklist from .migration_report import MigrationReport @@ -39,14 +47,14 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( src_instance: Instance, src_info: S, dst_info: D, - src_mod_manager: ModManager[S], - dst_mod_manager: ModManager[D], + src_mod_manager: ModManagerApi[S], + dst_mod_manager: ModManagerApi[D], use_hardlinks: bool, replace: bool, modname_limit: int, activate_new_instance: bool, included_tools: list[Tool], - ldialog: Optional[LoadingDialog] = None, + pdialog: Optional[ProgressDialog] = None, ) -> MigrationReport: """ Migrates an instance from one mod manager to another. @@ -62,8 +70,8 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( modname_limit (int): A character limit for mod names. activate_new_instance (bool): Whether to activate the new instance. included_tools (list[Tool]): A list of tools to migrate. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. + pdialog (Optional[ProgressDialog], optional): + Optional progress dialog. Defaults to None. Raises: GameNotFoundError: @@ -134,25 +142,40 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( scale_value(available_space), ) - dst_mod_manager.prepare_migration(dst_info) + # dst_mod_manager.prepare_migration(dst_info) - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Migrating instance {0}...").format( - src_info.display_name - ), + if pdialog is not None: + pdialog.updateMainProgress( + ProgressUpdate( + status_text=self.tr("Preparing destination instance..."), + value=0, + maximum=0, + ) ) # Try to load existing instance dst_instance: Instance try: dst_instance = dst_mod_manager.load_instance( - dst_info, modname_limit, blacklist, ldialog=ldialog + instance_data=dst_info, + modname_limit=modname_limit, + file_blacklist=blacklist, + update_callback=( + (lambda payload: pdialog.updateProgress(1, payload)) + if pdialog is not None + else None + ), ) self.log.warning("Migrating into existing instance...") except InstanceNotFoundError: dst_instance = dst_mod_manager.create_instance( - dst_info, src_instance.game_folder, ldialog + instance_data=dst_info, + game_folder=src_instance.game_folder, + update_callback=( + (lambda payload: pdialog.updateProgress(1, payload)) + if pdialog is not None + else None + ), ) self.log.info(f"Destination order matters: {dst_instance.order_matters}") @@ -163,26 +186,35 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( self.log.info(f"Included mods: {len(included_mods)}") for m, mod in enumerate(included_mods): - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Migrating mods...") + f" ({m}/{len(included_mods)})", - value1=m, - max1=len(included_mods), - show2=True, - text2=mod.display_name, + if pdialog is not None: + pdialog.updateMainProgress( + ProgressUpdate( + status_text=( + self.tr("Migrating mods...") + + f" ({m + 1} / {len(included_mods)})" + ), + value=m, + maximum=len(included_mods), + ) ) + pdialog.updateProgress(1, ProgressUpdate(status_text=mod.display_name)) + try: if not dst_instance.is_mod_installed(mod) or replace: dst_mod_manager.install_mod( - mod, - dst_instance, - dst_info, - src_mod_manager.get_actual_files(mod), - use_hardlinks, - replace, - blacklist, - ldialog, + mod=mod, + instance=dst_instance, + instance_data=dst_info, + file_redirects=src_mod_manager.get_actual_files(mod), + use_hardlinks=use_hardlinks, + replace=replace, + blacklist=blacklist, + update_callback=( + (lambda payload: pdialog.updateProgress(2, payload)) + if pdialog is not None + else None + ), ) else: self.log.info( @@ -195,25 +227,33 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( report.failed_mods[mod] = ex for t, tool in enumerate(included_tools): - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Migrating tools...") - + f" ({t}/{len(included_tools)})", - value1=t, - max1=len(included_tools), - show2=True, - text2=tool.display_name, + if pdialog is not None: + pdialog.updateMainProgress( + ProgressUpdate( + status_text=( + self.tr("Migrating tools...") + + f" ({t + 1} / {len(included_tools)})" + ), + value=t, + maximum=len(included_tools), + ) ) + pdialog.updateProgress(1, ProgressUpdate(status_text=tool.display_name)) + try: dst_mod_manager.add_tool( - tool, - dst_instance, - dst_info, - use_hardlinks, - replace, - blacklist, - ldialog, + tool=tool, + instance=dst_instance, + instance_data=dst_info, + use_hardlinks=use_hardlinks, + replace=replace, + blacklist=blacklist, + update_callback=( + (lambda payload: pdialog.updateProgress(2, payload)) + if pdialog is not None + else None + ), ) except Exception as ex: self.log.error( @@ -221,17 +261,29 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( ) report.failed_tools[tool] = ex + if pdialog is not None: + pdialog.updateMainProgress( + ProgressUpdate( + status_text=self.tr("Migrating INI files..."), value=0, maximum=0 + ) + ) + pdialog.removeProgress(2) + try: ini_files: list[Path] = src_mod_manager.get_ini_files( src_instance, src_info ) - dst_mod_manager.migrate_ini_files( - ini_files, - dst_info, - src_instance.separate_ini_files, - use_hardlinks, - replace, - ldialog, + dst_mod_manager.import_ini_files( + files=ini_files, + dst_instance_data=dst_info, + separate_ini_files=src_instance.separate_ini_files, + use_hardlinks=use_hardlinks, + replace=replace, + update_callback=( + (lambda payload: pdialog.updateProgress(1, payload)) + if pdialog is not None + else None + ), ) except Exception as ex: self.log.error( @@ -240,12 +292,29 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( ) report.other_errors[self.tr("Failed to migrate INI files.")] = ex + if pdialog is not None: + pdialog.updateMainProgress( + ProgressUpdate( + status_text=self.tr("Migrating additional files..."), + value=0, + maximum=0, + ) + ) + try: additional_files: list[Path] = src_mod_manager.get_additional_files( src_info ) - dst_mod_manager.migrate_additional_files( - additional_files, dst_info, use_hardlinks, replace, ldialog + dst_mod_manager.import_additional_files( + files=additional_files, + dst_instance_data=dst_info, + use_hardlinks=use_hardlinks, + replace=replace, + update_callback=( + (lambda payload: pdialog.updateProgress(1, payload)) + if pdialog is not None + else None + ), ) except Exception as ex: self.log.error( @@ -254,8 +323,77 @@ def migrate[S: InstanceInfo, D: InstanceInfo]( ) report.other_errors[self.tr("Failed to migrate additional files.")] = ex - dst_mod_manager.finalize_migration( - dst_instance, dst_info, activate_new_instance - ) + dst_mod_manager.finalize_instance(dst_instance, dst_info, activate_new_instance) self.log.info("Migration completed.") return report + + def get_completed_message( + self, src_instance_data: InstanceInfo, dst_instance_data: InstanceInfo + ) -> str: + """ + Generates a localized message with additional notes for the user to be shown + after the migration. + + Args: + src_instance_data (InstanceInfo): Information about the source mod instance. + dst_instance_data (InstanceInfo): + Information about the destination mod instance. + + Returns: + str: Localized message. + """ + + text: str = "" + + if dst_instance_data.get_mod_manager() == ModManager.ModOrganizer: + migrated_instance_data: MO2InstanceInfo = cast( + MO2InstanceInfo, dst_instance_data + ) + mo2: ModOrganizer = cast(ModOrganizer, ModManager.ModOrganizer.get_api()) + + if migrated_instance_data.use_root_builder: + if migrated_instance_data.is_global: + text += ( + self.tr( + "The usage of root builder was enabled.\n" + "In order to correctly deploy the root files, you have to " + "download and extract the root builder plugin from Nexus " + 'Mods to the "plugins" folder of your MO2 installation if ' + "not already installed." + ) + + "\n\n" + ) + else: + text += ( + self.tr( + "The usage of root builder was enabled.\n" + "In order to correctly deploy the root files, you have to " + "download and extract the root builder plugin from Nexus " + 'Mods to the "plugins" folder of the new MO2 installation.' + ) + + "\n\n" + ) + + if not migrated_instance_data.is_global and mo2.detect_global_instances(): + text += ( + self.tr( + "At least one global instance was detected.\n" + "Global instances cause issues with portable instances and it is " + "recommended to delete (or rename) the following folder:\n{0}" + ).format(str(mo2.appdata_path)) + + "\n\n" + ) + + if ( + dst_instance_data.get_mod_manager() != src_instance_data.get_mod_manager() + and src_instance_data.get_mod_manager() == ModManager.Vortex + ): + vortex: Vortex = cast(Vortex, ModManager.Vortex.get_api()) + if vortex.is_deployed(src_instance_data.game): + text += self.tr( + "Vortex is currently deployed to the game folder. It is strongly " + "recommended to purge the game directory before using the migrated " + "instance." + ) + + return text diff --git a/src/core/mod_manager/__init__.py b/src/core/mod_manager/__init__.py deleted file mode 100644 index d8b0c58..0000000 --- a/src/core/mod_manager/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from .mod_manager import ModManager -from .modorganizer.modorganizer import ModOrganizer -from .vortex.vortex import Vortex - -MOD_MANAGERS: list[type[ModManager]] = [ - Vortex, - ModOrganizer, -] -""" -This list contains all available mod managers. -""" diff --git a/src/core/mod_manager/exceptions.py b/src/core/mod_manager/exceptions.py deleted file mode 100644 index 7de5549..0000000 --- a/src/core/mod_manager/exceptions.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import override - -from PySide6.QtWidgets import QApplication - -from core.utilities.exceptions import ExceptionBase - - -class ModManagerError(ExceptionBase): - """ - Exception for general mod manager-related errors. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate("exceptions", "A mod manager error occured!") - - -class InstanceNotFoundError(ModManagerError): - """ - Exception when a mod instance does not exist. - """ - - def __init__(self, instance_name: str) -> None: - super().__init__(instance_name) - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", "The mod instance {0} could not be found!" - ) - - -class PreMigrationCheckFailedError(ModManagerError): - """ - Exception when the pre-migration check fails. - """ diff --git a/src/core/mod_manager/instance_info.py b/src/core/mod_manager/instance_info.py deleted file mode 100644 index f290547..0000000 --- a/src/core/mod_manager/instance_info.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from abc import ABC -from dataclasses import dataclass - -from core.game.game import Game - - -@dataclass(frozen=True) -class InstanceInfo(ABC): - """ - Base class for identifying an instance within a mod manager. - """ - - display_name: str - """ - The display name of the instance. - """ - - game: Game - """ - The primary game of this instance. - """ diff --git a/src/core/mod_manager/mod_manager.py b/src/core/mod_manager/mod_manager.py deleted file mode 100644 index 1dd1f28..0000000 --- a/src/core/mod_manager/mod_manager.py +++ /dev/null @@ -1,624 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import logging -import os -import shutil -from abc import abstractmethod -from pathlib import Path -from typing import Optional, override - -from cutleast_core_lib.core.utilities.logger import Logger -from cutleast_core_lib.core.utilities.scale import scale_value -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog -from PySide6.QtCore import QObject - -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.mod import Mod -from core.instance.tool import Tool - -from .instance_info import InstanceInfo - - -class ModManager[I: InstanceInfo](QObject): - """ - Abstract class for mod managers. - """ - - log: logging.Logger - - def __init__(self) -> None: - super().__init__() - - self.log = logging.getLogger(self.__repr__()) - - @staticmethod - @abstractmethod - def get_id() -> str: - """ - Returns: - str: The internal id of the mod manager. - """ - - @staticmethod - @abstractmethod - def get_display_name() -> str: - """ - Returns: - str: The display name of the mod manager. - """ - - @staticmethod - @abstractmethod - def get_icon_name() -> str: - """ - Returns: - str: The name of the icon resource of the mod manager. - """ - - @override - def __hash__(self) -> int: - return hash(self.get_id()) - - @abstractmethod - def get_instance_names(self, game: Game) -> list[str]: - """ - Loads and returns a list of the names of all mod instances that are managed - by this mod manager. - - Args: - game (Game): The selected game. - - Returns: - list[str]: The names of all mod instances. - """ - - @abstractmethod - def load_instance( - self, - instance_data: I, - modname_limit: int, - file_blacklist: list[str] = [], - game_folder: Optional[Path] = None, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - """ - Loads and returns the mod instance with the given name. - - Args: - instance_data (I): The data of the mod instance. - modname_limit (int): A character limit for mod names. - file_blacklist (list[str], optional): A list of files to ignore. - game_folder (Optional[Path], optional): The game folder of the instance. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - - Raises: - InstanceNotFoundError: If the mod instance does not exist. - GameNotFoundError: - If the game folder of the instance could not be found and is not - specified. - - Returns: - Instance: The mod instance with the given name. - """ - - @abstractmethod - def _load_mods( - self, - instance_data: I, - modname_limit: int, - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Mod]: - """ - Loads and returns a list of mods for the given instance name. - - Args: - instance_data (I): The data of the mod instance. - modname_limit (int): A character limit for mod names. - game_folder (Path): The game folder of the instance. - file_blacklist (list[str], optional): A list of files to ignore. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - - Returns: - list[Mod]: The list of mods. - """ - - @abstractmethod - def _load_tools( - self, - instance_data: I, - mods: list[Mod], - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Tool]: - """ - Loads and returns a list of tools for the given instance. - - Args: - instance_data (I): The data of the mod instance. - mods (list[Mod]): The list of already loaded mods. - game_folder (Path): The game folder of the instance. - file_blacklist (list[str], optional): A list of files to ignore. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - - Returns: - list[Tool]: The list of tools. - """ - - @staticmethod - def _get_mod_for_path( - path: Path, mods_by_folders: dict[Path, Mod] - ) -> Optional[Mod]: - """ - Returns the mod that contains the given path. - - Args: - path (Path): The path. - mods_by_folders (dict[Path, Mod]): The dict of mods by folders. - - Returns: - Optional[Mod]: The mod that contains the given path or None. - """ - - for mod_path, mod in mods_by_folders.items(): - if path.is_relative_to(mod_path): - return mod - - @staticmethod - @Logger.timeit(logger_name="ModManager") - def _index_modlist( - mods: list[Mod], file_blacklist: list[str] - ) -> dict[str, list[Mod]]: - """ - Indexes all mod files and maps each file to a list of mods that contain it. - - Args: - mods (list[Mod]): The list of mods. - file_blacklist (list[str], optional): A list of file paths to ignore. - - Returns: - dict[str, list[Mod]]: The indexed list of mods. - """ - - indexed_mods: dict[str, list[Mod]] = {} - for mod in mods: - for file in filter( - lambda f: f.name.lower() not in file_blacklist, mod.files - ): - indexed_mods.setdefault(str(file).lower(), []).append(mod) - - return indexed_mods - - @staticmethod - def _get_reversed_mod_conflicts(mods: list[Mod]) -> dict[Mod, list[Mod]]: - """ - Returns a dict of mods that overwrite other mods. - - Args: - mods (list[Mod]): The list of mods. - - Returns: - dict[Mod, list[Mod]]: The dict of mods that overwrite other mods. - """ - - mod_overrides: dict[Mod, list[Mod]] = {} - - for mod in mods: - if mod.mod_conflicts: - for overwriting_mod in mod.mod_conflicts: - mod_overrides.setdefault(overwriting_mod, []).append(mod) - - return mod_overrides - - @staticmethod - def get_actual_files(mod: Mod) -> dict[Path, Path]: - """ - Returns a dict of real file paths to actual file paths. - Only contains files where the real path differs from the actual path. - - For example: - `scripts\\_wetskyuiconfig.pex.mohidden` -> `scripts\\_wetskyuiconfig.pex` - - Args: - mod (Mod): The mod. - - Returns: - dict[Path, Path]: The dict of real file paths to actual file paths. - """ - - return {} - - @abstractmethod - def create_instance( - self, - instance_data: I, - game_folder: Path, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - """ - Creates an instance in this mod manager. - - Args: - instance_data (Instance_data): The customized instance data to create. - game_folder (Path): The game folder to use for the created instance. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - - Returns: - Instance: The created instance. - """ - - @abstractmethod - def install_mod( - self, - mod: Mod, - instance: Instance, - instance_data: I, - file_redirects: dict[Path, Path], - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - """ - Installs a mod to the current instance. - - Args: - mod (Mod): The mod to install. - instance (Instance): The instance to install the mod to. - instance_data (I): The data of the instance above. - file_redirects (dict[Path, Path]): A dict of file redirects. - use_hardlinks (bool): Whether to use hardlinks if possible. - replace (bool): Whether to replace existing files. - blacklist (list[str], optional): A list of files to not migrate. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - """ - - @abstractmethod - def add_tool( - self, - tool: Tool, - instance: Instance, - instance_data: I, - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - """ - Adds a tool to the mod manager. - - Args: - tool (Tool): The tool to add. - instance (Instance): The instance to add the tool to. - instance_data (I): The data of the instance above. - use_hardlinks (bool): Whether to use hardlinks if possible. - replace (bool): Whether to replace existing files. - blacklist (list[str], optional): A list of files to not migrate. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - """ - - def _migrate_mod_files( - self, - mod: Mod, - mod_folder: Path, - file_redirects: dict[Path, Path], - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - """ - Migrates the files of a mod to the destination path. - - Args: - mod (Mod): The mod to migrate. - mod_folder (Path): The destination path. - use_hardlinks (bool): Whether to use hardlinks if possible. - file_redirects (dict[Path, Path]): A dict of file redirects. - replace (bool): Whether to replace existing files. - blacklist (list[str], optional): A list of files to not migrate. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - """ - - for f, file in enumerate(mod.files): - if file.name.lower() in blacklist: - self.log.info( - f"Skipped file due to configured blacklist: {file.name!r}" - ) - continue - - src_path: Path = mod.path / file - dst_path: Path = mod_folder / file_redirects.get(file, file) - - if ldialog: - ldialog.updateProgress( - text2=f"{mod.display_name} ({f}/{len(mod.files)})", - value2=f, - max2=len(mod.files), - show3=True, - text3=f"{file.name} ({scale_value(src_path.stat().st_size)})", - ) - - if src_path == dst_path: - self.log.warning(f"Skipped file due to same path: {str(src_path)!r}") - continue - - dst_path.parent.mkdir(parents=True, exist_ok=True) - - if dst_path.is_file() and replace: - dst_path.unlink() - self.log.warning(f"Deleted existing file: {str(dst_path)!r}") - elif not replace: - self.log.info(f"Skipped existing file: {str(dst_path)!r}") - continue - - if src_path.drive.lower() == dst_path.drive.lower() and use_hardlinks: - os.link(src_path, dst_path) - else: - shutil.copyfile(src_path, dst_path) - - def get_ini_files(self, instance: Instance, instance_data: I) -> list[Path]: - """ - Returns a list of ini files to migrate. - - Args: - instance (Instance): The instance. - instance_data (I): The data of the instance. - - Returns: - list[Path]: The list of ini files. - """ - - ini_filenames: list[Path] = instance_data.game.inifiles - ini_dir: Path = self.get_ini_dir(instance_data, instance.separate_ini_files) - - return [(ini_dir / file) for file in ini_filenames] - - def get_ini_dir(self, instance_data: I, separate_ini_files: bool) -> Path: - """ - Returns path to folder for INI files, either game's INI folder or - instance's INI folder. - - Args: - instance_data (I): The data of the instance. - separate_ini_files (bool): Whether to use separate INI folders. - - Returns: - Path: The path to the INI folder. - """ - - if separate_ini_files: - return self.get_instance_ini_dir(instance_data) - - return instance_data.game.inidir - - @abstractmethod - def get_instance_ini_dir(self, instance_data: I) -> Path: - """ - Returns the path to the instance's INI folder. - - Args: - instance_data (I): The data of the instance. - - Returns: - Path: The path to the instance's INI folder. - """ - - def migrate_ini_files( - self, - files: list[Path], - instance_data: I, - separate_ini_files: bool, - use_hardlinks: bool, - replace: bool, - ldialog: Optional[LoadingDialog] = None, - ) -> None: - """ - Migrates the specified INI files to the destination path. - - Args: - files (list[Path]): The INI files to migrate. - instance_data (I): The data of the instance. - separate_ini_files (bool): Whether to use separate INI folders. - use_hardlinks (bool): Whether to use hardlinks if possible. - replace (bool): Whether to replace existing files. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - """ - - dest_folder: Path = self.get_ini_dir(instance_data, separate_ini_files) - - for f, file in enumerate(files): - dst_path: Path = dest_folder / file.name - - self.log.info( - f"Migrating ini file {file.name!r} from " - f"{str(file.parent)!r} to {str(dest_folder)!r}..." - ) - if ldialog: - ldialog.updateProgress( - text2=f"{file.name} ({f}/{len(files)})", - value2=f, - max2=len(files), - show3=True, - text3=f"{file.name} ({scale_value(file.stat().st_size)})", - ) - - if not file.is_file(): - self.log.warning(f"Skipped not existing file: {str(file)!r}") - continue - - dest_folder.mkdir(parents=True, exist_ok=True) - - if dst_path.is_file() and replace: - dst_path.unlink() - self.log.warning(f"Deleted existing file: {str(dst_path)!r}") - elif not replace: - self.log.info(f"Skipped existing file: {str(dst_path)!r}") - continue - - if file.drive.lower() == dst_path.drive.lower() and use_hardlinks: - os.link(file, dst_path) - else: - shutil.copyfile(file, dst_path) - - def get_additional_files(self, instance_data: I) -> list[Path]: - """ - Returns a list of additional files to migrate. - - Args: - instance_data (I): The data of the instance. - - Returns: - list[Path]: The list of additional files. - """ - - file_names: list[str] = instance_data.game.additional_files - add_folder: Path = self.get_additional_files_folder(instance_data) - - return [ - add_folder / file_name - for file_name in file_names - if (add_folder / file_name).is_file() - ] - - def migrate_additional_files( - self, - files: list[Path], - instance_data: I, - use_hardlinks: bool, - replace: bool, - ldialog: Optional[LoadingDialog] = None, - ) -> None: - """ - Migrates the specified additional files to the specified destination path. - - Args: - files (list[Path]): The list of additional files. - instance_data (I): The data of the instance. - use_hardlinks (bool): Whether to use hardlinks if possible. - replace (bool): Whether to replace existing files. - ldialog (Optional[LoadingDialog], optional): - Optional loading dialog. Defaults to None. - """ - - dest_folder: Path = self.get_additional_files_folder(instance_data) - - for f, file in enumerate(files): - dst_path: Path = dest_folder / file.name - - self.log.info( - f"Migrating additional file {file.name!r} from " - f"{str(file.parent)!r} to {str(dest_folder)!r}..." - ) - if ldialog: - ldialog.updateProgress( - text2=f"{file.name} ({f}/{len(files)})", - value2=f, - max2=len(files), - show3=True, - text3=f"{file.name} ({scale_value(file.stat().st_size)})", - ) - - dest_folder.mkdir(parents=True, exist_ok=True) - - if dst_path.is_file() and replace: - dst_path.unlink() - self.log.warning(f"Deleted existing file: {str(dst_path)!r}") - elif not replace: - self.log.info(f"Skipped existing file: {str(dst_path)!r}") - continue - - if file.drive.lower() == dst_path.drive.lower() and use_hardlinks: - os.link(file, dst_path) - else: - shutil.copyfile(file, dst_path) - - @abstractmethod - def get_additional_files_folder(self, instance_data: I) -> Path: - """ - Gets the path for the additional files of the specified instance. - - Args: - instance_data (I): The data of the instance. - - Returns: - Path: The path for the additional files. - """ - - def prepare_migration(self, instance_data: I) -> None: - """ - Prepares the migration process. Runs pre-migration checks, if necessary - and raises an Exception if there are issues or potential error sources. - - Args: - instance_data (I): The data of the instance. - - Raises: - PreMigrationCheckFailedError: when the pre-migration check fails. - """ - - def finalize_migration( - self, - migrated_instance: Instance, - migrated_instance_data: I, - activate_new_instance: bool, - ) -> None: - """ - Finalizes the migration process. - - Args: - migrated_instance (Instance): The migrated instance. - migrated_instance_data (I): The data of the migrated instance. - activate_new_instance (bool): - Whether to activate the new instance (if supported by the mod manager). - """ - - def get_completed_message(self, migrated_instance_data: I) -> str: - """ - Get text to display to the user after the migration is completed. - - Args: - migrated_instance_data (I): The data of the migrated instance. - - Returns: - str: Text to display to the user. - """ - - return "" - - @abstractmethod - def get_mods_path(self, instance_data: I) -> Path: - """ - Returns the path to the specified instance's mods folder. - - Args: - instance_data (I): The data of the instance. - - Returns: - Path: The path to the mods folder. - """ - - @abstractmethod - def is_instance_existing(self, instance_data: I) -> bool: - """ - Checks if the specified instance exists. - - Args: - instance_data (I): The data of the instance. - - Returns: - bool: Whether the instance exists. - """ diff --git a/src/core/mod_manager/modorganizer/__init__.py b/src/core/mod_manager/modorganizer/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/src/core/mod_manager/modorganizer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/src/core/mod_manager/modorganizer/exceptions.py b/src/core/mod_manager/modorganizer/exceptions.py deleted file mode 100644 index 16352d4..0000000 --- a/src/core/mod_manager/modorganizer/exceptions.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import override - -from PySide6.QtWidgets import QApplication - -from ..exceptions import PreMigrationCheckFailedError - - -class InvalidGlobalInstancePathError(PreMigrationCheckFailedError): - """ - Exception when the path for a global instance is outside of the - `%LOCALAPPDATA%\\ModOrganizer` folder. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "Invalid global instance path! " - "The path must not be outside of the %LOCALAPPDATA%\\ModOrganizer folder " - "when choosing the global instance type!", - ) - - -class CannotInstallGlobalMo2Error(PreMigrationCheckFailedError): - """ - Exception when a global instance is selected as destination and - the install MO2 checkbox is checked. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "Cannot install MO2 when a global instance is selected as destination!", - ) diff --git a/src/core/mod_manager/modorganizer/mo2_instance_info.py b/src/core/mod_manager/modorganizer/mo2_instance_info.py deleted file mode 100644 index d9d01b1..0000000 --- a/src/core/mod_manager/modorganizer/mo2_instance_info.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from dataclasses import dataclass -from pathlib import Path - -from ..instance_info import InstanceInfo - - -@dataclass(frozen=True) -class MO2InstanceInfo(InstanceInfo): - """ - Class for identifying an MO2 instance and profile. - """ - - profile: str - """ - The selected profile of the instance. - """ - - is_global: bool - """ - Whether the instance is a global or portable instance. - """ - - base_folder: Path - """ - Path to the base directory of the instance. - **The folder must contain the instance's ModOrganizer.ini file!** - """ - - mods_folder: Path - """ - Path to the instance's "mods" folder. - """ - - profiles_folder: Path - """ - Path to the instance's "profiles" folder. - """ - - install_mo2: bool = True - """ - Whether to install Mod Organizer 2 to the instance - (only relevant for portable destination instances). - """ - - use_root_builder: bool = True - """ - Whether to use the root builder MO2 plugin for mods with - files for the game's root folder. - MMM won't download the root builder plugin itself but will construct the respective - folder structures for root files instead of copying them to the real game folder. - """ diff --git a/src/core/mod_manager/modorganizer/modorganizer.py b/src/core/mod_manager/modorganizer/modorganizer.py deleted file mode 100644 index ec6d13b..0000000 --- a/src/core/mod_manager/modorganizer/modorganizer.py +++ /dev/null @@ -1,1106 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import os -import re -from copy import copy -from pathlib import Path -from typing import Any, Optional, override - -from cutleast_core_lib.core.archive.archive import Archive -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.core.utilities.scale import scale_value -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog - -from core.game.exceptions import GameNotFoundError -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.metadata import Metadata -from core.instance.mod import Mod -from core.instance.tool import Tool -from core.mod_manager.exceptions import InstanceNotFoundError -from core.mod_manager.modorganizer.exceptions import ( - CannotInstallGlobalMo2Error, - InvalidGlobalInstancePathError, -) -from core.utilities.downloader import Downloader -from core.utilities.filesystem import clean_fs_string -from core.utilities.ini_file import INIFile -from core.utilities.progress_update import ProgressUpdate -from core.utilities.reverse_dict import reverse_dict -from core.utilities.unique import unique - -from ..mod_manager import ModManager -from .mo2_instance_info import MO2InstanceInfo - - -class ModOrganizer(ModManager[MO2InstanceInfo]): - """ - Mod manager class for Mod Organizer 2. - """ - - # TODO: Make this dynamic instead of a fixed url - DOWNLOAD_URL: str = "https://github.com/ModOrganizer2/modorganizer/releases/download/v2.5.2/Mod.Organizer-2.5.2.7z" - - BYTE_ARRAY_PATTERN: re.Pattern[str] = re.compile(r"^@ByteArray\((.*)\)$") - INI_ARG_PATTERN: re.Pattern[str] = re.compile(r'(?:[^ "]+|"[^"]+")+') - INI_ARG_QUOTED_PATTERN: re.Pattern[str] = re.compile(r'^"?(([^"]|\\")+)"?$') - INI_QUOTE_PATTERN: re.Pattern[str] = re.compile(r'^"([^"]+)"$') - EXE_BLACKLIST: list[str] = ["Explorer++.exe"] - """List of executable names to ignore when loading tools.""" - - appdata_path = resolve(Path("%LOCALAPPDATA%") / "ModOrganizer") - - GAME_SHORT_NAME_OVERRIDES: dict[str, str] = { - "EnderalSpecialEdition": "EnderalSE", - } - """Dictionary of overrides for game short names.""" - - def __init__(self) -> None: - super().__init__() - - @override - def __repr__(self) -> str: - return "ModOrganizer" - - @override - @staticmethod - def get_id() -> str: - return "modorganizer" - - @override - @staticmethod - def get_display_name() -> str: - return "Mod Organizer 2" - - @override - @staticmethod - def get_icon_name() -> str: - return ":/icons/mo2.png" - - @override - def get_instance_names(self, game: Game) -> list[str]: - self.log.info(f"Getting global MO2 instances for {game.id}...") - - instances: list[str] = [] - - if self.appdata_path.is_dir(): - for instance_ini in self.appdata_path.glob("**/ModOrganizer.ini"): - ini_file = INIFile(instance_ini) - instance_data: dict[str, Any] = ini_file.load_file() - - if "General" not in instance_data: - continue - - instance_game: str = instance_data["General"].get("gameName", "") - if instance_game.lower() == game.display_name.lower(): - instances.append(instance_ini.parent.name) - - self.log.info(f"Got {len(instances)} instances.") - - return instances - - @override - def load_instance( - self, - instance_data: MO2InstanceInfo, - modname_limit: int, - file_blacklist: list[str] = [], - game_folder: Optional[Path] = None, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - instance_name: str = instance_data.display_name - profile_name: str = instance_data.profile - game: Game = instance_data.game - - if instance_data.is_global and instance_name not in self.get_instance_names( - game - ): - raise InstanceNotFoundError(f"{instance_name} > {profile_name}") - - instance_path: Path = instance_data.base_folder - mo2_ini_path: Path = instance_path / "ModOrganizer.ini" - - if not mo2_ini_path.is_file(): - raise InstanceNotFoundError(f"{instance_name} > {profile_name}") - - mo2_ini_data: dict[str, dict[str, Any]] = INIFile(mo2_ini_path).load_file() - raw_game_folder: Optional[str] = mo2_ini_data.get("General", {}).get("gamePath") - if raw_game_folder is not None: - raw_game_folder = ModOrganizer.BYTE_ARRAY_PATTERN.sub( - r"\1", raw_game_folder - ) - raw_game_folder = raw_game_folder.replace("\\\\", "\\") - game_folder = Path(raw_game_folder) - elif game_folder is None: - raise GameNotFoundError - - self.log.info( - f"Loading profile {profile_name!r} from instance " - f"{instance_name!r} at '{instance_path}'..." - ) - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading mods from {0} > {1}...").format( - instance_name, profile_name - ), - ) - - mods: list[Mod] = self._load_mods( - instance_data, modname_limit, game_folder, file_blacklist, ldialog - ) - - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading tools from {0} > {1}...").format( - instance_name, profile_name - ), - ) - - tools: list[Tool] = self._load_tools( - instance_data, mods, game_folder, file_blacklist, ldialog - ) - - instance = Instance( - display_name=f"{instance_name} > {profile_name}", - game_folder=game_folder, - mods=mods, - tools=tools, - order_matters=True, - ) - - self.log.info( - f"Loaded {instance_name} > {profile_name} with {len(mods)} mod(s) " - f"and {len(instance.tools)} tool(s)." - ) - - return instance - - @override - def _load_mods( - self, - instance_data: MO2InstanceInfo, - modname_limit: int, - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Mod]: - instance_name: str = instance_data.display_name - profile_name: str = instance_data.profile - - self.log.info(f"Loading mods from {instance_name} > {profile_name}...") - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading mods from {0} > {1}...").format( - instance_name, profile_name - ), - ) - - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - mods_dir: Path = ModOrganizer.get_mods_folder(mo2_ini_path) - prof_dir: Path = ModOrganizer.get_profiles_folder(mo2_ini_path) - modlist_txt_path: Path = prof_dir / instance_data.profile / "modlist.txt" - - if not (mods_dir.is_dir() and prof_dir.is_dir() and modlist_txt_path.is_file()): - raise InstanceNotFoundError(f"{instance_name} > {profile_name}") - - modnames: list[tuple[str, bool]] = self.__parse_modlist_txt(modlist_txt_path) - unmanaged_modnames: list[str] = [ - f.name - for f in mods_dir.iterdir() - if f.is_dir() and not any(f.name.lower() in m[0].lower() for m in modnames) - ] - if unmanaged_modnames: - self.log.warning(f"Found {len(unmanaged_modnames)} unmanaged mod(s):") - for modname in unmanaged_modnames: - self.log.warning(f" - {modname}") - modnames += [(m, False) for m in unmanaged_modnames] - mods: list[Mod] = [] - - for m, (modname, enabled) in enumerate(modnames): - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading mods from {0} > {1}...").format( - instance_name, profile_name - ) - + f" ({m}/{len(modnames)})", - value1=m, - max1=len(modnames), - show2=True, - text2=modname, - ) - - mod_path: Path = mods_dir / modname - mod_meta_path: Path = mod_path / "meta.ini" - metadata: Metadata - if mod_meta_path.is_file(): - metadata = self.__parse_meta_ini(mod_meta_path, instance_data.game) - else: - metadata = Metadata( - mod_id=None, file_id=None, version="", file_name="", game_id="" - ) - self.log.warning(f"No Metadata available for {modname!r}!") - - deploy_path: Optional[Path] = None - if (mod_path / "Root").is_dir(): - deploy_path = Path(".") - mod_path /= "Root" - - self.log.debug(f"Detected mod using Root Builder plugin: {modname}") - - mod = Mod( - display_name=modname.removesuffix("_separator"), - path=mod_path, - deploy_path=deploy_path, - metadata=metadata, - installed=True, - enabled=enabled, - mod_type=( - Mod.Type.Separator - if modname.endswith("_separator") - else Mod.Type.Regular - ), - ) - mod.files # build cache for mod files - mods.append(mod) - - # Load overwrite folder as mod - overwrite_folder: Path = ModOrganizer.get_overwrite_folder(mo2_ini_path) - if overwrite_folder.is_dir() and os.listdir(overwrite_folder): - overwrite_mod = Mod( - display_name="Overwrite", - path=overwrite_folder, - deploy_path=None, - metadata=Metadata( - mod_id=None, file_id=None, version="", file_name=None, game_id="" - ), - installed=True, - enabled=True, - mod_type=Mod.Type.Overwrite, - ) - overwrite_mod.files # build cache for mod files - mods.append(overwrite_mod) - - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Processing mod conflicts..."), - value1=0, - max1=0, - show2=False, - ) - - self.__process_conflicts(mods, file_blacklist, ldialog) - - self.log.info( - f"Loaded {len(mods)} mod(s) from {instance_name} > {profile_name}." - ) - - return mods - - def __parse_meta_ini(self, meta_ini_path: Path, default_game: Game) -> Metadata: - short_name_overrides: dict[str, str] = reverse_dict( - ModOrganizer.GAME_SHORT_NAME_OVERRIDES - ) - - ini_file = INIFile(meta_ini_path) - meta_ini_data: dict[str, Any] = ini_file.load_file() - - general: Optional[dict[str, Any]] = meta_ini_data.get("General") - mod_id: Optional[int] = None - file_id: Optional[int] = None - version: str = "" - game_id: str = default_game.nexus_id - install_file: Optional[str] = None - if general is not None: - try: - mod_id = int(general.get("modid") or 0) or None - version = general.get("version") or "" - if general.get("installationFile"): - install_file = Path(general["installationFile"] or "").name - - while version.endswith(".0") and version.count(".") > 1: - version = version.removesuffix(".0") - - try: - game_name: str = general["gameName"] - game_id = Game.get_game_by_short_name( - short_name_overrides.get(game_name, game_name) - ).nexus_id - except KeyError: - self.log.warning( - f"No game specified for {meta_ini_path.parent.name!r}!" - ) - except ValueError: - self.log.warning( - f"Unknown game for mod {meta_ini_path.parent.name!r}: {general.get('gameName')}" - ) - - if "installedFiles" in meta_ini_data: - file_id = ( - int(meta_ini_data["installedFiles"].get("1\\fileid") or 0) - or None - ) - except Exception as ex: - self.log.error( - f"Failed to parse meta.ini in {str(meta_ini_path.parent)!r}: {ex}" - ) - else: - self.log.warning(f"Incomplete meta.ini in {str(meta_ini_path.parent)!r}!") - - return Metadata( - mod_id=mod_id, - file_id=file_id, - version=version, - file_name=install_file, - game_id=game_id, - ) - - @staticmethod - def __parse_modlist_txt(modlist_txt_path: Path) -> list[tuple[str, bool]]: - with open(modlist_txt_path, "r", encoding="utf8") as modlist_file: - lines: list[str] = modlist_file.readlines() - - mods: list[tuple[str, bool]] = [ - (line[1:].removesuffix("\n"), line.startswith("+")) - for line in reversed(lines) - if line.strip() and line[0] in ("+", "-") - ] - - return mods - - @staticmethod - def __dump_modlist_txt(modlist_txt_path: Path, mods: list[Mod]) -> None: - lines: list[str] = unique( - [ - ( - ( - "+" - if mod.enabled and not mod.mod_type == Mod.Type.Separator - else "-" - ) - + clean_fs_string(mod.display_name) - + ("_separator" if mod.mod_type == Mod.Type.Separator else "") - + "\n" - ) - for mod in reversed(mods) - if mod.mod_type != Mod.Type.Overwrite - ], - key=lambda line: line.lower(), # ensure that there are no duplicates - ) - with open(modlist_txt_path, "w", encoding="utf8") as modlist_file: - modlist_file.writelines(lines) - - def __process_conflicts( - self, - mods: list[Mod], - file_blacklist: list[str], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - file_index: dict[str, list[Mod]] = ModOrganizer._index_modlist( - mods, file_blacklist - ) - self.log.debug(f"Modlist has {len(file_index)} file(s) in {len(mods)} mod(s).") - - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Processing mod conflicts..."), - value1=0, - max1=0, - ) - - for mod_list in file_index.values(): - for m, mod in enumerate(mod_list): - mod.mod_conflicts.extend(mod_list[m + 1 :]) - - # Remove duplicate conflicts - for mod in mods: - mod.mod_conflicts = unique(mod.mod_conflicts) - - # Process single file conflicts (.mohidden files) - if ldialog is not None: - ldialog.updateProgress(text1=self.tr("Processing single file conflicts...")) - - hidden_files: dict[str, list[Mod]] = { - f: m - for f, m in file_index.items() - if f.endswith(".mohidden") and f.removesuffix(".mohidden") in file_index - } - self.log.debug(f"Found {len(hidden_files)} hidden file(s) with conflicts.") - - for hidden_file, mod_list in hidden_files.items(): - real_file: str = hidden_file.removesuffix(".mohidden") - overwriting_mod: Mod = file_index[real_file][-1] - for mod in mod_list: - mod.file_conflicts[real_file] = overwriting_mod - - @override - def _load_tools( - self, - instance_data: MO2InstanceInfo, - mods: list[Mod], - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Tool]: - instance_name: str = instance_data.display_name - profile_name: str = instance_data.profile - mods_by_folders: dict[Path, Mod] = {m.path: m for m in mods} - - self.log.info(f"Loading tools from {instance_name} > {profile_name}...") - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading tools from {0} > {1}...").format( - instance_name, profile_name - ), - ) - - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - mo2_ini_data: dict[str, dict[str, Any]] = INIFile(mo2_ini_path).load_file() - custom_executables: dict[str, Any] = mo2_ini_data.get("customExecutables", {}) - custom_executables_size = int(custom_executables.get("size", 0)) - tools: list[Tool] = [] - - for i in range(1, custom_executables_size + 1): - try: - raw_exe_path: str = custom_executables[f"{i}\\binary"] - raw_args: str = custom_executables[f"{i}\\arguments"] or "" - name: str = custom_executables[f"{i}\\title"] - raw_working_dir: Optional[str] = custom_executables[ - f"{i}\\workingDirectory" - ] - except Exception as ex: - self.log.error(f"Failed to load tool with index {i}: {ex}", exc_info=ex) - continue - - exe_path = Path(raw_exe_path) - if exe_path.name in ModOrganizer.EXE_BLACKLIST: - self.log.debug( - f"Skipped tool '{exe_path.name}' due to mod manager blacklist." - ) - continue - - working_dir: Optional[Path] = ( - Path(raw_working_dir) if raw_working_dir is not None else None - ) - if working_dir == game_folder: - working_dir = None - - mod: Optional[Mod] = ModOrganizer._get_mod_for_path( - exe_path, mods_by_folders - ) - is_in_game_dir: bool = False - if mod is not None: - exe_path = exe_path.relative_to(mod.path) - elif exe_path.is_relative_to(game_folder): - exe_path = exe_path.relative_to(game_folder) - is_in_game_dir = True - - tool = Tool( - display_name=name, - mod=mod, - executable=exe_path, - commandline_args=ModOrganizer.process_ini_arguments(raw_args), - working_dir=working_dir, - is_in_game_dir=is_in_game_dir, - ) - tools.append(tool) - - self.log.info( - f"Loaded {len(tools)} tool(s) from {instance_name} > {profile_name}." - ) - - return tools - - @staticmethod - def process_ini_arguments(raw_args: str) -> list[str]: - """ - Processes a raw string of commandline arguments for an executable by splitting - it into a list of separate arguments. - - Examples: - `-D:\\"C:\\\\Games\\\\Nolvus Ascension\\\\STOCK GAME\\\\Data\\" -c:\\"C:\\\\Games\\\\Nolvus Ascension\\\\TOOLS\\\\SSE Edit\\\\Cache\\\\\\"` - - => `[r'-D:"C:\\Games\\Nolvus Ascension\\STOCK GAME\\Data"', r'-c:"C:\\Games\\Nolvus Ascension\\TOOLS\\SSE Edit\\Cache\\"']` - - Args: - raw_args (str): Raw string of commandline arguments. - - Returns: - list[str]: List of commandline arguments. - """ - - if raw_args.startswith('"') and raw_args.endswith('"'): - raw_args = ModOrganizer.INI_ARG_QUOTED_PATTERN.sub(r"\1", raw_args) - raw_args = raw_args.replace('\\"', '"').replace("\\\\", "\\") - - raw_matches: list[str] = ModOrganizer.INI_ARG_PATTERN.findall(raw_args) - args: list[str] = [ - ModOrganizer.INI_QUOTE_PATTERN.sub(r"\1", arg) for arg in raw_matches - ] - return args - - @override - @staticmethod - def get_actual_files(mod: Mod) -> dict[Path, Path]: - return { - Path(file): Path(file).with_suffix(file.suffix.removesuffix(".mohidden")) - for file in mod.files - if file.suffix.endswith(".mohidden") - and str(file).lower().removesuffix(".mohidden") in mod.file_conflicts - } - - @override - def create_instance( - self, - instance_data: MO2InstanceInfo, - game_folder: Path, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - self.log.info(f"Creating instance {instance_data.display_name!r}...") - - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - game: Game = instance_data.game - - mods_dir: str - if instance_data.mods_folder.is_relative_to(instance_data.base_folder): - mods_dir = "%BASE_DIR%/" + str( - instance_data.mods_folder.relative_to(instance_data.base_folder) - ).replace("\\", "/") - else: - mods_dir = str(instance_data.mods_folder).replace("\\", "/") - - prof_dir: str - if instance_data.profiles_folder.is_relative_to(instance_data.base_folder): - prof_dir = "%BASE_DIR%/" + str( - instance_data.profiles_folder.relative_to(instance_data.base_folder) - ).replace("\\", "/") - else: - prof_dir = str(instance_data.profiles_folder).replace("\\", "/") - - mo2_ini_path.parent.mkdir(parents=True, exist_ok=True) - mo2_ini_file = INIFile(mo2_ini_path) - mo2_ini_file.data = { - "General": { - "gameName": game.display_name, - "selected_profile": "@ByteArray(Default)", - "gamePath": str(game_folder).replace("\\", "/"), - "first_start": "true", - }, - "Settings": { - "base_directory": str(instance_data.base_folder).replace("\\", "/"), - "download_directory": "%BASE_DIR%/downloads", # TODO: Make this configurable - "mod_directory": mods_dir, - "profiles_directory": prof_dir, - "overwrite_directory": "%BASE_DIR%/overwrite", - "language": "en", - "style": "Paper Dark.qss", - }, - } - mo2_ini_file.save_file() - - instance_data.mods_folder.mkdir(parents=True, exist_ok=True) - instance_data.profiles_folder.mkdir(parents=True, exist_ok=True) - os.makedirs( - instance_data.profiles_folder / instance_data.profile, exist_ok=True - ) - os.makedirs(instance_data.base_folder / "downloads", exist_ok=True) - os.makedirs(instance_data.base_folder / "overwrite", exist_ok=True) - (instance_data.profiles_folder / instance_data.profile / "modlist.txt").touch() - - if instance_data.install_mo2: - self.__download_and_install_mo2(instance_data.base_folder, ldialog) - - self.log.info("Instance created successfully.") - - return Instance( - display_name=instance_data.display_name, - game_folder=game_folder, - mods=[], - tools=[], - order_matters=True, - ) - - def __download_and_install_mo2( - self, dest: Path, ldialog: Optional[LoadingDialog] = None - ) -> None: - self.log.info(f"Downloading and installing ModOrganizer to {str(dest)!r}...") - - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Downloading and installing ModOrganizer...") - ) - - downloaded_archive: Path = self.__download_mo2(dest, ldialog) - self.__install_mo2(downloaded_archive, dest, ldialog) - - downloaded_archive.unlink() - self.log.debug(f"Deleted downloaded {str(downloaded_archive)!r}.") - - self.log.info("ModOrganizer downloaded and installed successfully.") - - def __download_mo2( - self, dest: Path, ldialog: Optional[LoadingDialog] = None - ) -> Path: - self.log.info("Downloading ModOrganizer...") - - def update(progress_update: ProgressUpdate) -> None: - if ldialog is not None: - ldialog.updateProgress( - show2=True, - text2=self.tr("Downloading ModOrganizer...") - + f" ({scale_value(progress_update.current)} / " - f"{scale_value(progress_update.maximum)})", - value2=progress_update.current, - max2=progress_update.maximum, - ) - - return Downloader.single_download( - url=ModOrganizer.DOWNLOAD_URL, - dest_folder=dest, - progress_callback=update, - ) - - def __install_mo2( - self, - downloaded_archive: Path, - dest: Path, - ldialog: Optional[LoadingDialog] = None, - ) -> None: - self.log.info("Installing ModOrganizer...") - - if ldialog is not None: - ldialog.updateProgress( - text2=self.tr("Extracting archive..."), value2=0, max2=0 - ) - - archive: Archive = Archive.load_archive(downloaded_archive) - archive.extract_all(dest, full_paths=True) - - @override - def install_mod( - self, - mod: Mod, - instance: Instance, - instance_data: MO2InstanceInfo, - file_redirects: dict[Path, Path], - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - self.log.info(f"Installing mod {mod.display_name!r}...") - - game: Game - try: - game = Game.get_game_by_nexus_id(mod.metadata.game_id) - except ValueError: - self.log.warning( - f"Unsupported game '{mod.metadata.game_id}' for mod! Falling back to " - "instance's default..." - ) - game = instance_data.game - - mod_folder: Path - regular_deployment: bool = True - - if mod.mod_type in [Mod.Type.Regular, Mod.Type.Separator]: - mod_name: str = mod.display_name - if mod.mod_type == Mod.Type.Separator: - mod_name += "_separator" - mod_folder = instance_data.mods_folder / clean_fs_string(mod_name) - meta_ini_path: Path = mod_folder / "meta.ini" - if mod.deploy_path is not None and mod.deploy_path == Path("."): - if instance_data.use_root_builder: - mod_folder /= "Root" - else: - mod_folder = instance.game_folder - regular_deployment = False - elif mod.deploy_path is not None: - mod_folder /= mod.deploy_path - - self.log.debug(f"Deploy path: {mod.deploy_path}") - self.log.debug(f"Mod folder: {mod_folder}") - - if mod_folder.is_dir() and regular_deployment: - self.log.warning( - f"Mod {mod.display_name!r} already exists! Merging files..." - ) - mod_folder.mkdir(parents=True, exist_ok=True) - - # Create and write metadata to meta.ini - # if the mod doesn't already have one - if regular_deployment and Path("meta.ini") not in mod.files: - meta_ini_file = INIFile(meta_ini_path) - meta_ini_file.data = { - "General": { - "gameName": ModOrganizer.GAME_SHORT_NAME_OVERRIDES.get( - game.short_name, game.short_name - ), - "modid": mod.metadata.mod_id, - "version": mod.metadata.version, - "installationFile": mod.metadata.file_name, - }, - "installedFiles": { - "1\\modid": mod.metadata.mod_id, - "size": "1", - "1\\fileid": mod.metadata.file_id, - }, - } - meta_ini_file.save_file() - elif regular_deployment and Path("meta.ini") in mod.files: - meta_ini_path.write_bytes((mod.path / "meta.ini").read_bytes()) - self.log.info("Copied original meta.ini from mod.") - - # Process overwrite folder - elif mod.mod_type == Mod.Type.Overwrite: - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - mod_folder: Path = ModOrganizer.get_overwrite_folder(mo2_ini_path) - - else: - self.log.error(f"Unknown mod type: {mod.mod_type}") - return - - self._migrate_mod_files( - mod, mod_folder, file_redirects, use_hardlinks, replace, blacklist, ldialog - ) - - # Append .mohidden suffix to files in mod.file_conflicts - for file in mod.file_conflicts.keys(): - src: Path = mod_folder / file - dst: Path = src.with_suffix(src.suffix + ".mohidden") - os.rename(src, dst) - self.log.debug( - f"Renamed '{file}' to '{dst}' due to configured file conflict." - ) - - # Merge conflicts with already installed mods - if instance.is_mod_installed(mod): - existing_mod: Mod = instance.get_installed_mod(mod) - existing_mod.mod_conflicts = unique( - existing_mod.mod_conflicts + mod.mod_conflicts - ) - existing_mod.file_conflicts.update(mod.file_conflicts) - - elif regular_deployment: - new_mod: Mod = Mod.copy(mod) - new_mod.path = mod_folder - instance.mods.append(new_mod) - - @override - def add_tool( - self, - tool: Tool, - instance: Instance, - instance_data: MO2InstanceInfo, - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - if tool in instance.tools: - return - - self.log.info(f"Adding tool {tool.display_name!r}...") - - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - mo2_ini_file = INIFile(mo2_ini_path) - custom_executables: dict[str, Any] = mo2_ini_file.data.setdefault( - "customExecutables", {"size": 0} - ) - new_index = int(custom_executables["size"]) + 1 - - new_tool: Tool = copy(tool) - if new_tool.mod is not None and instance.is_mod_installed(new_tool.mod): - # Map tool to the installed mod - new_tool.mod = instance.get_installed_mod(new_tool.mod) - - custom_executables.update( - ModOrganizer._tool_to_ini_data(new_tool, new_index, instance.game_folder) - ) - custom_executables["size"] = new_index - - mo2_ini_file.save_file() - - @staticmethod - def _tool_to_ini_data(tool: Tool, index: int, game_folder: Path) -> dict[str, Any]: - """ - Creates a INI data section for the specified tool to be written to an - instance's ModOrganizer.ini file. - - Args: - tool (Tool): Tool to add to the instance - index (int): New index for the tool - game_folder (Path): Path to the game folder - - Returns: - dict[str, Any]: INI data - """ - - return { - f"{index}\\arguments": ModOrganizer.prepare_ini_arguments( - tool.commandline_args - ), - f"{index}\\binary": str(tool.get_full_executable_path(game_folder)).replace( - "\\", "/" - ), - f"{index}\\hide": False, - f"{index}\\ownicon": False, - f"{index}\\steamAppID": None, - f"{index}\\title": tool.display_name, - f"{index}\\toolbar": False, - f"{index}\\workingDirectory": str(tool.working_dir or ""), - } - - @staticmethod - def prepare_ini_arguments(args: list[str]) -> str: - """ - Prepares a list of arguments for writing to a ModOrganizer.ini file. - - Args: - args (list[str]): List of arguments - - Returns: - str: Concatenated and escaped list of arguments - """ - - return repr(" ".join(args))[1:-1] - - @override - def get_instance_ini_dir(self, instance_data: MO2InstanceInfo) -> Path: - return instance_data.profiles_folder / instance_data.profile - - @override - def get_additional_files_folder(self, instance_data: MO2InstanceInfo) -> Path: - return instance_data.profiles_folder / instance_data.profile - - @override - def prepare_migration(self, instance_data: MO2InstanceInfo) -> None: - if instance_data.is_global: - mo2_ini_path: Path = instance_data.base_folder / "ModOrganizer.ini" - if not mo2_ini_path.is_relative_to(self.appdata_path): - raise InvalidGlobalInstancePathError - - if instance_data.install_mo2: - raise CannotInstallGlobalMo2Error - - @override - def finalize_migration( - self, - migrated_instance: Instance, - migrated_instance_data: MO2InstanceInfo, - activate_new_instance: bool, - ) -> None: - modlist_txt_path: Path = ( - migrated_instance_data.profiles_folder - / migrated_instance_data.profile - / "modlist.txt" - ) - self.__dump_modlist_txt(modlist_txt_path, migrated_instance.get_loadorder()) - self.log.debug(f"Dumped modlist to {str(modlist_txt_path)!r}.") - - settings_ini_path: Path = ( - migrated_instance_data.profiles_folder - / migrated_instance_data.profile - / "settings.ini" - ) - settings_ini = INIFile(settings_ini_path) - settings_ini.data = { - "General": { - "LocalSaves": str(migrated_instance.separate_save_games).lower(), - "LocalSettings": str(migrated_instance.separate_ini_files).lower(), - } - } - settings_ini.save_file() - self.log.debug(f"Dumped settings to {str(settings_ini_path)!r}.") - - @staticmethod - def get_mods_folder(mo2_ini_path: Path) -> Path: - """ - Gets the path to the mods folder of the specified MO2 instance. - - Args: - mo2_ini_path (Path): Path to the ModOrganizer.ini file of the instance. - - Returns: - Path: Path to the mods folder. - """ - - ini_file = INIFile(mo2_ini_path) - ini_data: dict[str, Any] = ini_file.load_file() - - settings: dict[str, Any] = ini_data["Settings"] - base_dir = Path(settings.get("base_directory", mo2_ini_path.parent)) - - mods_dir: Path - if "mod_directory" in settings: - mods_dir = resolve(Path(settings["mod_directory"]), base_dir=str(base_dir)) - else: - mods_dir = base_dir / "mods" - - return mods_dir - - @staticmethod - def get_profiles_folder(mo2_ini_path: Path) -> Path: - """ - Gets the path to the profiles folder of the specified MO2 instance. - - Args: - mo2_ini_path (Path): Path to the ModOrganizer.ini file of the instance. - - Returns: - Path: Path to the profiles folder. - """ - - ini_file = INIFile(mo2_ini_path) - ini_data: dict[str, Any] = ini_file.load_file() - - settings: dict[str, Any] = ini_data["Settings"] - base_dir = Path(settings.get("base_directory", mo2_ini_path.parent)) - - prof_dir: Path - if "profiles_directory" in settings: - prof_dir = resolve( - Path(settings["profiles_directory"]), base_dir=str(base_dir) - ) - else: - prof_dir = base_dir / "profiles" - - return prof_dir - - @staticmethod - def get_overwrite_folder(mo2_ini_path: Path) -> Path: - """ - Gets the path to the overwrite folder of the specified MO2 instance. - - Args: - mo2_ini_path (Path): Path to the ModOrganizer.ini file of the instance. - - Returns: - Path: Path to the overwrite folder. - """ - - ini_file = INIFile(mo2_ini_path) - ini_data: dict[str, Any] = ini_file.load_file() - - settings: dict[str, Any] = ini_data["Settings"] - base_dir = Path(settings.get("base_directory", mo2_ini_path.parent)) - - overwrite_dir: Path - if "overwrite_directory" in settings: - overwrite_dir = resolve( - Path(settings["overwrite_directory"]), base_dir=str(base_dir) - ) - else: - overwrite_dir = base_dir / "overwrite" - - return overwrite_dir - - @staticmethod - def get_profile_names(mo2_ini_path: Path) -> list[str]: - """ - Gets the names of all profiles in the specified MO2 instance. - - Args: - mo2_ini_path (Path): Path to the ModOrganizer.ini file of the instance. - - Returns: - list[str]: List of profile names. - """ - - ini_file = INIFile(mo2_ini_path) - ini_data: dict[str, Any] = ini_file.load_file() - - settings: dict[str, Any] = ini_data["Settings"] - base_dir = Path(settings.get("base_directory", mo2_ini_path.parent)) - - prof_dir: Path - if "profiles_directory" in settings: - prof_dir = resolve( - Path(settings["profiles_directory"]), base_dir=str(base_dir) - ) - else: - prof_dir = base_dir / "profiles" - - return [prof.name for prof in prof_dir.iterdir() if prof.is_dir()] - - def detect_global_instances(self) -> bool: - """ - Checks for global instances at AppData\\Local\\ModOrganizer. - - Returns: - bool: Whether there are global MO2 instances. - """ - - self.log.info("Checking for global instances...") - - instances_found: bool = False - - global_instance_path: Path = resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" - if global_instance_path.is_dir(): - instances_found = ( - len(list(global_instance_path.glob("*/ModOrganizer.ini"))) > 0 - ) - - self.log.info(f"Global instances found: {instances_found}") - - return instances_found - - @override - def get_completed_message(self, migrated_instance_data: MO2InstanceInfo) -> str: - text: str = "" - - if migrated_instance_data.use_root_builder: - if migrated_instance_data.is_global: - text = ( - self.tr( - "The usage of root builder was enabled.\n" - "In order to correctly deploy the root files, you have to " - "download and extract the root builder plugin from Nexus Mods " - 'to the "plugins" folder of your MO2 installation if not ' - "already installed." - ) - + "\n\n" - ) - else: - text = ( - self.tr( - "The usage of root builder was enabled.\n" - "In order to correctly deploy the root files, you have to " - "download and extract the root builder plugin from Nexus Mods " - 'to the "plugins" folder of the new MO2 installation.' - ) - + "\n\n" - ) - - if not migrated_instance_data.is_global and self.detect_global_instances(): - text += self.tr( - "At least one global instance was detected.\n" - "Global instances cause issues with portable instances and it is " - "recommended to delete (or rename) the following folder:\n{0}" - ).format(str(self.appdata_path)) - - return text - - @override - def get_mods_path(self, instance_data: MO2InstanceInfo) -> Path: - return instance_data.mods_folder - - @override - def is_instance_existing(self, instance_data: MO2InstanceInfo) -> bool: - instance_name: str = instance_data.display_name - profile_name: str = instance_data.profile - game: Game = instance_data.game - - if instance_data.is_global and instance_name in self.get_instance_names(game): - return True - - instance_path: Path = instance_data.base_folder - mo2_ini_path: Path = instance_path / "ModOrganizer.ini" - if mo2_ini_path.is_file(): - profile_path: Path = ( - ModOrganizer.get_profiles_folder(mo2_ini_path) / profile_name - ) - return profile_path.is_dir() - - return False diff --git a/src/core/mod_manager/vortex/__init__.py b/src/core/mod_manager/vortex/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/src/core/mod_manager/vortex/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/src/core/mod_manager/vortex/exceptions.py b/src/core/mod_manager/vortex/exceptions.py deleted file mode 100644 index af24383..0000000 --- a/src/core/mod_manager/vortex/exceptions.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import override - -from PySide6.QtWidgets import QApplication - -from ..exceptions import ModManagerError, PreMigrationCheckFailedError - - -class VortexIsRunningError(ModManagerError): - """ - Exception that occurs when Vortex is running and locking its database. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "Vortex is running and blocking its database. Close Vortex and try again!", - ) - - -class VortexIsDeployedError(ModManagerError): - """ - Exception that occurs when user starts migration while Vortex is still deployed - to the game folder. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "Migration cannot continue while Vortex is deployed!\n" - "Open Vortex and purge the game directory.\n" - "Then click 'Continue' to complete the migration process.", - ) - - -class VortexNotFullySetupError(PreMigrationCheckFailedError): - """ - Exception that occurs when Vortex is not installed or ready for a migration. - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "Vortex is not installed or fully setup.\nFollow these steps and try again:\n" - "1. Install Vortex\n2. Start Vortex and enable the mod management for the " - "game.\n3. Enable profile management in Vortex' settings.", - ) - - -class OverwriteModNotSupportedError(ModManagerError): - """ - Exception that occurs when attempting to install a mod of type Overwrite - (MO2 overwrite folder). - """ - - @override - def getLocalizedMessage(self) -> str: - return QApplication.translate( - "exceptions", - "The overwrite folder of MO2 is not supported by Vortex!\n" - "Please create a separate mod from the overwrite folder and restart the " - "migration.", - ) diff --git a/src/core/mod_manager/vortex/profile_info.py b/src/core/mod_manager/vortex/profile_info.py deleted file mode 100644 index 599cae6..0000000 --- a/src/core/mod_manager/vortex/profile_info.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from dataclasses import dataclass - -from ..instance_info import InstanceInfo - - -@dataclass(frozen=True) -class ProfileInfo(InstanceInfo): - """ - Class for identifying a Vortex profile. - """ - - id: str - """ - The ID of the profile. - """ diff --git a/src/core/mod_manager/vortex/vortex.py b/src/core/mod_manager/vortex/vortex.py deleted file mode 100644 index 41b60fc..0000000 --- a/src/core/mod_manager/vortex/vortex.py +++ /dev/null @@ -1,1016 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import datetime -import random -import shutil -import string -import time -from copy import copy -from pathlib import Path -from typing import Any, Optional, override - -import plyvel -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog - -from core.game.exceptions import GameNotFoundError -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.metadata import Metadata -from core.instance.mod import Mod -from core.instance.tool import Tool -from core.utilities.filesystem import clean_fs_string -from core.utilities.leveldb import LevelDB - -from ..exceptions import InstanceNotFoundError -from ..mod_manager import ModManager -from .exceptions import ( - OverwriteModNotSupportedError, - VortexIsRunningError, - VortexNotFullySetupError, -) -from .profile_info import ProfileInfo - - -class Vortex(ModManager[ProfileInfo]): - """ - Mod manager class for Vortex. - """ - - __games: dict[str, Game] - """ - Dict of game names in the meta.ini file to game classes. - """ - - db_path: Path - __level_db: LevelDB - - def __init__(self) -> None: - super().__init__() - - self.db_path = resolve(Path("%APPDATA%") / "Vortex" / "state.v2") - self.__level_db = LevelDB( - self.db_path, - use_symlink=( - not LevelDB.is_db_readable(self.db_path) and self.db_path.is_dir() - ), - ) - - self.__games = {g.id: g for g in Game.get_supported_games()} - - @override - def __repr__(self) -> str: - return "Vortex" - - @override - @staticmethod - def get_id() -> str: - return "vortex" - - @override - @staticmethod - def get_display_name() -> str: - return "Vortex" - - @override - @staticmethod - def get_icon_name() -> str: - return ":/icons/vortex.png" - - @override - def get_instance_names(self, game: Game) -> list[str]: - self.log.info(f"Getting profiles for {game.id} from database...") - - if not self.db_path.is_dir(): - self.log.debug("Found no Vortex database.") - return [] - - try: - data = self.__level_db.get_section("persistent###profiles###") - except plyvel.IOError as ex: - raise VortexIsRunningError from ex - - profile_data_items: dict[str, dict] = data.get("persistent", {}).get( - "profiles", {} - ) - - profiles: list[str] = [] - for profile_id, profile_data in profile_data_items.items(): - profile_name: str = profile_data["name"] - game_id: str = profile_data["gameId"] - - if game_id.lower() == game.id.lower(): - profiles.append(f"{profile_name} ({profile_id})") - - self.log.info(f"Got {len(profiles)} profile(s) from database.") - - return profiles - - @override - def load_instance( - self, - instance_data: ProfileInfo, - modname_limit: int, - file_blacklist: list[str] = [], - game_folder: Optional[Path] = None, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - instance_name: str = instance_data.display_name - game: Game = instance_data.game - - if instance_name not in self.get_instance_names(game): - raise InstanceNotFoundError(instance_name) - - key: str = ( - f"settings###gameMode###discovered###{instance_data.game.id.lower()}###path" - ) - - raw_game_folder: Optional[str] = self.__level_db.get_key(key) - if raw_game_folder is not None: - game_folder = Path(raw_game_folder) - elif game_folder is None: - raise GameNotFoundError - - self.log.info(f"Loading profile {instance_name!r}...") - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading profile {0}...").format(instance_name), - ) - - mods: list[Mod] = self._load_mods( - instance_data, modname_limit, game_folder, file_blacklist, ldialog - ) - tools: list[Tool] = self._load_tools( - instance_data, mods, game_folder, file_blacklist, ldialog - ) - instance = Instance( - display_name=instance_name, game_folder=game_folder, mods=mods, tools=tools - ) - - self.log.info( - f"Loaded profile {instance_name!r} with {len(mods)} mod(s) " - f"and {len(instance.tools)} tool(s)." - ) - - return instance - - @override - def _load_mods( - self, - instance_data: ProfileInfo, - modname_limit: int, - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Mod]: - instance_name: str = instance_data.display_name - profile_id: str = instance_data.id - game: Game = instance_data.game - - self.log.debug(f"Loading mods from instance {instance_name!r}...") - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading mods from profile {0}...").format(instance_name), - ) - - game_id: str = game.id.lower() - - profiles_data: dict = self.__level_db.get_section("persistent###profiles###") - mod_state_data: dict[str, dict] = profiles_data["persistent"]["profiles"][ - profile_id - ].get("modState", {}) - - moddata: dict[str, Any] - # for modname, moddata in mod_state_data.items(): - # if moddata["enabled"]: - # modnames.append(modname) - modnames: list[str] = [m for m in mod_state_data] - - mods_data: dict = self.__level_db.get_section( - f"persistent###mods###{game_id}###" - ) - - if not mods_data: - return [] - - installed_mods: dict[str, dict] = mods_data["persistent"]["mods"][game_id] - staging_folder: Path = self.__get_staging_folder(game) - - mods: list[Mod] = [] - conflict_rules: dict[Mod, list[dict]] = {} - file_overrides: dict[Mod, list[str]] = {} - for m, modname in enumerate(modnames): - if modname not in installed_mods: - self.log.warning( - f"Failed to load mod {modname!r}: Mod is not installed!" - ) - continue - - if ldialog is not None: - ldialog.updateProgress( - text1=self.tr("Loading mods from profile {0}...").format( - instance_name - ) - + f" ({m}/{len(modnames)})", - value1=m, - max1=len(modnames), - show2=True, - text2=modname, - ) - - moddata = installed_mods[modname] - mod_meta_data: dict[str, Any] = moddata["attributes"] - display_name: str = ( - mod_meta_data.get("customFileName") - or mod_meta_data.get("logicalFileName") - or mod_meta_data.get("modName") - or modname - )[:modname_limit].strip("-_. ") # Limit mod name as it can get very long - mod_path: Path = staging_folder / moddata.get("installationPath", modname) - file_name: str = mod_meta_data.get("fileName", mod_path.name) - - deploy_path: Optional[Path] = None - modtype: Optional[str] = moddata.get("type") or None - - # Set deploy path to game's root folder - if modtype is not None and modtype != "collection": - self.log.debug(f"Mod '{display_name}' has type '{modtype}'.") - deploy_path = Path(".") - - # Ignore collection bundles - elif modtype == "collection": - continue - - if not mod_path.is_dir(): - self.log.warning( - f"Failed to load mod files for mod {display_name!r}: " - f"{str(mod_path)!r} does not exist!" - ) - continue - - mod_id: Optional[int] = None - file_id: Optional[int] = None - version: str = "" - dl_game_id: str = instance_data.game.nexus_id - try: - if mod_meta_data.get("modId"): - mod_id = int(mod_meta_data["modId"]) - if mod_meta_data.get("fileId"): - file_id = int(mod_meta_data["fileId"]) - version = mod_meta_data.get("version") or "" - - # Remove trailing .0 if any - while version.endswith(".0") and version.count(".") > 1: - version = version.removesuffix(".0") - - if ( - "downloadGame" in mod_meta_data - and mod_meta_data["downloadGame"] in self.__games - ): - dl_game_id = self.__games[mod_meta_data["downloadGame"]].nexus_id - elif "downloadGame" in mod_meta_data: - self.log.warning( - f"Unknown game for mod {display_name!r}: {mod_meta_data['downloadGame']}" - ) - except Exception as ex: - self.log.error( - f"Failed to process metadata for mod {display_name!r}: {ex}", - exc_info=ex, - ) - - mod = Mod( - display_name=display_name, - path=mod_path, - deploy_path=deploy_path, - metadata=Metadata( - mod_id=mod_id, - file_id=file_id, - version=version, - file_name=file_name, - game_id=dl_game_id, - ), - installed=True, - enabled=mod_state_data.get(modname, {}).get("enabled", False), - ) - mods.append(mod) - rules: list[dict] = moddata.get("rules", []) - if rules: - conflict_rules[mod] = rules - overrides: list[str] = moddata.get("fileOverrides", []) - if overrides: - file_overrides[mod] = overrides - - self.__process_conflict_rules(mods, conflict_rules) - - mod_overrides: dict[Mod, list[Mod]] = self._get_reversed_mod_conflicts(mods) - self.__process_file_overrides( - file_overrides, file_blacklist, mod_overrides, game, game_folder - ) - - self.log.debug(f"Loaded {len(mods)} mod(s) from instance {instance_name!r}.") - - return mods - - def __process_conflict_rules( - self, mods: list[Mod], conflict_rules: dict[Mod, list[dict]] - ) -> None: - self.log.info(f"Processing conflict rules for {len(conflict_rules)} mod(s)...") - mods_by_file_name: dict[str, Mod] = { - self.__get_unique_file_name(mod).rsplit(".", 1)[0]: mod for mod in mods - } - mod_overwrites: dict[Mod, list[Mod]] = {} - - for mod in mods: - rules: list[dict[str, dict | str]] = conflict_rules.get(mod, []) - - for rule in rules: - reference: dict[str, str] = rule["reference"] # type: ignore[assignment] - ref_modname: Optional[str] = reference.get("id") or reference.get( - "fileExpression" - ) - - if ref_modname is None: - self.log.warning( - "Failed to process mod conflict rule for mod " - f"{mod.display_name!r}: Reference mod name is empty!" - ) - continue - - ref_mod: Optional[Mod] = mods_by_file_name.get(ref_modname) - - # Ignore conflicts with mods that aren't relevant to us - if ref_mod is None: - continue - - ruletype: str = rule["type"] # type: ignore[assignment] - - if ruletype == "before": - mod_overwrites.setdefault(mod, []).append(ref_mod) - - elif ruletype == "after": - mod_overwrites.setdefault(ref_mod, []).append(mod) - - else: - self.log.warning( - "Failed to process mod conflict rule for mod " - f"{mod.display_name!r}: Unknown rule type {ruletype!r}!" - ) - - for mod, overwriting_mods in mod_overwrites.items(): - mod.mod_conflicts = overwriting_mods - - self.log.info("Processing conflict rules successful.") - - def __process_file_overrides( - self, - file_overrides: dict[Mod, list[str]], - file_blacklist: list[str], - mod_overrides: dict[Mod, list[Mod]], - game: Game, - game_folder: Path, - ) -> None: - self.log.info(f"Processing file overrides for {len(file_overrides)} mod(s)...") - - mods_folder: Path = game_folder / game.mods_folder - - for mod, files in file_overrides.items(): - if mod not in mod_overrides: - continue - - overwriting_files: dict[str, list[Mod]] = self._index_modlist( - mod_overrides[mod], file_blacklist - ) - if not overwriting_files: - self.log.debug( - f"Mod {mod.display_name!r} has irrelevant file overrides: {files}." - ) - continue - - for file in files: - if Path(file).is_relative_to(mods_folder): - file = str(Path(file).relative_to(mods_folder)) - overwriting_mods: list[Mod] = overwriting_files.get(file.lower(), []) - - if len(overwriting_mods) > 1: - self.log.warning( - "Detected file override for multiple mods: " - f"{', '.join(m.display_name for m in overwriting_mods)} " - f"override {mod.display_name!r} for file {file!r}." - ) - - if len(overwriting_mods) >= 1: - mod.file_conflicts[file] = overwriting_mods[0] - - self.log.info("Processing file overrides successful.") - - @override - def _load_tools( - self, - instance_data: ProfileInfo, - mods: list[Mod], - game_folder: Path, - file_blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> list[Tool]: - self.log.debug("Loading tools from Vortex...") - if ldialog is not None: - ldialog.updateProgress(self.tr("Loading tools from Vortex...")) - - mods_by_folders: dict[Path, Mod] = {m.path: m for m in mods} - game_id: str = instance_data.game.id.lower() - tools_data: dict[str, Any] = ( - self.__level_db.get_section( - f"settings###gameMode###discovered###{game_id}###tools###" - ) - .get("settings", {}) - .get("gameMode", {}) - .get("discovered", {}) - .get(game_id, {}) - .get("tools", {}) - ) - - tools: list[Tool] = [] - for tool_id, tool_data in tools_data.items(): - try: - name: str = tool_data["name"] - raw_exe_path: str = tool_data["path"] - raw_working_dir: Optional[str] = tool_data.get("workingDir") or None - args: list[str] = tool_data.get("parameters") or [] - except Exception as ex: - self.log.error( - f"Failed to load tool with id {tool_id!r}: {ex}", exc_info=ex - ) - continue - - exe_path = Path(raw_exe_path) - working_dir: Optional[Path] = ( - Path(raw_working_dir) if raw_working_dir is not None else None - ) - if working_dir == game_folder: - working_dir = None - - mod: Optional[Mod] = Vortex._get_mod_for_path(exe_path, mods_by_folders) - is_in_game_dir: bool = False - if mod is not None: - exe_path = exe_path.relative_to(mod.path) - elif exe_path.is_relative_to(game_folder): - exe_path = exe_path.relative_to(game_folder) - is_in_game_dir = True - - tool = Tool( - display_name=name, - mod=mod, - executable=exe_path, - commandline_args=args, - working_dir=working_dir, - is_in_game_dir=is_in_game_dir, - ) - tools.append(tool) - - self.log.info(f"Loaded {len(tools)} tools from Vortex.") - - return tools - - @override - def create_instance( - self, - instance_data: ProfileInfo, - game_folder: Path, - ldialog: Optional[LoadingDialog] = None, - ) -> Instance: - self.log.info( - f"Creating profile {instance_data.display_name!r} " - f"with id {instance_data.id!r}..." - ) - - game_id: str = instance_data.game.id.lower() - profile_name: str = instance_data.display_name - profile_id: str = instance_data.id - - profile_data: dict[str, Any] = { - "features": { # TODO: Make these customizable - "local_game_settings": False, - "local_saves": False, - }, - "gameId": game_id, - "modState": {}, - "id": profile_id, - "lastActivated": Vortex.format_unix_timestamp(time.time()), - "name": profile_name, - } - self.__level_db.set_section( - f"persistent###profiles###{profile_id}###", profile_data - ) - self.__level_db.save() - - # Create profile folder - app_path: Path = resolve(Path("%APPDATA%") / "Vortex") - prof_path: Path = app_path / game_id / "profiles" / profile_id - prof_path.mkdir(parents=True) - - self.log.info("Created Vortex profile.") - - return Instance( - display_name=instance_data.display_name, - game_folder=game_folder, - mods=[], - tools=self._load_tools(instance_data, [], game_folder, ldialog=ldialog), - ) - - @override - def install_mod( - self, - mod: Mod, - instance: Instance, - instance_data: ProfileInfo, - file_redirects: dict[Path, Path], - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - self.log.info(f"Installing mod {mod.display_name!r}...") - - if mod.mod_type == Mod.Type.Separator: - self.log.info("Skipped mod because separators are not supported by Vortex.") - return - - if mod.mod_type == Mod.Type.Overwrite: - raise OverwriteModNotSupportedError - - game_id: str = instance_data.game.id.lower() - staging_folder: Path = self.__get_staging_folder(instance_data.game) - - file_name: str = self.__get_unique_file_name(mod).rsplit(".", 1)[0] - mod_folder: Path = staging_folder / file_name - db_prefix: str = f"persistent###mods###{game_id}###{file_name}###" - db_mod_data: dict[str, Any] = ( - self.__level_db.get_section(db_prefix) - .get("persistent", {}) - .get("mods", {}) - .get(game_id, {}) - .get(file_name, {}) - ) - - if not db_mod_data: - logical_file_name: str = Vortex.get_logical_file_name( - self.__get_unique_file_name(mod), mod.metadata.mod_id or 0 - ) - source: str = "nexus" if mod.metadata.mod_id else "other" - modtype: Optional[str] = "dinput" if mod.deploy_path else None - - dl_game_id: str - try: - dl_game_id = Game.get_game_by_nexus_id(mod.metadata.game_id).id.lower() - except ValueError: - self.log.warning( - f"Unsupported game '{mod.metadata.game_id}' for mod! Falling back " - "to instance's default..." - ) - dl_game_id = instance_data.game.id.lower() - - moddata: dict[str, Any] = { - "attributes": { - "customFileName": mod.display_name, - "downloadGame": dl_game_id, - "installTime": Vortex.format_utc_timestamp(time.time()), - "fileName": self.__get_unique_file_name(mod), - "fileId": mod.metadata.file_id, - "modId": mod.metadata.mod_id, - "logicalFileName": logical_file_name, - "version": mod.metadata.version, - "source": source, - }, - "id": file_name, - "installationPath": file_name, - "state": "installed", - "type": modtype, - } - db_mod_data = moddata - - self._migrate_mod_files( - mod, - mod_folder, - file_redirects, - use_hardlinks, - replace, - blacklist, - ldialog, - ) - else: - self.log.info(f"Mod {mod.display_name!r} already installed.") - - rules: list[dict[str, Any]] = db_mod_data.get("rules", []) - # Check for rules - for overwriting_mod in mod.mod_conflicts: - overwriting_mod_filename: str = self.__get_unique_file_name( - overwriting_mod - ).rsplit(".", 1)[0] - - if overwriting_mod_filename == file_name: - raise ValueError( - f"Cyclic dependency detected in '{mod.display_name}': Mod conflicts with itself!" - ) - - # Check if a rule already exists - if overwriting_mod_filename in self.__parse_mod_conflict_rules(rules): - continue - - # Merge rules - rule: dict[str, Any] = { - "reference": { - "id": overwriting_mod_filename, - "idHint": overwriting_mod_filename, - "versionMatch": "*", - }, - "type": "before", - } - rules.append(rule) - self.log.debug( - f"Added conflict rule for mod {mod.display_name!r} " - f"overwritten by {overwriting_mod.display_name!r}." - ) - - if rules: - db_mod_data["rules"] = rules - - self.__level_db.set_section(db_prefix, db_mod_data) - - # Add mod to profile - profile_db_prefix: str = ( - f"persistent###profiles###{instance_data.id}###modState###{file_name}###" - ) - profile_mod_data: dict[str, Any] = ( - self.__level_db.get_section(profile_db_prefix) - .get("persistent", {}) - .get("profiles", {}) - .get(instance_data.id, {}) - .get("modState", {}) - .get(file_name, {}) - ) - profile_mod_data["enabled"] = mod.enabled - profile_mod_data["enabledTime"] = Vortex.format_unix_timestamp(time.time()) - self.__level_db.set_section(profile_db_prefix, profile_mod_data) - - if not instance.is_mod_installed(mod): - new_mod: Mod = Mod.copy(mod) - new_mod.path = mod_folder - instance.mods.append(new_mod) - - def __parse_mod_conflict_rules( - self, conflict_rules: list[dict[str, Any]] - ) -> list[str]: - """ - Parses the conflict rules from a mod from the Vortex database. - - Args: - conflict_rules (list[dict[str, Any]]): The conflict rules to parse. - - Returns: - list[str]: A list of all mods that are set to overwrite this mod. - """ - - overwriting_mods: list[str] = [] - for rule in conflict_rules: - reference: dict[str, str] = rule["reference"] - ref_modname: Optional[str] = reference.get("id") or reference.get( - "fileExpression" - ) - - if ref_modname is None: - self.log.warning( - "Failed to process mod conflict rule: Reference mod name is empty!" - ) - continue - - ruletype: str = rule["type"] - - if ruletype == "before": - overwriting_mods.append(ref_modname) - - return overwriting_mods - - def __get_staging_folder(self, game: Game) -> Path: - appdata_path: Path = resolve(Path("%APPDATA%") / "Vortex") - game_id: str = game.id.lower() - - try: - staging_folder_value: Optional[str] = self.__level_db.get_key( - f"settings###mods###installPath###{game_id}" - ) - except plyvel.IOError as ex: - raise VortexIsRunningError from ex - - staging_folder: Path - if staging_folder_value is None: - staging_folder = appdata_path / game_id / "mods" - else: - staging_folder = resolve( - Path(staging_folder_value), - sep=("{", "}"), - game=game_id, - userdata=str(appdata_path), - ) - - return staging_folder - - @override - def add_tool( - self, - tool: Tool, - instance: Instance, - instance_data: ProfileInfo, - use_hardlinks: bool, - replace: bool, - blacklist: list[str] = [], - ldialog: Optional[LoadingDialog] = None, - ) -> None: - self.log.info(f"Adding tool {tool.display_name!r}...") - - if tool in instance.tools: - self.log.info(f"Tool {tool.display_name!r} already exists.") - return - - game_id: str = instance_data.game.id.lower() - tool_id: str = Vortex.generate_id(length=11) - new_tool: Tool = copy(tool) - if new_tool.mod is not None and instance.is_mod_installed(new_tool.mod): - # Map tool to the installed mod - new_tool.mod = instance.get_installed_mod(new_tool.mod) - tool_data: dict[str, Any] = { - "custom": True, - "defaultPrimary": False, - "detach": True, - "exclusive": False, - "executable": None, - "id": tool_id, - "logo": f"{tool_id}.png", - "name": new_tool.display_name, - "parameters": [], - "path": str(new_tool.get_full_executable_path(instance.game_folder)), - "requiredFiles": [], - "shell": False, - "timestamp": int(time.time()), - "workingDirectory": str(new_tool.working_dir or ""), - } - - tool_prefix: str = ( - f"settings###gameMode###discovered###{game_id}###tools###{tool_id}###" - ) - self.__level_db.set_section(tool_prefix, tool_data) - instance.tools.append(new_tool) - - @override - def get_instance_ini_dir(self, instance_data: ProfileInfo) -> Path: - appdata_path: Path = resolve(Path("%APPDATA%") / "Vortex") - prof_path: Path = ( - appdata_path / instance_data.game.id.lower() / "profiles" / instance_data.id - ) - - return prof_path - - @override - def get_additional_files_folder(self, instance_data: ProfileInfo) -> Path: - appdata_path: Path = resolve(Path("%APPDATA%") / "Vortex") - prof_path: Path = ( - appdata_path / instance_data.game.id.lower() / "profiles" / instance_data.id - ) - - return prof_path - - def is_deployed(self, game: Game) -> bool: - """ - Checks if Vortex is currently deployed to the game folder. - - Args: - game (Game): Game to check deployment for - - Returns: - bool: `True` if Vortex is deployed, `False` otherwise - """ - - return (self.__get_staging_folder(game) / "vortex.deployment.msgpack").is_file() - - @override - def prepare_migration(self, instance_data: ProfileInfo) -> None: - appdata_path: Path = resolve(Path("%APPDATA%") / "Vortex") - game_folder: Path = appdata_path / instance_data.game.id.lower() - - if not game_folder.is_dir(): - raise VortexNotFullySetupError - - game_path_key: str = ( - f"settings###gameMode###discovered###{instance_data.game.id.lower()}###path" - ) - game_path: Optional[str] = self.__level_db.get_key(game_path_key) - if game_path is None: - raise VortexNotFullySetupError - - profile_management_key: str = "settings###interface###profilesVisible" - profile_management_enabled: bool = ( - self.__level_db.get_key(profile_management_key) or False - ) - if not profile_management_enabled: - raise VortexNotFullySetupError - - # Make a backup of the original Vortex database - backup_path: Path = appdata_path / ( - "state.v2-mmm_" + time.strftime("%Y-%m-%d_%H-%M-%S") - ) - shutil.copytree(appdata_path / "state.v2", backup_path) - self.log.info(f"Created backup of Vortex database at '{backup_path}'.") - - def __set_file_overrides( - self, mods: list[Mod], game: Game, game_folder: Path - ) -> None: - for mod in mods: - if not mod.file_conflicts: - continue - - self.log.info(f"Setting file overrides for {mod.display_name!r}...") - full_mod_name: str = self.__get_unique_file_name(mod).rsplit(".", 1)[0] - prefix: str = f"persistent###mods###{game.id.lower()}###{full_mod_name}###" - mod_data: dict[str, Any] = self.__level_db.get_section(prefix)[ - "persistent" - ]["mods"][game.id.lower()][full_mod_name] - mod_data["fileOverrides"] = [ - str(game_folder / game.mods_folder / file) - for file in mod.file_conflicts - ] - self.__level_db.set_section(prefix, mod_data) - - @override - def finalize_migration( - self, - migrated_instance: Instance, - migrated_instance_data: ProfileInfo, - activate_new_instance: bool, - ) -> None: - profile_db_prefix: str = ( - f"persistent###profiles###{migrated_instance_data.id}###" - ) - profile_data: dict[str, Any] = ( - self.__level_db.get_section(profile_db_prefix) - .setdefault("persistent", {}) - .setdefault("profiles", {}) - .setdefault(migrated_instance_data.id, {}) - ) - profile_data.setdefault("features", {})["local_game_settings"] = ( - migrated_instance.separate_ini_files - ) - profile_data["features"]["local_saves"] = migrated_instance.separate_save_games - self.__level_db.set_section(profile_db_prefix, profile_data) - - # Set file overrides - self.__set_file_overrides( - migrated_instance.mods, - migrated_instance_data.game, - migrated_instance.game_folder, - ) - - if activate_new_instance: - # Activate new profile - key: str = "settings###profiles###activeProfileId" - self.__level_db.set_key(key, migrated_instance_data.id) - - # Set last active profile - key = "settings###profiles###lastActiveProfile###" - key += migrated_instance_data.game.id.lower() - self.__level_db.set_key(key, migrated_instance_data.id) - - self.__level_db.save() - self.__level_db.del_symlink_path() - - @override - def get_completed_message(self, migrated_instance_data: ProfileInfo) -> str: - text: str = "" - - if self.is_deployed(migrated_instance_data.game): - text = self.tr( - "Vortex is currently deployed to the game folder. " - "It is strongly recommended to purge the game directory " - "before using the migrated instance." - ) - - return text - - @override - def get_mods_path(self, instance_data: ProfileInfo) -> Path: - return self.__get_staging_folder(instance_data.game) - - def __get_unique_file_name(self, mod: Mod) -> str: - return mod.metadata.file_name or Vortex.create_unique_file_name( - mod_name=mod.display_name, - mod_id=mod.metadata.mod_id, - file_id=mod.metadata.file_id, - version=mod.metadata.version, - ) - - @staticmethod - def get_logical_file_name(full_file_name: str, mod_id: int) -> str: - """ - Strips Nexus Mods-specific suffix from a full file name of a mod. - - Examples: - >>> Vortex.get_logical_file_name("(Part 1) SSE Engine Fixes for 1.5.39 - 1.5.97-17230-5-9-1-1664974289.7z") - "(Part 1) SSE Engine Fixes for 1.5.39 - 1.5.97" - - Args: - full_file_name (str): Full file name - mod_id (int): Mod ID, used for splitting - - Returns: - str: Logical file name - """ - - return full_file_name.rsplit(".", 1)[0].rsplit(f"-{mod_id}-")[0] - - @staticmethod - def create_unique_file_name( - mod_name: str, - mod_id: Optional[int], - file_id: Optional[int], - version: Optional[str], - ) -> str: - """ - Creates a unique full file name for a mod based on its name, - mod id, file id and version. - - Args: - mod_name (str): Display name of the mod - mod_id (Optional[int]): Nexus Mods Mod ID - file_id (Optional[int]): Nexus Mods File ID - version (Optional[str]): Mod version - - Returns: - str: Unique full file name - """ - - full_file_name: str = clean_fs_string(mod_name) - - if mod_id: - full_file_name += f"-{mod_id}" - - if file_id: - full_file_name += f"-{file_id}" - - if version: - full_file_name += f"-{version}" - - return clean_fs_string(full_file_name) - - @staticmethod - def format_utc_timestamp(timestamp: float) -> str: - """ - Formats a timestamp to a UTC string suffixed by a "Z" for insertion - into the Vortex database. - - Args: - timestamp (float): Unix timestamp (seconds since epoch) - - Returns: - str: UTC string in ISO format - """ - - return ( - datetime.datetime.fromtimestamp( - timestamp, datetime.timezone.utc - ).isoformat()[:-6] # remove timezone, Vortex doesn't want it - + "Z" - ) - - @staticmethod - def format_unix_timestamp(timestamp: float) -> int: - """ - Formats a Unix timestamp for the Vortex database. - - Args: - timestamp (float): Unix timestamp - - Returns: - int: Formatted timestamp with 3 decimal places without decimal point - """ - - return int(str(round(timestamp, 3)).replace(".", "")) - - @staticmethod - def generate_id(length: int = 9) -> str: - """ - Generates a unique id for a new profile or tool. - - Args: - length (int): The length of the generated profile/tool id - - Returns: - str: The generated profile/tool id - """ - - return "".join( - [random.choice(string.ascii_letters + string.digits) for _ in range(length)] - ) - - @override - def is_instance_existing(self, instance_data: ProfileInfo) -> bool: - return instance_data.display_name in self.get_instance_names(instance_data.game) diff --git a/src/core/utilities/downloader.py b/src/core/utilities/downloader.py deleted file mode 100644 index 7b0003b..0000000 --- a/src/core/utilities/downloader.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import logging -import os -import platform -from cgi import parse_header -from pathlib import Path -from typing import Optional - -import requests as req -from PySide6.QtCore import QObject, Signal -from PySide6.QtWidgets import QApplication - -from core.utilities.progress_update import ( - ProgressCallback, - ProgressUpdate, - safe_run_callback, -) - - -class Downloader(QObject): - """ - Class for downloading files from the internet. - """ - - log: logging.Logger = logging.getLogger("Downloader") - - __stop_signal: Signal = Signal() - - __running: bool = False - - CHUNK_SIZE: int = 1024 * 1024 # 1 MB - TIMEOUT: int = 5 # 5 seconds - - user_agent: str - - def __init__(self) -> None: - super().__init__() - - self.__stop_signal.connect(self.__stop_download) - - app_name: str = QApplication.applicationName() - app_version: str = QApplication.applicationVersion() - - self.user_agent = f"\ -{app_name}/{app_version} \ -(\ -{platform.system()} \ -{platform.version()}; \ -{platform.architecture()[0]}\ -)" - - def download( - self, - download_url: str, - dest_folder: Path, - file_name: Optional[str] = None, - progress_callback: Optional[ProgressCallback] = None, - ) -> Path: - """ - Downloads a file from the internet and saves it at a specified location. - - Args: - download_url (str): Direct download URL to file. - dest_folder (Path): Folder, to which the file gets downloaded to. - file_name (str, optional): - Name of downloaded file, only required if the server doesn't return it. - progress_callback (Optional[ProgressCallback], optional): - Optional function or method to call with a ProgressUpdate. Defaults to None. - - Returns: - Path: Path to downloaded file. - """ - - dl_path: Path - headers: dict[str, str] = {"User-Agent": self.user_agent} - - with req.Session() as session: - stream = session.get( - download_url, stream=True, headers=headers, timeout=self.TIMEOUT - ) - - total_size: int = int(stream.headers.get("Content-Length", "0")) - - _content = stream.headers.get("Content-Disposition") - if _content and file_name is None: - file_name = parse_header(_content)[1].get("filename", None) - - if file_name is None: - self.log.debug(f"Stream Headers: {stream.headers}") - raise ValueError("No filename given!") - - dl_path = dest_folder / file_name - - self.log.info( - f"Downloading {file_name!r} from {download_url!r} to {str(dest_folder)!r}..." - ) - - if total_size == 0: - self.log.warning( - "Total file size unknown. No progress information available!" - ) - - if dl_path.is_file(): - if dl_path.stat().st_size == total_size and total_size > 0: - self.log.info("File already downloaded.") - return dl_path - else: - os.remove(dl_path) - self.log.warning( - f"Removed already existing file from {str(dl_path)!r}!" - ) - - self.__running = True - current_size: int = 0 - with dl_path.open("wb") as output_file: - for data in stream.iter_content(self.CHUNK_SIZE): - if self.__running: - output_file.write(data) - current_size += len(data) - else: - break - - safe_run_callback( - progress_callback, - ProgressUpdate(current=current_size, maximum=total_size), - ) - - if self.__running and current_size == total_size: - self.log.info("Download complete!") - - else: - self.log.warning("Download incomplete!") - os.remove(dl_path) - - return dl_path - - def __stop_download(self) -> None: - self.__running = False - - def stop(self) -> None: - """ - Thread-safe method to send a stop signal to the running download. - """ - - self.__stop_signal.emit() - - @staticmethod - def single_download( - url: str, - dest_folder: Path, - file_name: Optional[str] = None, - progress_callback: Optional[ProgressCallback] = None, - ) -> Path: - """ - Downloads a single file from a given URL to a destination folder. - - Args: - url (str): URL of the file to download. - dest_folder (Path): Folder where the downloaded file should be saved. - file_name (Optional[str], optional): - Optional filename to use instead of the one in the URL. Defaults to None. - progress_callback (Optional[ProgressCallback], optional): - Optional function or method to call with a ProgressUpdate. Defaults to None. - - Returns: - Path: Path to the downloaded file. - """ - - return Downloader().download(url, dest_folder, file_name, progress_callback) diff --git a/src/core/utilities/filesystem.py b/src/core/utilities/filesystem.py index b6a3c5f..0792619 100644 --- a/src/core/utilities/filesystem.py +++ b/src/core/utilities/filesystem.py @@ -2,19 +2,7 @@ Copyright (c) Cutleast """ -import ctypes -import ctypes.wintypes -import logging -from os import makedirs -from pathlib import Path -from shutil import copyfile, disk_usage -from typing import Optional - -from cutleast_core_lib.core.filesystem.scanner import DirectoryScanner - -from .progress_update import ProgressCallback, ProgressUpdate, safe_run_callback - -log: logging.Logger = logging.getLogger("Filesystem") +from shutil import disk_usage def get_free_disk_space(disk: str) -> int: @@ -29,98 +17,3 @@ def get_free_disk_space(disk: str) -> int: """ return disk_usage(disk).free - - -def copy_folder( - src: Path, dst: Path, progress_callback: Optional[ProgressCallback] -) -> None: - """ - `shutil.copytree`-inspired function to copy entire - directory trees but with a progress callback. - - Args: - src (Path): Source folder - dst (Path): Destination folder - progress_callback (Optional[ProgressCallback]): Progress callback - """ - - files: list[Path] = [ - file.path.relative_to(src) for file in DirectoryScanner.scan_folder(src) - ] - - log.info(f"Copying {len(files)} files from {str(src)!r} to {str(dst)!r}...") - - total_size: int = sum((src / file).stat().st_size for file in files) - current_size: int = 0 - for f, file in enumerate(files): - log.debug(f"Copying {str(file)!r}... ({f + 1} / {len(files)})") - - src_file = src / file - dst_file = dst / file - - makedirs(dst_file.parent, exist_ok=True) - copyfile(src_file, dst_file) - - current_size += dst_file.stat().st_size - safe_run_callback(progress_callback, ProgressUpdate(current_size, total_size)) - - log.info("Copying completed.") - - -def get_documents_folder() -> Path: - """ - Gets the path to the user's documents folder. - - Returns: - Path: Path to user's documents folder - """ - - _buf: ctypes.Array[ctypes.c_wchar] = ctypes.create_unicode_buffer( - ctypes.wintypes.MAX_PATH - ) - ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, _buf) - doc_path: Path = Path(_buf.value) - - return doc_path - - -def get_common_files( - files1: list[str], files2: list[str], ignore_case: bool = True -) -> list[str]: - """ - Gets common files between two lists. - - Args: - files1 (list[str]): First list of files - files2 (list[str]): Second list of files - ignore_case (bool, optional): Toggles whether to ignore case. Defaults to True. - - Returns: - list[str]: List of common files - """ - - return [ - file - for file in files1 - if file in files2 - or (file.lower() in [f.lower() for f in files2] and ignore_case) - ] - - -def clean_fs_string(text: str) -> str: - """ - Cleans a string from illegal path characters like ':', '?' or '/'. - Also removes leading and trailing whitespace and trailing '.'. - - Args: - text (str): the string to be cleaned. - - Returns: - str: A cleaned-up string. - """ - - illegal_chars: str = r"""<>\/|*?":""" - output: str = "".join([c for c in text if c not in illegal_chars]) - output = output.strip().rstrip(".") - - return output diff --git a/src/core/utilities/filter.py b/src/core/utilities/filter.py deleted file mode 100644 index b5eba68..0000000 --- a/src/core/utilities/filter.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import Callable, Optional - - -def matches_filter( - text: str, filter: Optional[str], case_sensitive: bool = False -) -> bool: - """ - Checks if a string matches a filter. - - Args: - text (str): Text to check. - filter (Optional[str]): Filter to check against. - case_sensitive (bool, optional): Case sensitivity. Defaults to False. - - Returns: - bool: True if string matches filter or filter is None, False otherwise. - """ - - if filter is None: - return True - - if not case_sensitive: - text = text.lower() - filter = filter.lower() - - return filter.strip() in text.strip() - - -def get_first_match[T](items: list[T], filter_func: Callable[[T], bool]) -> T: - """ - Returns the first item of the specified list the specified filter function - returns True for. - - Args: - items (list[T]): List of items to check. - filter_func (Callable[[T], bool]): Filter function. - - Raises: - ValueError: If no item matches the filter function. - - Returns: - T: First item that matches the filter function. - """ - - for item in items: - if filter_func(item): - return item - - raise ValueError("No item matches the filter function") diff --git a/src/core/utilities/ini_file.py b/src/core/utilities/ini_file.py deleted file mode 100644 index 2f26830..0000000 --- a/src/core/utilities/ini_file.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import os -from pathlib import Path -from typing import Any - - -class INIFile: - """ - Class for INI files. Supports loading, changing and saving. - """ - - filename: Path - data: dict[str, Any] - - def __init__(self, filename: str | Path) -> None: - self.filename = Path(filename) - self.data = {} - - if self.filename.is_file(): - self.load_file() - - def save_file(self) -> None: - """ - Saves data to file. - """ - - lines: list[str] = [] - section: str - data: dict | Any - for section, data in self.data.items(): - if isinstance(data, dict): - lines.append(f"[{section}]\n") - - for key, value in data.items(): - if value is None: - value = "" - - lines.append(f"{key}={value}\n") - - else: - lines.append(f"{section}={data}\n") - - os.makedirs(self.filename.parent, exist_ok=True) - - with open(self.filename, "w", encoding="utf8") as file: - file.writelines(lines) - - def load_file(self) -> dict[str, Any]: - """ - Loads and parses data from file. Returns it as nested dict. - """ - - with open(self.filename, "r", encoding="utf8") as file: - lines = file.readlines() - - self.data = {} - cur_section = self.data - for line in lines: - line = line.strip() - - if line.startswith("[") and line.endswith("]"): - section = line[1:-1] - cur_section = self.data[section] = {} - - elif line.endswith("="): - cur_section[line[:-1]] = None - - elif "=" in line: - key, value = line.split("=", 1) - cur_section[key] = value.strip("\n") - - return self.data diff --git a/src/core/utilities/leveldb.py b/src/core/utilities/leveldb.py deleted file mode 100644 index f624155..0000000 --- a/src/core/utilities/leveldb.py +++ /dev/null @@ -1,341 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import logging -import os -from pathlib import Path -from typing import Any, Optional - -import jstyleson as json -import plyvel as ldb -import pyuac - - -class LevelDB: - """ - Class for accessing Vortex' LevelDB database. - - Consumers are encouraged to use these in-memory methods: - - `get_section()` - - `set_section()` - - `get_key()` - - `set_key()` - - and then save their changes with `save()`. - """ - - __path: Path - __use_symlink: bool - __symlink_path: Optional[Path] - - __data: dict[str, str] - __changes_pending: bool - - log: logging.Logger = logging.getLogger("LevelDB") - - def __init__(self, path: Path, use_symlink: bool = True) -> None: - """ - Args: - path (Path): Path to Vortex' state.v2 folder. - use_symlink (bool, optional): Whether to use symlinks. Defaults to True. - """ - - self.__path = path - self.__use_symlink = use_symlink - - self.__symlink_path = None - self.__data = {} - self.__changes_pending = False - - def get_symlink_path(self) -> Path: - """ - Creates a symlink to Vortex's database to avoid a database path with - non-ASCII characters which are not supported by plyvel. - - Asks for admin rights to create the symlink if it is required. - - Raises: - RuntimeError: when the user did not grant admin rights. - - Returns: - Path: Path to symlink or path to database if symlink is not used. - """ - - if not self.__use_symlink: - return self.__path - - if self.__symlink_path is None: - self.log.debug("Creating symlink to database...") - - symlink_path = Path("C:\\Users\\Public\\vortex_db") - - if symlink_path.is_symlink(): - symlink_path.unlink() - self.log.debug("Removed already existing symlink.") - - try: - os.symlink(self.__path, symlink_path, target_is_directory=True) - except OSError as ex: - self.log.error(f"Failed to create symlink: {ex}") - - if ( - pyuac.runAsAdmin( - [ - "cmd", - "/c", - "mklink", - "/D", - str(symlink_path), - str(self.__path), - ] - ) - != 0 - ): - raise RuntimeError("Failed to create symlink.") - - self.__symlink_path = symlink_path - - self.log.debug(f"Created symlink from '{symlink_path}' to '{self.__path}'.") - - return self.__symlink_path - - def del_symlink_path(self) -> None: - """ - Deletes database symlink if it exists. - """ - - if self.__symlink_path is not None: - self.log.debug("Deleting symlink...") - - if self.__symlink_path.is_symlink(): - os.unlink(self.__symlink_path) - - self.__symlink_path = None - self.log.debug("Symlink deleted.") - - def load(self, prefix: Optional[str | bytes] = None) -> dict[str, Any]: - """ - Loads all database entries matching an optional prefix into memory. - Unsaved in-memory changes that match the prefix are overwritten by this - operation. - - Args: - prefix (Optional[str | bytes], optional): - The prefix to match. Defaults to None. - - Returns: - dict[str, Any]: The loaded data. - """ - - db_path: Path = self.get_symlink_path() - self.log.info(f"Loading database from '{db_path}' with prefix {prefix!r}...") - - raw_data: dict[str, str] = {} - - if isinstance(prefix, str): - prefix = prefix.encode() - - with ldb.DB(str(db_path)) as database: - for key, value in database.iterator(prefix=prefix): - raw_data[key.decode()] = value.decode() - - self.__data.update(raw_data) - self.log.info("Database loaded.") - - return LevelDB.parse_flat_dict(raw_data) - - def save(self) -> None: - """ - Writes all pending changes to the database. - """ - - if not self.__changes_pending: - self.log.info("No changes pending, skipping save.") - return - - db_path: Path = self.get_symlink_path() - self.log.info(f"Saving database to '{db_path}'...") - - with ldb.DB(str(db_path)) as database: - with database.write_batch() as batch: - for key, value in self.__data.items(): - batch.put(key.encode(), value.encode()) - - self.__changes_pending = False - self.log.info("Database saved.") - - def get_section(self, prefix: str) -> dict[str, Any]: - """ - Returns all key-value pairs in the database that start with the given prefix. - Missing keys are loaded from the database if necessary without overwriting - pending changes. - - Args: - prefix (str): The prefix to filter keys. - - Returns: - dict[str, Any]: The nested dictionary. - """ - - prefix_bytes: bytes = prefix.encode() - - # load missing keys from database - db_path: Path = self.get_symlink_path() - with ldb.DB(str(db_path)) as database: - for raw_key, raw_value in database.iterator(prefix=prefix_bytes): - key: str = raw_key.decode() - if key not in self.__data: - self.__data[key] = raw_value.decode() - self.__changes_pending = True - - # filter loaded data by prefix - filtered_data: dict[str, str] = { - k: v for k, v in self.__data.items() if k.startswith(prefix) - } - - return LevelDB.parse_flat_dict(filtered_data) - - def set_section(self, prefix: str, data: dict[str, Any]) -> None: - """ - Sets the data of a prefixed section. This operation is performed in-memory and an - explicit call to `save()` is required to persist the changes to the database. - - Args: - prefix (str): The prefix of the section. - data (dict[str, Any]): The data to set. - """ - - raw_data: dict[str, str] = LevelDB.flatten_nested_dict(data, prefix=prefix) - self.__data.update(raw_data) - self.__changes_pending = True - - def get_key(self, key: str) -> Optional[Any]: - """ - Returns the deserialized value of a specified key. If the key is not in the - loaded in-memory data, it is attempted to load it from the database. - - Args: - key (str): The key to get. - - Returns: - Optional[Any]: The value of the key or None if the key does not exist. - """ - - if key not in self.__data: - self.load(prefix=key) - - if key in self.__data: - return json.loads(self.__data.get(key)) - - def set_key(self, key: str, value: Any) -> None: - """ - Sets the value of a specified key in the in-memory data. - - Args: - key (str): The key to set. - value (Any): The value to set. - """ - - self.__data[key] = json.dumps(value) - self.__changes_pending = True - - @staticmethod - def flatten_nested_dict( - nested_dict: dict, prefix: Optional[str] = None - ) -> dict[str, str]: - """ - This function takes a nested dictionary - and converts it back to a flat dictionary in this format: - ``` - {'key1###subkey1###subsubkey1###subsubsubkey1': 'subsubsubvalue1'} - ``` - - Args: - nested_dict (dict): The nested dictionary to flatten. - prefix (str, optional): - An optional prefix to add to each key. Defaults to None. - - Returns: - dict[str, str]: The flattened dictionary. - """ - - flat_dict: dict[str, str] = {} - - def flatten_dict_helper(dictionary: dict, _prefix: str = "") -> None: - for key, value in dictionary.items(): - if isinstance(value, dict): - flatten_dict_helper(value, _prefix + key + "###") - else: - flat_dict[(prefix or "") + _prefix + key] = json.dumps( - value, separators=(",", ":") - ) - - flatten_dict_helper(nested_dict) - - return flat_dict - - @staticmethod - def parse_flat_dict(data: dict[str, str]) -> dict: - """ - This function takes a dict in the format of - ``` - {'key1###subkey1###subsubkey1###subsubsubkey1': 'subsubsubvalue1'} - ``` - and converts it into a nested dictionary. - - Args: - data (dict[str, str]): The data to parse. - - Returns: - dict: The parsed dictionary. - """ - - result: dict = {} - - for keys, value in data.items(): - try: - keys = keys.strip().split("###") - - # Add keys and value to result - current = result - for key in keys[:-1]: - if key not in current: - current[key] = {} - current: dict[str, dict] = current[key] - value = json.loads(value) - current[keys[-1]] = value - except ValueError: - LevelDB.log.warning(f"Failed to process key: {keys:20}...") - continue - - return result - - @staticmethod - def is_db_readable(path: Path) -> bool: - """ - Checks if the level database at the specified path is readable. - - Args: - path (Path): The path to the level database. - - Returns: - bool: True if the database is readable, False otherwise. - """ - - try: - with ldb.DB(str(path)) as database: - # Attempt to read and decode the first key - for k, v in database.iterator(): - k.decode() - v.decode() - return True - - # This means the database is readable, but blocked by Vortex - except ldb.IOError: - return True - - except Exception: - pass - - return False diff --git a/src/core/utilities/progress_update.py b/src/core/utilities/progress_update.py deleted file mode 100644 index ce7f4c1..0000000 --- a/src/core/utilities/progress_update.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from dataclasses import dataclass -from enum import StrEnum -from typing import Callable, Optional, TypeAlias - - -@dataclass -class ProgressUpdate: - """ - Progress update item to be passed via QtCore.Signal. - """ - - current: int - """ - Current progress. - """ - - maximum: int - """ - Maximum progress (for eg. total file size). - """ - - status_text: Optional[str] = None - """ - Status text to be displayed, if any. - """ - - class Status(StrEnum): - """ - Various status codes that are not visible. - """ - - UserActionRequired = "user_action_required" - """ - Process cannot continue without user action. - """ - - Finished = "finished" - """ - Process is finished and item can be removed from GUI. - """ - - -ProgressCallback: TypeAlias = Callable[[ProgressUpdate], None] -""" -Function or method that takes a `ProgressUpdate` as positional argument. -""" - - -def safe_run_callback( - progress_callback: Optional[ProgressCallback], arg: ProgressUpdate -) -> None: - """ - Function to call a progress callback or do nothing if it is None. - - Args: - progress_callback (Optional[ProgressCallback]): Progress callback to call or None. - arg (ProgressUpdate): Argument to pass to progress callback. - """ - - if progress_callback is not None: - progress_callback(arg) diff --git a/src/core/utilities/reverse_dict.py b/src/core/utilities/reverse_dict.py deleted file mode 100644 index 6e86193..0000000 --- a/src/core/utilities/reverse_dict.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Copyright (c) Cutleast -""" - - -def reverse_dict[K, V](d: dict[K, V], /) -> dict[V, K]: - """ - Swaps the keys and values of a dictionary. - - Args: - d (dict[K, V]): The dictionary to reverse. - - Returns: - dict[V, K]: The reversed dictionary. - """ - - return {v: k for k, v in d.items()} diff --git a/src/core/utilities/unique.py b/src/core/utilities/unique.py deleted file mode 100644 index 9f66fa2..0000000 --- a/src/core/utilities/unique.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import Any, Callable, Iterable, Optional, TypeVar - -T = TypeVar("T") - - -def unique(iterable: Iterable[T], key: Optional[Callable[[T], Any]] = None) -> list[T]: - """ - Removes all duplicates from an iterable. - - Args: - iterable (Iterable[T]): Iterable with duplicates. - key (Optional[Callable[[T], Any]], optional): - Key function to identify unique elements. Defaults to None. - - Returns: - list[T]: List without duplicates. - """ - - if key is None: - return list({item: None for item in iterable}.keys()) - - else: - return list({key(item): item for item in iterable}.values()) diff --git a/src/ui/instance/instance_widget.py b/src/ui/instance/instance_widget.py index 2866685..df46a96 100644 --- a/src/ui/instance/instance_widget.py +++ b/src/ui/instance/instance_widget.py @@ -3,13 +3,12 @@ """ import qtawesome as qta +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.instance.tool import Tool from PySide6.QtCore import QSize from PySide6.QtWidgets import QTabWidget -from core.instance.instance import Instance -from core.instance.mod import Mod -from core.instance.tool import Tool - from .modlist_widget import ModlistWidget from .tools_widget import ToolsWidget diff --git a/src/ui/instance/modlist_menu.py b/src/ui/instance/modlist_menu.py index 3f90d06..006e651 100644 --- a/src/ui/instance/modlist_menu.py +++ b/src/ui/instance/modlist_menu.py @@ -6,10 +6,9 @@ import qtawesome as qta from cutleast_core_lib.ui.widgets.menu import Menu +from mod_manager_lib.core.instance.mod import Mod from PySide6.QtGui import QAction, QCursor, QIcon -from core.instance.mod import Mod - class ModlistMenu(Menu): """ diff --git a/src/ui/instance/modlist_widget.py b/src/ui/instance/modlist_widget.py index 63ffbfc..353d1a2 100644 --- a/src/ui/instance/modlist_widget.py +++ b/src/ui/instance/modlist_widget.py @@ -6,6 +6,7 @@ from typing import Optional from cutleast_core_lib.core.filesystem.utils import open_in_explorer +from cutleast_core_lib.core.utilities.filter import matches_filter from cutleast_core_lib.core.utilities.scale import scale_value from cutleast_core_lib.ui.utilities.icon_provider import IconProvider from cutleast_core_lib.ui.utilities.tree_widget import ( @@ -13,6 +14,8 @@ iter_toplevel_items, ) from cutleast_core_lib.ui.widgets.search_bar import SearchBar +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.mod import Mod from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QFont from PySide6.QtWidgets import ( @@ -27,9 +30,6 @@ QWidget, ) -from core.instance.instance import Instance -from core.instance.mod import Mod -from core.utilities.filter import matches_filter from ui.instance.modlist_menu import ModlistMenu diff --git a/src/ui/instance/tools_menu.py b/src/ui/instance/tools_menu.py index 41f17b9..986c38e 100644 --- a/src/ui/instance/tools_menu.py +++ b/src/ui/instance/tools_menu.py @@ -6,10 +6,9 @@ import qtawesome as qta from cutleast_core_lib.ui.widgets.menu import Menu +from mod_manager_lib.core.instance.tool import Tool from PySide6.QtGui import QAction, QCursor, QIcon -from core.instance.tool import Tool - class ToolsMenu(Menu): """ diff --git a/src/ui/instance/tools_widget.py b/src/ui/instance/tools_widget.py index a0a30e1..4788426 100644 --- a/src/ui/instance/tools_widget.py +++ b/src/ui/instance/tools_widget.py @@ -7,8 +7,11 @@ from typing import Optional from cutleast_core_lib.core.filesystem.utils import open_in_explorer +from cutleast_core_lib.core.utilities.filter import matches_filter from cutleast_core_lib.ui.utilities.tree_widget import iter_toplevel_items from cutleast_core_lib.ui.widgets.search_bar import SearchBar +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.tool import Tool from PySide6.QtCore import Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( @@ -22,10 +25,6 @@ QWidget, ) -from core.instance.instance import Instance -from core.instance.tool import Tool -from core.utilities.filter import matches_filter - from .tools_menu import ToolsMenu diff --git a/src/ui/main_widget.py b/src/ui/main_widget.py index 3fc8a5d..250a31c 100644 --- a/src/ui/main_widget.py +++ b/src/ui/main_widget.py @@ -4,18 +4,19 @@ from typing import Optional -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog +from cutleast_core_lib.ui.widgets.progress_dialog import ProgressDialog +from mod_manager_lib.core.game import Game +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.mod_manager.instance_info import InstanceInfo +from mod_manager_lib.core.mod_manager.mod_manager import ModManager +from mod_manager_lib.core.mod_manager.mod_manager_api import ModManagerApi from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QMessageBox, QSplitter from core.config.app_config import AppConfig -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.mod import Mod from core.migrator.migration_report import MigrationReport from core.migrator.migrator import Migrator -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager from ui.instance.instance_widget import InstanceWidget from ui.migrator.migration_report_dialog import MigrationReportDialog from ui.migrator.migrator_widget import MigratorWidget @@ -92,7 +93,10 @@ def migrate(self) -> None: if src_info is None or src_instance is None: raise ValueError("No source instance selected!") - if dst_mod_manager.is_instance_existing(dst_info): + src_mod_manager_api: ModManagerApi = src_mod_manager.get_api() + dst_mod_manager_api: ModManagerApi = dst_mod_manager.get_api() + + if dst_mod_manager_api.is_instance_existing(dst_info): reply = QMessageBox.question( QApplication.activeModalWidget(), self.tr("Destination instance already exists!"), @@ -112,22 +116,24 @@ def migrate(self) -> None: src_instance, self.__instance_widget.checked_mods ) - report: MigrationReport = LoadingDialog.run_callable( - QApplication.activeModalWidget(), - lambda ldialog: Migrator().migrate( + migrator = Migrator() + + report: MigrationReport = ProgressDialog( + lambda pdialog: migrator.migrate( src_instance=src_instance, src_info=src_info, dst_info=dst_info, - src_mod_manager=src_mod_manager, - dst_mod_manager=dst_mod_manager, + src_mod_manager=src_mod_manager_api, + dst_mod_manager=dst_mod_manager_api, use_hardlinks=self.app_config.use_hardlinks, replace=self.app_config.replace_when_merge, modname_limit=self.app_config.modname_limit, activate_new_instance=self.app_config.activate_new_instance, included_tools=self.__instance_widget.checked_tools, - ldialog=ldialog, + pdialog=pdialog, ), - ) + QApplication.activeModalWidget(), + ).run() if report.has_errors: QMessageBox.warning( @@ -137,7 +143,7 @@ def migrate(self) -> None: self.tr( "Migration completed with errors! Click 'Ok' to open the report.\n\n" ) - + dst_mod_manager.get_completed_message(dst_info) + + migrator.get_completed_message(src_info, dst_info) ).strip(), QMessageBox.StandardButton.Ok, ) @@ -149,7 +155,7 @@ def migrate(self) -> None: self.tr("Migration Complete"), ( self.tr("Migration completed successfully!\n\n") - + dst_mod_manager.get_completed_message(dst_info) + + migrator.get_completed_message(src_info, dst_info) ).strip(), QMessageBox.StandardButton.Ok, ) diff --git a/src/ui/migrator/instance_creator/__init__.py b/src/ui/migrator/instance_creator/__init__.py deleted file mode 100644 index a4fd128..0000000 --- a/src/ui/migrator/instance_creator/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from .base_creator_widget import BaseCreatorWidget -from .modorganizer_creator_widget import ModOrganizerCreatorWidget -from .vortex_creator_widget import VortexCreatorWidget - -INSTANCE_WIDGETS: list[type[BaseCreatorWidget]] = [ - VortexCreatorWidget, - ModOrganizerCreatorWidget, -] -""" -List of available instance widgets. -""" diff --git a/src/ui/migrator/instance_creator/base_creator_widget.py b/src/ui/migrator/instance_creator/base_creator_widget.py deleted file mode 100644 index bc4f0af..0000000 --- a/src/ui/migrator/instance_creator/base_creator_widget.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import logging -from abc import abstractmethod - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QWidget - -from core.game.game import Game -from core.mod_manager.instance_info import InstanceInfo - - -class BaseCreatorWidget[I: InstanceInfo](QWidget): - """ - Base class for customizing an instance for a preselected mod manager. - """ - - valid = Signal(bool) - """ - This signal gets emitted when the validation of the customized instance changes. - """ - - log: logging.Logger - - def __init__(self) -> None: - super().__init__() - - self.log = logging.getLogger(self.__class__.__name__) - - self._init_ui() - - @staticmethod - @abstractmethod - def get_id() -> str: - """ - Returns: - str: The internal id of the corresponding mod manager. - """ - - @abstractmethod - def _init_ui(self) -> None: ... - - @abstractmethod - def validate(self) -> bool: - """ - Validates the customized instance data. - - Returns: - bool: `True` if the customized instance is valid, `False` otherwise - """ - - @abstractmethod - def get_instance(self, game: Game) -> I: - """ - Gets the data for the customized instance. - - Args: - game (Game): The game of the instance - - Returns: - I: The data for the customized instance - """ diff --git a/src/ui/migrator/instance_creator/instance_creator_widget.py b/src/ui/migrator/instance_creator/instance_creator_widget.py deleted file mode 100644 index 73aeea6..0000000 --- a/src/ui/migrator/instance_creator/instance_creator_widget.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import Optional, override - -from PySide6.QtCore import QEvent, QObject, Qt, Signal -from PySide6.QtGui import QIcon, QWheelEvent -from PySide6.QtWidgets import ( - QComboBox, - QGridLayout, - QLabel, - QSpinBox, - QStackedLayout, - QVBoxLayout, - QWidget, -) - -from core.game.game import Game -from core.mod_manager import MOD_MANAGERS -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager - -from . import INSTANCE_WIDGETS -from .base_creator_widget import BaseCreatorWidget - - -class InstanceCreatorWidget(QWidget): - """ - Widget for creating and customizing the destination instance. - """ - - instance_valid = Signal(bool) - """ - This signal is emitted when the instance is valid. - """ - - __sel_mod_manager: Optional[ModManager] = None - """ - Selected destination mod manager. - """ - - __mod_managers: dict[ModManager, BaseCreatorWidget] - """ - Maps mod managers to their corresponding instance widgets. - """ - - __vlayout: QVBoxLayout - __mod_manager_dropdown: QComboBox - __instance_stack_layout: QStackedLayout - __placeholder_widget: QWidget - - def __init__(self) -> None: - super().__init__() - - self.__init_ui() - - def __init_ui(self) -> None: - self.setObjectName("secondary") - - self.__vlayout = QVBoxLayout() - self.__vlayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__vlayout) - - self.__init_header() - self.__init_instance_widgets() - - def __init_header(self) -> None: - glayout = QGridLayout() - glayout.setContentsMargins(0, 0, 0, 0) - glayout.setColumnStretch(0, 1) - glayout.setColumnStretch(1, 3) - self.__vlayout.addLayout(glayout) - - mod_manager_label = QLabel(self.tr("Mod Manager:")) - glayout.addWidget(mod_manager_label, 0, 0) - - self.__mod_manager_dropdown = QComboBox() - self.__mod_manager_dropdown.installEventFilter(self) - self.__mod_manager_dropdown.setEditable(False) - self.__mod_manager_dropdown.addItem(self.tr("Please select...")) - self.__mod_manager_dropdown.addItems( - [mod_manager.get_display_name() for mod_manager in MOD_MANAGERS] - ) - for m, mod_manager in enumerate(MOD_MANAGERS, start=1): - self.__mod_manager_dropdown.setItemIcon( - m, QIcon(mod_manager.get_icon_name()) - ) - - self.__mod_manager_dropdown.currentTextChanged.connect( - self.__on_mod_manager_select - ) - glayout.addWidget(self.__mod_manager_dropdown, 0, 1) - - def __init_instance_widgets(self) -> None: - self.__instance_stack_layout = QStackedLayout() - self.__instance_stack_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.__placeholder_widget = QWidget() - self.__instance_stack_layout.addWidget(self.__placeholder_widget) - self.__vlayout.addLayout(self.__instance_stack_layout) - - self.__mod_managers = {} - mod_manager_ids: dict[str, type[ModManager]] = { - mod_manager.get_id(): mod_manager for mod_manager in MOD_MANAGERS - } - - for instance_widget_type in INSTANCE_WIDGETS: - mod_manager: ModManager = mod_manager_ids[instance_widget_type.get_id()]() - - instance_widget: BaseCreatorWidget = instance_widget_type() - instance_widget.valid.connect(self.instance_valid.emit) - - self.__instance_stack_layout.addWidget(instance_widget) - self.__mod_managers[mod_manager] = instance_widget - - def __on_mod_manager_select(self, value: str) -> None: - mod_manager_names: dict[str, ModManager] = { - mod_manager.get_display_name(): mod_manager - for mod_manager in self.__mod_managers.keys() - } - - selected_mod_manager: Optional[ModManager] = mod_manager_names.get(value) - - self.__set_cur_mod_manager(selected_mod_manager) - - def __set_cur_mod_manager(self, mod_manager: Optional[ModManager]) -> None: - if mod_manager is not None: - instance_widget: BaseCreatorWidget = self.__mod_managers[mod_manager] - self.__instance_stack_layout.setCurrentWidget(instance_widget) - self.instance_valid.emit(instance_widget.validate()) - else: - self.__instance_stack_layout.setCurrentWidget(self.__placeholder_widget) - self.instance_valid.emit(False) - - self.__sel_mod_manager = mod_manager - - def get_selected_mod_manager(self) -> Optional[ModManager]: - """ - Returns the currently selected mod manager. - - Returns: - Optional[ModManager]: The selected mod manager. - """ - - return self.__sel_mod_manager - - def validate(self) -> bool: - """ - Returns whether the currently selected instance data is valid. - - Returns: - bool: whether the currently selected instance data is valid - """ - - if self.__sel_mod_manager is not None: - instance_widget: BaseCreatorWidget = self.__mod_managers[ - self.__sel_mod_manager - ] - return instance_widget.validate() - - return False - - def get_instance_data(self, game: Game) -> InstanceInfo: - """ - Returns the customized destination instance data. - - Args: - game (Game): The selected game. - - Raises: - ValueError: - when no mod manager is selected or the customized instance is invalid. - - Returns: - InstanceData: The customized destination instance data. - """ - - mod_manager: Optional[ModManager] = self.get_selected_mod_manager() - - if mod_manager is None: - raise ValueError("No mod manager selected!") - - instance_widget: BaseCreatorWidget = self.__mod_managers[mod_manager] - - if not instance_widget.validate(): - raise ValueError("Customized instance data is invalid!") - - return instance_widget.get_instance(game) - - @override - def eventFilter(self, source: QObject, event: QEvent) -> bool: - if ( - event.type() == QEvent.Type.Wheel - and (isinstance(source, QComboBox) or isinstance(source, QSpinBox)) - and isinstance(event, QWheelEvent) - ): - self.wheelEvent(event) - return True - - return super().eventFilter(source, event) diff --git a/src/ui/migrator/instance_creator/modorganizer_creator_widget.py b/src/ui/migrator/instance_creator/modorganizer_creator_widget.py deleted file mode 100644 index 8a524f4..0000000 --- a/src/ui/migrator/instance_creator/modorganizer_creator_widget.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path -from typing import override - -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.ui.widgets.browse_edit import BrowseLineEdit -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QCheckBox, - QFileDialog, - QGridLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QRadioButton, -) - -from core.game.game import Game -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer - -from .base_creator_widget import BaseCreatorWidget - - -class ModOrganizerCreatorWidget(BaseCreatorWidget[MO2InstanceInfo]): - """ - Class for creating and customizing ModOrganizer instances. - """ - - __glayout: QGridLayout - __instance_name_entry: QLineEdit - __use_portable: QRadioButton - __use_global: QRadioButton - __instance_path_entry: BrowseLineEdit - __mods_path_entry: BrowseLineEdit - __install_mo2: QCheckBox - __use_root_builder: QCheckBox - - @override - @staticmethod - def get_id() -> str: - return ModOrganizer.get_id() - - @override - def _init_ui(self) -> None: - self.__glayout = QGridLayout() - self.__glayout.setContentsMargins(0, 0, 0, 0) - self.__glayout.setColumnStretch(0, 1) - self.__glayout.setColumnStretch(1, 3) - self.__glayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__glayout) - - instance_name_label = QLabel(self.tr("Instance name:")) - self.__glayout.addWidget(instance_name_label, 0, 0) - self.__instance_name_entry = QLineEdit() - self.__instance_name_entry.setPlaceholderText( - self.tr("eg. My Migrated Instance") - ) - self.__instance_name_entry.textChanged.connect( - lambda _: self.valid.emit(self.validate()) - ) - self.__instance_name_entry.textChanged.connect(self.__on_name_change) - self.__glayout.addWidget(self.__instance_name_entry, 0, 1) - - instance_type_label = QLabel(self.tr("Instance type:")) - self.__glayout.addWidget(instance_type_label, 3, 0) - - hlayout = QHBoxLayout() - hlayout.setContentsMargins(0, 0, 0, 0) - self.__glayout.addLayout(hlayout, 3, 1) - - self.__use_portable = QRadioButton(self.tr("Portable instance")) - self.__use_portable.toggled.connect(lambda _: self.valid.emit(self.validate())) - hlayout.addWidget(self.__use_portable) - - self.__use_global = QRadioButton(self.tr("Global instance")) - self.__use_global.toggled.connect(lambda _: self.valid.emit(self.validate())) - self.__use_global.toggled.connect(self.__on_global_toggled) - hlayout.addWidget(self.__use_global) - - instance_path_label = QLabel(self.tr("Instance path:")) - self.__glayout.addWidget(instance_path_label, 1, 0) - self.__instance_path_entry = BrowseLineEdit() - self.__instance_path_entry.setPlaceholderText( - self.tr("eg. C:\\Modding\\My Migrated Instance") - ) - self.__instance_path_entry.setFileMode(QFileDialog.FileMode.Directory) - self.__instance_path_entry.pathChanged.connect( - lambda _: self.valid.emit(self.validate()) - ) - self.__instance_path_entry.pathChanged.connect(self.__on_path_change) - self.__glayout.addWidget(self.__instance_path_entry, 1, 1) - - mods_path_label = QLabel(self.tr("Mods path:")) - self.__glayout.addWidget(mods_path_label, 2, 0) - self.__mods_path_entry = BrowseLineEdit() - self.__mods_path_entry.setPlaceholderText( - self.tr("eg. C:\\Modding\\My Migrated Instance\\mods") - ) - self.__mods_path_entry.setFileMode(QFileDialog.FileMode.Directory) - self.__mods_path_entry.pathChanged.connect( - lambda _: self.valid.emit(self.validate()) - ) - self.__glayout.addWidget(self.__mods_path_entry, 2, 1) - - install_mo2_label = QLabel(self.tr("Install Mod Organizer 2:")) - self.__glayout.addWidget(install_mo2_label, 4, 0) - - self.__install_mo2 = QCheckBox() - self.__install_mo2.toggled.connect(lambda _: self.valid.emit(self.validate())) - self.__glayout.addWidget(self.__install_mo2, 4, 1) - - use_root_builder_label = QLabel(self.tr("Use Root Builder plugin:")) - self.__glayout.addWidget(use_root_builder_label, 5, 0) - self.__use_root_builder = QCheckBox() - self.__use_root_builder.setToolTip( - self.tr( - "If enabled, mod files for the game's root folder will be moved to a " - '"Root" subfolder in their mod instead of copied to the game\'s root folder.' - ) - ) - self.__use_root_builder.toggled.connect( - lambda _: self.valid.emit(self.validate()) - ) - self.__glayout.addWidget(self.__use_root_builder, 5, 1) - - self.__use_global.setChecked(True) - - def __on_name_change(self, new_name: str) -> None: - if ( - self.__instance_path_entry.text().strip() - and not self.__use_global.isChecked() - ): - instance_path = Path(self.__instance_path_entry.text()) - - try: - self.__instance_path_entry.setPath(instance_path.parent / new_name) - except Exception as ex: - self.log.warning("Failed to update instance path!", exc_info=ex) - - elif self.__use_global.isChecked(): - self.__instance_path_entry.setPath( - resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" / new_name - ) - - def __on_path_change(self, old_path: Path, new_path: Path) -> None: - if new_path != Path(): - if not self.__use_global.isChecked(): - self.__instance_name_entry.setText(new_path.name) - - if old_path != Path() and self.__mods_path_entry.getPath() != Path(): - old_instance_path = Path(old_path) - mods_path = Path(self.__mods_path_entry.text()) - - if mods_path.is_relative_to(old_instance_path): - try: - self.__mods_path_entry.setPath( - new_path / mods_path.relative_to(old_instance_path) - ) - except Exception as ex: - self.log.warning("Failed to update mods path!", exc_info=ex) - - elif self.__mods_path_entry.getPath() == Path(): - self.__mods_path_entry.setPath(new_path / "mods") - - def __on_global_toggled(self, checked: bool) -> None: - if checked: - self.__instance_path_entry.setDisabled(True) - instance_path: Path = ( - resolve(Path("%LOCALAPPDATA%")) - / "ModOrganizer" - / self.__instance_name_entry.text() - ) - self.__instance_path_entry.setPath(instance_path) - self.__mods_path_entry.setPath(instance_path / "mods") - - self.__install_mo2.setChecked(False) - else: - self.__instance_path_entry.setText("") - self.__mods_path_entry.setText("") - - self.__install_mo2.setDisabled(checked) - self.__instance_path_entry.setDisabled(checked) - - @override - def validate(self) -> bool: - valid: bool = True - - if not self.__instance_name_entry.text().strip(): - valid = False - - instance_path = self.__instance_path_entry.getPath() - if instance_path == Path() or ( - # Only check for parent path for portable instances - not instance_path.parent.is_dir() and not self.__use_global.isChecked() - ): - valid = False - - mods_path = self.__mods_path_entry.getPath() - if mods_path == Path() or ( - # Only check for parent path for portable instances - (not mods_path.parent.is_dir() and not self.__use_global.isChecked()) - and not mods_path.is_relative_to(instance_path) - ): - valid = False - - if self.__use_global.isChecked() and self.__install_mo2.isChecked(): - valid = False - - return valid - - @override - def get_instance(self, game: Game) -> MO2InstanceInfo: - mo2_instance = MO2InstanceInfo( - display_name=self.__instance_name_entry.text(), - game=game, - profile="Default", - is_global=self.__use_global.isChecked(), - base_folder=self.__instance_path_entry.getPath(), - mods_folder=self.__mods_path_entry.getPath(), - profiles_folder=Path(self.__instance_path_entry.text()) / "profiles", - install_mo2=self.__install_mo2.isChecked(), - use_root_builder=self.__use_root_builder.isChecked(), - ) - - return mo2_instance diff --git a/src/ui/migrator/instance_creator/vortex_creator_widget.py b/src/ui/migrator/instance_creator/vortex_creator_widget.py deleted file mode 100644 index 07810d7..0000000 --- a/src/ui/migrator/instance_creator/vortex_creator_widget.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import override - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QGridLayout, QLabel, QLineEdit - -from core.game.game import Game -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.mod_manager.vortex.vortex import Vortex - -from .base_creator_widget import BaseCreatorWidget - - -class VortexCreatorWidget(BaseCreatorWidget[ProfileInfo]): - """ - Class for creating and customizing Vortex profiles. - """ - - __glayout: QGridLayout - __profile_name_entry: QLineEdit - - @override - @staticmethod - def get_id() -> str: - return Vortex.get_id() - - @override - def _init_ui(self) -> None: - self.__glayout = QGridLayout() - self.__glayout.setContentsMargins(0, 0, 0, 0) - self.__glayout.setColumnStretch(0, 1) - self.__glayout.setColumnStretch(1, 3) - self.__glayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__glayout) - - profile_name_label = QLabel(self.tr("Profile name:")) - self.__glayout.addWidget(profile_name_label, 0, 0) - - self.__profile_name_entry = QLineEdit() - self.__profile_name_entry.textChanged.connect( - lambda _: self.valid.emit(self.validate()) - ) - self.__glayout.addWidget(self.__profile_name_entry, 0, 1) - - @override - def validate(self) -> bool: - return bool(self.__profile_name_entry.text().strip()) - - @override - def get_instance(self, game: Game) -> ProfileInfo: - profile = ProfileInfo( - display_name=self.__profile_name_entry.text(), - game=game, - id=Vortex.generate_id(), - ) - - return profile diff --git a/src/ui/migrator/instance_selector/__init__.py b/src/ui/migrator/instance_selector/__init__.py deleted file mode 100644 index 8cfdd2b..0000000 --- a/src/ui/migrator/instance_selector/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from .base_selector_widget import BaseSelectorWidget -from .modorganizer_selector_widget import ModOrganizerSelectorWidget -from .vortex_selector_widget import VortexSelectorWidget - -INSTANCE_WIDGETS: list[type[BaseSelectorWidget]] = [ - VortexSelectorWidget, - ModOrganizerSelectorWidget, -] -""" -List of available instance widgets. -""" diff --git a/src/ui/migrator/instance_selector/base_selector_widget.py b/src/ui/migrator/instance_selector/base_selector_widget.py deleted file mode 100644 index 78af848..0000000 --- a/src/ui/migrator/instance_selector/base_selector_widget.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from abc import abstractmethod -from typing import override - -from PySide6.QtCore import QEvent, QObject, Signal -from PySide6.QtGui import QWheelEvent -from PySide6.QtWidgets import QComboBox, QSpinBox, QWidget - -from core.game.game import Game -from core.mod_manager.instance_info import InstanceInfo - - -class BaseSelectorWidget[I: InstanceInfo](QWidget): - """ - Base class for selecting instances from a preselected mod manager. - """ - - _instance_names: list[str] - """ - List of possible instance names. - """ - - changed = Signal() - """ - This signal gets emitted everytime the selected instance changes. - """ - - valid = Signal(bool) - """ - This signal gets emitted when the validation of the selected instance changes. - """ - - def __init__(self, instance_names: list[str] = []) -> None: - super().__init__() - - self._instance_names = instance_names - - self._init_ui() - - self.changed.connect(self.__on_change) - - @staticmethod - @abstractmethod - def get_id() -> str: - """ - Returns: - str: The internal id of the corresponding mod manager - """ - - @abstractmethod - def _init_ui(self) -> None: ... - - def __on_change(self) -> None: - self.valid.emit(self.validate()) - - @abstractmethod - def _update(self) -> None: ... - - def set_instances(self, instance_names: list[str]) -> None: - """ - Sets the list of possible instances. - - Args: - instance_names (list[str]): The list of possible instances - """ - - self._instance_names = instance_names - - self._update() - - @abstractmethod - def validate(self) -> bool: - """ - Validates the selected instance. - - Returns: - bool: `True` if the selected instance is valid, `False` otherwise - """ - - @abstractmethod - def get_instance(self, game: Game) -> InstanceInfo: - """ - Returns the data for the selected instance. - - Returns: - InstanceInfo: The data for the selected instance - """ - - @abstractmethod - def reset(self) -> None: - """ - Resets the user selection. - """ - - @override - def eventFilter(self, source: QObject, event: QEvent) -> bool: - if ( - event.type() == QEvent.Type.Wheel - and (isinstance(source, QComboBox) or isinstance(source, QSpinBox)) - and isinstance(event, QWheelEvent) - ): - self.wheelEvent(event) - return True - - return super().eventFilter(source, event) diff --git a/src/ui/migrator/instance_selector/instance_selector_widget.py b/src/ui/migrator/instance_selector/instance_selector_widget.py deleted file mode 100644 index 0e78823..0000000 --- a/src/ui/migrator/instance_selector/instance_selector_widget.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from typing import Optional, override - -from PySide6.QtCore import QEvent, QObject, Qt, Signal -from PySide6.QtGui import QIcon, QWheelEvent -from PySide6.QtWidgets import ( - QComboBox, - QGridLayout, - QLabel, - QSpinBox, - QStackedLayout, - QVBoxLayout, - QWidget, -) - -from core.game.game import Game -from core.mod_manager import MOD_MANAGERS -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager - -from . import INSTANCE_WIDGETS -from .base_selector_widget import BaseSelectorWidget - - -class InstanceSelectorWidget(QWidget): - """ - Widget for selecting the source instance. - """ - - instance_valid = Signal(bool) - """ - This signal is emitted when the validation status of the selected instance changes. - """ - - changed = Signal() - """ - This signal is emitted everytime the user changes something at the selection. - """ - - __cur_game: Optional[Game] = None - """ - Currently selected game. - """ - - __cur_instance_data: Optional[InstanceInfo] = None - """ - Currently selected instance data. - """ - - __cur_mod_manager: Optional[ModManager] = None - """ - Currently selected mod manager. - """ - - __mod_managers: dict[ModManager, BaseSelectorWidget] - """ - Maps mod managers to their corresponding instance widgets. - """ - - __vlayout: QVBoxLayout - __mod_manager_dropdown: QComboBox - __instance_stack_layout: QStackedLayout - __placeholder_widget: QWidget - - def __init__(self) -> None: - super().__init__() - - self.__init_ui() - - def __init_ui(self) -> None: - self.setObjectName("secondary") - - self.__vlayout = QVBoxLayout() - self.__vlayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__vlayout) - - self.__init_header() - self.__init_instance_widgets() - - def __init_header(self) -> None: - glayout = QGridLayout() - glayout.setContentsMargins(0, 0, 0, 0) - glayout.setColumnStretch(0, 1) - glayout.setColumnStretch(1, 3) - self.__vlayout.addLayout(glayout) - - mod_manager_label = QLabel(self.tr("Mod Manager:")) - glayout.addWidget(mod_manager_label, 1, 0) - - self.__mod_manager_dropdown = QComboBox() - self.__mod_manager_dropdown.installEventFilter(self) - self.__mod_manager_dropdown.setEditable(False) - self.__mod_manager_dropdown.addItem(self.tr("Please select...")) - self.__mod_manager_dropdown.addItems( - [mod_manager.get_display_name() for mod_manager in MOD_MANAGERS] - ) - for m, mod_manager in enumerate(MOD_MANAGERS, start=1): - self.__mod_manager_dropdown.setItemIcon( - m, QIcon(mod_manager.get_icon_name()) - ) - self.__mod_manager_dropdown.currentTextChanged.connect( - self.__on_mod_manager_select - ) - glayout.addWidget(self.__mod_manager_dropdown, 1, 1) - - def __init_instance_widgets(self) -> None: - self.__instance_stack_layout = QStackedLayout() - self.__instance_stack_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.__placeholder_widget = QWidget() - self.__instance_stack_layout.addWidget(self.__placeholder_widget) - self.__vlayout.addLayout(self.__instance_stack_layout) - - self.__mod_managers = {} - mod_manager_ids: dict[str, type[ModManager]] = { - mod_manager.get_id(): mod_manager for mod_manager in MOD_MANAGERS - } - - for instance_widget_type in INSTANCE_WIDGETS: - mod_manager: ModManager = mod_manager_ids[instance_widget_type.get_id()]() - instance_widget: BaseSelectorWidget = instance_widget_type() - - instance_widget.changed.connect(self.changed.emit) - instance_widget.valid.connect(self.__on_valid) - - self.__instance_stack_layout.addWidget(instance_widget) - self.__mod_managers[mod_manager] = instance_widget - - def set_cur_game(self, game: Optional[Game]) -> None: - """ - Sets the current game of the instance selector widget. - - Args: - game (Optional[Game]): The game - """ - - self.__cur_game = game - - # Reset selected instance - self.__mod_manager_dropdown.setCurrentIndex(0) - self.__cur_mod_manager = None - self.__cur_instance_data = None - - def __on_mod_manager_select(self, value: str) -> None: - mod_manager_names: dict[str, ModManager] = { - mod_manager.get_display_name(): mod_manager - for mod_manager in self.__mod_managers.keys() - } - - selected_mod_manager: Optional[ModManager] = mod_manager_names.get(value) - - self.__set_cur_mod_manager(selected_mod_manager) - - def __set_cur_mod_manager(self, mod_manager: Optional[ModManager]) -> None: - if mod_manager is not None: - game: Optional[Game] = self.__cur_game - - if game is None: - raise ValueError("No game selected.") - - instance_widget: BaseSelectorWidget = self.__mod_managers[mod_manager] - instance_widget.set_instances(mod_manager.get_instance_names(game)) - self.__instance_stack_layout.setCurrentWidget(instance_widget) - self.__on_valid(instance_widget.validate()) - else: - cur_widget: QWidget = self.__instance_stack_layout.currentWidget() - if isinstance(cur_widget, BaseSelectorWidget): - cur_widget.reset() - self.__instance_stack_layout.setCurrentWidget(self.__placeholder_widget) - self.__on_valid(False) - - self.__cur_mod_manager = mod_manager - self.changed.emit() - - def __on_valid(self, valid: bool) -> None: - if valid and self.__cur_mod_manager is not None and self.__cur_game is not None: - instance_widget: BaseSelectorWidget = self.__mod_managers[ - self.__cur_mod_manager - ] - if instance_widget.validate(): - self.__cur_instance_data = instance_widget.get_instance(self.__cur_game) - else: - self.__cur_instance_data = None - else: - self.__cur_instance_data = None - - self.instance_valid.emit(self.__cur_instance_data is not None) - - def validate(self) -> bool: - """ - Returns whether the currently selected instance data is valid. - - Returns: - bool: whether the currently selected instance data is valid - """ - - if self.__cur_mod_manager is not None and self.__cur_game is not None: - instance_widget: BaseSelectorWidget = self.__mod_managers[ - self.__cur_mod_manager - ] - return instance_widget.validate() - - return False - - def get_cur_instance_data(self) -> InstanceInfo: - """ - Returns the currently selected instance data. - - Raises: - ValueError: when no instance data is selected. - - Returns: - InstanceInfo: The instance data. - """ - - if self.__cur_instance_data is None: - raise ValueError("No instance data selected.") - - return self.__cur_instance_data - - def get_selected_mod_manager(self) -> Optional[ModManager]: - """ - Returns the currently selected mod manager. - - Returns: - Optional[ModManager]: The selected mod manager or None. - """ - - return self.__cur_mod_manager - - @override - def eventFilter(self, source: QObject, event: QEvent) -> bool: - if ( - event.type() == QEvent.Type.Wheel - and (isinstance(source, QComboBox) or isinstance(source, QSpinBox)) - and isinstance(event, QWheelEvent) - ): - self.wheelEvent(event) - return True - - return super().eventFilter(source, event) diff --git a/src/ui/migrator/instance_selector/modorganizer_selector_widget.py b/src/ui/migrator/instance_selector/modorganizer_selector_widget.py deleted file mode 100644 index 782d095..0000000 --- a/src/ui/migrator/instance_selector/modorganizer_selector_widget.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path -from typing import override - -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.ui.widgets.browse_edit import BrowseLineEdit -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QFileDialog, QGridLayout, QLabel - -from core.game.game import Game -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer - -from .base_selector_widget import BaseSelectorWidget - - -class ModOrganizerSelectorWidget(BaseSelectorWidget[MO2InstanceInfo]): - """ - Class for selecting instances from Mod Organizer 2. - """ - - __instance_dropdown: QComboBox - __portable_path_entry: BrowseLineEdit - __profile_dropdown: QComboBox - __glayout: QGridLayout - - @override - @staticmethod - def get_id() -> str: - return ModOrganizer.get_id() - - @override - def _init_ui(self) -> None: - self.__glayout = QGridLayout() - self.__glayout.setContentsMargins(0, 0, 0, 0) - self.__glayout.setColumnStretch(0, 1) - self.__glayout.setColumnStretch(1, 3) - self.__glayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__glayout) - - instance_label = QLabel(self.tr("Instance:")) - self.__glayout.addWidget(instance_label, 0, 0) - - self.__instance_dropdown = QComboBox() - self.__instance_dropdown.installEventFilter(self) - self.__instance_dropdown.addItem(self.tr("Please select...")) - self.__instance_dropdown.addItems(self._instance_names) - self.__instance_dropdown.addItem("Portable") - self.__instance_dropdown.currentTextChanged.connect( - lambda _: self.changed.emit() - ) - self.__instance_dropdown.currentTextChanged.connect( - lambda _: self.__update_profile_dropdown() - ) - self.__glayout.addWidget(self.__instance_dropdown, 0, 1) - - portable_path_label = QLabel(self.tr("Portable path:")) - self.__glayout.addWidget(portable_path_label, 1, 0) - - self.__portable_path_entry = BrowseLineEdit() - self.__portable_path_entry.setFileMode(QFileDialog.FileMode.Directory) - self.__portable_path_entry.textChanged.connect(lambda _: self.changed.emit()) - self.__portable_path_entry.textChanged.connect( - lambda _: self.__update_profile_dropdown() - ) - self.__portable_path_entry.setDisabled(True) - self.__glayout.addWidget(self.__portable_path_entry, 1, 1) - - profile_label = QLabel(self.tr("Profile:")) - self.__glayout.addWidget(profile_label, 2, 0) - - self.__profile_dropdown = QComboBox() - self.__profile_dropdown.installEventFilter(self) - self.__profile_dropdown.addItem(self.tr("Please select...")) - self.__profile_dropdown.currentTextChanged.connect( - lambda _: self.changed.emit() - ) - self.__profile_dropdown.setDisabled(True) - self.__glayout.addWidget(self.__profile_dropdown, 2, 1) - - @override - def _update(self) -> None: - self.__instance_dropdown.clear() - self.__instance_dropdown.addItem(self.tr("Please select...")) - self.__instance_dropdown.addItems(self._instance_names) - self.__instance_dropdown.addItem("Portable") - self.__update_profile_dropdown() - - def __update_profile_dropdown(self) -> None: - instance_name: str = self.__instance_dropdown.currentText() - is_global: bool = instance_name != "Portable" - - self.__portable_path_entry.setDisabled(is_global) - - instance_path: Path - if is_global: - instance_path = ( - resolve(Path("%LOCALAPPDATA%") / "ModOrganizer") / instance_name - ) - else: - instance_path = Path(self.__portable_path_entry.text()) - - mo2_ini_path: Path = instance_path / "ModOrganizer.ini" - - self.__profile_dropdown.clear() - self.__profile_dropdown.addItem(self.tr("Please select...")) - if mo2_ini_path.is_file(): - self.__profile_dropdown.addItems( - ModOrganizer.get_profile_names(instance_path / "ModOrganizer.ini") - ) - self.__profile_dropdown.setEnabled(self.__profile_dropdown.count() > 1) - self.changed.emit() - - @override - def validate(self) -> bool: - valid: bool = ( - 0 - < self.__instance_dropdown.currentIndex() - < (self.__instance_dropdown.count() - 1) - or ( - self.__instance_dropdown.currentText() == "Portable" - and Path( - self.__portable_path_entry.text() + "/ModOrganizer.ini" - ).is_file() - ) - ) and self.__profile_dropdown.currentIndex() != 0 - - return valid - - @override - def get_instance(self, game: Game) -> MO2InstanceInfo: - instance_name: str = self.__instance_dropdown.currentText() - is_global: bool = instance_name != "Portable" - profile_name: str = self.__profile_dropdown.currentText() - - instance_path: Path - if is_global: - instance_path = ( - resolve(Path("%LOCALAPPDATA%") / "ModOrganizer") / instance_name - ) - else: - instance_path = Path(self.__portable_path_entry.text()) - - if not (instance_path / "ModOrganizer.ini").is_file(): - raise ValueError( - f"Invalid instance: {instance_path!r}! Please select a valid MO2 instance!" - ) - - instance_ini_path: Path = instance_path / "ModOrganizer.ini" - mods_folder: Path = ModOrganizer.get_mods_folder(instance_ini_path) - profiles_folder: Path = ModOrganizer.get_profiles_folder(instance_ini_path) - - return MO2InstanceInfo( - display_name=instance_name, - game=game, - profile=profile_name, - is_global=is_global, - base_folder=instance_path, - mods_folder=mods_folder, - profiles_folder=profiles_folder, - ) - - @override - def reset(self) -> None: - self.__instance_dropdown.setCurrentIndex(0) - self.__portable_path_entry.setText("") - self.__profile_dropdown.setCurrentIndex(0) diff --git a/src/ui/migrator/instance_selector/vortex_selector_widget.py b/src/ui/migrator/instance_selector/vortex_selector_widget.py deleted file mode 100644 index 8ca0bf8..0000000 --- a/src/ui/migrator/instance_selector/vortex_selector_widget.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import re -from typing import Optional, override - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QGridLayout, QLabel - -from core.game.game import Game -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.mod_manager.vortex.vortex import Vortex - -from .base_selector_widget import BaseSelectorWidget - - -class VortexSelectorWidget(BaseSelectorWidget[ProfileInfo]): - """ - Class for selecting profiles from Vortex. - """ - - __profile_dropdown: QComboBox - __glayout: QGridLayout - - @override - @staticmethod - def get_id() -> str: - return Vortex.get_id() - - @override - def _init_ui(self) -> None: - self.__glayout = QGridLayout() - self.__glayout.setContentsMargins(0, 0, 0, 0) - self.__glayout.setColumnStretch(0, 1) - self.__glayout.setColumnStretch(1, 3) - self.__glayout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(self.__glayout) - - profile_label = QLabel(self.tr("Profile:")) - self.__glayout.addWidget(profile_label, 0, 0) - - self.__profile_dropdown = QComboBox() - self.__profile_dropdown.installEventFilter(self) - self.__profile_dropdown.addItem(self.tr("Please select...")) - self.__profile_dropdown.addItems(self._instance_names) - self.__profile_dropdown.currentTextChanged.connect( - lambda _: self.changed.emit() - ) - self.__glayout.addWidget(self.__profile_dropdown, 0, 1) - - @override - def _update(self) -> None: - self.__profile_dropdown.clear() - self.__profile_dropdown.addItem(self.tr("Please select...")) - self.__profile_dropdown.addItems(self._instance_names) - self.changed.emit() - - @override - def validate(self) -> bool: - return self.__profile_dropdown.currentIndex() != 0 - - @override - def get_instance(self, game: Game) -> ProfileInfo: - instance_name: str = self.__profile_dropdown.currentText() - match: Optional[re.Match] = re.match(r"^(.*) \((.*)\)$", instance_name) - - if match is None: - raise ValueError(f"Invalid instance name: {instance_name!r}") - - profile_id: str = match.group(2) - - return ProfileInfo(display_name=instance_name, game=game, id=profile_id) - - @override - def reset(self) -> None: - self.__profile_dropdown.setCurrentIndex(0) diff --git a/src/ui/migrator/migration_report_dialog.py b/src/ui/migrator/migration_report_dialog.py index cd4cecb..73b7ba4 100644 --- a/src/ui/migrator/migration_report_dialog.py +++ b/src/ui/migrator/migration_report_dialog.py @@ -4,6 +4,7 @@ from typing import Optional +from cutleast_core_lib.core.utilities.exceptions import format_exception from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, @@ -17,7 +18,6 @@ ) from core.migrator.migration_report import MigrationReport -from core.utilities.exceptions import format_exception class MigrationReportDialog(QDialog): @@ -66,7 +66,7 @@ def __init_list_widget(self) -> None: def __init_error_text_box(self) -> None: self.__error_text_box = QPlainTextEdit() self.__error_text_box.setReadOnly(True) - self.__error_text_box.setObjectName("protocol") + self.__error_text_box.setObjectName("monospace") self.addWidget(self.__error_text_box) def __init_errors(self) -> None: diff --git a/src/ui/migrator/migrator_widget.py b/src/ui/migrator/migrator_widget.py index 6d03a43..fbbaeb8 100644 --- a/src/ui/migrator/migrator_widget.py +++ b/src/ui/migrator/migrator_widget.py @@ -6,8 +6,21 @@ from typing import Optional, override import qtawesome as qta -from cutleast_core_lib.ui.widgets.loading_dialog import LoadingDialog +from cutleast_core_lib.ui.widgets.progress_dialog import ProgressDialog from cutleast_core_lib.ui.widgets.smooth_scroll_area import SmoothScrollArea +from mod_manager_lib.core.exceptions import GameNotFoundError +from mod_manager_lib.core.game import Game +from mod_manager_lib.core.game_service import GameService +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.mod_manager.instance_info import InstanceInfo +from mod_manager_lib.core.mod_manager.mod_manager import ModManager +from mod_manager_lib.core.mod_manager.mod_manager_api import ModManagerApi +from mod_manager_lib.ui.instance_creator.instance_creator_widget import ( + InstanceCreatorWidget, +) +from mod_manager_lib.ui.instance_selector.instance_selector_widget import ( + InstanceSelectorWidget, +) from PySide6.QtCore import QEvent, QObject, QSize, Qt, Signal from PySide6.QtGui import QIcon, QWheelEvent from PySide6.QtWidgets import ( @@ -27,15 +40,7 @@ ) from core.config.app_config import AppConfig -from core.game.exceptions import GameNotFoundError -from core.game.game import Game -from core.instance.instance import Instance from core.migrator.file_blacklist import FileBlacklist -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager - -from .instance_creator.instance_creator_widget import InstanceCreatorWidget -from .instance_selector.instance_selector_widget import InstanceSelectorWidget class MigratorWidget(SmoothScrollArea): @@ -60,6 +65,9 @@ class MigratorWidget(SmoothScrollArea): __cur_game: Optional[Game] = None """Currently selected game.""" + __cur_mod_manager: Optional[ModManager] = None + """Mod manager of the currently loaded source instance.""" + __cur_instance: Optional[Instance] = None """Currently loaded source instance.""" @@ -78,7 +86,9 @@ def __init__(self, app_config: AppConfig) -> None: super().__init__() self.app_config = app_config - self.__games = {game.display_name: game for game in Game.get_supported_games()} + self.__games = { + game.display_name: game for game in GameService.get_supported_games() + } self.__init_ui() @@ -121,7 +131,6 @@ def __init_ui(self) -> None: def __init_game_dropdown(self) -> None: hlayout = QHBoxLayout() - hlayout.addSpacing(9) self.__vlayout.addLayout(hlayout) game_label = QLabel(self.tr("Game:")) @@ -139,7 +148,6 @@ def __init_game_dropdown(self) -> None: self.__game_dropdown.currentTextChanged.connect(self.__on_game_select) self.__game_dropdown.setFixedWidth(250) hlayout.addWidget(self.__game_dropdown) - hlayout.addSpacing(9) def __init_src_selector(self) -> None: hlayout = QHBoxLayout() @@ -241,29 +249,32 @@ def __on_game_select(self, value: str) -> None: self.__cur_instance = None def __load_src_instance(self) -> None: - mod_manager: Optional[ModManager] = ( - self.__src_selector.get_selected_mod_manager() - ) - if mod_manager is None: - raise ValueError("No mod manager selected.") - game: Optional[Game] = self.__cur_game if game is None: raise ValueError("No game selected.") - instance_data: InstanceInfo = self.__src_selector.get_cur_instance_data() + instance_data: Optional[InstanceInfo] = ( + self.__src_selector.get_cur_instance_data() + ) + + if instance_data is None: + raise ValueError("No instance selected.") + + self.__cur_mod_manager = instance_data.get_mod_manager() + mod_manager_api: ModManagerApi = self.__cur_mod_manager.get_api() + mod_instance: Instance try: - mod_instance = LoadingDialog.run_callable( - QApplication.activeModalWidget(), - lambda ldialog: mod_manager.load_instance( + mod_instance = ProgressDialog( + lambda pdialog: mod_manager_api.load_instance( instance_data=instance_data, modname_limit=self.app_config.modname_limit, file_blacklist=FileBlacklist.get_files(), game_folder=self.__game_folders.get(game), - ldialog=ldialog, + update_callback=pdialog.updateMainProgress, ), - ) + QApplication.activeModalWidget(), + ).run() except GameNotFoundError: QMessageBox.warning( QApplication.activeModalWidget(), @@ -314,7 +325,7 @@ def get_src_mod_manager(self) -> Optional[ModManager]: Optional[ModManager]: The mod manager """ - return self.__src_selector.get_selected_mod_manager() + return self.__cur_mod_manager def get_src_instance_info(self) -> Optional[InstanceInfo]: """ @@ -347,7 +358,7 @@ def get_dst_mod_manager(self) -> Optional[ModManager]: if self.__dst_instance_tab.currentWidget() is self.__dst_creator: return self.__dst_creator.get_selected_mod_manager() else: - return self.__dst_selector.get_selected_mod_manager() + return self.__dst_selector.get_cur_mod_manager() def get_dst_instance_info(self, game: Game) -> InstanceInfo: """ @@ -366,7 +377,14 @@ def get_dst_instance_info(self, game: Game) -> InstanceInfo: if self.__dst_instance_tab.currentWidget() is self.__dst_creator: return self.__dst_creator.get_instance_data(game) else: - return self.__dst_selector.get_cur_instance_data() + dst_instance: Optional[InstanceInfo] = ( + self.__dst_selector.get_cur_instance_data() + ) + + if dst_instance is None: + raise ValueError("No destination instance selected.") + + return dst_instance @override def eventFilter(self, source: QObject, event: QEvent) -> bool: diff --git a/src/ui/widgets/shadow_scroll_area.py b/src/ui/widgets/shadow_scroll_area.py deleted file mode 100644 index 5a957ed..0000000 --- a/src/ui/widgets/shadow_scroll_area.py +++ /dev/null @@ -1,106 +0,0 @@ -# type: ignore -""" -Copyright (c) Cutleast -""" - -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QLinearGradient, QPainter -from PySide6.QtWidgets import ( - QApplication, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) - - -class ShadowOverlay(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.top_shadow_visible = False - self.bottom_shadow_visible = True - self.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents - ) # Let clicks pass through - self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop) - - def update_shadows(self, top_visible, bottom_visible): - self.top_shadow_visible = top_visible - self.bottom_shadow_visible = bottom_visible - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - gradient_height = 20 - width = self.width() - - # Draw top shadow if visible - if self.top_shadow_visible: - top_gradient = QLinearGradient(0, 0, 0, gradient_height) - top_gradient.setColorAt(0.0, QColor(0, 0, 0, 80)) - top_gradient.setColorAt(1.0, QColor(0, 0, 0, 0)) - painter.fillRect(0, 0, width, gradient_height, top_gradient) - - # Draw bottom shadow if visible - if self.bottom_shadow_visible: - bottom_gradient = QLinearGradient( - 0, self.height() - gradient_height, 0, self.height() - ) - bottom_gradient.setColorAt(0.0, QColor(0, 0, 0, 0)) - bottom_gradient.setColorAt(1.0, QColor(0, 0, 0, 80)) - painter.fillRect( - 0, - self.height() - gradient_height, - width, - gradient_height, - bottom_gradient, - ) - - painter.end() - - -class ShadowScrollArea(QScrollArea): - def __init__(self, parent=None): - super().__init__(parent) - self.setWidgetResizable(True) - self.overlay = ShadowOverlay(self.viewport()) # Overlay on top of the viewport - self.verticalScrollBar().valueChanged.connect(self.update_overlay) - self.update_overlay() # Initial update - - def resizeEvent(self, event): - super().resizeEvent(event) - self.overlay.resize(self.viewport().size()) # Resize overlay with the viewport - - def update_overlay(self): - scrollbar = self.verticalScrollBar() - top_visible = scrollbar.value() > 0 - bottom_visible = scrollbar.value() < scrollbar.maximum() - self.overlay.update_shadows(top_visible, bottom_visible) - - -# Example usage -class ExampleApp(QWidget): - def __init__(self): - super().__init__() - layout = QVBoxLayout(self) - - scroll_area = ShadowScrollArea() - content = QWidget() - content_layout = QVBoxLayout(content) - - # Add a bunch of labels to make the area scrollable - for i in range(50): - content_layout.addWidget(QPushButton(f"Item {i + 1}")) - - scroll_area.setWidget(content) - layout.addWidget(scroll_area) - - -if __name__ == "__main__": - import sys - - app = QApplication(sys.argv) - window = ExampleApp() - window.resize(400, 600) - window.show() - sys.exit(app.exec()) diff --git a/tests/base_test.py b/tests/base_test.py index a128255..b3e8656 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -3,6 +3,7 @@ """ import json +import logging import os import shutil import tempfile @@ -12,22 +13,26 @@ import pytest from cutleast_core_lib.core.utilities.env_resolver import resolve +from cutleast_core_lib.core.utilities.qt_res_provider import read_resource from cutleast_core_lib.test.base_test import BaseTest as CoreBaseTest +from cutleast_core_lib.test.utils import Utils +from mod_manager_lib.core.game_service import GameService +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.metadata import Metadata +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.instance.tool import Tool +from mod_manager_lib.core.mod_manager.modorganizer.mo2_instance_info import ( + MO2InstanceInfo, +) +from mod_manager_lib.core.mod_manager.modorganizer.modorganizer import ModOrganizer +from mod_manager_lib.core.mod_manager.vortex.leveldb import LevelDB +from mod_manager_lib.core.mod_manager.vortex.profile_info import ProfileInfo from pyfakefs.fake_filesystem import FakeFilesystem from pytest_mock import MockerFixture from setup.mock_plyvel import MockPlyvelDB from core.config.app_config import AppConfig -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.metadata import Metadata -from core.instance.mod import Mod -from core.instance.tool import Tool from core.migrator.file_blacklist import FileBlacklist -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.utilities.leveldb import LevelDB from resources_rc import qt_resource_data as qt_resource_data @@ -62,6 +67,20 @@ def app_config(self, data_folder: Path) -> AppConfig: return AppConfig.load(data_folder / "config") + @pytest.fixture(autouse=True) + def game_service(self) -> Generator[GameService, None, None]: + """ + Returns a game service instance for testing. + + Yields: + GameService: The game service instance + """ + + yield GameService(read_resource(":/games.json")) + + Utils.reset_singleton(GameService) + logging.debug("GameService singleton reset.") + @pytest.fixture def test_fs(self, data_folder: Path, test_fs: FakeFilesystem) -> FakeFilesystem: """ @@ -136,7 +155,7 @@ def mo2_instance_info(self) -> MO2InstanceInfo: return MO2InstanceInfo( display_name="Test Instance", - game=Game.get_game_by_id("skyrimse"), + game=GameService.get_game_by_id("skyrimse"), profile="Default", is_global=False, base_folder=base_dir_path, @@ -155,7 +174,7 @@ def vortex_profile_info(self) -> ProfileInfo: return ProfileInfo( display_name="Test Instance (1a2b3c4d)", - game=Game.get_game_by_id("skyrimse"), + game=GameService.get_game_by_id("skyrimse"), id="1a2b3c4d", ) diff --git a/tests/core/game/__init__.py b/tests/core/game/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/tests/core/game/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/tests/core/game/test_game.py b/tests/core/game/test_game.py deleted file mode 100644 index 38f7667..0000000 --- a/tests/core/game/test_game.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from base_test import BaseTest - -from core.game.game import Game - - -class TestGame(BaseTest): - """ - Tests for `core.game.game.Game`. - """ - - def test_get_games_with_cache(self) -> None: - """ - Tests the cached `core.game.game.Game.get_supported_games()` method. - """ - - # when - games1: list[Game] = Game.get_supported_games() - games2: list[Game] = Game.get_supported_games() - - # then - assert games1 == games2 - assert games1 is games2 - assert all(games1[i] is games2[i] for i in range(len(games1))) - - def test_get_game_by_id_with_cache(self) -> None: - """ - Tests the cached `core.game.game.Game.get_game_by_id()` method. - """ - - # given - skyrimse: Game = Game.get_game_by_id("skyrimse") - - # when - cached_skyrimse: Game = Game.get_game_by_id("skyrimse") - - # then - assert skyrimse is cached_skyrimse diff --git a/tests/core/instance/__init__.py b/tests/core/instance/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/tests/core/instance/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/tests/core/instance/test_instance.py b/tests/core/instance/test_instance.py deleted file mode 100644 index bec0d3c..0000000 --- a/tests/core/instance/test_instance.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from base_test import BaseTest - -from core.instance.instance import Instance -from core.instance.mod import Mod - - -class TestInstance(BaseTest): - """ - Tests `core.instance.instance.Instance`. - """ - - def test_loadorder_simple(self, instance: Instance) -> None: - """ - Tests `core.instance.instance.Instance.loadorder` on a simple conflict between - two mods. - """ - - # given - overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert overwritten_mod.mod_conflicts == [overwriting_mod] - - # when - loadorder: list[Mod] = instance.loadorder - - # then - assert loadorder.index(overwritten_mod) < loadorder.index(overwriting_mod) - - def test_get_loadorder_without_order_matters(self, instance: Instance) -> None: - """ - Tests `core.instance.instance.Instance.get_loadorder` with `order_matters=False`. - """ - - # given - overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert overwritten_mod.mod_conflicts == [overwriting_mod] - - # when - loadorder: list[Mod] = instance.get_loadorder(False) - - # then - assert loadorder.index(overwritten_mod) < loadorder.index(overwriting_mod) - - def test_loadorder_unchanged(self, instance: Instance) -> None: - """ - Tests `core.instance.instance.Instance.loadorder` with `order_matters=True`. - """ - - # given - instance.order_matters = True - overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert overwritten_mod.mod_conflicts == [overwriting_mod] - - # when - loadorder: list[Mod] = instance.loadorder - - # then - assert loadorder.index(overwritten_mod) == instance.mods.index(overwritten_mod) - assert loadorder.index(overwriting_mod) == instance.mods.index(overwriting_mod) - - def test_get_loadorder_with_missing_mod(self, instance: Instance) -> None: - """ - Tests `core.instance.instance.Instance.get_loadorder` with a mod that is - referenced by a mod conflict but missing in the instance. - """ - - # given - instance.order_matters = True - overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert overwritten_mod.mod_conflicts == [overwriting_mod] - - # when - instance.mods.remove(overwriting_mod) - - # then (mod conflict should be unaffected) - assert overwritten_mod.mod_conflicts == [overwriting_mod] - - # when - loadorder: list[Mod] = instance.get_loadorder(False) - sorted_index: int = loadorder.index(overwritten_mod) - original_index: int = ( - # subtract 1 for removed mod - instance.mods.index(overwritten_mod) - 1 - ) - - # then - assert sorted_index == original_index diff --git a/tests/core/migrator/test_migrator.py b/tests/core/migrator/test_migrator.py index 6898cfd..bfb25ad 100644 --- a/tests/core/migrator/test_migrator.py +++ b/tests/core/migrator/test_migrator.py @@ -9,29 +9,41 @@ from cutleast_core_lib.core.utilities.env_resolver import resolve from cutleast_core_lib.core.utilities.scale import scale_value from cutleast_core_lib.test.utils import Utils +from mod_manager_lib.core.game import Game +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.metadata import Metadata +from mod_manager_lib.core.instance.mod import Mod +from mod_manager_lib.core.instance.tool import Tool +from mod_manager_lib.core.mod_manager.instance_info import InstanceInfo +from mod_manager_lib.core.mod_manager.mod_manager_api import ModManagerApi +from mod_manager_lib.core.mod_manager.modorganizer.mo2_instance_info import ( + MO2InstanceInfo, +) +from mod_manager_lib.core.mod_manager.modorganizer.modorganizer import ModOrganizer +from mod_manager_lib.core.mod_manager.vortex.exceptions import ( + OverwriteModNotSupportedError, +) +from mod_manager_lib.core.mod_manager.vortex.profile_info import ProfileInfo +from mod_manager_lib.core.mod_manager.vortex.vortex import Vortex from pyfakefs.fake_filesystem import FakeFilesystem from setup.mock_plyvel import MockPlyvelDB from core.config.app_config import AppConfig -from core.instance.instance import Instance -from core.instance.metadata import Metadata -from core.instance.mod import Mod -from core.instance.tool import Tool -from core.migrator.migration_report import MigrationReport -from core.migrator.migrator import FileBlacklist, Migrator -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer -from core.mod_manager.vortex.exceptions import OverwriteModNotSupportedError -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.mod_manager.vortex.vortex import Vortex -from core.utilities.exceptions import ( +from core.migrator.exceptions import ( NotEnoughSpaceError, SameModsLocationDiffManagerError, ) +from core.migrator.migration_report import MigrationReport +from core.migrator.migrator import FileBlacklist, Migrator from core.utilities.filesystem import get_free_disk_space -from tests.core.mod_manager.test_vortex import get_staging_folder_stub + + +def get_staging_folder_stub(game: Game) -> Path: + """ + Stub for the private `Vortex.get_staging_folder` method. + """ + + raise NotImplementedError class TestMigrator(BaseTest): @@ -720,7 +732,7 @@ def assert_modlists_equal( f: m.metadata for f, m in mod2.file_conflicts.items() } if check_files: - assert list( + assert sorted( filter( # Do not check special files lambda f: str(f).lower() not in FileBlacklist.get_files() and str(f) not in mod1.file_conflicts @@ -728,7 +740,7 @@ def assert_modlists_equal( and f not in redirects2, mod1.files, ) - ) == list( + ) == sorted( filter( # Do not check special files lambda f: str(f).lower() not in FileBlacklist.get_files() and str(f) not in mod2.file_conflicts @@ -778,8 +790,8 @@ def assert_additional_files_equal[I1: InstanceInfo, I2: InstanceInfo]( self, src_info: I1, dst_info: I2, - src_mod_manager: ModManager[I1], - dst_mod_manager: ModManager[I2], + src_mod_manager: ModManagerApi[I1], + dst_mod_manager: ModManagerApi[I2], ) -> None: """ Asserts that the additional files of two instances are equal. @@ -804,7 +816,7 @@ def assert_additional_files_equal[I1: InstanceInfo, I2: InstanceInfo]( assert source_files == destination_files def __get_file_redirects( - self, mods: list[Mod], src_mod_manager: ModManager + self, mods: list[Mod], src_mod_manager: ModManagerApi ) -> dict[Mod, dict[Path, Path]]: file_redirects: dict[Mod, dict[Path, Path]] = {} for mod in mods: diff --git a/tests/core/mod_manager/__init__.py b/tests/core/mod_manager/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/tests/core/mod_manager/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/tests/core/mod_manager/test_modorganizer.py b/tests/core/mod_manager/test_modorganizer.py deleted file mode 100644 index e925172..0000000 --- a/tests/core/mod_manager/test_modorganizer.py +++ /dev/null @@ -1,553 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path -from typing import Any - -import pytest -from base_test import BaseTest -from cutleast_core_lib.test.utils import Utils -from pyfakefs.fake_filesystem import FakeFilesystem - -from core.config.app_config import AppConfig -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.metadata import Metadata -from core.instance.mod import Mod -from core.instance.tool import Tool -from core.migrator.file_blacklist import FileBlacklist -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer -from core.utilities.ini_file import INIFile - - -class TestModOrganizer(BaseTest): - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer`. - """ - - @staticmethod - def parse_meta_ini_stub(meta_ini_path: Path, default_game: Game) -> Metadata: - """ - Stub for `core.mod_manager.modorganizer.modorganizer.ModOrganizer.__parse_meta_ini()`. - """ - - raise NotImplementedError - - PARSE_META_INI_DATA: list[tuple[Path, Metadata]] = [ - ( - Path("Test Mods_separator") / "meta.ini", - Metadata( - mod_id=None, - file_id=None, - version="", - file_name=None, - game_id="skyrimspecialedition", - ), - ), - ( - Path("RS Children Overhaul") / "meta.ini", - Metadata( - mod_id=2650, - file_id=128013, - version="1.1.3", - file_name="RSSE Children Overhaul 1.1.3 with hotfix 1-2650-1-1-3HF1-1583835543.7z", - game_id="enderalspecialedition", # to test mods from different games - ), - ), - ] - - @pytest.mark.parametrize("meta_ini_path, expected_metadata", PARSE_META_INI_DATA) - def test_parse_meta_ini( - self, meta_ini_path: Path, expected_metadata: Metadata, data_folder: Path - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.__parse_meta_ini()`. - """ - - # given - mo2 = ModOrganizer() - test_meta_ini_path: Path = data_folder / "mod_instance" / "mods" / meta_ini_path - - # when - metadata: Metadata = Utils.get_private_method( - mo2, "parse_meta_ini", TestModOrganizer.parse_meta_ini_stub - )( - meta_ini_path=test_meta_ini_path, - default_game=Game.get_game_by_id("skyrimse"), - ) - - # then - assert metadata == expected_metadata - - def test_load_instance( - self, - app_config: AppConfig, - test_fs: FakeFilesystem, - mo2_instance_info: MO2InstanceInfo, - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.load_instance()`. - """ - - # given - mo2 = ModOrganizer() - - # when - instance: Instance = mo2.load_instance( - mo2_instance_info, app_config.modname_limit, FileBlacklist.get_files() - ) - - # then - assert len(instance.mods) == 11 - assert len(instance.tools) == 3 - - # when - obsidian_weathers: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - obsidian_weathers_german: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert obsidian_weathers.mod_conflicts == [obsidian_weathers_german] - assert obsidian_weathers_german.mod_conflicts == [] - assert obsidian_weathers.file_conflicts == {} - assert obsidian_weathers_german.file_conflicts == {} - - # when - wet_and_cold: Mod = self.get_mod_by_name("Wet and Cold SE", instance) - wet_and_cold_german: Mod = self.get_mod_by_name( - "Wet and Cold SE - German", instance - ) - - # then - assert ( - wet_and_cold_german.file_conflicts["scripts\\_wetskyuiconfig.pex"] - == wet_and_cold - ) - - # when - skse_loader: Tool = self.get_tool_by_name("SKSE", instance) - dip: Tool = self.get_tool_by_name("DIP", instance) - dip_mod: Mod = self.get_mod_by_name("Dynamic Interface Patcher - DIP", instance) - - # then - assert skse_loader.executable == Path("skse64_loader.exe") - assert skse_loader.mod is None - assert skse_loader.commandline_args == [] - assert skse_loader.is_in_game_dir - assert skse_loader.working_dir is None - assert dip.executable == Path("DIP\\DIP.exe") - assert dip.mod is dip_mod - assert dip.commandline_args == [] - assert not dip.is_in_game_dir - assert dip.working_dir is None - - # when - overwrite_mod: Mod = instance.mods[-1] - - # then - assert overwrite_mod.display_name == "Overwrite" - assert overwrite_mod.mod_type == Mod.Type.Overwrite - assert overwrite_mod.files == [Path("test.txt")] - - @staticmethod - def process_conflicts_stub(mods: list[Mod], file_blacklist: list[str]) -> None: - """ - Method stub for `ModOrganizer.__process_conflicts()`. - """ - - raise NotImplementedError - - def test_process_conflicts(self, monkeypatch: pytest.MonkeyPatch) -> None: - """ - Tests `ModOrganizer.__process_conflicts()`. - """ - - # given - mo2 = ModOrganizer() - mods: list[Mod] = [ - TestModOrganizer.create_blank_mod("test_mod_1"), - TestModOrganizer.create_blank_mod("test_mod_2"), - TestModOrganizer.create_blank_mod("test_mod_3"), - TestModOrganizer.create_blank_mod("test_mod_4"), - TestModOrganizer.create_blank_mod("test_mod_5"), - ] - file_index: dict[str, list[Mod]] = { - "test_file_1": [mods[0], mods[2], mods[4]], - "test_file_2": [mods[0], mods[1]], - "test_file_2.mohidden": [mods[2]], - "test_file_3": [mods[4]], - } - - for i in range( - 100_000, 500_000 - ): # simulate a large mod list with lots of files - file_index[f"test_file_{i}"] = [mods[i // 100_000]] - - # add some hidden files - if i % 5 == 0: - file_index[f"hidden_test_file_{i}.mohidden"] = [mods[i // 100_000]] - - # when - monkeypatch.setattr( - ModOrganizer, "_index_modlist", lambda mods, file_blacklist: file_index - ) - Utils.get_private_method(mo2, "process_conflicts", self.process_conflicts_stub)( - mods, [] - ) - - # then - assert mods[0].mod_conflicts == [mods[2], mods[4], mods[1]] - assert mods[1].mod_conflicts == [] - assert mods[2].mod_conflicts == [mods[4]] - assert mods[3].mod_conflicts == [] - assert mods[4].mod_conflicts == [] - - assert mods[2].file_conflicts["test_file_2"] == mods[1] - - def test_get_actual_files(self) -> None: - """ - Tests `ModOrganizer.get_actual_files()`. - """ - - # given - mo2 = ModOrganizer() - mod1_files: list[Path] = [Path("Test_File_1"), Path("test_file_2")] - mod1: Mod = self.create_blank_mod("test_mod_1", mod1_files) - mod2_files: list[Path] = [ - Path("Test_File_2.mohidden"), - Path("Test_File_3"), - Path("test_file_4.mohidden"), - ] - mod2: Mod = self.create_blank_mod("test_mod_2", mod2_files) - mod2.file_conflicts = {"test_file_2": mod1} - - # when - mod1_file_redirects: dict[Path, Path] = mo2.get_actual_files(mod1) - mod2_file_redirects: dict[Path, Path] = mo2.get_actual_files(mod2) - - # then - assert mod1_file_redirects == {} - assert mod2_file_redirects == { - Path("test_file_2.mohidden"): Path("test_file_2") - } - - def test_create_instance(self, test_fs: FakeFilesystem) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.create_instance()`. - """ - - # given - mo2 = ModOrganizer() - game: Game = Game.get_game_by_id("skyrimse") - game_folder = Path("E:\\SteamLibrary\\Skyrim Special Edition") - test_instance_path = Path("E:\\Modding\\Test Instance") - instance_data = MO2InstanceInfo( - display_name="Test Instance", - game=game, - profile="Default", - is_global=False, - base_folder=test_instance_path, - mods_folder=test_instance_path / "mods", - profiles_folder=test_instance_path / "profiles", - install_mo2=False, - ) - - # when - instance: Instance = mo2.create_instance(instance_data, game_folder) - - # then - assert instance.mods == [] - assert instance.tools == [] - assert instance_data.base_folder.is_dir() - assert instance_data.mods_folder.is_dir() - assert instance_data.profiles_folder.is_dir() - assert (instance_data.base_folder / "ModOrganizer.ini").is_file() - assert (instance_data.profiles_folder / instance_data.profile).is_dir() - assert ( - instance_data.profiles_folder / instance_data.profile / "modlist.txt" - ).is_file() - - # when - ini_data: dict[str, Any] = INIFile( - instance_data.base_folder / "ModOrganizer.ini" - ).load_file() - - # then - assert ini_data["General"]["gameName"] == game.display_name - assert ini_data["General"]["gamePath"] == str(game_folder).replace("\\", "/") - - def test_install_mod( - self, app_config: AppConfig, test_fs: FakeFilesystem, instance: Instance - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.install_mod()`. - """ - - self.test_create_instance(test_fs) - - # given - mo2 = ModOrganizer() - test_instance_path = Path("E:\\Modding\\Test Instance") - instance_data = MO2InstanceInfo( - display_name="Test Instance", - game=Game.get_game_by_id("skyrimse"), - profile="Default", - is_global=False, - base_folder=test_instance_path, - mods_folder=test_instance_path / "mods", - profiles_folder=test_instance_path / "profiles", - install_mo2=False, # This is important for now as the download is not mocked, yet - ) - dst_instance: Instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # when - for mod in [overwritten_mod, overwriting_mod]: - mo2.install_mod( - mod, - dst_instance, - instance_data, - file_redirects=mo2.get_actual_files(mod), - use_hardlinks=True, - replace=True, - blacklist=FileBlacklist.get_files(), - ) - mo2.finalize_migration(dst_instance, instance_data, activate_new_instance=True) - - dst_instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - migrated_overwritten_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", dst_instance - ) - migrated_overwriting_mod: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", dst_instance - ) - - # then - assert migrated_overwritten_mod.metadata == overwritten_mod.metadata - assert migrated_overwriting_mod.metadata == overwriting_mod.metadata - assert migrated_overwritten_mod.mod_conflicts == [migrated_overwriting_mod] - assert dst_instance.loadorder.index( - migrated_overwritten_mod - ) < dst_instance.loadorder.index(migrated_overwriting_mod) - assert migrated_overwritten_mod.files == overwritten_mod.files - assert migrated_overwriting_mod.files == overwriting_mod.files - - def test_install_mod_with_separator( - self, app_config: AppConfig, test_fs: FakeFilesystem, instance: Instance - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.install_mod()` - with a separator mod. - """ - - self.test_create_instance(test_fs) - - # given - mo2 = ModOrganizer() - test_instance_path = Path("E:\\Modding\\Test Instance") - instance_data = MO2InstanceInfo( - display_name="Test Instance", - game=Game.get_game_by_id("skyrimse"), - profile="Default", - is_global=False, - base_folder=test_instance_path, - mods_folder=test_instance_path / "mods", - profiles_folder=test_instance_path / "profiles", - install_mo2=False, # This is important for now as the download is not mocked, yet - ) - dst_instance: Instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - separator_mod: Mod = self.get_mod_by_name("Test Mods", instance) - - # when - mo2.install_mod( - separator_mod, - dst_instance, - instance_data, - file_redirects=mo2.get_actual_files(separator_mod), - use_hardlinks=True, - replace=True, - blacklist=FileBlacklist.get_files(), - ) - mo2.finalize_migration(dst_instance, instance_data, activate_new_instance=True) - - dst_instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - migrated_separator_mod: Mod = self.get_mod_by_name("Test Mods", dst_instance) - - # then - assert migrated_separator_mod.mod_type == Mod.Type.Separator - assert dst_instance.loadorder[-1] is migrated_separator_mod - - def test_install_mod_with_overwrite( - self, app_config: AppConfig, test_fs: FakeFilesystem, instance: Instance - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.install_mod()` - with an overwrite mod. - """ - - self.test_create_instance(test_fs) - - # given - mo2 = ModOrganizer() - test_instance_path = Path("E:\\Modding\\Test Instance") - instance_data = MO2InstanceInfo( - display_name="Test Instance", - game=Game.get_game_by_id("skyrimse"), - profile="Default", - is_global=False, - base_folder=test_instance_path, - mods_folder=test_instance_path / "mods", - profiles_folder=test_instance_path / "profiles", - install_mo2=False, # This is important for now as the download is not mocked, yet - ) - dst_instance: Instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - overwrite_mod: Mod = instance.mods[-1] - - # when - mo2.install_mod( - overwrite_mod, - dst_instance, - instance_data, - file_redirects=mo2.get_actual_files(overwrite_mod), - use_hardlinks=True, - replace=True, - blacklist=FileBlacklist.get_files(), - ) - mo2.finalize_migration(dst_instance, instance_data, activate_new_instance=True) - - dst_instance = mo2.load_instance( - instance_data, app_config.modname_limit, FileBlacklist.get_files() - ) - migrated_overwrite_mod: Mod = dst_instance.mods[-1] - - # then - assert migrated_overwrite_mod.mod_type == Mod.Type.Overwrite - assert Path("E:\\Modding\\Test Instance\\overwrite\\test.txt").is_file() - assert ( - Path("E:\\Modding\\Test Instance\\overwrite\\test.txt").read_text() - == "This file should make MMM to load the overwrite folder as extra mod." - ) - assert ( - "+Overwrite" - not in Path("E:\\Modding\\Test Instance\\profiles\\Default\\modlist.txt") - .read_text() - .splitlines() - ) - - PROCESS_INI_ARGS_DATA: list[tuple[str, list[str]]] = [ - ( - r"""-D:\"C:\\Games\\Nolvus Ascension\\STOCK GAME\\Data\" -c:\"C:\\Games\\Nolvus Ascension\\TOOLS\\SSE Edit\\Cache\\\"""", - [ - '-D:"C:\\Games\\Nolvus Ascension\\STOCK GAME\\Data"', - '-c:"C:\\Games\\Nolvus Ascension\\TOOLS\\SSE Edit\\Cache\\"', - ], - ), - ( - r"""-DontCache -D:\"C:\\Games\\Nolvus Ascension\\STOCK GAME\\Data\"""", - [ - "-DontCache", - '-D:"C:\\Games\\Nolvus Ascension\\STOCK GAME\\Data"', - ], - ), - ( - r"""\"C:\\Games\\Nolvus Ascension\\STOCK GAME\"""", - [ - "C:\\Games\\Nolvus Ascension\\STOCK GAME", - ], - ), - ( - r'"--game=\"Skyrim Special Edition\""', - [ - '--game="Skyrim Special Edition"', - ], - ), - ] - - @pytest.mark.parametrize("raw_args, expected_args", PROCESS_INI_ARGS_DATA) - def test_process_ini_arguments( - self, raw_args: str, expected_args: list[str] - ) -> None: - """ - Tests `ModOrganizer.process_ini_arguments()`. - """ - - # when - actual_args: list[str] = ModOrganizer.process_ini_arguments(raw_args) - - # then - assert actual_args == expected_args - - def test_is_instance_existing( - self, test_fs: FakeFilesystem, mo2_instance_info: MO2InstanceInfo - ) -> None: - """ - Tests `ModOrganizer.is_instance_existing()`. - """ - - # given - mo2 = ModOrganizer() - - # when/then - assert mo2.is_instance_existing(mo2_instance_info) - - # when - non_existing_instance_info = MO2InstanceInfo( - display_name="Non Existing Instance", - game=Game.get_game_by_id("skyrimse"), - profile="Default", - is_global=False, - base_folder=Path("E:\\Modding\\Non Existing Instance"), - mods_folder=Path("E:\\Modding\\Non Existing Instance\\mods"), - profiles_folder=Path("E:\\Modding\\Non Existing Instance\\profiles"), - ) - - # then - assert not mo2.is_instance_existing(non_existing_instance_info) - - # when - non_existing_profile = MO2InstanceInfo( - display_name=mo2_instance_info.display_name, - game=mo2_instance_info.game, - profile="Non Existing Profile", - is_global=mo2_instance_info.is_global, - base_folder=mo2_instance_info.base_folder, - mods_folder=mo2_instance_info.mods_folder, - profiles_folder=mo2_instance_info.profiles_folder, - ) - - # then - assert not mo2.is_instance_existing(non_existing_profile) - - # when - wrong_game = MO2InstanceInfo( - display_name=mo2_instance_info.display_name, - game=Game.get_game_by_id("skyrim"), - profile=mo2_instance_info.profile, - is_global=mo2_instance_info.is_global, - base_folder=mo2_instance_info.base_folder, - mods_folder=mo2_instance_info.mods_folder, - profiles_folder=mo2_instance_info.profiles_folder, - ) - - # then - assert mo2.is_instance_existing(wrong_game) diff --git a/tests/core/mod_manager/test_vortex.py b/tests/core/mod_manager/test_vortex.py deleted file mode 100644 index 957fa23..0000000 --- a/tests/core/mod_manager/test_vortex.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import json -from pathlib import Path -from typing import Any - -import pytest -from base_test import BaseTest -from cutleast_core_lib.test.utils import Utils -from pyfakefs.fake_filesystem import FakeFilesystem -from setup.mock_plyvel import MockPlyvelDB - -from core.config.app_config import AppConfig -from core.game.game import Game -from core.instance.instance import Instance -from core.instance.mod import Mod -from core.instance.tool import Tool -from core.migrator.file_blacklist import FileBlacklist -from core.mod_manager.vortex.exceptions import VortexNotFullySetupError -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.mod_manager.vortex.vortex import Vortex -from core.utilities.leveldb import LevelDB - - -def get_staging_folder_stub(game: Game) -> Path: - """ - Stub for the private `Vortex.get_staging_folder` method. - """ - - raise NotImplementedError - - -class TestVortex(BaseTest): - """ - Tests `core.mod_manager.vortex.Vortex`. - """ - - DATABASE: tuple[str, type[LevelDB]] = ("level_db", LevelDB) - RAW_DATA: tuple[str, type[dict[bytes, bytes]]] = ("data", dict) - - def test_load_instance( - self, - data_folder: Path, - app_config: AppConfig, - vortex_profile_info: ProfileInfo, - full_vortex_db: MockPlyvelDB, - test_fs: FakeFilesystem, - ) -> None: - """ - Tests `core.mod_manager.modorganizer.modorganizer.ModOrganizer.load_instance()`. - """ - - # given - vortex = Vortex() - vortex.db_path.mkdir(parents=True, exist_ok=True) - - # when - instance: Instance = vortex.load_instance( - vortex_profile_info, app_config.modname_limit, FileBlacklist.get_files() - ) - - # then - assert len(instance.mods) == 8 - assert len(instance.tools) == 3 - - # test game folder - assert instance.game_folder == Path("E:\\SteamLibrary\\Skyrim Special Edition") - - # test mod name length limit - assert all( - len(mod.display_name) <= app_config.modname_limit for mod in instance.mods - ) - - # when - obsidian_weathers: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons", instance - ) - obsidian_weathers_german: Mod = self.get_mod_by_name( - "Obsidian Weathers and Seasons - German", instance - ) - - # then - assert obsidian_weathers.mod_conflicts == [obsidian_weathers_german] - assert obsidian_weathers_german.mod_conflicts == [] - assert obsidian_weathers.file_conflicts == {} - assert obsidian_weathers_german.file_conflicts == {} - - # when - wet_and_cold: Mod = self.get_mod_by_name("Wet and Cold SE", instance) - wet_and_cold_german: Mod = self.get_mod_by_name( - "Wet and Cold SE - German", instance - ) - - # then - assert ( - wet_and_cold_german.file_conflicts["scripts\\_wetskyuiconfig.pex"] - == wet_and_cold - ) - - # when - skse_loader: Tool = self.get_tool_by_name("Skyrim Script Extender 64", instance) - dip: Tool = self.get_tool_by_name("DIP", instance) - dip_mod: Mod = self.get_mod_by_name("Dynamic Interface Patcher - DIP", instance) - - # then - assert skse_loader.executable == Path("skse64_loader.exe") - assert skse_loader.mod is None - assert skse_loader.commandline_args == [] - assert skse_loader.is_in_game_dir - assert skse_loader.working_dir is None - assert dip.executable == Path("DIP\\DIP.exe") - assert dip.mod is dip_mod - assert dip.commandline_args == [] - assert not dip.is_in_game_dir - assert dip.working_dir is None - - def test_create_instance( - self, test_fs: FakeFilesystem, ready_vortex_db: MockPlyvelDB - ) -> None: - """ - Tests `core.mod_manager.vortex.Vortex.create_instance()` - """ - - # given - vortex = Vortex() - vortex.db_path.mkdir(parents=True, exist_ok=True) - game_folder = Path("E:\\SteamLibrary\\Skyrim Special Edition") - database: LevelDB = Utils.get_private_field(vortex, *TestVortex.DATABASE) - profile_info = ProfileInfo( - display_name="Test profile", - game=Game.get_game_by_id("skyrimse"), - id="5e6f7g8h9j", - ) - prefix: str = f"persistent###profiles###{profile_info.id}###" - expected_profile_data: dict[str, Any] = { - "features": { - "local_game_settings": False, - "local_saves": False, - }, - "gameId": "skyrimse", - "id": profile_info.id, - "name": profile_info.display_name, - } - - # when - vortex.create_instance(profile_info, game_folder) - profile_data: dict[str, Any] = database.load(prefix)["persistent"]["profiles"][ - profile_info.id - ] - profile_data.pop("lastActivated") # remove unique timestamp - - # then - assert profile_data == expected_profile_data - - # when - raw_data: dict[bytes, bytes] = Utils.get_private_field( - ready_vortex_db, *TestVortex.RAW_DATA - ) - profile_prefix: bytes = prefix.encode() - - # then - assert ( - raw_data[profile_prefix + b"features###local_game_settings"] - == json.dumps(False).encode() - ) - assert ( - raw_data[profile_prefix + b"features###local_saves"] - == json.dumps(False).encode() - ) - assert raw_data[profile_prefix + b"gameId"] == json.dumps("skyrimse").encode() - assert raw_data[profile_prefix + b"id"] == json.dumps(profile_info.id).encode() - assert ( - raw_data[profile_prefix + b"name"] - == json.dumps(profile_info.display_name).encode() - ) - - def test_vortex_not_installed(self, empty_vortex_db: MockPlyvelDB) -> None: - """ - Tests if `core.mod_manager.vortex.Vortex` raises a `VortexNotInstalledError` - when running a pre-migration check on an empty Vortex database. - """ - - # given - vortex = Vortex() - profile_info = ProfileInfo( - display_name="Test profile", - game=Game.get_game_by_id("skyrimse"), - id=Vortex.generate_id(), - ) - - # then - with pytest.raises(VortexNotFullySetupError): - vortex.prepare_migration(profile_info) - - logical_file_name_data: list[tuple[str, int, str]] = [ - ( - "(Part 1) SSE Engine Fixes for 1.5.39 - 1.5.97-17230-5-9-1-1664974289.7z", - 17230, - "(Part 1) SSE Engine Fixes for 1.5.39 - 1.5.97", - ), - ( - "(Part 2) Engine Fixes - skse64 Preloader and TBB Lib-17230-2020-3-1611367474.7z", - 17230, - "(Part 2) Engine Fixes - skse64 Preloader and TBB Lib", - ), - ( - "Constructible Object Custom Keyword System NG-81731-1-1-1-1713893656.zip", - 81731, - "Constructible Object Custom Keyword System NG", - ), - ( - "RaceMenu Anniversary Edition v0-4-19-16-19080-0-4-19-16-1706297897.7z", - 19080, - "RaceMenu Anniversary Edition v0-4-19-16", - ), - ("Test Mod Name.7z", 0, "Test Mod Name"), - ] - - @pytest.mark.parametrize( - "file_name, mod_id, expected_logical_name", logical_file_name_data - ) - def test_get_logical_file_name( - self, file_name: str, mod_id: int, expected_logical_name: str - ) -> None: - """ - Tests `core.mod_manager.vortex.Vortex.get_logical_file_name()` - """ - - # when - logical_file_name: str = Vortex.get_logical_file_name(file_name, mod_id) - - # then - assert logical_file_name == expected_logical_name - - def test_install_mod( - self, - app_config: AppConfig, - test_fs: FakeFilesystem, - ready_vortex_db: MockPlyvelDB, - instance: Instance, - ) -> None: - """ - Tests `core.mod_manager.vortex.Vortex.install_mod()`. - """ - - self.test_create_instance(test_fs, ready_vortex_db) - - # given - vortex = Vortex() - profile_info = ProfileInfo( - display_name="Test profile (5e6f7g8h9j)", - game=Game.get_game_by_id("skyrimse"), - id="5e6f7g8h9j", - ) - dst_profile: Instance = vortex.load_instance( - profile_info, app_config.modname_limit, FileBlacklist.get_files() - ) - mod = instance.mods[1] - - # when - vortex.install_mod( - mod, - dst_profile, - profile_info, - file_redirects={}, - use_hardlinks=True, - replace=True, - ) - - # then - dst_profile = vortex.load_instance( - profile_info, app_config.modname_limit, FileBlacklist.get_files() - ) - assert dst_profile.mods[-1].metadata == mod.metadata - - def test_format_utc_timestamp(self) -> None: - """ - Tests `core.mod_manager.vortex.Vortex.format_utc_timestamp()`. - """ - - # given - timestamp: float = 1743321182.5131004 - expected_result: str = "2025-03-30T07:53:02.513100Z" - - # when - actual_result: str = Vortex.format_utc_timestamp(timestamp) - - # then - assert actual_result == expected_result - - def test_format_unix_timestamp(self) -> None: - """ - Tests `core.mod_manager.vortex.Vortex.format_unix_timestamp()`. - """ - - # given - timestamp: float = 1743321182.5131004 - expected_result: int = 1743321182513 - - # when - actual_result: int = Vortex.format_unix_timestamp(timestamp) - - # then - assert actual_result == expected_result - - def test_is_instance_existing( - self, full_vortex_db: MockPlyvelDB, vortex_profile_info: ProfileInfo - ) -> None: - """ - Tests `Vortex.is_instance_existing()`. - """ - - # given - vortex = Vortex() - vortex.db_path.mkdir(parents=True, exist_ok=True) - - # when/then - assert vortex.is_instance_existing(vortex_profile_info) - - # when - non_existing_profile = ProfileInfo( - display_name="Non Existing Profile", - game=vortex_profile_info.game, - id="xyz1234", - ) - - # then - assert not vortex.is_instance_existing(non_existing_profile) diff --git a/tests/core/utilities/test_leveldb.py b/tests/core/utilities/test_leveldb.py deleted file mode 100644 index cd51021..0000000 --- a/tests/core/utilities/test_leveldb.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path -from typing import Any - -import pytest -from base_test import BaseTest -from cutleast_core_lib.test.utils import Utils -from setup.mock_plyvel import MockPlyvelDB - -from core.utilities.leveldb import LevelDB - - -class TestLevelDB(BaseTest): - """ - Tests `core.utilities.leveldb.LevelDB`. - """ - - DATA: tuple[str, type[dict[str, str]]] = "data", dict[str, str] - """Identifier for accessing the private data field.""" - - CHANGES_PENDING: tuple[str, type[bool]] = "changes_pending", bool - """Identifier for accessing the private changes_pending field.""" - - test_load_cases: list[tuple[str, dict[str, Any]]] = [ - ( - "settings###gameMode###discovered###skyrimse###environment###SteamAPPId", - { - "settings": { - "gameMode": { - "discovered": { - "skyrimse": {"environment": {"SteamAPPId": "489830"}} - } - } - } - }, - ), - ( - "persistent###profiles###1a2b3c4d###features###local_game_settings", - { - "persistent": { - "profiles": { - "1a2b3c4d": {"features": {"local_game_settings": False}} - } - } - }, - ), - ] - - @pytest.mark.parametrize("prefix, expected_output", test_load_cases) - def test_load( - self, - prefix: str, - expected_output: dict[str, Any], - full_vortex_db: MockPlyvelDB, - ) -> None: - """ - Tests `core.utilities.leveldb.LevelDB.load()`. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - - # when/then - real_output: dict[str, Any] = leveldb.load(prefix=prefix) - assert real_output == expected_output - - def test_get_key_lazy_loads_missing_key(self, full_vortex_db: MockPlyvelDB) -> None: - """ - Tests that get_key() loads missing keys on-demand from the database. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - - # key exists only in DB, not in __data - key = "settings###gameMode###discovered###skyrimse###environment###SteamAPPId" - - # when - value = leveldb.get_key(key) - - # then - assert isinstance(value, str) and value == "489830" - - def test_get_key_returns_in_memory_if_present( - self, full_vortex_db: MockPlyvelDB - ) -> None: - """ - Tests that get_key() returns in-memory data without DB access. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - key = "persistent###profiles###custom_key" - leveldb.set_key(key, "in-memory") - - # when - result = leveldb.get_key(key) - - # then - assert result == "in-memory" - - def test_set_key_marks_changes_pending(self, full_vortex_db: MockPlyvelDB) -> None: - """ - Tests that set_key() updates in-memory data and marks changes pending. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - - # when - leveldb.set_key("abc", "123") - - # internal state check - assert Utils.get_private_field(leveldb, *TestLevelDB.CHANGES_PENDING) is True - assert Utils.get_private_field(leveldb, *TestLevelDB.DATA)["abc"] == '"123"' - - def test_get_section_loads_missing_keys_without_overwriting( - self, full_vortex_db: MockPlyvelDB - ) -> None: - """ - Tests that get_section() loads missing keys from DB but does not overwrite - existing in-memory values. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - - prefix = "settings###" - - # simulate pending in-memory modification - leveldb.set_key( - "settings###gameMode###discovered###skyrimse###environment###SteamAPPId", - "LOCAL_OVERRIDE", - ) - - # when - section = leveldb.get_section(prefix) - - # then - # override must remain intact - assert ( - section["settings"]["gameMode"]["discovered"]["skyrimse"]["environment"][ - "SteamAPPId" - ] - == "LOCAL_OVERRIDE" - ) - - def test_save_writes_changes_and_clears_pending( - self, full_vortex_db: MockPlyvelDB - ) -> None: - """ - Tests that save() writes in-memory keys to DB and resets pending state. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - key = "k1###sub" - leveldb.set_key(key, "v1") - - # precondition - assert Utils.get_private_field(leveldb, *TestLevelDB.CHANGES_PENDING) is True - - # when - leveldb.save() - - # then - assert full_vortex_db.get(key.encode()) == b'"v1"' - assert Utils.get_private_field(leveldb, *TestLevelDB.CHANGES_PENDING) is False - - def test_get_section_parsing(self, full_vortex_db: MockPlyvelDB) -> None: - """ - Tests that get_section() correctly parses nested data. - """ - - # given - leveldb = LevelDB(Path(), use_symlink=False) - - # when - section = leveldb.get_section( - "settings###gameMode###discovered###skyrimse###environment" - ) - - # then - assert section == { - "settings": { - "gameMode": { - "discovered": { - "skyrimse": {"environment": {"SteamAPPId": "489830"}} - } - } - } - } - - def test_flatten_nested_dict(self) -> None: - """ - Tests `core.utilities.leveldb.LevelDB.flatten_nested_dict()`. - """ - - # given - data: dict[str, Any] = { - "key1": {"subkey1": {"subsubkey1": {"subsubsubkey1": "subsubsubvalue1"}}}, - "key2": "value2", - } - expected: dict[str, str] = { - "key1###subkey1###subsubkey1###subsubsubkey1": '"subsubsubvalue1"', - "key2": '"value2"', - } - - # when - result: dict[str, str] = LevelDB.flatten_nested_dict(data) - - # then - assert result == expected - - def test_flatten_nested_dict_with_prefix(self) -> None: - """ - Tests `core.utilities.leveldb.LevelDB.flatten_nested_dict()` with a prefix. - """ - - # given - data: dict[str, Any] = { - "key1": {"subkey1": {"subsubkey1": {"subsubsubkey1": "subsubsubvalue1"}}}, - "key2": "value2", - } - prefix = "prefix###" - expected: dict[str, str] = { - "prefix###key1###subkey1###subsubkey1###subsubsubkey1": '"subsubsubvalue1"', - "prefix###key2": '"value2"', - } - - # when - result: dict[str, str] = LevelDB.flatten_nested_dict(data, prefix=prefix) - - # then - assert result == expected - - def test_parse_flat_dict(self) -> None: - """ - Tests `core.utilities.leveldb.LevelDB.parse_flat_dict()`. - """ - - # given - data: dict[str, str] = { - "key1###subkey1###subsubkey1###subsubsubkey1": '"subsubsubvalue1"', - "key2": '"value2"', - } - expected: dict[str, Any] = { - "key1": {"subkey1": {"subsubkey1": {"subsubsubkey1": "subsubsubvalue1"}}}, - "key2": "value2", - } - - # when - result: dict[str, Any] = LevelDB.parse_flat_dict(data) - - # then - assert result == expected diff --git a/tests/ui/instance/test_modlist_widget.py b/tests/ui/instance/test_modlist_widget.py index bf318e3..555351f 100644 --- a/tests/ui/instance/test_modlist_widget.py +++ b/tests/ui/instance/test_modlist_widget.py @@ -5,12 +5,12 @@ import pytest from cutleast_core_lib.test.utils import Utils from cutleast_core_lib.ui.widgets.search_bar import SearchBar +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.instance.mod import Mod from PySide6.QtCore import Qt from PySide6.QtWidgets import QLabel, QLCDNumber, QTreeWidget, QTreeWidgetItem from pytestqt.qtbot import QtBot -from core.instance.instance import Instance -from core.instance.mod import Mod from tests.base_test import BaseTest from ui.instance.modlist_widget import ModlistWidget diff --git a/tests/ui/migrator/instance_creator/__init__.py b/tests/ui/migrator/instance_creator/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/tests/ui/migrator/instance_creator/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/tests/ui/migrator/instance_creator/test_modorganizer_creator_widget.py b/tests/ui/migrator/instance_creator/test_modorganizer_creator_widget.py deleted file mode 100644 index 3ea259e..0000000 --- a/tests/ui/migrator/instance_creator/test_modorganizer_creator_widget.py +++ /dev/null @@ -1,356 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -from pathlib import Path - -import pytest -from cutleast_core_lib.core.utilities.env_resolver import resolve -from cutleast_core_lib.test.utils import Utils -from cutleast_core_lib.ui.widgets.browse_edit import BrowseLineEdit -from pyfakefs.fake_filesystem import FakeFilesystem -from PySide6.QtWidgets import QCheckBox, QLineEdit, QRadioButton -from pytestqt.qtbot import QtBot - -from tests.base_test import BaseTest -from ui.migrator.instance_creator.modorganizer_creator_widget import ( - ModOrganizerCreatorWidget, -) - - -class TestModOrganizerCreatorWidget(BaseTest): - """ - Tests `ModOrganizerCreatorWidget`. - """ - - NAME_ENTRY: tuple[str, type[QLineEdit]] = ("instance_name_entry", QLineEdit) - """Identifier for accessing the private instance_name_entry field.""" - - INSTANCE_PATH_ENTRY: tuple[str, type[BrowseLineEdit]] = ( - "instance_path_entry", - BrowseLineEdit, - ) - """Identifier for accessing the private instance_path_entry field.""" - - MODS_PATH_ENTRY: tuple[str, type[BrowseLineEdit]] = ( - "mods_path_entry", - BrowseLineEdit, - ) - """Identifier for accessing the private mods_path_entry field.""" - - USE_PORTABLE: tuple[str, type[QRadioButton]] = ("use_portable", QRadioButton) - """Identifier for accessing the private use_portable field.""" - - USE_GLOBAL: tuple[str, type[QRadioButton]] = ("use_global", QRadioButton) - """Identifier for accessing the private use_global field.""" - - INSTALL_MO2: tuple[str, type[QCheckBox]] = ("install_mo2", QCheckBox) - """Identifier for accessing the private install_mo2 field.""" - - USE_ROOT_BUILDER: tuple[str, type[QCheckBox]] = ("use_root_builder", QCheckBox) - """Identifier for accessing the private use_root_builder field.""" - - @pytest.fixture - def widget(self, qtbot: QtBot) -> ModOrganizerCreatorWidget: - """ - Fixture to create and provide a ModOrganizerWidget instance for tests. - """ - - mo2_widget = ModOrganizerCreatorWidget() - qtbot.addWidget(mo2_widget) - mo2_widget.show() - return mo2_widget - - def assert_initial_state(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Asserts the initial state of the widget. - """ - - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - use_global: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_GLOBAL - ) - - assert name_entry.text() == "" - assert name_entry.isEnabled() - assert ( - instance_path_entry.getPath() - == resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" - ) - assert not instance_path_entry.isEnabled() - assert ( - mods_path_entry.getPath() - == resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" / "mods" - ) - assert mods_path_entry.isEnabled() - assert not use_portable.isChecked() - assert use_global.isChecked() - - def assert_global_state(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Asserts state right after the global radiobutton was checked. - """ - - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - use_global: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_GLOBAL - ) - - assert ( - instance_path_entry.getPath() - == resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" / name_entry.text() - ) - assert not instance_path_entry.isEnabled() - assert mods_path_entry.getPath() == ( - resolve(Path("%LOCALAPPDATA%")) - / "ModOrganizer" - / name_entry.text() - / "mods" - ) - assert mods_path_entry.isEnabled() - assert not use_portable.isChecked() - assert use_global.isChecked() - - def assert_portable_state(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Asserts state right after the portable radiobutton was checked. - """ - - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - use_global: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_GLOBAL - ) - - assert instance_path_entry.getPath() == Path() - assert instance_path_entry.isEnabled() - assert mods_path_entry.getPath() == Path() - assert mods_path_entry.isEnabled() - assert use_portable.isChecked() - assert not use_global.isChecked() - - def test_initial_state(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Tests the initial state of the widget. - """ - - self.assert_initial_state(widget) - - def test_on_name_change(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Tests `ModOrganizerCreatorWidget.__on_name_change()`. - """ - - # given - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - new_name: str = "New Name" - - # when - self.assert_initial_state(widget) - name_entry.setText(new_name) - - # then - assert ( - instance_path_entry.getPath() - == resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" / new_name - ) - assert mods_path_entry.getPath() == ( - resolve(Path("%LOCALAPPDATA%")) / "ModOrganizer" / new_name / "mods" - ) - - def test_on_path_change(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Tests `ModOrganizerCreatorWidget.__on_path_change()`. - """ - - # given - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - new_path = Path("New Path") - - # when - use_portable.setChecked(True) - self.assert_portable_state(widget) - instance_path_entry.setPath(new_path) - - # then - assert name_entry.text() == new_path.name - assert instance_path_entry.getPath() == new_path - assert mods_path_entry.getPath() == new_path / "mods" - - def test_on_path_change_relative(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Tests `ModOrganizerCreatorWidget.__on_path_change()` with a preinserted mods - folder relative to the instance path. - """ - - # given - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - old_path = Path("Old Path") - new_path = Path("New Path") - - # when - use_portable.setChecked(True) - self.assert_portable_state(widget) - instance_path_entry.setPath(old_path) - mods_path_entry.setPath(old_path / "mods") - instance_path_entry.setPath(new_path) - - # then - assert instance_path_entry.getPath() == new_path - assert mods_path_entry.getPath() == new_path / "mods" - - def test_on_path_change_independent( - self, widget: ModOrganizerCreatorWidget - ) -> None: - """ - Tests `ModOrganizerCreatorWidget.__on_path_change()` with a preinserted mods - folder independent from the instance path. - """ - - # given - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - old_path = Path("Old Path") - new_path = Path("New Path") - - # when - use_portable.setChecked(True) - self.assert_portable_state(widget) - instance_path_entry.setPath(old_path) - mods_path_entry.setPath(Path("mods")) - instance_path_entry.setPath(new_path) - - # then - assert instance_path_entry.getPath() == new_path - assert mods_path_entry.getPath() == Path("mods") - - def test_on_global_toggled(self, widget: ModOrganizerCreatorWidget) -> None: - """ - Tests `ModOrganizerCreatorWidget.__on_global_toggled()`. - """ - - # given - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - use_global: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_GLOBAL - ) - - # when - use_portable.setChecked(True) - - # then - self.assert_portable_state(widget) - - # when - name_entry.setText("Test") - use_global.setChecked(True) - - # then - self.assert_global_state(widget) - - def test_validate( - self, test_fs: FakeFilesystem, widget: ModOrganizerCreatorWidget - ) -> None: - """ - Tests the validation of the user input. - """ - - # given - name_entry: QLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.NAME_ENTRY - ) - instance_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.INSTANCE_PATH_ENTRY - ) - mods_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.MODS_PATH_ENTRY - ) - use_portable: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_PORTABLE - ) - use_global: QRadioButton = Utils.get_private_field( - widget, *TestModOrganizerCreatorWidget.USE_GLOBAL - ) - - # when - use_portable.setChecked(True) - instance_path_entry.setPath(Path("Instance Path")) - mods_path_entry.setPath(Path("Mods Path")) - - # then - assert widget.validate() - - # when - use_global.setChecked(True) - name_entry.setText("Test") - - # then - assert widget.validate() diff --git a/tests/ui/migrator/instance_selector/__init__.py b/tests/ui/migrator/instance_selector/__init__.py deleted file mode 100644 index 9b8baf1..0000000 --- a/tests/ui/migrator/instance_selector/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Copyright (c) Cutleast -""" diff --git a/tests/ui/migrator/instance_selector/test_modorganizer_selector_widget.py b/tests/ui/migrator/instance_selector/test_modorganizer_selector_widget.py deleted file mode 100644 index 8dc831b..0000000 --- a/tests/ui/migrator/instance_selector/test_modorganizer_selector_widget.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Copyright (c) Cutleast -""" - -import pytest -from cutleast_core_lib.test.utils import Utils -from cutleast_core_lib.ui.widgets.browse_edit import BrowseLineEdit -from pyfakefs.fake_filesystem import FakeFilesystem -from PySide6.QtWidgets import QComboBox -from pytestqt.qtbot import QtBot - -from core.game.game import Game -from core.mod_manager.modorganizer.modorganizer import ModOrganizer -from tests.base_test import BaseTest -from ui.migrator.instance_selector.modorganizer_selector_widget import ( - ModOrganizerSelectorWidget, -) - - -class TestModOrganizerSelectorWidget(BaseTest): - """ - Tests `ModOrganizerSelectorWidget`. - """ - - INSTANCE_DROPDOWN: tuple[str, type[QComboBox]] = ("instance_dropdown", QComboBox) - """Identifier for accessing the private instance_dropdown field.""" - - PORTABLE_PATH_ENTRY: tuple[str, type[BrowseLineEdit]] = ( - "portable_path_entry", - BrowseLineEdit, - ) - """Identifier for accessing the private portable_path_entry field.""" - - PROFILE_DROPDOWN: tuple[str, type[QComboBox]] = ("profile_dropdown", QComboBox) - """Identifier for accessing the private profile_dropdown field.""" - - @pytest.fixture - def widget( - self, test_fs: FakeFilesystem, qtbot: QtBot - ) -> ModOrganizerSelectorWidget: - """ - Fixture to create and provide a ModOrganizerSelectorWidget instance for tests. - """ - - mo2_widget = ModOrganizerSelectorWidget( - ModOrganizer().get_instance_names(Game.get_game_by_id("skyrimse")) - ) - qtbot.addWidget(mo2_widget) - mo2_widget.show() - return mo2_widget - - def assert_initial_state(self, widget: ModOrganizerSelectorWidget) -> None: - """ - Asserts the initial state of the widget. - """ - - instance_dropdown: QComboBox = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.INSTANCE_DROPDOWN - ) - portable_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.PORTABLE_PATH_ENTRY - ) - profile_dropdown: QComboBox = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.PROFILE_DROPDOWN - ) - - assert instance_dropdown.currentIndex() == 0 - assert instance_dropdown.isEnabled() - assert portable_path_entry.text() == "" - assert not portable_path_entry.isEnabled() - assert profile_dropdown.currentIndex() == 0 - assert not profile_dropdown.isEnabled() - assert not widget.validate() - - def test_initial_state(self, widget: ModOrganizerSelectorWidget) -> None: - """ - Tests the initial state of the widget. - """ - - self.assert_initial_state(widget) - - def test_select_global_instance( - self, test_fs: FakeFilesystem, widget: ModOrganizerSelectorWidget - ) -> None: - """ - Tests the selection of a global instance. - """ - - # given - instance_dropdown: QComboBox = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.INSTANCE_DROPDOWN - ) - portable_path_entry: BrowseLineEdit = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.PORTABLE_PATH_ENTRY - ) - profile_dropdown: QComboBox = Utils.get_private_field( - widget, *TestModOrganizerSelectorWidget.PROFILE_DROPDOWN - ) - - # then - assert instance_dropdown.count() == 3 - assert instance_dropdown.itemText(1) == "Test Instance" - - # when - instance_dropdown.setCurrentIndex(1) - - # then - assert not portable_path_entry.isEnabled() - assert profile_dropdown.isEnabled() - assert profile_dropdown.count() == 3 - assert profile_dropdown.itemText(1) == "Default" - assert profile_dropdown.itemText(2) == "TestProfile" - assert not widget.validate() - - # when - profile_dropdown.setCurrentIndex(2) - - # then - assert widget.validate() diff --git a/tests/ui/migrator/test_migrator_widget.py b/tests/ui/migrator/test_migrator_widget.py index 71d6685..fb4c884 100644 --- a/tests/ui/migrator/test_migrator_widget.py +++ b/tests/ui/migrator/test_migrator_widget.py @@ -3,37 +3,49 @@ """ from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, cast import pytest from cutleast_core_lib.test.utils import Utils from cutleast_core_lib.ui.widgets.browse_edit import BrowseLineEdit +from cutleast_core_lib.ui.widgets.enum_placeholder_dropdown import ( + EnumPlaceholderDropdown, +) +from cutleast_core_lib.ui.widgets.placeholder_dropdown import PlaceholderDropdown +from mod_manager_lib.core.game import Game +from mod_manager_lib.core.game_service import GameService +from mod_manager_lib.core.instance.instance import Instance +from mod_manager_lib.core.mod_manager.instance_info import InstanceInfo +from mod_manager_lib.core.mod_manager.mod_manager import ModManager +from mod_manager_lib.core.mod_manager.modorganizer.mo2_instance_info import ( + MO2InstanceInfo, +) +from mod_manager_lib.core.mod_manager.vortex.profile_info import ProfileInfo +from mod_manager_lib.core.mod_manager.vortex.vortex import Vortex +from mod_manager_lib.ui.instance_creator.base_creator_widget import BaseCreatorWidget +from mod_manager_lib.ui.instance_creator.instance_creator_widget import ( + InstanceCreatorWidget, +) +from mod_manager_lib.ui.instance_creator.vortex_creator_widget import ( + VortexCreatorWidget, +) +from mod_manager_lib.ui.instance_selector.base_selector_widget import BaseSelectorWidget +from mod_manager_lib.ui.instance_selector.instance_selector_widget import ( + InstanceSelectorWidget, +) +from mod_manager_lib.ui.instance_selector.modorganizer_selector_widget import ( + ModOrganizerSelectorWidget, +) +from mod_manager_lib.ui.instance_selector.vortex_selector_widget import ( + VortexSelectorWidget, +) from pyfakefs.fake_filesystem import FakeFilesystem from PySide6.QtWidgets import QComboBox, QLineEdit, QPushButton, QTabWidget from pytestqt.qtbot import QtBot from setup.mock_plyvel import MockPlyvelDB from core.config.app_config import AppConfig -from core.game.game import Game -from core.instance.instance import Instance -from core.mod_manager.instance_info import InstanceInfo -from core.mod_manager.mod_manager import ModManager -from core.mod_manager.modorganizer.mo2_instance_info import MO2InstanceInfo -from core.mod_manager.modorganizer.modorganizer import ModOrganizer -from core.mod_manager.vortex.profile_info import ProfileInfo -from core.mod_manager.vortex.vortex import Vortex from tests.base_test import BaseTest -from ui.migrator.instance_creator.base_creator_widget import BaseCreatorWidget -from ui.migrator.instance_creator.instance_creator_widget import InstanceCreatorWidget -from ui.migrator.instance_creator.vortex_creator_widget import VortexCreatorWidget -from ui.migrator.instance_selector import VortexSelectorWidget -from ui.migrator.instance_selector.base_selector_widget import BaseSelectorWidget -from ui.migrator.instance_selector.instance_selector_widget import ( - InstanceSelectorWidget, -) -from ui.migrator.instance_selector.modorganizer_selector_widget import ( - ModOrganizerSelectorWidget, -) from ui.migrator.migrator_widget import MigratorWidget @@ -51,9 +63,9 @@ class TestMigratorWidget(BaseTest): ) """Identifier for accessing the private src_selector field.""" - MOD_MANAGER_DROPDOWN: tuple[str, type[QComboBox]] = ( + MOD_MANAGER_DROPDOWN: tuple[str, type[EnumPlaceholderDropdown[ModManager]]] = ( "mod_manager_dropdown", - QComboBox, + EnumPlaceholderDropdown[ModManager], ) """ Identifier for accessing the private mod_manager_dropdown field of InstanceSelector @@ -132,12 +144,11 @@ def test_initial_state(self, widget: MigratorWidget) -> None: # then assert game_dropdown.currentIndex() == 0 assert game_dropdown.isEnabled() - assert game_dropdown.count() == len(Game.get_supported_games()) + 1 + assert game_dropdown.count() == len(GameService.get_supported_games()) + 1 assert widget.get_selected_game() is None assert not src_selector.isEnabled() - assert src_selector.get_selected_mod_manager() is None - with pytest.raises(ValueError, match="No instance data selected."): - src_selector.get_cur_instance_data() + assert src_selector.get_cur_mod_manager() is None + assert src_selector.get_cur_instance_data() is None assert not load_src_button.isEnabled() assert not dst_instance_tab.isEnabled() @@ -156,15 +167,17 @@ def test_select_src_instance( """ # given - skyrimse: Game = Game.get_game_by_id("skyrimse") + skyrimse: Game = GameService.get_game_by_id("skyrimse") game_dropdown: QComboBox = Utils.get_private_field( widget, *TestMigratorWidget.GAME_DROPDOWN ) src_selector: InstanceSelectorWidget = Utils.get_private_field( widget, *TestMigratorWidget.SRC_SELECTOR ) - src_mod_manager_dropdown: QComboBox = Utils.get_private_field( - src_selector, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + src_mod_manager_dropdown: EnumPlaceholderDropdown[ModManager] = ( + Utils.get_private_field( + src_selector, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + ) ) src_mod_managers: dict[ModManager, BaseSelectorWidget] = ( Utils.get_private_field( @@ -187,11 +200,11 @@ def test_select_src_instance( assert not dst_creator.isEnabled() # when - src_mod_manager_dropdown.setCurrentText(ModOrganizer.get_display_name()) + src_mod_manager_dropdown.setCurrentValue(ModManager.ModOrganizer) # then - cur_mod_manager: Optional[ModManager] = src_selector.get_selected_mod_manager() - assert isinstance(cur_mod_manager, ModOrganizer) + cur_mod_manager: Optional[ModManager] = src_selector.get_cur_mod_manager() + assert cur_mod_manager == ModManager.ModOrganizer # when mo2_selector_widget: BaseSelectorWidget = src_mod_managers[cur_mod_manager] @@ -258,7 +271,7 @@ def test_change_src_instance( self.test_select_src_instance(widget, test_fs, instance, qtbot) # given - skyrimse: Game = Game.get_game_by_id("skyrimse") + skyrimse: Game = GameService.get_game_by_id("skyrimse") game_dropdown: QComboBox = Utils.get_private_field( widget, *TestMigratorWidget.GAME_DROPDOWN ) @@ -273,7 +286,7 @@ def test_change_src_instance( src_selector, *TestMigratorWidget.MOD_MANAGER_SELECTORS ) ) - vortex: Vortex = {m.get_id(): m for m in src_mod_managers}[Vortex.get_id()] # type: ignore + vortex: Vortex = cast(Vortex, ModManager.Vortex.get_api()) vortex.db_path.mkdir(parents=True, exist_ok=True) load_src_button: QPushButton = Utils.get_private_field( widget, *TestMigratorWidget.LOAD_SRC_BUTTON @@ -285,10 +298,9 @@ def test_change_src_instance( # then assert widget.get_selected_game() is None assert widget.get_src_instance() is None - with pytest.raises(ValueError): - widget.get_src_instance_info() + assert widget.get_src_instance_info() is None assert not src_mod_manager_dropdown.isEnabled() - assert src_mod_manager_dropdown.currentIndex() == 0 + assert src_mod_manager_dropdown.currentIndex() == -1 assert not src_selector.isEnabled() assert not load_src_button.isEnabled() @@ -302,8 +314,8 @@ def test_change_src_instance( src_mod_manager_dropdown.setCurrentText(Vortex.get_display_name()) # then - cur_mod_manager: Optional[ModManager] = src_selector.get_selected_mod_manager() - assert isinstance(cur_mod_manager, Vortex) + cur_mod_manager: Optional[ModManager] = src_selector.get_cur_mod_manager() + assert cur_mod_manager == ModManager.Vortex # when vortex_selector_widget: BaseSelectorWidget = src_mod_managers[cur_mod_manager] @@ -312,16 +324,16 @@ def test_change_src_instance( assert isinstance(vortex_selector_widget, VortexSelectorWidget) # when - profile_dropdown: QComboBox = Utils.get_private_field( - vortex_selector_widget, "profile_dropdown", QComboBox + profile_dropdown: PlaceholderDropdown = Utils.get_private_field( + vortex_selector_widget, "profile_dropdown", PlaceholderDropdown ) # then - assert profile_dropdown.count() == 3 - assert profile_dropdown.itemText(2) == vortex_profile_info.display_name + assert profile_dropdown.count() == 2 + assert profile_dropdown.itemText(1) == vortex_profile_info.display_name # when - profile_dropdown.setCurrentIndex(2) + profile_dropdown.setCurrentIndex(1) # then assert load_src_button.isEnabled() @@ -365,13 +377,15 @@ def test_create_dst_instance( dst_creator: InstanceCreatorWidget = Utils.get_private_field( widget, *TestMigratorWidget.DST_CREATOR ) - dst_mod_manager_dropdown: QComboBox = Utils.get_private_field( - dst_creator, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + dst_mod_manager_dropdown: EnumPlaceholderDropdown[ModManager] = ( + Utils.get_private_field( + dst_creator, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + ) ) dst_mod_managers: dict[ModManager, BaseCreatorWidget] = Utils.get_private_field( dst_creator, *TestMigratorWidget.MOD_MANAGER_CREATORS ) - vortex: Vortex = {m.get_id(): m for m in dst_mod_managers}[Vortex.get_id()] # type: ignore + vortex: Vortex = cast(Vortex, ModManager.Vortex.get_api()) vortex.db_path.mkdir(parents=True, exist_ok=True) migrate_button: QPushButton = Utils.get_private_field( widget, *TestMigratorWidget.MIGRATE_BUTTON @@ -383,13 +397,13 @@ def test_create_dst_instance( assert not migrate_button.isEnabled() # when - dst_mod_manager_dropdown.setCurrentText(Vortex.get_display_name()) + dst_mod_manager_dropdown.setCurrentValue(ModManager.Vortex) # then - assert dst_creator.get_selected_mod_manager() is vortex + assert dst_creator.get_selected_mod_manager() == ModManager.Vortex # when - vortex_creator_widget: BaseCreatorWidget = dst_mod_managers[vortex] + vortex_creator_widget: BaseCreatorWidget = dst_mod_managers[ModManager.Vortex] # then assert isinstance(vortex_creator_widget, VortexCreatorWidget) @@ -405,7 +419,7 @@ def test_create_dst_instance( # when profile: ProfileInfo = vortex_creator_widget.get_instance( - Game.get_game_by_id("skyrimse") + GameService.get_game_by_id("skyrimse") ) # then @@ -430,7 +444,7 @@ def test_dst_instance_tab( self.test_select_src_instance(widget, test_fs, instance, qtbot) # given - skyrimse: Game = Game.get_game_by_id("skyrimse") + skyrimse: Game = GameService.get_game_by_id("skyrimse") dst_instance_tab: QTabWidget = Utils.get_private_field( widget, *TestMigratorWidget.DST_INSTANCE_TAB ) @@ -440,30 +454,28 @@ def test_dst_instance_tab( dst_selector: InstanceSelectorWidget = Utils.get_private_field( widget, *TestMigratorWidget.DST_SELECTOR ) - creator_mod_manager_dropdown: QComboBox = Utils.get_private_field( - dst_creator, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + creator_mod_manager_dropdown: EnumPlaceholderDropdown[ModManager] = ( + Utils.get_private_field( + dst_creator, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + ) ) mod_manager_creators: dict[ModManager, BaseCreatorWidget] = ( Utils.get_private_field( dst_creator, *TestMigratorWidget.MOD_MANAGER_CREATORS ) ) - creator_vortex: Vortex = {m.get_id(): m for m in mod_manager_creators}[ - Vortex.get_id() - ] # type: ignore - creator_vortex.db_path.mkdir(parents=True, exist_ok=True) - selector_mod_manager_dropdown: QComboBox = Utils.get_private_field( - dst_selector, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + vortex: Vortex = cast(Vortex, ModManager.Vortex.get_api()) + vortex.db_path.mkdir(parents=True, exist_ok=True) + selector_mod_manager_dropdown: EnumPlaceholderDropdown[ModManager] = ( + Utils.get_private_field( + dst_selector, *TestMigratorWidget.MOD_MANAGER_DROPDOWN + ) ) mod_manager_selectors: dict[ModManager, BaseSelectorWidget] = ( Utils.get_private_field( dst_selector, *TestMigratorWidget.MOD_MANAGER_SELECTORS ) ) - selector_vortex: Vortex = {m.get_id(): m for m in mod_manager_selectors}[ - Vortex.get_id() - ] # type: ignore - selector_vortex.db_path.mkdir(parents=True, exist_ok=True) migrate_button: QPushButton = Utils.get_private_field( widget, *TestMigratorWidget.MIGRATE_BUTTON ) @@ -484,26 +496,26 @@ def test_dst_instance_tab( assert not migrate_button.isEnabled() # when - selector_mod_manager_dropdown.setCurrentText(Vortex.get_display_name()) + selector_mod_manager_dropdown.setCurrentValue(ModManager.Vortex) vortex_selector_widget: BaseSelectorWidget = mod_manager_selectors[ - selector_vortex + ModManager.Vortex ] # then assert isinstance(vortex_selector_widget, VortexSelectorWidget) # when - selector_profile_dropdown: QComboBox = Utils.get_private_field( - vortex_selector_widget, "profile_dropdown", QComboBox + selector_profile_dropdown: PlaceholderDropdown = Utils.get_private_field( + vortex_selector_widget, "profile_dropdown", PlaceholderDropdown ) # then assert selector_profile_dropdown.isEnabled() - assert selector_profile_dropdown.count() == 3 + assert selector_profile_dropdown.count() == 2 assert not migrate_button.isEnabled() # when - selector_profile_dropdown.setCurrentIndex(1) + selector_profile_dropdown.setCurrentIndex(0) # then assert migrate_button.isEnabled() @@ -515,7 +527,9 @@ def test_dst_instance_tab( assert not migrate_button.isEnabled() # when - vortex_creator_widget: BaseCreatorWidget = mod_manager_creators[creator_vortex] + vortex_creator_widget: BaseCreatorWidget = mod_manager_creators[ + ModManager.Vortex + ] # then assert isinstance(vortex_creator_widget, VortexCreatorWidget) diff --git a/update_lupdate_file.bat b/update_lupdate_file.bat index 17ce7ad..7253e66 100644 --- a/update_lupdate_file.bat +++ b/update_lupdate_file.bat @@ -2,10 +2,12 @@ uv run core-lib\src\cutleast_core_lib\scripts\generate_qt_lupdate_file.py ^ --include-directory=src ^ --include-directory=core-lib\src\cutleast_core_lib ^ +--include-directory=mod-manager-lib\src\mod_manager_lib ^ --exclude-file=src/resources_rc.py ^ --relative-to=. ^ --add-translation=res/loc/de.ts ^ --add-translation=res/loc/pt_BR.ts ^ --out-file=qt_lupdate.json ^ --include-path=src ^ ---include-path=core-lib\src\cutleast_core_lib \ No newline at end of file +--include-path=core-lib\src\cutleast_core_lib ^ +--include-path=mod-manager-lib\src\mod_manager_lib diff --git a/uv.lock b/uv.lock index f075dec..ccd35eb 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ resolution-markers = [ [manifest] members = [ "cutleast-core-lib", + "mod-manager-lib", "mod-manager-migrator", ] @@ -696,14 +697,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] +[[package]] +name = "mod-manager-lib" +version = "1.0.0" +source = { editable = "mod-manager-lib" } +dependencies = [ + { name = "cutleast-core-lib" }, + { name = "plyvel-ci" }, + { name = "pyuac" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyfakefs" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-qt" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cutleast-core-lib", editable = "core-lib" }, + { name = "plyvel-ci" }, + { name = "pyuac" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyfakefs" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-qt" }, + { name = "ruff" }, +] + [[package]] name = "mod-manager-migrator" version = "3.1.0b1" source = { virtual = "." } dependencies = [ { name = "cutleast-core-lib" }, - { name = "plyvel-ci" }, - { name = "pyuac" }, + { name = "mod-manager-lib" }, ] [package.dev-dependencies] @@ -722,8 +761,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cutleast-core-lib", editable = "core-lib" }, - { name = "plyvel-ci" }, - { name = "pyuac" }, + { name = "mod-manager-lib", editable = "mod-manager-lib" }, ] [package.metadata.requires-dev]