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)