Add sql plaintext export and (encrypted) backup options
This commit is contained in:
parent
f8e0a7f179
commit
27ba33959c
4 changed files with 94 additions and 13 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue