wip: database checker

This commit is contained in:
2025-09-12 00:02:28 +08:00
parent 5db2207ee0
commit 1453686de6
24 changed files with 1060 additions and 430 deletions

52
app.py Normal file
View File

@ -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()

View File

@ -4,7 +4,8 @@ from enum import StrEnum
class _General(StrEnum):
Language = "Language"
DatabaseUrl = "DatabaseUrl"
DatabaseType = "DatabaseType"
DatabaseConn = "DatabaseConn"
class _Ocr(StrEnum):

View File

@ -1,4 +1,10 @@
from dataclasses import dataclass
from enum import StrEnum
class GeneralDatabaseType(StrEnum):
FILE = "file"
URL = "url"
@dataclass(frozen=True)

View File

@ -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]

24
ui/qmls/App.qml Normal file
View File

@ -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 {}
}
}

68
ui/qmls/AppMain.qml Normal file
View File

@ -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'
}
}

View File

@ -8,6 +8,6 @@ Label {
anchors.topMargin: 7
anchors.bottomMargin: 10
font.pointSize: 12
font.pointSize: 14
font.bold: true
}

View File

@ -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()
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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')
}
}
}
}
}

View File

@ -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 ? `<font color="lightgreen">Yes</font>` : `<font color="lightpink">No</font>`;
}
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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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(
'<font color="green">OK</font>'
if initted
else '<font color="red">Not initted</font>'
)
self.continueButton.setEnabled(initted)
except Exception as e:
self.dbVersionLabel.setText("-")
self.dbCheckConnLabel.setText(
f'<font color="red">Error: {e}</font>'
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()

View File

@ -1,154 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DatabaseChecker</class>
<widget class="QWidget" name="DatabaseChecker">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>350</width>
<height>250</height>
</rect>
</property>
<property name="windowTitle">
<string notr="true">DatabaseChecker</string>
</property>
<layout class="QFormLayout" name="formLayout">
<property name="labelAlignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>dbPathLabel</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="FileSelector" name="dbDirSelector" native="true"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>dbFilenameLabel</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="dbFilenameLineEdit"/>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="confirmDbPathButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>confirmDbPathButton</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="1">
<widget class="QLabel" name="dbVersionLabel">
<property name="text">
<string notr="true">-</string>
</property>
</widget>
</item>
<item row="6" column="1">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>dbCheckConnLabel</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QLabel" name="dbCheckConnLabel">
<property name="text">
<string notr="true">...</string>
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<widget class="QPushButton" name="continueButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>continueButton</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>dbVersionLabel</string>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>dbReInitLabel</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QPushButton" name="dbReInitButton">
<property name="text">
<string>dbReInitButton</string>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>FileSelector</class>
<extends>QWidget</extends>
<header>ui.implements.components.fileSelector</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@ -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

1
ui/utils/common.py Normal file
View File

@ -0,0 +1 @@
UTILS_QML_IMPORT_NAME = "internal.ui.utils"

48
ui/utils/url.py Normal file
View File

@ -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()

View File

@ -0,0 +1,2 @@
from .databaseInit import DatabaseInitViewModel
from .overview import OverviewViewModel

1
ui/viewmodels/common.py Normal file
View File

@ -0,0 +1 @@
VM_QML_IMPORT_NAME = "internal.ui.vm"

View File

@ -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

21
ui/viewmodels/overview.py Normal file
View File

@ -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)