diff --git a/package.nix b/package.nix index b242333..48f4f64 100644 --- a/package.nix +++ b/package.nix @@ -5,7 +5,7 @@ }: python3Packages.buildPythonApplication rec { pname = "leek"; - version = "0.2"; + version = "0.3"; pyproject = true; src = ./.; diff --git a/pyproject.toml b/pyproject.toml index b0cc6ce..1ccbed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "tomlkit", "vdf", ] -requires-python = ">=3.6" +requires-python = ">=3.12" [project.readme] file = "README.md" diff --git a/src/leek/leek_app.py b/src/leek/leek_app.py index 701046c..f85592e 100644 --- a/src/leek/leek_app.py +++ b/src/leek/leek_app.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication from PySide6.QtCore import QUrl from PySide6.QtQml import QQmlApplicationEngine from leek.mod_list import QAbstractListModel # noqa: F401, needs to be imported for QML +from leek.qmod_installer import QModInstaller # noqa: F401, needs to be imported for QML def main(): diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py new file mode 100644 index 0000000..485d93f --- /dev/null +++ b/src/leek/mod_installer.py @@ -0,0 +1,118 @@ +from pathlib import Path +from zipfile import Path as ZipPath +from zipfile import ZipFile, is_zipfile +from leek.utils import GameFinder + + +class ModInstaller: + __archive_path: Path + __archive_format: str + __is_flat: bool + __is_installed: bool + + def __init__(self, mod_path: Path) -> None: + self.__archive_path = mod_path + self.__is_installed = False + if is_zipfile(mod_path): + self.__archive_format = "zip" + archive: ZipFile = ZipFile(mod_path) + path: ZipPath = ZipPath(archive) + + # Make sure archive contains a mod + things_in_root: list[ZipPath] = list(path.iterdir()) + config_toml: ZipPath + match len(things_in_root): + case 0: + raise EmptyArchiveError + case 1: + config_toml = things_in_root[0] / "config.toml" + if config_toml.exists() and config_toml.is_file(): + # Mod can be extracted as is + self.__is_flat = False + self.__check_if_already_installed() + return + case _: + config_toml = path / "config.toml" + if config_toml.exists() and config_toml.is_file(): + # Mod needs to be extracted in a folder + self.__is_flat = True + self.__check_if_already_installed() + return + + # If we ever get here there's either no mod on the archive, or we failed to find it + raise NoModExceptionError(str(mod_path)) + + else: + raise UnsupportedArchiveTypeError(str(mod_path)) + + @property + def is_installed(self) -> bool: + return self.__is_installed + + def install(self) -> None: + """ + Install the mod to Project Diva's mod folder + """ + if self.__is_installed: + return + + game_path: Path = GameFinder.find() + if self.__is_flat: + mod_folder_name: str = self.__get_mod_folder_name() + ZipFile(self.__archive_path).extractall( + path=str(game_path / "mods" / mod_folder_name) + ) + else: + ZipFile(self.__archive_path).extractall(path=str(game_path / "mods")) + + self.__is_installed = True + + def __get_mod_folder_name(self) -> str: + mod_folder_name: str + if self.__is_flat: + mod_folder_name = self.__archive_path.stem + else: + path: ZipPath = ZipPath(ZipFile(self.__archive_path)) + things_in_root: list[ZipPath] = list(path.iterdir()) + mod_folder_name = things_in_root[0].stem + + return mod_folder_name + + def __check_if_already_installed(self) -> None: + installed_mod_path: Path = ( + GameFinder.find() / "mods" / self.__get_mod_folder_name() + ) + if installed_mod_path.exists(): + self.__is_installed = True + + +class UnsupportedArchiveTypeError(Exception): + """ + Rased when ModInstaller runs into an archive that it can't extract + """ + + __message: str + + def __init__(self, archive_path: str) -> None: + self.__message = f"Don't know how to unpack archive at {archive_path}" + + def __str__(self) -> str: + return f"{self.__message}" + + +class EmptyArchiveError(Exception): + pass + + +class NoModExceptionError(Exception): + """ + Raised if the archive does not have a mod inside + """ + + __message: str + + def __init__(self, archive_path: str) -> None: + self.__message = f"Archive at {archive_path} does not have a mod" + + def __str__(self) -> str: + return f"{self.__message}" diff --git a/src/leek/mod_list.py b/src/leek/mod_list.py index ee12488..3c13d58 100644 --- a/src/leek/mod_list.py +++ b/src/leek/mod_list.py @@ -1,4 +1,4 @@ -from PySide6.QtQml import QmlElement +from PySide6.QtQml import QmlElement, QmlSingleton from PySide6.QtCore import QAbstractListModel, QModelIndex from leek.mod import InvalidModError from leek.qmod import QMod @@ -12,6 +12,7 @@ QML_IMPORT_MAJOR_VERSION = 1 # Qt follows C++ naming conventions # ruff: noqa: N802 @QmlElement +@QmlSingleton class QModListModel(QAbstractListModel): def __init__(self, parent=None) -> None: super().__init__(parent=parent) @@ -47,3 +48,16 @@ class QModListModel(QAbstractListModel): else: result = None return result + + def removeRows(self, row, count, parent=QModelIndex()) -> bool: + super().beginRemoveRows(parent, row, row + count - 1) + for index in range(row, row + count): + deleted_mod: QMod = self.mods.pop(index) + for root, dirs, files in deleted_mod.pathlib_path.walk(top_down=False): + for name in files: + (root / name).unlink() + for name in dirs: + (root / name).rmdir() + deleted_mod.pathlib_path.rmdir() + super().endRemoveRows() + return False diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index 9b6b912..cf6889b 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -1,8 +1,10 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls as Controls +import QtQuick.Dialogs as Dialogs import org.kde.kirigami as Kirigami import Leek +import Leek.QModInstaller Kirigami.ApplicationWindow { id: root @@ -11,15 +13,89 @@ Kirigami.ApplicationWindow { globalDrawer: Kirigami.GlobalDrawer { actions: [ + Kirigami.Action { + // download-symbolic and install-symbolic are the same icon + // but install looks worse for some reason + id: addAction + icon.name: "download-symbolic" + text: "Add mod" + shortcut: StandardKey.New + onTriggered: modFileDialog.open() + }, Kirigami.Action { text: "Quit" icon.name: "application-exit-symbolic" + shortcut: StandardKey.Quit onTriggered: Qt.quit() } ] isMenu: true } + Component.onCompleted: { + QModInstaller.statusChanged.connect(installerStatusChanged); + QModInstaller.finishedInstall.connect(installDialog.close); + } + + function installerStatusChanged() { + switch (QModInstaller.status) { + case "initialized": + if (QModInstaller.installed) { + installInfoMessage.text = "This mod is already installed!"; + installInfoDialog.open(); + } else { + installDialog.open(); + QModInstaller.install(); + } + break; + case "NoModExceptionError": + installInfoMessage.text = "This file does not have a mod"; + installInfoDialog.open(); + break; + case "UnsupportedArchiveTypeError": + installInfoMessage.text = "I don't know how to unpack this file, sorry :("; + installInfoDialog.open(); + break; + } + } + + Dialogs.FileDialog { + id: modFileDialog + + nameFilters: ["Project DIVA Mods (*.zip *.7z *.rar)"] + selectedNameFilter.index: 0 + + onAccepted: { + QModInstaller.modPath = selectedFile; + } + } + + Kirigami.Dialog { + id: installDialog + title: "Installing…" + // standardButtons: Kirigami.Dialog.Ok + closePolicy: Controls.Popup.NoAutoClose + showCloseButton: false + padding: Kirigami.Units.largeSpacing + Kirigami.LoadingPlaceholder { + // Layout.alignment: Qt.AlignCenter + anchors.centerIn: parent + determinate: false + text: "Unpacking…" + } + } + + Kirigami.Dialog { + id: installInfoDialog + title: "Add mod" + standardButtons: Kirigami.Dialog.Ok + padding: Kirigami.Units.largeSpacing + Controls.Label { + id: installInfoMessage + text: "You should never see this text" + } + } + pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" @@ -28,16 +104,27 @@ Kirigami.ApplicationWindow { id: modsView delegate: ModCardDelegate {} - model: QModListModel {} + model: QModListModel + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + icon.name: "edit-none" + visible: modsView.count === 0 + text: "There's no mods installed" + explanation: "Install a mod, and it will show up here" + helpfulAction: addAction + } } } component ModCardDelegate: Kirigami.AbstractCard { + required property int index required property QMod mod showClickFeedback: true onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { - mod: mod + mod: mod, + index: index }) // headerOrientation: Qt.Horizontal @@ -96,19 +183,6 @@ Kirigami.ApplicationWindow { onClicked: mod.enabled = checked } - Controls.Button { - text: "Delete" - onClicked: notImplementedDialog.open() - Kirigami.Dialog { - id: notImplementedDialog - title: "Not implemented!" - standardButtons: Kirigami.Dialog.Ok - padding: Kirigami.Units.largeSpacing - Controls.Label { - text: "Deleting is not implemented yet" - } - } - } } } } diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml index 326b760..528affb 100644 --- a/src/leek/qml/ModPage.qml +++ b/src/leek/qml/ModPage.qml @@ -6,6 +6,7 @@ import Leek Kirigami.Page { id: root + required property int index required property QMod mod title: "Local mod" @@ -15,6 +16,33 @@ Kirigami.Page { applicationWindow().pageStack.pop(); } + Kirigami.Dialog { + id: deleteConfirmationDialog + title: "Delete mod" + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + padding: Kirigami.Units.largeSpacing + Controls.Label { + text: "Permanently delete this mod?" + } + onAccepted: { + QModListModel.removeRows(index, 1); + applicationWindow().pageStack.pop(); + } + onOpened: { + const deleteButton = standardButton(Kirigami.Dialog.Cancel); + deleteButton.forceActiveFocus(); + } + } + + actions: [ + Kirigami.Action { + text: "Delete" + icon.name: "delete-symbolic" + shortcut: StandardKey.Delete + onTriggered: deleteConfirmationDialog.open() + } + ] + ColumnLayout { anchors { top: parent.top diff --git a/src/leek/qmod.py b/src/leek/qmod.py index 1e12bc4..1131468 100644 --- a/src/leek/qmod.py +++ b/src/leek/qmod.py @@ -28,6 +28,14 @@ class QMod(QObject): # Pass though all exceptions raise + @property + def pathlib_path(self) -> Path: + return self.__mod.path + + @Property(str, constant=True) + def path(self) -> str: + return str(self.__mod.path) + @Property(str, constant=True) def name(self) -> str | None: return self.__mod.name diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py new file mode 100644 index 0000000..7d7939f --- /dev/null +++ b/src/leek/qmod_installer.py @@ -0,0 +1,93 @@ +from pathlib import Path +from urllib.parse import urlparse + +from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, Signal, Slot +from PySide6.QtQml import QmlElement, QmlSingleton + +from leek.mod_installer import ( + ModInstaller, + NoModExceptionError, + UnsupportedArchiveTypeError, +) + +QML_IMPORT_NAME = "Leek.QModInstaller" +QML_IMPORT_MAJOR_VERSION = 1 + + +# Qt follows C++ naming conventions +# ruff: noqa: N802 +@QmlElement +@QmlSingleton +class QModInstaller(QObject): + """ + Qt Wrapper around the ModInstaller() class + """ + + __mod_installer: ModInstaller + __status: str + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.__status = "uninitialized" + + mod_path_changed = Signal(name="modPathChanged") + + @Property(str, notify=mod_path_changed) + def modPath(self) -> str: + return self.test + + @modPath.setter # type: ignore[no-redef] + def modPath(self, value: str) -> None: + try: + # In python 3.13 pathlib gained a from_uri() method which would be very useful + parsed_path: str = urlparse(value, "file").path + self.__mod_installer = ModInstaller(Path(parsed_path)) + self.__status = "initialized" + self.status_changed.emit() + except UnsupportedArchiveTypeError: + self.__status = "UnsupportedArchiveTypeError" + self.status_changed.emit() + except NoModExceptionError: + self.__status = "NoModExceptionError" + self.status_changed.emit() + + install_status_changed = Signal(name="installStatusChanged") + + @Property(bool, notify=install_status_changed) + def installed(self) -> bool: + return self.__mod_installer.is_installed + + status_changed = Signal(name="statusChanged") + + @Property(str, notify=status_changed) + def status(self) -> str: + return self.__status + + finished_install = Signal(name="finishedInstall") + + @Slot() + def install(self) -> None: + worker = InstallWorker(InstallWorkerSignals(), self.__mod_installer) + worker.signals.installed.connect(self.finished_install) + QThreadPool.globalInstance().start(worker) + + +class InstallWorker(QRunnable): + __installer: ModInstaller + + def __init__(self, signals, installer: ModInstaller) -> None: + super().__init__() + self.signals = signals + self.__installer = installer + + # @Slot() + def run(self) -> None: + self.__installer.install() + self.signals.installed.emit() + + +class InstallWorkerSignals(QObject): + def __init__(self): + super().__init__() + + installed = Signal()