Add the ability to choose the database path at startup. Add more tests. Add bandit
All checks were successful
CI / test (push) Successful in 3m49s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 21s

This commit is contained in:
Miguel Jacq 2025-11-17 15:15:00 +11:00
parent 8c7226964a
commit 6bc5b66d3f
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 297 additions and 97 deletions

View file

@ -15,7 +15,7 @@ jobs:
run: | run: |
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
black pyflakes3 vulture black pyflakes3 vulture python3-bandit
- name: Run linters - name: Run linters
run: | run: |
@ -24,3 +24,4 @@ jobs:
pyflakes3 bouquin/* pyflakes3 bouquin/*
pyflakes3 tests/* pyflakes3 tests/*
vulture vulture
bandit -s B110 -r bouquin/

View file

@ -1,3 +1,8 @@
# 0.3.2
* Add weekday letters on left axis of Statistics page
* Add the ability to choose the database path at startup
# 0.3.1 # 0.3.1
* Make it possible to add a tag from the Tag Browser * Make it possible to add a tag from the Tag Browser

View file

@ -433,7 +433,7 @@ class DBManager:
""" """
if not name: if not name:
return "#CCCCCC" return "#CCCCCC"
h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) # nosec
return _TAG_COLORS[h % len(_TAG_COLORS)] return _TAG_COLORS[h % len(_TAG_COLORS)]
# -------- Tags: per-page ------------------------------------------- # -------- Tags: per-page -------------------------------------------
@ -514,7 +514,7 @@ class DBManager:
SELECT id, name SELECT id, name
FROM tags FROM tags
WHERE name IN ({placeholders}); WHERE name IN ({placeholders});
""", """, # nosec
tuple(final_tag_names), tuple(final_tag_names),
).fetchall() ).fetchall()
ids_by_name = {r["name"]: r["id"] for r in rows} ids_by_name = {r["name"]: r["id"] for r in rows}

View file

@ -1,12 +1,16 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QDialogButtonBox, QDialogButtonBox,
QFileDialog,
) )
from . import strings from . import strings
@ -18,32 +22,85 @@ class KeyPrompt(QDialog):
parent=None, parent=None,
title: str = strings._("key_prompt_enter_key"), title: str = strings._("key_prompt_enter_key"),
message: str = strings._("key_prompt_enter_key"), message: str = strings._("key_prompt_enter_key"),
initial_db_path: str | Path | None = None,
show_db_change: bool = False,
): ):
""" """
Prompt the user for the key required to decrypt the database. Prompt the user for the key required to decrypt the database.
Used when opening the app, unlocking the idle locked screen, Used when opening the app, unlocking the idle locked screen,
or when rekeying. or when rekeying.
If show_db_change is true, also show a QFileDialog allowing to
select a database file, else the default from settings is used.
""" """
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)
self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.addWidget(QLabel(message)) v.addWidget(QLabel(message))
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.Password) # DB chooser
v.addWidget(self.edit) self.path_edit: QLineEdit | None = None
if show_db_change:
path_row = QHBoxLayout()
self.path_edit = QLineEdit()
if self._db_path is not None:
self.path_edit.setText(str(self._db_path))
browse_btn = QPushButton(strings._("select_notebook"))
def _browse():
start_dir = str(self._db_path or "")
fname, _ = QFileDialog.getOpenFileName(
self,
strings._("select_notebook"),
start_dir,
"SQLCipher DB (*.db);;All files (*)",
)
if fname:
self._db_path = Path(fname)
if self.path_edit is not None:
self.path_edit.setText(fname)
browse_btn.clicked.connect(_browse)
path_row.addWidget(self.path_edit, 1)
path_row.addWidget(browse_btn)
v.addLayout(path_row)
# Key entry
self.key_entry = QLineEdit()
self.key_entry.setEchoMode(QLineEdit.Password)
v.addWidget(self.key_entry)
toggle = QPushButton(strings._("show")) toggle = QPushButton(strings._("show"))
toggle.setCheckable(True) toggle.setCheckable(True)
toggle.toggled.connect( toggle.toggled.connect(
lambda c: self.edit.setEchoMode( lambda c: self.key_entry.setEchoMode(
QLineEdit.Normal if c else QLineEdit.Password QLineEdit.Normal if c else QLineEdit.Password
) )
) )
v.addWidget(toggle) v.addWidget(toggle)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept) bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject) bb.rejected.connect(self.reject)
v.addWidget(bb) v.addWidget(bb)
self.key_entry.setFocus()
self.resize(500, self.sizeHint().height())
def key(self) -> str: def key(self) -> str:
return self.edit.text() return self.key_entry.text()
def db_path(self) -> Path | None:
"""Return the chosen DB path (or None if unchanged/not shown)."""
if self.path_edit is not None:
text = self.path_edit.text().strip()
if text:
return Path(text)
return self._db_path

View file

@ -147,5 +147,7 @@
"stats_heatmap_metric": "Colour by", "stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words", "stats_metric_words": "Words",
"stats_metric_revisions": "Revisions", "stats_metric_revisions": "Revisions",
"stats_no_data": "No statistics available yet." "stats_no_data": "No statistics available yet.",
"select_notebook": "Select notebook"
} }

View file

@ -329,14 +329,14 @@ class MainWindow(QMainWindow):
try: try:
self.db = DBManager(self.cfg) self.db = DBManager(self.cfg)
ok = self.db.connect() ok = self.db.connect()
return ok
except Exception as e: except Exception as e:
if str(e) == "file is not a database": if str(e) == "file is not a database":
error = strings._("db_key_incorrect") error = strings._("db_key_incorrect")
else: else:
error = str(e) error = str(e)
QMessageBox.critical(self, strings._("db_database_error"), error) QMessageBox.critical(self, strings._("db_database_error"), error)
return False sys.exit(1)
return ok
def _prompt_for_key_until_valid(self, first_time: bool) -> bool: def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
""" """
@ -349,10 +349,20 @@ class MainWindow(QMainWindow):
title = strings._("unlock_encrypted_notebook") title = strings._("unlock_encrypted_notebook")
message = strings._("unlock_encrypted_notebook_explanation") message = strings._("unlock_encrypted_notebook_explanation")
while True: while True:
dlg = KeyPrompt(self, title, message) dlg = KeyPrompt(
self, title, message, initial_db_path=self.cfg.path, show_db_change=True
)
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
return False return False
self.cfg.key = dlg.key() self.cfg.key = dlg.key()
# Update DB path if the user changed it
new_path = dlg.db_path()
if new_path is not None and new_path != self.cfg.path:
self.cfg.path = new_path
# Persist immediately so next run is pre-filled with this file
save_db_config(self.cfg)
if self._try_connect(): if self._try_connect():
return True return True

View file

@ -13,15 +13,31 @@ def get_settings() -> QSettings:
return QSettings(APP_ORG, APP_NAME) return QSettings(APP_ORG, APP_NAME)
def _default_db_location() -> Path:
"""Where we put the notebook if nothing has been configured yet."""
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
base.mkdir(parents=True, exist_ok=True)
return base / "notebook.db"
def load_db_config() -> DBConfig: def load_db_config() -> DBConfig:
s = get_settings() s = get_settings()
default_db_path = str(
Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
/ "notebook.db"
)
path = Path(s.value("db/path", default_db_path)) # --- DB Path -------------------------------------------------------
# Prefer the new key; fall back to the legacy one.
path_str = s.value("db/default_db", "", type=str)
if not path_str:
legacy = s.value("db/path", "", type=str)
if legacy:
path_str = legacy
# Optional: migrate and clean up the old key
s.setValue("db/default_db", legacy)
s.remove("db/path")
path = Path(path_str) if path_str else _default_db_location()
# --- Other settings ------------------------------------------------
key = s.value("db/key", "") key = s.value("db/key", "")
idle = s.value("ui/idle_minutes", 15, type=int) idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str) theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool) move_todos = s.value("ui/move_todos", False, type=bool)
@ -38,7 +54,7 @@ def load_db_config() -> DBConfig:
def save_db_config(cfg: DBConfig) -> None: def save_db_config(cfg: DBConfig) -> None:
s = get_settings() s = get_settings()
s.setValue("db/path", str(cfg.path)) s.setValue("db/default_db", str(cfg.path))
s.setValue("db/key", str(cfg.key)) s.setValue("db/key", str(cfg.key))
s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/theme", str(cfg.theme))

View file

@ -12,10 +12,7 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QHBoxLayout, QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QWidget,
QLineEdit,
QPushButton, QPushButton,
QFileDialog,
QDialogButtonBox, QDialogButtonBox,
QRadioButton, QRadioButton,
QSizePolicy, QSizePolicy,
@ -47,7 +44,7 @@ class SettingsDialog(QDialog):
self.setMinimumWidth(560) self.setMinimumWidth(560)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
current_settings = load_db_config() self.current_settings = load_db_config()
# Add theme selection # Add theme selection
theme_group = QGroupBox(strings._("theme")) theme_group = QGroupBox(strings._("theme"))
@ -58,7 +55,7 @@ class SettingsDialog(QDialog):
self.theme_dark = QRadioButton(strings._("dark")) self.theme_dark = QRadioButton(strings._("dark"))
# Load current theme from settings # Load current theme from settings
current_theme = current_settings.theme current_theme = self.current_settings.theme
if current_theme == Theme.DARK.value: if current_theme == Theme.DARK.value:
self.theme_dark.setChecked(True) self.theme_dark.setChecked(True)
elif current_theme == Theme.LIGHT.value: elif current_theme == Theme.LIGHT.value:
@ -80,7 +77,7 @@ class SettingsDialog(QDialog):
self.locale_combobox = QComboBox() self.locale_combobox = QComboBox()
self.locale_combobox.addItems(strings._AVAILABLE) self.locale_combobox.addItems(strings._AVAILABLE)
self.locale_combobox.setCurrentText(current_settings.locale) self.locale_combobox.setCurrentText(self.current_settings.locale)
locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft) locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft)
# Explanation for locale # Explanation for locale
@ -104,26 +101,12 @@ class SettingsDialog(QDialog):
self.move_todos = QCheckBox( self.move_todos = QCheckBox(
strings._("move_yesterdays_unchecked_todos_to_today_on_startup") strings._("move_yesterdays_unchecked_todos_to_today_on_startup")
) )
self.move_todos.setChecked(current_settings.move_todos) self.move_todos.setChecked(self.current_settings.move_todos)
self.move_todos.setCursor(Qt.PointingHandCursor) self.move_todos.setCursor(Qt.PointingHandCursor)
behaviour_layout.addWidget(self.move_todos) behaviour_layout.addWidget(self.move_todos)
form.addRow(behaviour_group) form.addRow(behaviour_group)
self.path_edit = QLineEdit(str(self._cfg.path))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
browse_btn = QPushButton(strings._("browse"))
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
browse_btn.clicked.connect(self._browse)
path_row = QWidget()
h = QHBoxLayout(path_row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(self.path_edit, 1)
h.addWidget(browse_btn, 0)
h.setStretch(0, 1)
h.setStretch(1, 0)
form.addRow(strings._("database_path"), path_row)
# Encryption settings # Encryption settings
enc_group = QGroupBox(strings._("encryption")) enc_group = QGroupBox(strings._("encryption"))
enc = QVBoxLayout(enc_group) enc = QVBoxLayout(enc_group)
@ -132,7 +115,7 @@ class SettingsDialog(QDialog):
# Checkbox to remember key # Checkbox to remember key
self.save_key_btn = QCheckBox(strings._("remember_key")) self.save_key_btn = QCheckBox(strings._("remember_key"))
self.key = current_settings.key or "" self.key = self.current_settings.key or ""
self.save_key_btn.setChecked(bool(self.key)) self.save_key_btn.setChecked(bool(self.key))
self.save_key_btn.setCursor(Qt.PointingHandCursor) self.save_key_btn.setCursor(Qt.PointingHandCursor)
self.save_key_btn.toggled.connect(self._save_key_btn_clicked) self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
@ -236,16 +219,6 @@ class SettingsDialog(QDialog):
v.addLayout(form) v.addLayout(form)
v.addWidget(bb, 0, Qt.AlignRight) v.addWidget(bb, 0, Qt.AlignRight)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
self,
strings._("database_path"),
self.path_edit.text(),
"(*.db);;(*)",
)
if p:
self.path_edit.setText(p)
def _save(self): def _save(self):
# Save the selected theme into QSettings # Save the selected theme into QSettings
if self.theme_dark.isChecked(): if self.theme_dark.isChecked():
@ -258,7 +231,7 @@ class SettingsDialog(QDialog):
key_to_save = self.key if self.save_key_btn.isChecked() else "" key_to_save = self.key if self.save_key_btn.isChecked() else ""
self._cfg = DBConfig( self._cfg = DBConfig(
path=Path(self.path_edit.text()), path=Path(self.current_settings.path),
key=key_to_save, key=key_to_save,
idle_minutes=self.idle_spin.value(), idle_minutes=self.idle_spin.value(),
theme=selected_theme.value, theme=selected_theme.value,

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.3.1" version = "0.3.2"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

View file

@ -33,10 +33,10 @@ def isolate_qsettings(tmp_path_factory):
def tmp_db_cfg(tmp_path): def tmp_db_cfg(tmp_path):
from bouquin.db import DBConfig from bouquin.db import DBConfig
db_path = tmp_path / "notebook.db" default_db = tmp_path / "notebook.db"
key = "test-secret-key" key = "test-secret-key"
return DBConfig( return DBConfig(
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True path=default_db, key=key, idle_minutes=0, theme="light", move_todos=True
) )

View file

@ -5,5 +5,5 @@ def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt() kp = KeyPrompt()
qtbot.addWidget(kp) qtbot.addWidget(kp)
kp.show() kp.show()
kp.edit.setText("swordfish") kp.key_entry.setText("swordfish")
assert kp.key() == "swordfish" assert kp.key() == "swordfish"

View file

@ -18,7 +18,7 @@ from unittest.mock import Mock, patch
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0) s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
@ -48,7 +48,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def _auto_accept_keyprompt(): def _auto_accept_keyprompt():
for wdg in QApplication.topLevelWidgets(): for wdg in QApplication.topLevelWidgets():
if isinstance(wdg, KeyPrompt): if isinstance(wdg, KeyPrompt):
wdg.edit.setText(tmp_db_cfg.key) wdg.key_entry.setText(tmp_db_cfg.key)
wdg.accept() wdg.accept()
w._enter_lock() w._enter_lock()
@ -59,7 +59,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/move_todos", True) s.setValue("ui/move_todos", True)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
@ -122,7 +122,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings from bouquin.settings import get_settings
s = get_settings() s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key) s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0) s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
@ -196,7 +196,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings from bouquin.settings import get_settings
s = get_settings() s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key) s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0) s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
@ -251,7 +251,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
from bouquin.settings import get_settings from bouquin.settings import get_settings
s = get_settings() s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/default_db", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key) s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0) s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light") s.setValue("ui/theme", "light")
@ -472,8 +472,24 @@ def test_try_connect_maps_errors(
mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
) )
ok = w._try_connect() # Intercept sys.exit so the test process doesn't actually die
assert ok is False exited = {}
def fake_exit(code=0):
exited["code"] = code
# mimic real behaviour: raise SystemExit so callers see a fatal exit
raise SystemExit(code)
monkeypatch.setattr(mwmod.sys, "exit", fake_exit, raising=True)
# _try_connect should now raise SystemExit instead of returning
with pytest.raises(SystemExit):
w._try_connect()
# We attempted to exit with code 1
assert exited["code"] == 1
# And we still showed the right error message
assert "database" in shown["title"].lower() assert "database" in shown["title"].lower()
if expect_key_msg: if expect_key_msg:
assert "key" in shown["text"].lower() assert "key" in shown["text"].lower()
@ -499,6 +515,9 @@ def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch
def key(self): def key(self):
return "" return ""
def db_path(self) -> Path | None:
return "foo.db"
monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True) monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True)
assert w._prompt_for_key_until_valid(first_time=False) is False assert w._prompt_for_key_until_valid(first_time=False) is False
@ -517,6 +536,9 @@ def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch
def key(self): def key(self):
return "abc" return "abc"
def db_path(self) -> Path | None:
return "foo.db"
monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True) monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True)
monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True) monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True)
assert w._prompt_for_key_until_valid(first_time=True) is True assert w._prompt_for_key_until_valid(first_time=True) is True

View file

@ -6,17 +6,30 @@ from bouquin.settings import (
from bouquin.db import DBConfig from bouquin.db import DBConfig
def test_load_and_save_db_config_roundtrip(app, tmp_path): def _clear_db_settings():
s = get_settings() s = get_settings()
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]: for k in [
"db/default_db",
"db/path", # legacy key
"db/key",
"ui/idle_minutes",
"ui/theme",
"ui/move_todos",
"ui/locale",
]:
s.remove(k) s.remove(k)
def test_load_and_save_db_config_roundtrip(app, tmp_path):
_clear_db_settings()
cfg = DBConfig( cfg = DBConfig(
path=tmp_path / "notes.db", path=tmp_path / "notes.db",
key="abc123", key="abc123",
idle_minutes=7, idle_minutes=7,
theme="dark", theme="dark",
move_todos=True, move_todos=True,
locale="en",
) )
save_db_config(cfg) save_db_config(cfg)
@ -26,3 +39,21 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
assert loaded.idle_minutes == cfg.idle_minutes assert loaded.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos assert loaded.move_todos == cfg.move_todos
assert loaded.locale == cfg.locale
def test_load_db_config_migrates_legacy_db_path(app, tmp_path):
_clear_db_settings()
s = get_settings()
legacy_path = tmp_path / "legacy.db"
s.setValue("db/path", str(legacy_path))
cfg = load_db_config()
# Uses the legacy value…
assert cfg.path == legacy_path
# …but also migrates to the new key and clears the old one.
assert s.value("db/default_db", "", type=str) == str(legacy_path)
assert s.value("db/path", "", type=str) == ""

View file

@ -8,7 +8,7 @@ from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...)) # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
app = QApplication.instance() app = QApplication.instance()
parent = QWidget() parent = QWidget()
@ -17,7 +17,6 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() dlg.show()
dlg.path_edit.setText(str(tmp_path / "alt.db"))
dlg.idle_spin.setValue(3) dlg.idle_spin.setValue(3)
dlg.theme_light.setChecked(True) dlg.theme_light.setChecked(True)
dlg.move_todos.setChecked(True) dlg.move_todos.setChecked(True)
@ -34,7 +33,6 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path)
dlg._save() dlg._save()
cfg = dlg.config cfg = dlg.config
assert cfg.path.name == "alt.db"
assert cfg.idle_minutes == 3 assert cfg.idle_minutes == 3
assert cfg.theme in ("light", "dark", "system") assert cfg.theme in ("light", "dark", "system")
@ -55,7 +53,7 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
def _pump(): def _pump():
for w in QApplication.topLevelWidgets(): for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt): if isinstance(w, KeyPrompt):
w.edit.setText("supersecret") w.key_entry.setText("supersecret")
w.accept() w.accept()
elif isinstance(w, QMessageBox): elif isinstance(w, QMessageBox):
w.accept() w.accept()
@ -99,7 +97,7 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
def _pump_popups(): def _pump_popups():
for w in QApplication.topLevelWidgets(): for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt): if isinstance(w, KeyPrompt):
w.edit.setText(keys.pop(0) if keys else "zzz") w.key_entry.setText(keys.pop(0) if keys else "zzz")
w.accept() w.accept()
elif isinstance(w, QMessageBox): elif isinstance(w, QMessageBox):
w.accept() w.accept()
@ -141,7 +139,7 @@ def test_change_key_success(qtbot, tmp_path, app):
def _pump(): def _pump():
for w in QApplication.topLevelWidgets(): for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt): if isinstance(w, KeyPrompt):
w.edit.setText(keys.pop(0) if keys else "newkey") w.key_entry.setText(keys.pop(0) if keys else "newkey")
w.accept() w.accept()
elif isinstance(w, QMessageBox): elif isinstance(w, QMessageBox):
w.accept() w.accept()
@ -203,27 +201,6 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
assert called["text"] assert called["text"]
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
cfg = DBConfig(
path=tmp_path / "x.db", key="k", idle_minutes=0, theme="light", move_todos=True
)
dlg = SettingsDialog(cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
p = tmp_path / "new_file.db"
monkeypatch.setattr(
sd.QFileDialog,
"getSaveFileName",
staticmethod(lambda *a, **k: (str(p), "DB Files (*.db)")),
raising=False,
)
dlg._browse()
assert dlg.path_edit.text().endswith("new_file.db")
class _Host(QWidget): class _Host(QWidget):
def __init__(self, themes): def __init__(self, themes):
super().__init__() super().__init__()

View file

@ -0,0 +1,106 @@
import datetime as _dt
from PySide6.QtWidgets import QLabel
from bouquin.statistics_dialog import StatisticsDialog
from bouquin import strings
class FakeStatsDB:
"""Minimal stub that returns a fixed stats payload."""
def __init__(self):
d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2)
self.stats = (
2, # pages_with_content
5, # total_revisions
"2024-01-02", # page_most_revisions
3, # page_most_revisions_count
{d1: 10, d2: 20}, # words_by_date
30, # total_words
4, # unique_tags
"2024-01-02", # page_most_tags
2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date
)
self.called = False
def gather_stats(self):
self.called = True
return self.stats
def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Make sure we have a known language for label texts
strings.load_strings("en")
db = FakeStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
# Stats were actually requested from the DB
assert db.called
# Window title comes from translations
assert dlg.windowTitle() == strings._("statistics")
# Grab all label texts for simple content checks
label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)}
# Page with most revisions / tags are rendered as "DATE (COUNT)"
assert "2024-01-02 (3)" in label_texts
assert "2024-01-02 (2)" in label_texts
# Heatmap is created and uses "words" by default
words_by_date = db.stats[4]
revisions_by_date = db.stats[-1]
assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date
# Switching the metric to "revisions" should swap the dataset
dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions
qtbot.wait(10)
assert dlg._heatmap._data == revisions_by_date
class EmptyStatsDB:
"""Stub that returns a 'no data yet' stats payload."""
def __init__(self):
self.called = False
def gather_stats(self):
self.called = True
return (
0, # pages_with_content
0, # total_revisions
None, # page_most_revisions
0,
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
0,
{}, # revisions_by_date
)
def test_statistics_dialog_no_data_shows_placeholder(qtbot):
strings.load_strings("en")
db = EmptyStatsDB()
dlg = StatisticsDialog(db)
qtbot.addWidget(dlg)
dlg.show()
assert db.called
label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)]
assert strings._("stats_no_data") in label_texts
# When there's no data, the heatmap and metric combo shouldn't exist
assert not hasattr(dlg, "metric_combo")
assert not hasattr(dlg, "_heatmap")

View file

@ -12,7 +12,7 @@ from bouquin.history_dialog import HistoryDialog
def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db):
# point to the temp encrypted DB # point to the temp encrypted DB
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -45,7 +45,7 @@ def test_toolbar_signals_dispatch_once_per_click(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch qtbot, app, tmp_db_cfg, fresh_db, monkeypatch
): ):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -116,7 +116,7 @@ def test_history_and_insert_image_not_duplicated(
qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path
): ):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -156,7 +156,7 @@ def test_history_and_insert_image_not_duplicated(
def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -171,7 +171,7 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db):
def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings() s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key) s.setValue("db/key", tmp_db_cfg.key)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))