From 1453686de6c2f00fcac21261c479e2714bc9d222 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 12 Sep 2025 00:02:28 +0800 Subject: [PATCH] wip: database checker --- app.py | 52 +++ core/settings/keys.py | 3 +- core/settings/values.py | 6 + pyproject.toml | 1 + ui/qmls/App.qml | 24 ++ ui/qmls/AppMain.qml | 68 ++++ ui/qmls/Components/SectionTitle.qml | 2 +- .../DatabaseChecker/DatabaseFileCreator.qml | 56 +++ .../Dialog_ConfirmConnection.qml | 31 ++ ui/qmls/DatabaseChecker/Dialog_Error.qml | 34 ++ ui/qmls/DatabaseChecker/HorizontalDivider.qml | 13 + ui/qmls/DatabaseChecker/Index.qml | 124 ++++++ .../DatabaseChecker/Section_DatabaseInfo.qml | 138 +++++++ .../Section_SelectOrCreate.qml | 69 ++++ ui/startup/databaseChecker.py | 136 ------- ui/startup/databaseChecker.ui | 154 -------- ui/startup/databaseChecker_ui.py | 138 ------- ui/{startup => utils}/__init__.py | 0 ui/utils/common.py | 1 + ui/utils/url.py | 48 +++ ui/viewmodels/__init__.py | 2 + ui/viewmodels/common.py | 1 + ui/viewmodels/databaseInit.py | 368 ++++++++++++++++++ ui/viewmodels/overview.py | 21 + 24 files changed, 1060 insertions(+), 430 deletions(-) create mode 100644 app.py create mode 100644 ui/qmls/App.qml create mode 100644 ui/qmls/AppMain.qml create mode 100644 ui/qmls/DatabaseChecker/DatabaseFileCreator.qml create mode 100644 ui/qmls/DatabaseChecker/Dialog_ConfirmConnection.qml create mode 100644 ui/qmls/DatabaseChecker/Dialog_Error.qml create mode 100644 ui/qmls/DatabaseChecker/HorizontalDivider.qml create mode 100644 ui/qmls/DatabaseChecker/Index.qml create mode 100644 ui/qmls/DatabaseChecker/Section_DatabaseInfo.qml create mode 100644 ui/qmls/DatabaseChecker/Section_SelectOrCreate.qml delete mode 100644 ui/startup/databaseChecker.py delete mode 100644 ui/startup/databaseChecker.ui delete mode 100644 ui/startup/databaseChecker_ui.py rename ui/{startup => utils}/__init__.py (100%) create mode 100644 ui/utils/common.py create mode 100644 ui/utils/url.py create mode 100644 ui/viewmodels/__init__.py create mode 100644 ui/viewmodels/common.py create mode 100644 ui/viewmodels/databaseInit.py create mode 100644 ui/viewmodels/overview.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..538eaa7 --- /dev/null +++ b/app.py @@ -0,0 +1,52 @@ +import logging +import sys +from pathlib import Path + +from PySide6.QtCore import QCoreApplication, QObject, Qt, QUrl +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtQml import QQmlApplicationEngine +from PySide6.QtQuickControls2 import QQuickStyle + +from ui.resources import resources_rc # noqa: F401 +from ui.utils import url # noqa: F401 +from ui.viewmodels import overview # noqa: F401 + +CURRENT_DIRECTORY = Path(__file__).resolve().parent +DEFAULT_FONTS = ["微软雅黑", "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI"] + +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s][%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) + + +def main() -> None: + app = QGuiApplication(sys.argv) + app.setFont(DEFAULT_FONTS) + app.setApplicationName("arcaea-offline-pyside-ui") + app.setApplicationDisplayName("Arcaea Offline") + app.setWindowIcon(QIcon(":/images/icon.png")) + + QQuickStyle.setStyle("Fusion") + + engine = QQmlApplicationEngine() + + def onEngineObjectCreated(obj: QObject | None, objUrl: QUrl) -> None: + if obj is None: + logger.critical("rootObject is None! Exiting!") + QCoreApplication.exit(-1) + + engine.objectCreated.connect( + onEngineObjectCreated, + Qt.ConnectionType.QueuedConnection, + ) + + engine.load("ui/qmls/App.qml") + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/core/settings/keys.py b/core/settings/keys.py index d0f2f54..3a94792 100644 --- a/core/settings/keys.py +++ b/core/settings/keys.py @@ -4,7 +4,8 @@ from enum import StrEnum class _General(StrEnum): Language = "Language" - DatabaseUrl = "DatabaseUrl" + DatabaseType = "DatabaseType" + DatabaseConn = "DatabaseConn" class _Ocr(StrEnum): diff --git a/core/settings/values.py b/core/settings/values.py index 1505a30..35f613b 100644 --- a/core/settings/values.py +++ b/core/settings/values.py @@ -1,4 +1,10 @@ from dataclasses import dataclass +from enum import StrEnum + + +class GeneralDatabaseType(StrEnum): + FILE = "file" + URL = "url" @dataclass(frozen=True) diff --git a/pyproject.toml b/pyproject.toml index f202a8d..a67938c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ ignore = [ "N806", # non-lowercase-variable-in-function "N815", # mixed-case-variable-in-class-scope "N816", # mixed-case-variable-in-global-scope + "N999", # invalid-module-name ] [tool.pyright] diff --git a/ui/qmls/App.qml b/ui/qmls/App.qml new file mode 100644 index 0000000..937d433 --- /dev/null +++ b/ui/qmls/App.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "./DatabaseChecker" as DatabaseChecker + +ApplicationWindow { + visible: true + + width: 800 + height: 600 + + StackLayout { + id: stackLayout + anchors.fill: parent + currentIndex: 0 + + DatabaseChecker.Index { + onReady: parent.currentIndex = 1 + } + + AppMain {} + } +} diff --git a/ui/qmls/AppMain.qml b/ui/qmls/AppMain.qml new file mode 100644 index 0000000..bad04b3 --- /dev/null +++ b/ui/qmls/AppMain.qml @@ -0,0 +1,68 @@ +import QtQuick +import QtQuick.Layouts + +RowLayout { + id: layout + spacing: 5 + + ListModel { + id: navListModel + + ListElement { + _id: 'home' + label: 'Overview' + qmlSource: 'Overview.qml' + } + ListElement { + _id: 'database' + label: 'Database' + qmlSource: '404.qml' + } + } + + ListView { + id: navListView + + Layout.preferredWidth: 200 + Layout.fillHeight: true + + model: navListModel + focus: true + + delegate: Item { + required property int index + required property string label + width: parent.width + height: 30 + + MouseArea { + anchors.fill: parent + onClicked: () => { + navListView.currentIndex = index; + } + } + + Text { + anchors.margins: 5 + anchors.fill: parent + + text: parent.label + } + } + + highlight: Rectangle { + width: parent.width + height: 30 + color: "#FFFF88" + y: ListView.view.currentItem.y + } + } + + Loader { + Layout.preferredWidth: 500 + Layout.fillWidth: true + Layout.fillHeight: true + + source: navListView.currentIndex > -1 ? navListModel.get(navListView.currentIndex).qmlSource : '404.qml' + } +} diff --git a/ui/qmls/Components/SectionTitle.qml b/ui/qmls/Components/SectionTitle.qml index 9d2744d..a185706 100644 --- a/ui/qmls/Components/SectionTitle.qml +++ b/ui/qmls/Components/SectionTitle.qml @@ -8,6 +8,6 @@ Label { anchors.topMargin: 7 anchors.bottomMargin: 10 - font.pointSize: 12 + font.pointSize: 14 font.bold: true } diff --git a/ui/qmls/DatabaseChecker/DatabaseFileCreator.qml b/ui/qmls/DatabaseChecker/DatabaseFileCreator.qml new file mode 100644 index 0000000..85687e5 --- /dev/null +++ b/ui/qmls/DatabaseChecker/DatabaseFileCreator.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../Components" + +ColumnLayout { + id: root + + signal confirm + + required property url directoryUrl + required property string filename + + GridLayout { + columns: 2 + + Label { + text: "Directory" + } + + DirectorySelector { + Layout.fillWidth: true + + directoryUrl: root.directoryUrl + onDirectoryUrlChanged: { + root.directoryUrl = this.directoryUrl; + } + } + + Label { + text: "Filename" + } + + TextField { + Layout.fillWidth: true + + text: root.filename + placeholderText: 'Please enter…' + onEditingFinished: { + root.filename = this.text; + } + onAccepted: { + confirmButton.click(); + } + } + } + + Button { + id: confirmButton + Layout.alignment: Qt.AlignRight + + text: 'Confirm' + onClicked: root.confirm() + } +} diff --git a/ui/qmls/DatabaseChecker/Dialog_ConfirmConnection.qml b/ui/qmls/DatabaseChecker/Dialog_ConfirmConnection.qml new file mode 100644 index 0000000..cd74e4c --- /dev/null +++ b/ui/qmls/DatabaseChecker/Dialog_ConfirmConnection.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property string databaseUrl + + padding: 10 + + title: qsTr('Confirm Database Connection') + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + Overlay.modal: Rectangle { + color: Qt.alpha('gray', 0.2) + } + + ColumnLayout { + Label { + text: 'The connection below will be saved to settings. Confirm?' + } + + Pane { + Label { + id: databaseUrlLabel + text: root.databaseUrl + } + } + } +} diff --git a/ui/qmls/DatabaseChecker/Dialog_Error.qml b/ui/qmls/DatabaseChecker/Dialog_Error.qml new file mode 100644 index 0000000..bd94cfc --- /dev/null +++ b/ui/qmls/DatabaseChecker/Dialog_Error.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: root + + required property string errorTitle + required property string errorMessage + + padding: 10 + + title: qsTr('Error') + standardButtons: Dialog.Ok + modal: true + Overlay.modal: Rectangle { + color: Qt.alpha('darkgray', 0.5) + } + + ColumnLayout { + spacing: 5 + + Label { + font.pointSize: 12 + font.bold: true + + text: root.errorTitle + } + + Label { + text: root.errorMessage + } + } +} diff --git a/ui/qmls/DatabaseChecker/HorizontalDivider.qml b/ui/qmls/DatabaseChecker/HorizontalDivider.qml new file mode 100644 index 0000000..997012b --- /dev/null +++ b/ui/qmls/DatabaseChecker/HorizontalDivider.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Layouts + +Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + + Layout.topMargin: 5 + Layout.bottomMargin: 5 + + color: "lightgray" + opacity: 0.5 +} diff --git a/ui/qmls/DatabaseChecker/Index.qml b/ui/qmls/DatabaseChecker/Index.qml new file mode 100644 index 0000000..7e71077 --- /dev/null +++ b/ui/qmls/DatabaseChecker/Index.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import internal.ui.vm 1.0 +import "../Components" + +Page { + id: root + + property bool showConfirmConnectionDialog: false + + signal ready + + function confirm(source: string): void { + if (!source) + throw new Error("source is required"); + + const shouldConfirm = (source === 'continueButton' && vm.canConfirmSilently) || source === 'dialog'; + + if (shouldConfirm) { + vm.confirmCurrentConnection(); + root.ready(); + } else { + root.showConfirmConnectionDialog = true; + } + } + + DatabaseInitViewModel { + id: vm + + onSelectFileUrlChanged: { + selectOrCreate.selectFileUrl = this.selectFileUrl; + } + onDirectoryUrlChanged: { + selectOrCreate.directoryUrl = this.directoryUrl; + } + onFilenameChanged: { + selectOrCreate.filename = this.filename; + } + onDatabaseUrlChanged: { + confirmConnectionDialog.databaseUrl = this.databaseUrl; + } + onDatabaseInfoChanged: { + databaseInfo.info = this.databaseInfo; + } + onCanContinueChanged: { + continueButton.enabled = this.canContinue; + } + } + + Page { + padding: 10 + anchors.fill: parent + + Dialog_ConfirmConnection { + id: confirmConnectionDialog + + anchors.centerIn: parent + visible: root.showConfirmConnectionDialog + + onAccepted: root.confirm('dialog') + onClosed: root.showConfirmConnectionDialog = false + + databaseUrl: vm.databaseUrl + } + + ColumnLayout { + width: parent.width + spacing: 2 + + SectionTitle { + text: qsTr('Select or Create Database') + } + + Section_SelectOrCreate { + id: selectOrCreate + selectFileUrl: vm.selectFileUrl + createDirectoryUrl: vm.directoryUrl + createFilename: vm.filename + + onUiModeChanged: uiMode => vm.uiMode = uiMode + onSelectFileUrlChanged: { + vm.selectFileUrl = selectFileUrl; + } + onCreateDirectoryUrlChanged: { + vm.directoryUrl = createDirectoryUrl; + } + onCreateFilenameChanged: { + vm.filename = createFilename; + } + onConfirmCreate: { + vm.loadDatabaseInfo(); + } + } + + HorizontalDivider {} + + SectionTitle { + text: 'Database Status' + } + + Section_DatabaseInfo { + id: databaseInfo + info: vm.databaseInfo + } + } + + footer: Pane { + padding: 5 + RowLayout { + Button { + id: continueButton + Layout.alignment: Qt.AlignHCenter + + text: qsTr('Continue') + enabled: vm.canContinue + + onClicked: root.confirm('continueButton') + } + } + } + } +} diff --git a/ui/qmls/DatabaseChecker/Section_DatabaseInfo.qml b/ui/qmls/DatabaseChecker/Section_DatabaseInfo.qml new file mode 100644 index 0000000..9e0fa11 --- /dev/null +++ b/ui/qmls/DatabaseChecker/Section_DatabaseInfo.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var info: { + 'url': undefined, + 'error': { + 'title': undefined, + 'message': undefined + }, + 'initialized': undefined, + 'version': undefined + } + property bool showErrorDialog: false + + function hasError(): bool { + return info?.error?.title !== undefined || info?.error?.message !== undefined; + } + + function displayText(value): string { + return value ?? '-'; + } + + function displayBool(value): string { + if (value === undefined) + return '-'; + // TODO: color success & error + return value ? `Yes` : `No`; + } + + component LabelLabel: Label { + Layout.alignment: Qt.AlignRight | Qt.AlignBaseline + font.pointSize: 10 + } + + SystemPalette { + id: palette + } + + Dialog_Error { + parent: Overlay.overlay + + anchors.centerIn: parent + visible: root.hasError() && root.showErrorDialog + + errorTitle: root.displayText(root.info?.error?.title) + errorMessage: root.displayText(root.info?.error?.message) + + onClosed: root.showErrorDialog = false + } + + Pane { + clip: true + background: Rectangle { + color: Qt.darker(Qt.alpha(palette.window, 0.9), 0.2) + } + + // Layout.preferredHeight: root.hasError() ? this.implicitHeight : 0 + Layout.preferredHeight: 0 + Behavior on Layout.preferredHeight { + PropertyAnimation { + duration: 300 + easing.type: Easing.InOutCubic + } + } + + ColumnLayout { + anchors.fill: parent + + Label { + font.bold: true + text: root.displayText(root.info.error?.title) + } + + Label { + text: root.displayText(root.info.error?.message) + } + } + } + + GridLayout { + columns: 2 + columnSpacing: 10 + + LabelLabel { + text: 'Connection' + } + + Label { + text: root.info.url + } + + LabelLabel { + text: 'Initialized' + } + + Label { + text: root.displayBool(root.info?.initialized) + } + + LabelLabel { + text: 'Version' + } + + Label { + text: root.displayText(root.info?.version) + } + + Column { + Layout.alignment: Qt.AlignRight | Qt.AlignBaseline + + LabelLabel { + text: 'Error' + } + + ToolButton { + Layout.preferredWidth: root.hasError() ? this.implicitWidth : 0 + Behavior on Layout.preferredWidth { + PropertyAnimation { + duration: 300 + easing.type: Easing.InOutCubic + } + } + + text: '[?]' + onClicked: root.showErrorDialog = true + } + } + + Label { + Layout.alignment: Qt.AlignBaseline + text: root.displayText(root.info?.error?.title) + } + } +} diff --git a/ui/qmls/DatabaseChecker/Section_SelectOrCreate.qml b/ui/qmls/DatabaseChecker/Section_SelectOrCreate.qml new file mode 100644 index 0000000..9e61119 --- /dev/null +++ b/ui/qmls/DatabaseChecker/Section_SelectOrCreate.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../Components" + +ColumnLayout { + id: root + + property alias selectFileUrl: fileSelector.url + property alias createDirectoryUrl: fileCreator.directoryUrl + property alias createFilename: fileCreator.filename + + signal uiModeChanged(string value) + signal confirmCreate + + ListModel { + id: uiModeModel + ListElement { + value: 'select' + } + ListElement { + value: 'create' + } + } + + TabBar { + id: tabBar + Layout.fillWidth: true + + TabButton { + text: qsTr("Select Existing") + width: implicitWidth + 10 + } + TabButton { + text: qsTr("Create New File") + width: implicitWidth + 10 + } + + onCurrentIndexChanged: { + const idx = this.currentIndex; + root.uiModeChanged(uiModeModel.get(idx).value); + } + } + + StackLayout { + currentIndex: tabBar.currentIndex + + Layout.fillWidth: true + Layout.preferredHeight: children[currentIndex].height + Behavior on Layout.preferredHeight { + PropertyAnimation { + duration: 300 + easing.type: Easing.InOutCubic + } + } + clip: true + + FileSelector { + id: fileSelector + } + + DatabaseFileCreator { + id: fileCreator + + onConfirm: root.confirmCreate() + } + } +} diff --git a/ui/startup/databaseChecker.py b/ui/startup/databaseChecker.py deleted file mode 100644 index 176d8f8..0000000 --- a/ui/startup/databaseChecker.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import traceback -from pathlib import Path - -from arcaea_offline.database import Database -from PySide6.QtCore import QCoreApplication, QDir, QFileInfo, QSysInfo, Qt, QUrl, Slot -from PySide6.QtWidgets import QDialog, QMessageBox - -from core.database import DatabaseInitCheckResult, check_db_init, create_engine -from core.settings import SettingsKeys, settings - -from .databaseChecker_ui import Ui_DatabaseChecker - -logger = logging.getLogger(__name__) - - -class DatabaseChecker(Ui_DatabaseChecker, QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setupUi(self) - self.setWindowFlag(Qt.WindowType.WindowMinimizeButtonHint, False) - self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, False) - self.setWindowFlag(Qt.WindowType.WindowCloseButtonHint, True) - self.dbDirSelector.setMode(self.dbDirSelector.getExistingDirectory) - - self.confirmDbByExistingSettings = False - if dbUrlString := settings.stringValue(SettingsKeys.General.DatabaseUrl): - dbFileUrl = QUrl(dbUrlString.replace("sqlite://", "file://")) - dbFileInfo = QFileInfo(dbFileUrl.toLocalFile()) - if dbFileInfo.exists(): - self.dbDirSelector.selectFile(dbFileInfo.path()) - self.dbFilenameLineEdit.setText(dbFileInfo.fileName()) - self.confirmDbByExistingSettings = True - self.confirmDbPathButton.click() - else: - self.dbDirSelector.selectFile(QDir.currentPath()) - self.dbFilenameLineEdit.setText("arcaea_offline.db") - else: - self.dbDirSelector.selectFile(QDir.currentPath()) - self.dbFilenameLineEdit.setText("arcaea_offline.db") - - def writeDatabaseUrlToSettings(self, databaseUrl: str): - settings.setValue(SettingsKeys.General.DatabaseUrl, databaseUrl) - - def dbPath(self): - return QDir(self.dbDirSelector.selectedFiles()[0]) - - def dbFileInfo(self): - return QFileInfo( - QDir.cleanPath( - self.dbPath().absoluteFilePath(self.dbFilenameLineEdit.text()) - ) - ) - - def dbFileUrl(self): - return QUrl.fromLocalFile(self.dbFileInfo().filePath()) - - def dbSqliteUrl(self): - kernelType = QSysInfo.kernelType() - # the slash count varies depending on the kernel - # https://docs.sqlalchemy.org/en/20/core/engines.html#sqlite - if kernelType == "winnt": - return QUrl(self.dbFileUrl().toString().replace("file://", "sqlite://")) - else: - return QUrl(self.dbFileUrl().toString().replace("file://", "sqlite:///")) - - def confirmDb(self) -> DatabaseInitCheckResult: - dbFileInfo = self.dbFileInfo() - dbPath = Path(dbFileInfo.absoluteFilePath()) - - return check_db_init(dbPath) - - def updateLabels(self): - result = self.confirmDb() - try: - db = Database() - version = db.version() - initted = db.check_init() - self.dbVersionLabel.setText(str(version)) - self.dbCheckConnLabel.setText( - 'OK' - if initted - else 'Not initted' - ) - self.continueButton.setEnabled(initted) - except Exception as e: - self.dbVersionLabel.setText("-") - self.dbCheckConnLabel.setText( - f'Error: {e}' - if result & DatabaseInitCheckResult.FILE_EXISTS - else "-" - ) - self.continueButton.setEnabled(False) - - @Slot() - def on_confirmDbPathButton_clicked(self): - dbSqliteUrl = self.dbSqliteUrl() - self.writeDatabaseUrlToSettings(dbSqliteUrl.toString()) - - result = self.confirmDb() - if result & DatabaseInitCheckResult.INITIALIZED: - if not self.confirmDbByExistingSettings: - self.writeDatabaseUrlToSettings(dbSqliteUrl.toString()) - elif result & DatabaseInitCheckResult.FILE_EXISTS: - confirm_try_init = QMessageBox.question( - self, - None, - QCoreApplication.translate("DatabaseChecker", "dialog.tryInitExistingDatabase"), - ) # fmt: skip - if confirm_try_init == QMessageBox.StandardButton.Yes: - try: - Database().init(checkfirst=True) - except Exception as e: - logger.exception("Error while initializing an existing database") - QMessageBox.critical( - self, None, "\n".join(traceback.format_exception(e)) - ) - else: - confirm_new_database = QMessageBox.question( - self, - None, - QCoreApplication.translate("DatabaseChecker", "dialog.confirmNewDatabase"), - ) # fmt: skip - if confirm_new_database == QMessageBox.StandardButton.Yes: - db = Database(create_engine(dbSqliteUrl)) - db.init() - self.updateLabels() - - @Slot() - def on_dbReInitButton_clicked(self): - Database().init(checkfirst=True) - QMessageBox.information(self, None, "OK") - - @Slot() - def on_continueButton_clicked(self): - self.accept() diff --git a/ui/startup/databaseChecker.ui b/ui/startup/databaseChecker.ui deleted file mode 100644 index 2267096..0000000 --- a/ui/startup/databaseChecker.ui +++ /dev/null @@ -1,154 +0,0 @@ - - - DatabaseChecker - - - - 0 - 0 - 350 - 250 - - - - DatabaseChecker - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - dbPathLabel - - - - - - - - - - dbFilenameLabel - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - confirmDbPathButton - - - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - dbCheckConnLabel - - - - - - - ... - - - - - - - false - - - continueButton - - - - - - - dbVersionLabel - - - - - - - Qt::Horizontal - - - - - - - dbReInitLabel - - - - - - - dbReInitButton - - - - - - - - FileSelector - QWidget -
ui.implements.components.fileSelector
- 1 -
-
- - -
diff --git a/ui/startup/databaseChecker_ui.py b/ui/startup/databaseChecker_ui.py deleted file mode 100644 index ace5cd0..0000000 --- a/ui/startup/databaseChecker_ui.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- - -################################################################################ -## Form generated from reading UI file 'databaseChecker.ui' -## -## Created by: Qt User Interface Compiler version 6.5.2 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, - QMetaObject, QObject, QPoint, QRect, - QSize, QTime, QUrl, Qt) -from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, - QFont, QFontDatabase, QGradient, QIcon, - QImage, QKeySequence, QLinearGradient, QPainter, - QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QApplication, QFormLayout, QFrame, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QSizePolicy, - QSpacerItem, QWidget) - -from ui.implements.components.fileSelector import FileSelector - -class Ui_DatabaseChecker(object): - def setupUi(self, DatabaseChecker): - if not DatabaseChecker.objectName(): - DatabaseChecker.setObjectName(u"DatabaseChecker") - DatabaseChecker.resize(350, 250) - DatabaseChecker.setWindowTitle(u"DatabaseChecker") - self.formLayout = QFormLayout(DatabaseChecker) - self.formLayout.setObjectName(u"formLayout") - self.formLayout.setLabelAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) - self.label = QLabel(DatabaseChecker) - self.label.setObjectName(u"label") - - self.formLayout.setWidget(0, QFormLayout.LabelRole, self.label) - - self.dbDirSelector = FileSelector(DatabaseChecker) - self.dbDirSelector.setObjectName(u"dbDirSelector") - - self.formLayout.setWidget(0, QFormLayout.FieldRole, self.dbDirSelector) - - self.label_3 = QLabel(DatabaseChecker) - self.label_3.setObjectName(u"label_3") - - self.formLayout.setWidget(1, QFormLayout.LabelRole, self.label_3) - - self.dbFilenameLineEdit = QLineEdit(DatabaseChecker) - self.dbFilenameLineEdit.setObjectName(u"dbFilenameLineEdit") - - self.formLayout.setWidget(1, QFormLayout.FieldRole, self.dbFilenameLineEdit) - - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setObjectName(u"horizontalLayout") - self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) - - self.horizontalLayout.addItem(self.horizontalSpacer) - - self.confirmDbPathButton = QPushButton(DatabaseChecker) - self.confirmDbPathButton.setObjectName(u"confirmDbPathButton") - sizePolicy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.confirmDbPathButton.sizePolicy().hasHeightForWidth()) - self.confirmDbPathButton.setSizePolicy(sizePolicy) - - self.horizontalLayout.addWidget(self.confirmDbPathButton) - - - self.formLayout.setLayout(2, QFormLayout.FieldRole, self.horizontalLayout) - - self.dbVersionLabel = QLabel(DatabaseChecker) - self.dbVersionLabel.setObjectName(u"dbVersionLabel") - self.dbVersionLabel.setText(u"-") - - self.formLayout.setWidget(4, QFormLayout.FieldRole, self.dbVersionLabel) - - self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) - - self.formLayout.setItem(6, QFormLayout.FieldRole, self.verticalSpacer) - - self.label_5 = QLabel(DatabaseChecker) - self.label_5.setObjectName(u"label_5") - - self.formLayout.setWidget(7, QFormLayout.LabelRole, self.label_5) - - self.dbCheckConnLabel = QLabel(DatabaseChecker) - self.dbCheckConnLabel.setObjectName(u"dbCheckConnLabel") - self.dbCheckConnLabel.setText(u"...") - - self.formLayout.setWidget(7, QFormLayout.FieldRole, self.dbCheckConnLabel) - - self.continueButton = QPushButton(DatabaseChecker) - self.continueButton.setObjectName(u"continueButton") - self.continueButton.setEnabled(False) - - self.formLayout.setWidget(8, QFormLayout.SpanningRole, self.continueButton) - - self.label_2 = QLabel(DatabaseChecker) - self.label_2.setObjectName(u"label_2") - - self.formLayout.setWidget(4, QFormLayout.LabelRole, self.label_2) - - self.line = QFrame(DatabaseChecker) - self.line.setObjectName(u"line") - self.line.setFrameShape(QFrame.HLine) - self.line.setFrameShadow(QFrame.Sunken) - - self.formLayout.setWidget(3, QFormLayout.SpanningRole, self.line) - - self.label_4 = QLabel(DatabaseChecker) - self.label_4.setObjectName(u"label_4") - - self.formLayout.setWidget(5, QFormLayout.LabelRole, self.label_4) - - self.dbReInitButton = QPushButton(DatabaseChecker) - self.dbReInitButton.setObjectName(u"dbReInitButton") - - self.formLayout.setWidget(5, QFormLayout.FieldRole, self.dbReInitButton) - - - self.retranslateUi(DatabaseChecker) - - QMetaObject.connectSlotsByName(DatabaseChecker) - # setupUi - - def retranslateUi(self, DatabaseChecker): - self.label.setText(QCoreApplication.translate("DatabaseChecker", u"dbPathLabel", None)) - self.label_3.setText(QCoreApplication.translate("DatabaseChecker", u"dbFilenameLabel", None)) - self.confirmDbPathButton.setText(QCoreApplication.translate("DatabaseChecker", u"confirmDbPathButton", None)) - self.label_5.setText(QCoreApplication.translate("DatabaseChecker", u"dbCheckConnLabel", None)) - self.continueButton.setText(QCoreApplication.translate("DatabaseChecker", u"continueButton", None)) - self.label_2.setText(QCoreApplication.translate("DatabaseChecker", u"dbVersionLabel", None)) - self.label_4.setText(QCoreApplication.translate("DatabaseChecker", u"dbReInitLabel", None)) - self.dbReInitButton.setText(QCoreApplication.translate("DatabaseChecker", u"dbReInitButton", None)) - pass - # retranslateUi - diff --git a/ui/startup/__init__.py b/ui/utils/__init__.py similarity index 100% rename from ui/startup/__init__.py rename to ui/utils/__init__.py diff --git a/ui/utils/common.py b/ui/utils/common.py new file mode 100644 index 0000000..04d4b14 --- /dev/null +++ b/ui/utils/common.py @@ -0,0 +1 @@ +UTILS_QML_IMPORT_NAME = "internal.ui.utils" diff --git a/ui/utils/url.py b/ui/utils/url.py new file mode 100644 index 0000000..d06bcee --- /dev/null +++ b/ui/utils/url.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from PySide6.QtCore import QFileInfo, QObject, QUrl, Slot +from PySide6.QtQml import QmlElement, QmlSingleton + +from .common import UTILS_QML_IMPORT_NAME + +QML_IMPORT_NAME = UTILS_QML_IMPORT_NAME +QML_IMPORT_MAJOR_VERSION = 1 +QML_IMPORT_MINOR_VERSION = 0 + + +@QmlElement +@QmlSingleton +class UrlUtils(QObject): + @Slot(str, result=bool) + @staticmethod + def isDir(url: str): + return QFileInfo(QUrl(url).toLocalFile()).isDir() + + @Slot(str, result=bool) + @staticmethod + def isFile(url: str): + return QFileInfo(QUrl(url).toLocalFile()).isFile() + + @Slot(str, result=str) + @staticmethod + def stem(url: str): + return Path(QUrl(url).toLocalFile()).stem + + @Slot(str, result=str) + @staticmethod + def name(url: str): + return Path(QUrl(url).toLocalFile()).name + + @Slot(str, result=QUrl) + @staticmethod + def parent(url: str): + return QUrl.fromLocalFile(Path(QUrl(url).toLocalFile()).parent) + + +@QmlElement +@QmlSingleton +class UrlFormatUtils(QObject): + @Slot(str, result=str) + @staticmethod + def toLocalFile(url: str): + return QUrl(url).toLocalFile() diff --git a/ui/viewmodels/__init__.py b/ui/viewmodels/__init__.py new file mode 100644 index 0000000..412f96a --- /dev/null +++ b/ui/viewmodels/__init__.py @@ -0,0 +1,2 @@ +from .databaseInit import DatabaseInitViewModel +from .overview import OverviewViewModel diff --git a/ui/viewmodels/common.py b/ui/viewmodels/common.py new file mode 100644 index 0000000..287944b --- /dev/null +++ b/ui/viewmodels/common.py @@ -0,0 +1 @@ +VM_QML_IMPORT_NAME = "internal.ui.vm" diff --git a/ui/viewmodels/databaseInit.py b/ui/viewmodels/databaseInit.py new file mode 100644 index 0000000..2ebb171 --- /dev/null +++ b/ui/viewmodels/databaseInit.py @@ -0,0 +1,368 @@ +import dataclasses +import logging +from enum import StrEnum +from pathlib import Path + +from arcaea_offline.models import ( + CalculatedPotential, + Chart, + ConfigBase, + ScoreBest, + ScoreCalculated, + ScoresBase, + ScoresViewBase, + SongsBase, + SongsViewBase, +) +from arcaea_offline.models import ( + Property as AoProperty, +) +from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot +from PySide6.QtQml import QmlElement +from sqlalchemy import inspect, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from core.database import create_engine, db_path_to_sqlite_url, sqlite_url_to_db_path +from core.settings import SettingsKeys, settings +from core.settings.values import GeneralDatabaseType + +from .common import VM_QML_IMPORT_NAME + +logger = logging.getLogger(__name__) + +QML_IMPORT_NAME = VM_QML_IMPORT_NAME +QML_IMPORT_MAJOR_VERSION = 1 +QML_IMPORT_MINOR_VERSION = 0 + + +class _FmwiwsDatabase: + """Fuck Me Why I Wrote Singleton Database""" + + def __init__(self, url: str): + self.engine = create_engine(url) + + def init(self, *, checkfirst: bool = True): + # create tables & views + if checkfirst: + # > https://github.com/kvesteri/sqlalchemy-utils/issues/396 + # > view.create_view() causes DuplicateTableError on + # > Base.metadata.create_all(checkfirst=True) + # so if `checkfirst` is True, drop these views before creating + SongsViewBase.metadata.drop_all(self.engine) + ScoresViewBase.metadata.drop_all(self.engine) + + SongsBase.metadata.create_all(self.engine, checkfirst=checkfirst) + SongsViewBase.metadata.create_all(self.engine) + ScoresBase.metadata.create_all(self.engine, checkfirst=checkfirst) + ScoresViewBase.metadata.create_all(self.engine) + ConfigBase.metadata.create_all(self.engine, checkfirst=checkfirst) + + version_property = AoProperty(key="version", value="4") + with Session(self.engine) as session: + session.merge(version_property) + session.commit() + + def is_initialized(self): + expect_tables = ( + list(SongsBase.metadata.tables.keys()) + + list(ScoresBase.metadata.tables.keys()) + + list(ConfigBase.metadata.tables.keys()) + + [ + Chart.__tablename__, + ScoreCalculated.__tablename__, + ScoreBest.__tablename__, + CalculatedPotential.__tablename__, + ] + ) + + return all(inspect(self.engine).has_table(t) for t in expect_tables) + + def version(self): + with Session(self.engine) as session: + stmt = select(AoProperty.value).where(AoProperty.key == "version") + result = session.scalar(stmt) + + return None if result is None else int(result) + + +class _UiMode(StrEnum): + SELECT = "select" + CREATE = "create" + + +@dataclasses.dataclass +class DatabaseInfo: + url: str + error: Exception | None = None + initialized: bool | None = None + version: int | None = None + + @property + def error_dict(self): + e = self.error + + if e is None: + return None + + return { + "title": e.__class__.__name__, + "message": str(e), + } + + def to_qml_dict(self): + return { + "url": self.url, + "error": self.error_dict, + "initialized": self.initialized, + "version": self.version, + } + + +@QmlElement +class DatabaseInitViewModel(QObject): + _void = Signal() + uiModeChanged = Signal() + selectFileUrlChanged = Signal() + directoryUrlChanged = Signal() + filenameChanged = Signal() + databaseUrlChanged = Signal() + databaseInfoChanged = Signal() + canContinueChanged = Signal() + canConfirmSilentlyChanged = Signal() + + def __init__(self): + super().__init__() + + self._selectFileUrl: QUrl | None = None + self._directoryUrl: QUrl = QUrl() + self._filename: str = "" + self._databaseInfo: DatabaseInfo | None = None + + self._uiMode = _UiMode.SELECT + + self.directoryUrlChanged.connect(lambda: self.databaseUrlChanged.emit()) + self.filenameChanged.connect(lambda: self.databaseUrlChanged.emit()) + self.selectFileUrlChanged.connect(self.databaseUrlChanged) + self.databaseInfoChanged.connect(self.canContinueChanged) + + self.databaseUrlChanged.connect(self.onDatabaseUrlChanged) + + self._loadSettings() + + def onDatabaseUrlChanged(self): + self.loadDatabaseInfo() + + @property + def _settingsDatabaseFile(self) -> Path | None: + # TODO: process database type when available + if ( + settings.stringValue(SettingsKeys.General.DatabaseType) + != GeneralDatabaseType.FILE + ): + return None + + file = settings.stringValue(SettingsKeys.General.DatabaseConn) + + if not file: + logger.debug("No database file specified in settings") + return + + filepath = Path(file) + if not filepath.exists(): + logger.warning("Cannot find database file: %s", file) + return + + return filepath + + def _loadSettings(self) -> None: + fileUrl = self._settingsDatabaseFile + if fileUrl is None: + return + logger.info("Loading database from settings: %s", fileUrl) + + self.setUiMode(_UiMode.SELECT) + self.setSelectFileUrl(fileUrl) + self.loadDatabaseInfo() + + def _makeDatabaseInfo(self, dbUrl: str): + info = DatabaseInfo(url=dbUrl) + + path = sqlite_url_to_db_path(dbUrl) + if not path.exists(): + e = FileNotFoundError() + e.strerror = f"{path} does not exist" + info.error = e + return info + + try: + db = _FmwiwsDatabase(dbUrl) + info.initialized = db.is_initialized() + info.version = db.version() + except SQLAlchemyError as e: + logger.exception("Error loading database info") + info.error = e + + logger.debug("Database info for %r: %r", dbUrl, info) + return info + + @Slot() + def loadDatabaseInfo(self): + dbUrl = self.getDatabaseUrl() + logger.info("Loading database info: %s", dbUrl) + if dbUrl is None: + logger.warning("Database URL is None") + return + + self._databaseInfo = self._makeDatabaseInfo(dbUrl.toString()) + self.databaseInfoChanged.emit() + + @Slot(str) + def createFile(self, dbUrl: str): + file = sqlite_url_to_db_path(dbUrl) + + if file.exists(): + logger.warning( + "Attempted to create an existing file, check UI logic? (%s)", file + ) + return + + file.touch(mode=0o644) + logger.info("Created file %s", file) + + @Slot() + def confirmCurrentConnection(self): + dbInfo = self._databaseInfo + if dbInfo is None: + logger.warning("Current database info is None, ignoring") + return + + settings.setValue( + SettingsKeys.General.DatabaseType, + GeneralDatabaseType.FILE.value, + ) + settings.setValue( + SettingsKeys.General.DatabaseConn, + str(sqlite_url_to_db_path(dbInfo.url).resolve().as_posix()), + ) + + @Slot(str) + def initialize(self, dbUrl: str): + try: + db = _FmwiwsDatabase(dbUrl) + db.init() + except SQLAlchemyError: + logger.exception("Error initializing database %s", dbUrl) + + # region properties + def getUiMode(self): + return self._uiMode.value + + def setUiMode(self, mode: str | _UiMode): + if isinstance(mode, _UiMode): + self._uiMode = mode + elif isinstance(mode, str): + try: + self._uiMode = _UiMode(mode) + except ValueError: + logger.warning("Invalid UI mode: %s", mode) + + self.uiModeChanged.emit() + + def getSelectFileUrl(self): + return self._selectFileUrl + + def setSelectFileUrl(self, value: Path | QUrl | None): + if isinstance(value, Path): + value = QUrl.fromLocalFile(value.as_posix()) + + self._selectFileUrl = value + self.selectFileUrlChanged.emit() + + def getDirectoryUrl(self): + return self._directoryUrl + + def setDirectoryUrl(self, value: QUrl | None): + self._directoryUrl = value or QUrl() + self.directoryUrlChanged.emit() + + def getFilename(self): + return self._filename + + def setFilename(self, value: str | None): + self._filename = value or "" + self.filenameChanged.emit() + + def getDatabaseUrl(self): + if self._uiMode == _UiMode.SELECT: + fileUrl = self.getSelectFileUrl() + if fileUrl is None: + return None + return db_path_to_sqlite_url(Path(fileUrl.toLocalFile())) + + directoryUrl = self.getDirectoryUrl() + filename = self.getFilename() + + databasePath = Path(directoryUrl.toLocalFile()) / filename + databaseUrl = db_path_to_sqlite_url(databasePath) + + return databaseUrl + + def getDatabaseInfo(self): + if self._databaseInfo is None: + return { + "url": "", + "initialized": None, + "version": None, + "error": None, + } + + return self._databaseInfo.to_qml_dict() + + def getCanContinue(self): + return ( + self._databaseInfo is not None + and self._databaseInfo.error is None + and self._databaseInfo.version == 4 # noqa: PLR2004 + and self._databaseInfo.initialized + ) + + def getCanConfirmSilently(self): + """Whether the user can confirm database connection without a dialog popping up""" + if self.getUiMode() != _UiMode.SELECT: + return False + + filepath = self._settingsDatabaseFile + if filepath is None: + return False + + return ( + self._databaseInfo is not None + and self._databaseInfo.error is None + and self.getDatabaseUrl() == db_path_to_sqlite_url(filepath) + ) + + uiMode = Property(str, getUiMode, setUiMode, notify=uiModeChanged) + selectFileUrl = Property( + QUrl, + getSelectFileUrl, + setSelectFileUrl, + notify=selectFileUrlChanged, + ) + directoryUrl = Property( + QUrl, + getDirectoryUrl, + setDirectoryUrl, + notify=directoryUrlChanged, + ) + filename = Property(str, getFilename, setFilename, notify=filenameChanged) + databaseUrl = Property(QUrl, getDatabaseUrl, None, notify=databaseUrlChanged) + databaseInfo = Property(dict, getDatabaseInfo, None, notify=databaseInfoChanged) + canContinue = Property(bool, getCanContinue, None, notify=canContinueChanged) + canConfirmSilently = Property( + bool, + getCanConfirmSilently, + None, + notify=canConfirmSilentlyChanged, + ) + # endregion diff --git a/ui/viewmodels/overview.py b/ui/viewmodels/overview.py new file mode 100644 index 0000000..72ffb83 --- /dev/null +++ b/ui/viewmodels/overview.py @@ -0,0 +1,21 @@ +from PySide6.QtCore import Property, QObject, Signal +from PySide6.QtQml import QmlElement + +from .common import VM_QML_IMPORT_NAME + +QML_IMPORT_NAME = VM_QML_IMPORT_NAME +QML_IMPORT_MAJOR_VERSION = 1 +QML_IMPORT_MINOR_VERSION = 0 + + +@QmlElement +class OverviewViewModel(QObject): + _void = Signal() + + def __init__(self): + super().__init__() + + def get_b30(self): + return 0 + + b30 = Property(float, get_b30, None, notify=_void)