Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve DB upgrade #86

Merged
merged 4 commits into from
Nov 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
print("Python 3.11 or higher is required")
sys.exit(1)

parser = argparse.ArgumentParser(description="Build Nexus")
parser = argparse.ArgumentParser(description="Build nexus")
parser.add_argument('-n', "--no-build", action="store_true", help="Skip building the executable, only setup")
parser.add_argument('-d', "--devel", action="store_true", help="Install module locally and git hooks")
parser.add_argument('-u', "--ui-only", action="store_true", help="Only convert ui files"
Expand Down
10 changes: 6 additions & 4 deletions src/nexus/Freqlog/Freqlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def _log_and_reset_word(min_length: int = 2) -> None:
if active_modifier_keys.intersection({kbd.Key.ctrl, kbd.Key.ctrl_l, kbd.Key.ctrl_r,
kbd.Key.cmd, kbd.Key.cmd_l, kbd.Key.cmd_r}):
# Remove last word from word
# TODO: make this work - rn _log_and_reset_word() is called immediately upon ctrl/cmd keydown
# FIXME: make this work - rn _log_and_reset_word() is called immediately upon ctrl/cmd keydown
# TODO: make this configurable (i.e. for vim, etc)
if " " in word:
word = word[:word.rfind(" ")]
Expand Down Expand Up @@ -232,11 +232,13 @@ def _get_chords(self):
if self.dev:
self.dev.close()

def __init__(self, path: str = Defaults.DEFAULT_DB_PATH, loggable: bool = True):
def __init__(self, path: str = Defaults.DEFAULT_DB_PATH, loggable: bool = True,
upgrade_callback: callable = None) -> None:
"""
Initialize Freqlog
:param path: Path to backend (currently == SQLiteBackend)
:param loggable: Whether to create listeners
:param upgrade_callback: Callback to run if database is upgraded
:raises ValueError: If the database version is newer than the current version
"""
logging.info("Initializing freqlog")
Expand Down Expand Up @@ -274,7 +276,7 @@ def __init__(self, path: str = Defaults.DEFAULT_DB_PATH, loggable: bool = True):
if self.dev:
self.dev.close()

self.backend: Backend = SQLiteBackend(path)
self.backend: Backend = SQLiteBackend(path, upgrade_callback)
self.q: Queue = Queue()
self.listener: kbd.Listener | None = None
self.mouse_listener: mouse.Listener | None = None
Expand Down Expand Up @@ -311,7 +313,7 @@ def start_logging(self, new_word_threshold: float | None = None, chord_char_thre
logging.warning("Started freqlogging")
self._process_queue()

def stop_logging(self) -> None: # TODO: find out why this runs twice on one Ctrl-C (does it still?)
def stop_logging(self) -> None: # FIXME: find out why this runs twice on one Ctrl-C (does it still?)
if self.killed: # TODO: Forcibly kill if already killed once
exit(1) # This doesn't work rn
self.killed = True
Expand Down
37 changes: 25 additions & 12 deletions src/nexus/Freqlog/backends/SQLite/SQLiteBackend.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@

class SQLiteBackend(Backend):

@staticmethod
def decode_version(version: int) -> str:
return f"{version >> 16}.{version >> 8 & 0xFF}.{version & 0xFF}"

@staticmethod
def encode_version(version: str) -> int:
return int(version.split(".")[0]) << 16 | int(version.split(".")[1]) << 8 | int(version.split(".")[2])

@staticmethod
def decode_version(version: int) -> str:
return f"{version >> 16}.{version >> 8 & 0xFF}.{version & 0xFF}"

@staticmethod
def _init_db(cursor: Cursor, sql_version: int):
"""
Initialize the database
"""
# WARNING: Remember to change _upgrade_database and merge_db when changing DDL
# WARNING: Remember to bump version and change _upgrade_database and merge_db when changing DDL
cursor.execute(f"PRAGMA user_version = {sql_version}")

# Freqloq table
Expand All @@ -54,23 +54,26 @@ def _init_db(cursor: Cursor, sql_version: int):
cursor.execute("CREATE TABLE IF NOT EXISTS banlist_lower (word TEXT PRIMARY KEY COLLATE NOCASE,"
"dateadded timestamp NOT NULL) WITHOUT ROWID")

def __init__(self, db_path: str) -> None:
def __init__(self, db_path: str, upgrade_callback: callable = None) -> None:
"""
Initialize the SQLite backend
:param db_path: Path to the database file
:param upgrade_callback: Callback to call when upgrading the database.
Should take one argument: the new version, and call sys.exit() if an upgrade is unwanted
:raises ValueError: If the database version is newer than the current version
"""
self.db_path = db_path
self.conn = sqlite3.connect(self.db_path)
self.cursor = self.conn.cursor()
self.upgrade_callback = upgrade_callback

# Versioning
old_version = self._fetchone("PRAGMA user_version")[0]

# Encode major, minor and patch version into a single 4-byte integer
sql_version: int = self.encode_version(__version__)
if old_version < sql_version:
self._upgrade_database(sql_version)
self._upgrade_database(self.decode_version(old_version))
elif old_version > sql_version:
raise ValueError(
f"Database version {self.decode_version(old_version)} is newer than the current version {__version__}")
Expand Down Expand Up @@ -98,16 +101,26 @@ def _fetchall(self, query: str, params=None) -> list[tuple]:
self.cursor.execute(query)
return self.cursor.fetchall()

def _upgrade_database(self, sql_version: int) -> None:
"""Upgrade database to current version"""
def _upgrade_database(self, old_version: str) -> None:
"""
Upgrade database to current version
:param old_version: Existing database version
"""
if self.upgrade_callback:
self.upgrade_callback(old_version)
logging.warning(f"Upgrading database from {self.decode_version(self._fetchone('PRAGMA user_version')[0])} to "
f"{old_version}")

# TODO: populate this function when changing DDL
# Remember to warn users to back up their database before upgrading
pass

def get_version(self) -> str:
"""Get the version of the database"""
return self.decode_version(self._fetchone("PRAGMA user_version")[0])

def set_version(self, version: str) -> None:
"""Set database version to a specific version"""
self._execute(f"PRAGMA user_version = {self.encode_version(version)}")

def get_word_metadata(self, word: str, case: CaseSensitivity) -> WordMetadata | None:
"""
Get metadata for a word
Expand Down Expand Up @@ -466,7 +479,7 @@ def merge_backend(self, src_db_path: str, dst_db_path: str, ban_date: Age) -> No
raise ValueError("dst_db_path must be writable") from e

# DB meta
src_db = SQLiteBackend(src_db_path)
src_db = SQLiteBackend(src_db_path, self.upgrade_callback)
dst_db = SQLiteBackend(dst_db_path)

# Merge databases
Expand Down
98 changes: 68 additions & 30 deletions src/nexus/GUI.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import argparse
import os
import signal
from threading import Thread
from pathlib import Path
from typing import Literal

from PySide6.QtCore import Qt, QTranslator, QLocale
from PySide6.QtWidgets import QApplication, QPushButton, QStatusBar, QTableWidget, QTableWidgetItem, QMainWindow, \
QDialog, QFileDialog, QDialogButtonBox, QVBoxLayout, QLabel, QMenu, QSystemTrayIcon
QDialog, QFileDialog, QMenu, QSystemTrayIcon, QMessageBox
from PySide6.QtGui import QIcon, QAction

from nexus import __id__, __version__
Expand Down Expand Up @@ -49,19 +50,14 @@ def __init__(self):
self.setupUi(self)


class ConfirmDialog(QDialog):
def __init__(self, title: str, message: str):
class ConfirmDialog(QMessageBox):
def __init__(self, title: str, message: str, ok_callback: callable) -> None:
super().__init__()
self.setWindowTitle(title)
buttons = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(buttons)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.layout = QVBoxLayout()
msg_label = QLabel(message)
self.layout.addWidget(msg_label)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
self.setText(message)
self.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel)
self.buttonClicked.connect(
lambda btn: ok_callback() if btn == self.button(QMessageBox.StandardButton.Ok) else self.reject())


class Translator(QTranslator):
Expand Down Expand Up @@ -102,7 +98,7 @@ def __init__(self, args: argparse.Namespace):
self.start_stop_tray_menu_action = QAction(self.tr("GUI", "Start/stop logging"))
self.quit_tray_menu_action = QAction(self.tr("GUI", "Quit"))
self.start_stop_tray_menu_action.triggered.connect(self.start_stop)
self.quit_tray_menu_action.triggered.connect(self.app.quit)
self.quit_tray_menu_action.triggered.connect(self.graceful_quit)
self.tray_menu.addAction(self.start_stop_tray_menu_action)
self.tray_menu.addAction(self.quit_tray_menu_action)
self.tray.setContextMenu(self.tray_menu)
Expand All @@ -117,7 +113,7 @@ def __init__(self, args: argparse.Namespace):
self.statusbar: QStatusBar = self.window.statusbar

# Menu bar
self.window.actionQuit.triggered.connect(self.app.quit)
self.window.actionQuit.triggered.connect(self.graceful_quit)
self.window.actionNexus_Dark.triggered.connect(lambda: self.set_style('Nexus_Dark'))
self.window.actionQt_Default.triggered.connect(lambda: self.set_style('Fusion'))
self.window.actionPlatform_Default.triggered.connect(lambda: self.set_style('Default'))
Expand All @@ -128,6 +124,9 @@ def __init__(self, args: argparse.Namespace):
self.start_stop_button.clicked.connect(self.start_stop)
self.window.refreshButton.clicked.connect(self.refresh)

# Window close button
self.window.closeEvent = lambda event: self.window.hide() # FIXME: this is quitting instead of hiding

# Set default number of entries
self.window.chentry_entries_input.setValue(Defaults.DEFAULT_NUM_WORDS_GUI)
self.window.chord_entries_input.setValue(Defaults.DEFAULT_NUM_WORDS_GUI)
Expand All @@ -140,6 +139,9 @@ def __init__(self, args: argparse.Namespace):
[self.tr("GUI", WordMetadataAttrLabel[col]) for col in self.chentry_columns])
self.chentry_table.sortByColumn(1, Qt.SortOrder.DescendingOrder)

# Refresh when chentry table header clicked
self.chentry_table.horizontalHeader().sectionClicked.connect(self.refresh_chentry_table)

# Chentry table right click menu
self.chentry_context_menu = QMenu(self.chentry_table)
self.chentry_table.contextMenuEvent = lambda event: self.chentry_context_menu.exec_(event.globalPos())
Expand Down Expand Up @@ -194,6 +196,9 @@ def __init__(self, args: argparse.Namespace):
[self.tr("GUI", ChordMetadataAttrLabel[col]) for col in self.chord_columns])
self.chord_table.sortByColumn(1, Qt.SortOrder.DescendingOrder)

# Refresh when chord table header clicked
self.chord_table.horizontalHeader().sectionClicked.connect(self.refresh_chord_table)

# Chord table right click menu
self.chord_context_menu = QMenu(self.chord_table)
self.chord_table.contextMenuEvent = lambda event: self.chord_context_menu.exec_(event.globalPos())
Expand All @@ -212,7 +217,13 @@ def __init__(self, args: argparse.Namespace):
self.set_style('Nexus_Dark')

self.freqlog: Freqlog | None = None # for logging
self.temp_freqlog: Freqlog = Freqlog(args.freqlog_db_path, loggable=False) # for other operations
try:
self.temp_freqlog: Freqlog = Freqlog(args.freqlog_db_path, loggable=False,
upgrade_callback=self.prompt_for_upgrade) # for other operations
except Exception as e:
ConfirmDialog(self.tr("GUI", "Error"), self.tr("GUI", "Error opening database: {}").format(e),
self.graceful_quit).exec()
raise PermissionError(e)
self.logging_thread: Thread | None = None
self.start_stop_button_started = False
self.args = args
Expand Down Expand Up @@ -242,11 +253,17 @@ def set_style(self, style: Literal['Nexus_Dark', 'Fusion', 'Default']):

def start_logging(self):
if not self.freqlog:
self.freqlog = Freqlog(self.args.freqlog_db_path, loggable=True)
try:
self.freqlog = Freqlog(self.args.freqlog_db_path, loggable=True)
except Exception as e:
ConfirmDialog(self.tr("GUI", "Error"), self.tr("GUI", "Error opening database: {}").format(e),
self.graceful_quit).exec()
raise PermissionError(e)
self.freqlog.start_logging()

def stop_logging(self):
self.freqlog.stop_logging()
if self.freqlog:
self.freqlog.stop_logging()
self.freqlog = None

def start_stop(self):
Expand All @@ -263,7 +280,7 @@ def start_stop(self):
self.logging_thread.start()

# Wait until logging starts
# TODO: Replace this with something to prevent spam-clicking the button restarting logging
# FIXME: Replace this with something to prevent spam-clicking the button restarting logging
while not (self.freqlog and self.freqlog.is_logging):
pass

Expand Down Expand Up @@ -409,7 +426,7 @@ def refresh(self):
self.statusbar.showMessage(self.tr("GUI", "Loaded {}/{} freqlogged words, {}/{} logged chords "
"(no CharaChorder device with chords connected)").format(
len(words), num_words, len(chords), num_chords))
else: # TODO: this is an inaccurate count of chords, because chords on device can be modified (i.e. + shift)
else: # FIXME: this is an inaccurate count of chords, because chords on device can be modified (i.e. + shift)
self.statusbar.showMessage(self.tr("GUI", "Loaded {}/{} freqlogged words, {}/{} logged chords "
"(+ {} unused chords on device)").format(
len(words), num_words, len(chords), num_chords, self.temp_freqlog.num_chords - num_chords))
Expand Down Expand Up @@ -499,9 +516,8 @@ def _confirm_unban():
self.statusbar.showMessage(
self.tr("GUI", "Unbanned {}/{} selected words").format(res, len(selected_words)))

conf_dialog = ConfirmDialog(self.tr("GUI", "Confirm unban"), confirm_text.format(len(selected_words)))
conf_dialog.buttonBox.accepted.connect(_confirm_unban)
conf_dialog.exec()
ConfirmDialog(self.tr("GUI", "Confirm unban"), confirm_text.format(len(selected_words)),
ok_callback=_confirm_unban).exec()
_refresh_banlist()

bl_dialog.addButton.clicked.connect(_banword)
Expand Down Expand Up @@ -565,16 +581,15 @@ def _confirm_ban():
self.statusbar.showMessage(
self.tr("GUI", "Banned and deleted {}/{} selected words").format(res, len(selected_words)))

conf_dialog = ConfirmDialog(self.tr("GUI", "Confirm ban"), confirm_text.format(len(selected_words)))
conf_dialog.buttonBox.accepted.connect(_confirm_ban)
conf_dialog.exec()
ConfirmDialog(self.tr("GUI", "Confirm ban"), confirm_text.format(len(selected_words)), _confirm_ban).exec()

def delete_entry(self, is_chord=False):
"""Controller for right click menu/delete key delete entry"""
# Get word(s) from selected row(s)
table = self.chord_table if is_chord else self.chentry_table
selected_words = {table.item(row.row(), 0).text(): CaseSensitivity.INSENSITIVE for row in
table.selectionModel().selectedRows()}
selected_words = ([table.item(row.row(), 0).text() for row in table.selectionModel().selectedRows()]
if is_chord else {table.item(row.row(), 0).text(): CaseSensitivity.INSENSITIVE for row in
table.selectionModel().selectedRows()})
if len(selected_words) == 1:
# Truncate word for display if too long
word = list(selected_words.keys())[0]
Expand Down Expand Up @@ -609,12 +624,35 @@ def _confirm_delete():
self.statusbar.showMessage(
self.tr("GUI", "Deleted {}/{} selected words").format(res, len(selected_words)))

conf_dialog = ConfirmDialog(self.tr("GUI", "Confirm delete"), confirm_text.format(len(selected_words)))
conf_dialog.buttonBox.accepted.connect(_confirm_delete)
conf_dialog.exec()
ConfirmDialog(self.tr("GUI", "Confirm delete"), confirm_text.format(len(selected_words)),
_confirm_delete).exec()

def prompt_for_upgrade(self, db_version: str) -> None:
"""Prompt user to upgrade"""
msg_box = QMessageBox()
msg_box.setWindowTitle(self.tr("GUI", "Database Upgrade"))
msg_box.setText(self.tr("GUI", "You are running version {} of nexus, but your database is on version {}."
"\nBackup your database before pressing 'Yes' to upgrade your database, or press"
" 'No' to exit without upgrading.").format(__version__, db_version))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
result = msg_box.exec()

if result != QMessageBox.StandardButton.Yes:
raise PermissionError("Database upgrade cancelled")

def graceful_quit(self):
"""Quit gracefully"""
if self.freqlog:
self.freqlog.stop_logging()
self.freqlog = None
self.app.quit()

def exec(self):
"""Start the GUI"""
# Handle SIGINT
signal.signal(signal.SIGINT, self.graceful_quit)

# Start GUI
self.window.show()
self.refresh()
self.app.exec()
2 changes: 1 addition & 1 deletion src/nexus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__author__ = "CharaChorder"
__id__ = "com.charachorder.nexus"
__version__ = "0.4.0"
__version__ = "0.4.1"
Loading