Compare commits
27 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf1f622196 | |||
| 8be97c1f5b | |||
| fa8421ff42 | |||
| 9ed941b37e | |||
| e7d487c180 | |||
| 53f0341d98 | |||
| 0cd631c92c | |||
| e94d11f138 | |||
| 035c333dcf | |||
| b0cebe12a0 | |||
| 0ad0b3e605 | |||
| a130a23a8a | |||
| 56b63fe673 | |||
| 6290a18c52 | |||
| bd2713fe8d | |||
| 8b40e82c36 | |||
| 95364a5596 | |||
| 4dba9ad425 | |||
| 2bda69ba4b | |||
| 7ac5f7470e | |||
| b61d1d1fa9 | |||
| 5baa7840de | |||
| 3024eb9a3d | |||
| a2789b1704 | |||
| cf8e77d844 | |||
| 1061acbe6b | |||
| 631cdb4938 |
9 changed files with 354 additions and 18 deletions
|
|
@ -5,7 +5,7 @@
|
|||
}:
|
||||
python3Packages.buildPythonApplication rec {
|
||||
pname = "leek";
|
||||
version = "0.2";
|
||||
version = "0.3";
|
||||
pyproject = true;
|
||||
|
||||
src = ./.;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ dependencies = [
|
|||
"tomlkit",
|
||||
"vdf",
|
||||
]
|
||||
requires-python = ">=3.6"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[project.readme]
|
||||
file = "README.md"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
118
src/leek/mod_installer.py
Normal file
118
src/leek/mod_installer.py
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
93
src/leek/qmod_installer.py
Normal file
93
src/leek/qmod_installer.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue