Add sql plaintext export and (encrypted) backup options

This commit is contained in:
Miguel Jacq 2025-11-04 14:25:03 +11:00
parent f8e0a7f179
commit 27ba33959c
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
4 changed files with 94 additions and 13 deletions

View file

@ -3,6 +3,8 @@
* More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be * More styling/toolbar fixes to support toggled-on styles, exclusive styles (e.g it should not be
possible to set both H1 and H2 at once) possible to set both H1 and H2 at once)
* Fix small bug in export of HTML or arbitrary extension * Fix small bug in export of HTML or arbitrary extension
* Add plaintext SQLite3 Export option
* Add Backup option (database remains encrypted with SQLCipher)
# 0.1.8 # 0.1.8

View file

@ -409,7 +409,7 @@ class DBManager:
f.write(separator) f.write(separator)
def export_html( def export_html(
self, entries: Sequence[Entry], file_path: str, title: str = "Entries export" self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
) -> None: ) -> None:
parts = [ parts = [
"<!doctype html>", "<!doctype html>",
@ -430,6 +430,25 @@ class DBManager:
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write("\n".join(parts)) f.write("\n".join(parts))
def export_sql(self, file_path: str) -> None:
"""
Exports the encrypted database as plaintext SQL.
"""
cur = self.conn.cursor()
cur.execute(f"ATTACH DATABASE '{file_path}' AS plaintext KEY '';")
cur.execute("SELECT sqlcipher_export('plaintext')")
cur.execute("DETACH DATABASE plaintext")
def export_sqlcipher(self, file_path: str) -> None:
"""
Exports the encrypted database as an encrypted database with the same key.
Intended for Bouquin-compatible backups.
"""
cur = self.conn.cursor()
cur.execute(f"ATTACH DATABASE '{file_path}' AS backup KEY '{self.cfg.key}'")
cur.execute("SELECT sqlcipher_export('backup')")
cur.execute("DETACH DATABASE backup")
def export_by_extension(self, file_path: str) -> None: def export_by_extension(self, file_path: str) -> None:
entries = self.get_all_entries() entries = self.get_all_entries()
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import datetime
import os import os
import sys import sys
@ -214,6 +215,10 @@ class MainWindow(QMainWindow):
act_export.setShortcut("Ctrl+E") act_export.setShortcut("Ctrl+E")
act_export.triggered.connect(self._export) act_export.triggered.connect(self._export)
file_menu.addAction(act_export) file_menu.addAction(act_export)
act_backup = QAction("&Backup", self)
act_backup.setShortcut("Ctrl+Shift+B")
act_backup.triggered.connect(self._backup)
file_menu.addAction(act_backup)
file_menu.addSeparator() file_menu.addSeparator()
act_quit = QAction("&Quit", self) act_quit = QAction("&Quit", self)
act_quit.setShortcut("Ctrl+Q") act_quit.setShortcut("Ctrl+Q")
@ -223,21 +228,21 @@ class MainWindow(QMainWindow):
# Navigate menu with next/previous/today # Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate") nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self) act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P") act_prev.setShortcut("Ctrl+Shift+P")
act_prev.setShortcutContext(Qt.ApplicationShortcut) act_prev.setShortcutContext(Qt.ApplicationShortcut)
act_prev.triggered.connect(lambda: self._adjust_day(-1)) act_prev.triggered.connect(lambda: self._adjust_day(-1))
nav_menu.addAction(act_prev) nav_menu.addAction(act_prev)
self.addAction(act_prev) self.addAction(act_prev)
act_next = QAction("Next Day", self) act_next = QAction("Next Day", self)
act_next.setShortcut("Ctrl+N") act_next.setShortcut("Ctrl+Shift+N")
act_next.setShortcutContext(Qt.ApplicationShortcut) act_next.setShortcutContext(Qt.ApplicationShortcut)
act_next.triggered.connect(lambda: self._adjust_day(1)) act_next.triggered.connect(lambda: self._adjust_day(1))
nav_menu.addAction(act_next) nav_menu.addAction(act_next)
self.addAction(act_next) self.addAction(act_next)
act_today = QAction("Today", self) act_today = QAction("Today", self)
act_today.setShortcut("Ctrl+T") act_today.setShortcut("Ctrl+Shift+T")
act_today.setShortcutContext(Qt.ApplicationShortcut) act_today.setShortcutContext(Qt.ApplicationShortcut)
act_today.triggered.connect(self._adjust_today) act_today.triggered.connect(self._adjust_today)
nav_menu.addAction(act_today) nav_menu.addAction(act_today)
@ -552,13 +557,31 @@ class MainWindow(QMainWindow):
# ----------------- Export handler ----------------- # # ----------------- Export handler ----------------- #
@Slot() @Slot()
def _export(self): def _export(self):
try: warning_title = "Unencrypted export"
self.export_dialog() warning_message = """
except Exception as e: Exporting the database will be unencrypted!
QMessageBox.critical(self, "Export failed", str(e))
def export_dialog(self) -> None: Are you sure you want to continue?
filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
If you want an encrypted backup, choose Backup instead of Export.
"""
dlg = QMessageBox()
dlg.setWindowTitle(warning_title)
dlg.setText(warning_message)
dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
dlg.setIcon(QMessageBox.Warning)
dlg.show()
dlg.adjustSize()
if dlg.exec() != QMessageBox.Yes:
return False
filters = (
"Text (*.txt);;"
"JSON (*.json);;"
"CSV (*.csv);;"
"HTML (*.html);;"
"SQL (*.sql);;"
)
start_dir = os.path.join(os.path.expanduser("~"), "Documents") start_dir = os.path.join(os.path.expanduser("~"), "Documents")
filename, selected_filter = QFileDialog.getSaveFileName( filename, selected_filter = QFileDialog.getSaveFileName(
@ -572,6 +595,7 @@ class MainWindow(QMainWindow):
"JSON (*.json)": ".json", "JSON (*.json)": ".json",
"CSV (*.csv)": ".csv", "CSV (*.csv)": ".csv",
"HTML (*.html)": ".html", "HTML (*.html)": ".html",
"SQL (*.sql)": ".sql",
}.get(selected_filter, ".txt") }.get(selected_filter, ".txt")
if not Path(filename).suffix: if not Path(filename).suffix:
@ -587,6 +611,8 @@ class MainWindow(QMainWindow):
self.db.export_csv(entries, filename) self.db.export_csv(entries, filename)
elif selected_filter.startswith("HTML"): elif selected_filter.startswith("HTML"):
self.db.export_html(entries, filename) self.db.export_html(entries, filename)
elif selected_filter.startswith("SQL"):
self.db.export_sql(filename)
else: else:
self.db.export_by_extension(entries, filename) self.db.export_by_extension(entries, filename)
@ -594,6 +620,39 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Export failed", str(e)) QMessageBox.critical(self, "Export failed", str(e))
# ----------------- Backup handler ----------------- #
@Slot()
def _backup(self):
filters = "SQLCipher (*.db);;"
now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
start_dir = os.path.join(
os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db"
)
filename, selected_filter = QFileDialog.getSaveFileName(
self, "Backup encrypted notebook", start_dir, filters
)
if not filename:
return # user cancelled
default_ext = {
"SQLCipher (*.db)": ".db",
}.get(selected_filter, ".db")
if not Path(filename).suffix:
filename += default_ext
try:
if selected_filter.startswith("SQL"):
self.db.export_sqlcipher(filename)
QMessageBox.information(
self, "Backup complete", f"Saved to:\n{filename}"
)
except Exception as e:
QMessageBox.critical(self, "Backup failed", str(e))
# ----------------- Help handlers ----------------- #
def _open_docs(self): def _open_docs(self):
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help" url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
url = QUrl.fromUserInput(url_str) url = QUrl.fromUserInput(url_str)
@ -610,7 +669,7 @@ class MainWindow(QMainWindow):
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}" self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
) )
# Idle handlers # ----------------- Idle handlers ----------------- #
def _apply_idle_minutes(self, minutes: int): def _apply_idle_minutes(self, minutes: int):
minutes = max(0, int(minutes)) minutes = max(0, int(minutes))
if not hasattr(self, "_idle_timer"): if not hasattr(self, "_idle_timer"):
@ -668,13 +727,14 @@ class MainWindow(QMainWindow):
tb.setEnabled(True) tb.setEnabled(True)
self._idle_timer.start() self._idle_timer.start()
# Close app handler - save window position and database # ----------------- Close handlers ----------------- #
def closeEvent(self, event): def closeEvent(self, event):
try: try:
# Save window position # Save window position
self.settings.setValue("main/geometry", self.saveGeometry()) self.settings.setValue("main/geometry", self.saveGeometry())
self.settings.setValue("main/windowState", self.saveState()) self.settings.setValue("main/windowState", self.saveState())
self.settings.setValue("main/maximized", self.isMaximized()) self.settings.setValue("main/maximized", self.isMaximized())
# Ensure we save any last pending edits to the db # Ensure we save any last pending edits to the db
self._save_current() self._save_current()
self.db.close() self.db.close()

View file

@ -73,7 +73,7 @@ class ToolBar(QToolBar):
self.actNormal = QAction("N", self) self.actNormal = QAction("N", self)
self.actNormal.setToolTip("Normal paragraph text") self.actNormal.setToolTip("Normal paragraph text")
self.actNormal.setCheckable(True) self.actNormal.setCheckable(True)
self.actNormal.setShortcut("Ctrl+O") self.actNormal.setShortcut("Ctrl+N")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
# Lists # Lists