Compare commits
No commits in common. "aad1ba5d7d515ab3df52066aea31077f581242b9" and "ada1d8ffadac114721a9b15b5dee296e27fe1197" have entirely different histories.
aad1ba5d7d
...
ada1d8ffad
25 changed files with 100 additions and 988 deletions
|
|
@ -1,8 +1,6 @@
|
||||||
# 0.1.11
|
# 0.1.11
|
||||||
|
|
||||||
* Add missing export extensions to export_by_extension
|
* Add missing export extensions to export_by_extension
|
||||||
* Fix focusing on editor after leaving the app and returning
|
|
||||||
* More code coverage and removing obsolete bits of code
|
|
||||||
|
|
||||||
# 0.1.10.2
|
# 0.1.10.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,25 +257,62 @@ class DBManager:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
def get_version(self, *, version_id: int) -> dict | None:
|
def get_version(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
date_iso: str | None = None,
|
||||||
|
version_no: int | None = None,
|
||||||
|
version_id: int | None = None,
|
||||||
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Fetch a specific version by version_id.
|
Fetch a specific version by (date, version_no) OR by version_id.
|
||||||
Returns a dict with keys: id, date, version_no, created_at, note, content.
|
Returns a dict with keys: id, date, version_no, created_at, note, content.
|
||||||
"""
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
if version_id is not None:
|
||||||
row = cur.execute(
|
row = cur.execute(
|
||||||
"SELECT id, date, version_no, created_at, note, content "
|
"SELECT id, date, version_no, created_at, note, content "
|
||||||
"FROM versions WHERE id=?;",
|
"FROM versions WHERE id=?;",
|
||||||
(version_id,),
|
(version_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
else:
|
||||||
|
if date_iso is None or version_no is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Provide either version_id OR (date_iso and version_no)"
|
||||||
|
)
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT id, date, version_no, created_at, note, content "
|
||||||
|
"FROM versions WHERE date=? AND version_no=?;",
|
||||||
|
(date_iso, version_no),
|
||||||
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def revert_to_version(self, date_iso: str, version_id: int) -> None:
|
def revert_to_version(
|
||||||
|
self,
|
||||||
|
date_iso: str,
|
||||||
|
*,
|
||||||
|
version_no: int | None = None,
|
||||||
|
version_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Point the page head (pages.current_version_id) to an existing version.
|
Point the page head (pages.current_version_id) to an existing version.
|
||||||
|
Fast revert: no content is rewritten.
|
||||||
"""
|
"""
|
||||||
|
if self.conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
if version_no is None:
|
||||||
|
raise ValueError("Provide version_no or version_id")
|
||||||
|
row = cur.execute(
|
||||||
|
"SELECT id FROM versions WHERE date=? AND version_no=?;",
|
||||||
|
(date_iso, version_no),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Version not found for this date")
|
||||||
|
version_id = int(row["id"])
|
||||||
|
else:
|
||||||
# Ensure that version_id belongs to the given date
|
# Ensure that version_id belongs to the given date
|
||||||
row = cur.execute(
|
row = cur.execute(
|
||||||
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
||||||
|
|
@ -305,18 +342,20 @@ class DBManager:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [(r[0], r[1]) for r in rows]
|
return [(r[0], r[1]) for r in rows]
|
||||||
|
|
||||||
def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
|
def export_json(
|
||||||
|
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Export to json.
|
Export to json.
|
||||||
"""
|
"""
|
||||||
data = [{"date": d, "content": c} for d, c in entries]
|
data = [{"date": d, "content": c} for d, c in entries]
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
if pretty:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
else:
|
||||||
|
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
||||||
"""
|
|
||||||
Export pages to CSV.
|
|
||||||
"""
|
|
||||||
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
||||||
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
|
|
@ -330,10 +369,6 @@ class DBManager:
|
||||||
separator: str = "\n\n— — — — —\n\n",
|
separator: str = "\n\n— — — — —\n\n",
|
||||||
strip_html: bool = True,
|
strip_html: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Strip the HTML from the latest version of the pages
|
|
||||||
and save to a text file.
|
|
||||||
"""
|
|
||||||
import re, html as _html
|
import re, html as _html
|
||||||
|
|
||||||
# Precompiled patterns
|
# Precompiled patterns
|
||||||
|
|
@ -372,9 +407,6 @@ class DBManager:
|
||||||
def export_html(
|
def export_html(
|
||||||
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Export to HTML with a heading.
|
|
||||||
"""
|
|
||||||
parts = [
|
parts = [
|
||||||
"<!doctype html>",
|
"<!doctype html>",
|
||||||
'<html lang="en">',
|
'<html lang="en">',
|
||||||
|
|
@ -397,10 +429,6 @@ class DBManager:
|
||||||
def export_markdown(
|
def export_markdown(
|
||||||
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Export to HTML, similar to export_html, but then convert to Markdown
|
|
||||||
using markdownify, and finally save to file.
|
|
||||||
"""
|
|
||||||
parts = [
|
parts = [
|
||||||
"<!doctype html>",
|
"<!doctype html>",
|
||||||
'<html lang="en">',
|
'<html lang="en">',
|
||||||
|
|
@ -441,10 +469,6 @@ class DBManager:
|
||||||
cur.execute("DETACH DATABASE backup")
|
cur.execute("DETACH DATABASE backup")
|
||||||
|
|
||||||
def export_by_extension(self, file_path: str) -> None:
|
def export_by_extension(self, file_path: str) -> None:
|
||||||
"""
|
|
||||||
Fallback catch-all that runs one of the above functions based on
|
|
||||||
the extension of the file name that was chosen by the user.
|
|
||||||
"""
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
@ -459,7 +483,7 @@ class DBManager:
|
||||||
elif ext in {".sql", ".sqlite"}:
|
elif ext in {".sql", ".sqlite"}:
|
||||||
self.export_sql(file_path)
|
self.export_sql(file_path)
|
||||||
elif ext == ".md":
|
elif ext == ".md":
|
||||||
self.export_markdown(entries, file_path)
|
self.export_markdown(file_path)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported extension: {ext}")
|
raise ValueError(f"Unsupported extension: {ext}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,10 @@ class Editor(QTextEdit):
|
||||||
bc.setPosition(b.position() + b.length())
|
bc.setPosition(b.position() + b.length())
|
||||||
return blocks > 0 and (codeish / blocks) >= 0.6
|
return blocks > 0 and (codeish / blocks) >= 0.6
|
||||||
|
|
||||||
def _nearest_code_frame(self, cursor, tolerant: bool = False):
|
def _nearest_code_frame(self, cursor=None, tolerant: bool = False):
|
||||||
"""Walk up parents from the cursor and return the first code frame."""
|
"""Walk up parents from the cursor and return the first code frame."""
|
||||||
|
if cursor is None:
|
||||||
|
cursor = self.textCursor()
|
||||||
f = cursor.currentFrame()
|
f = cursor.currentFrame()
|
||||||
while f:
|
while f:
|
||||||
if self._is_code_frame(f, tolerant=tolerant):
|
if self._is_code_frame(f, tolerant=tolerant):
|
||||||
|
|
@ -269,6 +271,16 @@ class Editor(QTextEdit):
|
||||||
cur.endEditBlock()
|
cur.endEditBlock()
|
||||||
self.viewport().update()
|
self.viewport().update()
|
||||||
|
|
||||||
|
def _safe_select(self, cur: QTextCursor, start: int, end: int):
|
||||||
|
"""Select [start, end] inclusive without exceeding document bounds."""
|
||||||
|
doc_max = max(0, self.document().characterCount() - 1)
|
||||||
|
s = max(0, min(start, doc_max))
|
||||||
|
e = max(0, min(end, doc_max))
|
||||||
|
if e < s:
|
||||||
|
s, e = e, s
|
||||||
|
cur.setPosition(s)
|
||||||
|
cur.setPosition(e, QTextCursor.KeepAnchor)
|
||||||
|
|
||||||
def _trim_url_end(self, url: str) -> str:
|
def _trim_url_end(self, url: str) -> str:
|
||||||
# strip common trailing punctuation not part of the URL
|
# strip common trailing punctuation not part of the URL
|
||||||
trimmed = url.rstrip(".,;:!?\"'")
|
trimmed = url.rstrip(".,;:!?\"'")
|
||||||
|
|
@ -842,6 +854,14 @@ class Editor(QTextEdit):
|
||||||
break
|
break
|
||||||
b = b.next()
|
b = b.next()
|
||||||
|
|
||||||
|
def toggle_current_checkbox_state(self):
|
||||||
|
"""Tick/untick the current line if it starts with a checkbox."""
|
||||||
|
b = self.textCursor().block()
|
||||||
|
state, _ = self._checkbox_info_for_block(b)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
self._set_block_checkbox_state(b, not state)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def apply_weight(self):
|
def apply_weight(self):
|
||||||
cur = self.currentCharFormat()
|
cur = self.currentCharFormat()
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class HistoryDialog(QDialog):
|
||||||
# Diff vs current (textual diff)
|
# Diff vs current (textual diff)
|
||||||
cur = self._db.get_version(version_id=self._current_id)
|
cur = self._db.get_version(version_id=self._current_id)
|
||||||
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||||
# Enable revert only if selecting a non-current version
|
# Enable revert only if selecting a non-current
|
||||||
self.btn_revert.setEnabled(sel_id != self._current_id)
|
self.btn_revert.setEnabled(sel_id != self._current_id)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
|
@ -167,7 +167,7 @@ class HistoryDialog(QDialog):
|
||||||
sel_id = item.data(Qt.UserRole)
|
sel_id = item.data(Qt.UserRole)
|
||||||
if sel_id == self._current_id:
|
if sel_id == self._current_id:
|
||||||
return
|
return
|
||||||
# Flip head pointer to the older version
|
# Flip head pointer
|
||||||
try:
|
try:
|
||||||
self._db.revert_to_version(self._date, version_id=sel_id)
|
self._db.revert_to_version(self._date, version_id=sel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,6 @@ class KeyPrompt(QDialog):
|
||||||
title: str = "Enter key",
|
title: str = "Enter key",
|
||||||
message: str = "Enter key",
|
message: str = "Enter key",
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Prompt the user for the key required to decrypt the database.
|
|
||||||
|
|
||||||
Used when opening the app, unlocking the idle locked screen,
|
|
||||||
or when rekeying.
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
||||||
|
|
||||||
class LockOverlay(QWidget):
|
class LockOverlay(QWidget):
|
||||||
def __init__(self, parent: QWidget, on_unlock: callable):
|
def __init__(self, parent: QWidget, on_unlock: callable):
|
||||||
"""
|
|
||||||
Widget that 'locks' the screen after a configured idle time.
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("LockOverlay")
|
self.setObjectName("LockOverlay")
|
||||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
|
@ -42,9 +39,6 @@ class LockOverlay(QWidget):
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|
||||||
def _is_dark(self, pal: QPalette) -> bool:
|
def _is_dark(self, pal: QPalette) -> bool:
|
||||||
"""
|
|
||||||
Detect if dark mode is in use.
|
|
||||||
"""
|
|
||||||
c = pal.color(QPalette.Window)
|
c = pal.color(QPalette.Window)
|
||||||
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
||||||
return luma < 0.5
|
return luma < 0.5
|
||||||
|
|
@ -64,7 +58,7 @@ class LockOverlay(QWidget):
|
||||||
|
|
||||||
self.setStyleSheet(
|
self.setStyleSheet(
|
||||||
f"""
|
f"""
|
||||||
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
|
||||||
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
||||||
|
|
||||||
#LockOverlay QPushButton#unlockButton {{
|
#LockOverlay QPushButton#unlockButton {{
|
||||||
|
|
@ -119,7 +113,7 @@ class LockOverlay(QWidget):
|
||||||
|
|
||||||
def changeEvent(self, ev):
|
def changeEvent(self, ev):
|
||||||
super().changeEvent(ev)
|
super().changeEvent(ev)
|
||||||
# Only re-style on palette flips (user changed theme)
|
# Only re-style on palette flips
|
||||||
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
||||||
self._apply_overlay_style()
|
self._apply_overlay_style()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ from PySide6.QtGui import (
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
QPalette,
|
QPalette,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
QTextCursor,
|
|
||||||
QTextListFormat,
|
QTextListFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
|
@ -122,7 +121,7 @@ class MainWindow(QMainWindow):
|
||||||
split = QSplitter()
|
split = QSplitter()
|
||||||
split.addWidget(left_panel)
|
split.addWidget(left_panel)
|
||||||
split.addWidget(self.editor)
|
split.addWidget(self.editor)
|
||||||
split.setStretchFactor(1, 1)
|
split.setStretchFactor(1, 1) # editor grows
|
||||||
|
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
lay = QVBoxLayout(container)
|
lay = QVBoxLayout(container)
|
||||||
|
|
@ -147,16 +146,6 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
QApplication.instance().installEventFilter(self)
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
# Focus on the editor
|
|
||||||
self.setFocusPolicy(Qt.StrongFocus)
|
|
||||||
self.editor.setFocusPolicy(Qt.StrongFocus)
|
|
||||||
self.toolBar.setFocusPolicy(Qt.NoFocus)
|
|
||||||
for w in self.toolBar.findChildren(QWidget):
|
|
||||||
w.setFocusPolicy(Qt.NoFocus)
|
|
||||||
QGuiApplication.instance().applicationStateChanged.connect(
|
|
||||||
self._on_app_state_changed
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status bar for feedback
|
# Status bar for feedback
|
||||||
self.statusBar().showMessage("Ready", 800)
|
self.statusBar().showMessage("Ready", 800)
|
||||||
|
|
||||||
|
|
@ -292,7 +281,7 @@ class MainWindow(QMainWindow):
|
||||||
if hasattr(self, "_lock_overlay"):
|
if hasattr(self, "_lock_overlay"):
|
||||||
self._lock_overlay._apply_overlay_style()
|
self._lock_overlay._apply_overlay_style()
|
||||||
self._apply_calendar_text_colors()
|
self._apply_calendar_text_colors()
|
||||||
self._apply_link_css()
|
self._apply_link_css() # Reapply link styles based on the current theme
|
||||||
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
||||||
self.calendar.update()
|
self.calendar.update()
|
||||||
self.editor.viewport().update()
|
self.editor.viewport().update()
|
||||||
|
|
@ -309,6 +298,7 @@ class MainWindow(QMainWindow):
|
||||||
css = "" # Default to no custom styling for links (system or light theme)
|
css = "" # Default to no custom styling for links (system or light theme)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Apply to the editor (QTextEdit or any other relevant widgets)
|
||||||
self.editor.document().setDefaultStyleSheet(css)
|
self.editor.document().setDefaultStyleSheet(css)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -357,6 +347,7 @@ class MainWindow(QMainWindow):
|
||||||
self.calendar.setPalette(app_pal)
|
self.calendar.setPalette(app_pal)
|
||||||
self.calendar.setStyleSheet("")
|
self.calendar.setStyleSheet("")
|
||||||
|
|
||||||
|
# Keep weekend text color in sync with the current palette
|
||||||
self._apply_calendar_text_colors()
|
self._apply_calendar_text_colors()
|
||||||
self.calendar.update()
|
self.calendar.update()
|
||||||
|
|
||||||
|
|
@ -492,8 +483,7 @@ class MainWindow(QMainWindow):
|
||||||
# Inject the extra_data before the closing </body></html>
|
# Inject the extra_data before the closing </body></html>
|
||||||
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
|
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
|
||||||
text = modified
|
text = modified
|
||||||
# Force a save now so we don't lose it.
|
self.editor.setHtml(text)
|
||||||
self._set_editor_html_preserve_view(text)
|
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self._save_date(date_iso, True)
|
self._save_date(date_iso, True)
|
||||||
|
|
||||||
|
|
@ -501,7 +491,9 @@ class MainWindow(QMainWindow):
|
||||||
QMessageBox.critical(self, "Read Error", str(e))
|
QMessageBox.critical(self, "Read Error", str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_editor_html_preserve_view(text)
|
self.editor.blockSignals(True)
|
||||||
|
self.editor.setHtml(text)
|
||||||
|
self.editor.blockSignals(False)
|
||||||
|
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
# track which date the editor currently represents
|
# track which date the editor currently represents
|
||||||
|
|
@ -860,14 +852,9 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
if event.type() == QEvent.KeyPress and not self._locked:
|
if event.type() == QEvent.KeyPress and not self._locked:
|
||||||
self._idle_timer.start()
|
self._idle_timer.start()
|
||||||
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
|
|
||||||
QTimer.singleShot(0, self._focus_editor_now)
|
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
def _enter_lock(self):
|
def _enter_lock(self):
|
||||||
"""
|
|
||||||
Trigger the lock overlay and disable widgets
|
|
||||||
"""
|
|
||||||
if self._locked:
|
if self._locked:
|
||||||
return
|
return
|
||||||
self._locked = True
|
self._locked = True
|
||||||
|
|
@ -883,10 +870,6 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def _on_unlock_clicked(self):
|
def _on_unlock_clicked(self):
|
||||||
"""
|
|
||||||
Prompt for key to unlock screen
|
|
||||||
If successful, re-enable widgets
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
ok = self._prompt_for_key_until_valid(first_time=False)
|
ok = self._prompt_for_key_until_valid(first_time=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -903,7 +886,6 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
if tb:
|
if tb:
|
||||||
tb.setEnabled(True)
|
tb.setEnabled(True)
|
||||||
self._idle_timer.start()
|
self._idle_timer.start()
|
||||||
QTimer.singleShot(0, self._focus_editor_now)
|
|
||||||
|
|
||||||
# ----------------- Close handlers ----------------- #
|
# ----------------- Close handlers ----------------- #
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
|
@ -919,61 +901,3 @@ If you want an encrypted backup, choose Backup instead of Export.
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
# ----------------- Below logic helps focus the editor ----------------- #
|
|
||||||
|
|
||||||
def _focus_editor_now(self):
|
|
||||||
"""Give focus to the editor and ensure the caret is visible."""
|
|
||||||
if getattr(self, "_locked", False):
|
|
||||||
return
|
|
||||||
if not self.isActiveWindow():
|
|
||||||
return
|
|
||||||
# Belt-and-suspenders: do it now and once more on the next tick
|
|
||||||
self.editor.setFocus(Qt.ActiveWindowFocusReason)
|
|
||||||
self.editor.ensureCursorVisible()
|
|
||||||
QTimer.singleShot(
|
|
||||||
0,
|
|
||||||
lambda: (
|
|
||||||
self.editor.setFocus(Qt.ActiveWindowFocusReason),
|
|
||||||
self.editor.ensureCursorVisible(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_app_state_changed(self, state):
|
|
||||||
# Called on macOS/Wayland/Windows when the whole app re-activates
|
|
||||||
if state == Qt.ApplicationActive and self.isActiveWindow():
|
|
||||||
QTimer.singleShot(0, self._focus_editor_now)
|
|
||||||
|
|
||||||
def changeEvent(self, ev):
|
|
||||||
# Called on some platforms when the window's activation state flips
|
|
||||||
super().changeEvent(ev)
|
|
||||||
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
|
||||||
QTimer.singleShot(0, self._focus_editor_now)
|
|
||||||
|
|
||||||
def _set_editor_html_preserve_view(self, html: str):
|
|
||||||
ed = self.editor
|
|
||||||
|
|
||||||
# Save caret/selection and scroll
|
|
||||||
cur = ed.textCursor()
|
|
||||||
old_pos, old_anchor = cur.position(), cur.anchor()
|
|
||||||
v = ed.verticalScrollBar().value()
|
|
||||||
h = ed.horizontalScrollBar().value()
|
|
||||||
|
|
||||||
# Only touch the doc if it actually changed
|
|
||||||
ed.blockSignals(True)
|
|
||||||
if ed.toHtml() != html:
|
|
||||||
ed.setHtml(html)
|
|
||||||
ed.blockSignals(False)
|
|
||||||
|
|
||||||
# Restore scroll first
|
|
||||||
ed.verticalScrollBar().setValue(v)
|
|
||||||
ed.horizontalScrollBar().setValue(h)
|
|
||||||
|
|
||||||
# Restore caret/selection
|
|
||||||
cur = ed.textCursor()
|
|
||||||
cur.setPosition(old_anchor)
|
|
||||||
mode = (
|
|
||||||
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
|
|
||||||
)
|
|
||||||
cur.setPosition(old_pos, mode)
|
|
||||||
ed.setTextCursor(cur)
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ class SaveDialog(QDialog):
|
||||||
title: str = "Enter a name for this version",
|
title: str = "Enter a name for this version",
|
||||||
message: str = "Enter a name for this version?",
|
message: str = "Enter a name for this version?",
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Used for explicitly saving a new version of a page.
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
v = QVBoxLayout(self)
|
v = QVBoxLayout(self)
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class Search(QWidget):
|
||||||
try:
|
try:
|
||||||
rows: Iterable[Row] = self._db.search_entries(q)
|
rows: Iterable[Row] = self._db.search_entries(q)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# be quiet on DB errors here; caller can surface if desired
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
self._populate_results(q, rows)
|
self._populate_results(q, rows)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class ThemeManager(QObject):
|
||||||
scheme = getattr(hints, "colorScheme", None)
|
scheme = getattr(hints, "colorScheme", None)
|
||||||
if callable(scheme):
|
if callable(scheme):
|
||||||
scheme = hints.colorScheme()
|
scheme = hints.colorScheme()
|
||||||
# 0=Light, 1=Dark; fall back to Light
|
# 0=Light, 1=Dark in newer Qt; fall back to Light
|
||||||
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
||||||
|
|
||||||
# Always use Fusion so palette applies consistently cross-platform
|
# Always use Fusion so palette applies consistently cross-platform
|
||||||
|
|
@ -58,6 +58,7 @@ class ThemeManager(QObject):
|
||||||
if theme == Theme.DARK:
|
if theme == Theme.DARK:
|
||||||
pal = self._dark_palette()
|
pal = self._dark_palette()
|
||||||
self._app.setPalette(pal)
|
self._app.setPalette(pal)
|
||||||
|
# keep stylesheet empty unless you need widget-specific tweaks
|
||||||
self._app.setStyleSheet("")
|
self._app.setStyleSheet("")
|
||||||
else:
|
else:
|
||||||
pal = self._light_palette()
|
pal = self._light_palette()
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,6 @@ class ToolBar(QToolBar):
|
||||||
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
|
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
|
||||||
a.setActionGroup(self.grpAlign)
|
a.setActionGroup(self.grpAlign)
|
||||||
|
|
||||||
self.grpLists = QActionGroup(self)
|
|
||||||
self.grpLists.setExclusive(True)
|
|
||||||
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
|
||||||
a.setActionGroup(self.grpLists)
|
|
||||||
|
|
||||||
# Add actions
|
# Add actions
|
||||||
self.addActions(
|
self.addActions(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -102,32 +102,3 @@ def theme_parent_widget(qtbot):
|
||||||
parent = _Parent()
|
parent = _Parent()
|
||||||
qtbot.addWidget(parent)
|
qtbot.addWidget(parent)
|
||||||
return parent
|
return parent
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def qapp():
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
|
||||||
yield app
|
|
||||||
# do not quit; pytest might still need it
|
|
||||||
# app.quit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_db_path(tmp_path):
|
|
||||||
return tmp_path / "notebook.db"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def cfg(temp_db_path):
|
|
||||||
# Use the real DBConfig from the app (SQLCipher-backed)
|
|
||||||
from bouquin.db import DBConfig
|
|
||||||
|
|
||||||
return DBConfig(
|
|
||||||
path=Path(temp_db_path),
|
|
||||||
key="testkey",
|
|
||||||
idle_minutes=0,
|
|
||||||
theme="system",
|
|
||||||
move_todos=True,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bouquin.db import DBManager, DBConfig
|
|
||||||
|
|
||||||
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
|
|
||||||
from sqlcipher3 import dbapi2 as sqlite
|
|
||||||
|
|
||||||
|
|
||||||
def connect_raw_sqlcipher(db_path: Path, key: str):
|
|
||||||
conn = sqlite.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite.Row
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(f"PRAGMA key = '{key}';")
|
|
||||||
cur.execute("PRAGMA foreign_keys = ON;")
|
|
||||||
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
|
|
||||||
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
|
|
||||||
db_path = cfg.path
|
|
||||||
conn = connect_raw_sqlcipher(db_path, cfg.key)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO entries(date, content) VALUES(?, ?);",
|
|
||||||
("2025-01-02", "<p>Hello</p>"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Now use the real DBManager, which will run _ensure_schema and migrate
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
|
|
||||||
# After migration, legacy table should be gone and content reachable via get_entry
|
|
||||||
text = mgr.get_entry("2025-01-02")
|
|
||||||
assert "Hello" in text
|
|
||||||
|
|
||||||
cur = mgr.conn.cursor()
|
|
||||||
# entries table should be dropped
|
|
||||||
with pytest.raises(sqlite.OperationalError):
|
|
||||||
cur.execute("SELECT count(*) FROM entries;").fetchone()
|
|
||||||
|
|
||||||
# pages & versions exist and head points to v1
|
|
||||||
rows = cur.execute(
|
|
||||||
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
|
|
||||||
).fetchone()
|
|
||||||
assert rows is not None and rows["current_version_id"] is not None
|
|
||||||
vers = mgr.list_versions("2025-01-02")
|
|
||||||
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
mgr.save_new_version("2025-01-03", "<p>x</p>")
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_db(cfg: DBConfig) -> DBManager:
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
return mgr
|
|
||||||
|
|
||||||
|
|
||||||
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
|
|
||||||
mgr = _bootstrap_db(cfg)
|
|
||||||
# Create two versions for the same date
|
|
||||||
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
|
|
||||||
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
|
|
||||||
assert ver1_no == 1 and ver2_no == 2
|
|
||||||
|
|
||||||
# Revert using version_id
|
|
||||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
|
|
||||||
cur = mgr.conn.cursor()
|
|
||||||
head2 = cur.execute(
|
|
||||||
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
|
|
||||||
).fetchone()[0]
|
|
||||||
assert head2 == ver2_id
|
|
||||||
|
|
||||||
# Error: version_id belongs to a different date
|
|
||||||
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
|
|
||||||
mgr = _bootstrap_db(cfg)
|
|
||||||
# Seed a couple of entries
|
|
||||||
mgr.save_new_version("2025-01-06", "<p>A</p>")
|
|
||||||
mgr.save_new_version("2025-01-07", "<p>B</p>")
|
|
||||||
|
|
||||||
# Prepare output files
|
|
||||||
out = tmp_path
|
|
||||||
exts = [
|
|
||||||
".json",
|
|
||||||
".csv",
|
|
||||||
".txt",
|
|
||||||
".html",
|
|
||||||
".sql",
|
|
||||||
] # exclude .md due to different signature
|
|
||||||
for ext in exts:
|
|
||||||
path = out / f"export{ext}"
|
|
||||||
mgr.export_by_extension(str(path))
|
|
||||||
assert path.exists() and path.stat().st_size > 0
|
|
||||||
|
|
||||||
# Markdown export uses a different signature (entries + path)
|
|
||||||
entries = mgr.get_all_entries()
|
|
||||||
md_path = out / "export.md"
|
|
||||||
mgr.export_markdown(entries, str(md_path))
|
|
||||||
assert md_path.exists() and md_path.stat().st_size > 0
|
|
||||||
|
|
||||||
# Run VACUUM path
|
|
||||||
mgr.compact() # should not raise
|
|
||||||
|
|
@ -114,7 +114,7 @@ def test_export_by_extension_and_unknown(tmp_path):
|
||||||
import types
|
import types
|
||||||
|
|
||||||
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
|
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
|
||||||
for ext in [".json", ".csv", ".txt", ".html", ".md"]:
|
for ext in [".json", ".csv", ".txt", ".html"]:
|
||||||
path = tmp_path / f"route{ext}"
|
path = tmp_path / f"route{ext}"
|
||||||
mgr.export_by_extension(str(path))
|
mgr.export_by_extension(str(path))
|
||||||
assert path.exists()
|
assert path.exists()
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,10 @@ def test_linkify_trims_trailing_punctuation(qtbot):
|
||||||
|
|
||||||
|
|
||||||
def test_code_block_enter_exits_on_empty_line(qtbot):
|
def test_code_block_enter_exits_on_empty_line(qtbot):
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QTextCursor
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from bouquin.editor import Editor
|
||||||
|
|
||||||
e = _mk_editor()
|
e = _mk_editor()
|
||||||
qtbot.addWidget(e)
|
qtbot.addWidget(e)
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import base64
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtCore import Qt, QMimeData, QByteArray
|
|
||||||
from PySide6.QtGui import QImage, QTextCursor
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def editor(app, qtbot):
|
|
||||||
themes = ThemeManager(app, ThemeConfig())
|
|
||||||
e = Editor(themes)
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.show()
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def test_todo_prefix_converts_to_checkbox_on_space(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("TODO")
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
QTest.keyClick(editor, Qt.Key_Space)
|
|
||||||
# Now the line should start with the checkbox glyph and a space
|
|
||||||
assert editor.toPlainText().startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_enter_inside_empty_code_frame_jumps_out(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("") # single empty block
|
|
||||||
# Apply code block to current line
|
|
||||||
editor.apply_code()
|
|
||||||
# Cursor is inside the code frame. Press Enter on empty block should jump out.
|
|
||||||
QTest.keyClick(editor, Qt.Key_Return)
|
|
||||||
# We expect two blocks: one code block (with a newline inserted) and then a normal block
|
|
||||||
txt = editor.toPlainText()
|
|
||||||
assert "\n" in txt # a normal paragraph created after exiting the frame
|
|
||||||
|
|
||||||
|
|
||||||
def test_insertFromMimeData_with_data_image(editor):
|
|
||||||
# Build an in-memory PNG and embed as data URL inside HTML
|
|
||||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
|
||||||
img.fill(0xFF00FF00) # green
|
|
||||||
ba = QByteArray()
|
|
||||||
from PySide6.QtCore import QBuffer, QIODevice
|
|
||||||
|
|
||||||
buf = QBuffer(ba)
|
|
||||||
buf.open(QIODevice.WriteOnly)
|
|
||||||
img.save(buf, "PNG")
|
|
||||||
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
|
|
||||||
html = f'<img src="data:image/png;base64,{data_b64}"/>'
|
|
||||||
|
|
||||||
md = QMimeData()
|
|
||||||
md.setHtml(html)
|
|
||||||
editor.insertFromMimeData(md)
|
|
||||||
|
|
||||||
# HTML export with embedded images should contain a data: URL
|
|
||||||
h = editor.to_html_with_embedded_images()
|
|
||||||
assert "data:image/png;base64," in h
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_checkboxes_selection(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("item 1\nitem 2")
|
|
||||||
# Select both lines
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.Start)
|
|
||||||
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
# Toggle on -> inserts ☐
|
|
||||||
editor.toggle_checkboxes()
|
|
||||||
assert editor.toPlainText().startswith("☐ ")
|
|
||||||
# Toggle again -> remove ☐
|
|
||||||
editor.toggle_checkboxes()
|
|
||||||
assert not editor.toPlainText().startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_heading_then_enter_reverts_to_normal(editor):
|
|
||||||
editor.clear()
|
|
||||||
editor.setPlainText("A heading")
|
|
||||||
# Apply H2 via apply_heading(size=18)
|
|
||||||
editor.apply_heading(18)
|
|
||||||
c = editor.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
editor.setTextCursor(c)
|
|
||||||
# Press Enter -> new block should be Normal (not bold/large)
|
|
||||||
QTest.keyClick(editor, Qt.Key_Return)
|
|
||||||
# The new block exists
|
|
||||||
txt = editor.toPlainText()
|
|
||||||
assert "\n" in txt
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
from PySide6.QtCore import QUrl
|
|
||||||
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
|
|
||||||
from bouquin.theme import ThemeManager
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_editor(qapp, cfg):
|
|
||||||
themes = ThemeManager(qapp, cfg)
|
|
||||||
ed = Editor(themes)
|
|
||||||
ed.resize(400, 300)
|
|
||||||
return ed
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_scale_and_reset(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
|
|
||||||
# Register an image resource and insert it at the cursor
|
|
||||||
img = QImage(20, 10, QImage.Format_ARGB32)
|
|
||||||
img.fill(QColor(200, 0, 0))
|
|
||||||
url = QUrl("test://img")
|
|
||||||
from PySide6.QtGui import QTextDocument
|
|
||||||
|
|
||||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
|
||||||
|
|
||||||
fmt = QTextImageFormat()
|
|
||||||
fmt.setName(url.toString())
|
|
||||||
# No explicit width -> code should use original width
|
|
||||||
tc = ed.textCursor()
|
|
||||||
tc.insertImage(fmt)
|
|
||||||
|
|
||||||
# Place cursor at start (on the image) and scale
|
|
||||||
tc = ed.textCursor()
|
|
||||||
tc.movePosition(QTextCursor.Start)
|
|
||||||
ed.setTextCursor(tc)
|
|
||||||
ed._scale_image_at_cursor(1.5) # increases width
|
|
||||||
ed._reset_image_size() # restores to original width
|
|
||||||
|
|
||||||
# Ensure resulting HTML contains an <img> tag
|
|
||||||
html = ed.toHtml()
|
|
||||||
assert "<img" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_image_size_fallbacks(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
|
|
||||||
fmt = QTextImageFormat()
|
|
||||||
fmt.setName("") # no resource available
|
|
||||||
tc = ed.textCursor()
|
|
||||||
# Insert a single character to have a valid cursor
|
|
||||||
tc.insertText("x")
|
|
||||||
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
|
|
||||||
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
|
|
||||||
ed = _mk_editor(qapp, cfg)
|
|
||||||
|
|
||||||
# Insert an anchor + image and ensure HTML embedding + retint pass runs
|
|
||||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
|
||||||
img.fill(QColor(0, 200, 0))
|
|
||||||
url = QUrl("test://img2")
|
|
||||||
from PySide6.QtGui import QTextDocument
|
|
||||||
|
|
||||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
|
||||||
|
|
||||||
# Compose HTML with a link and an image referencing our resource
|
|
||||||
ed.setHtml(
|
|
||||||
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
|
|
||||||
)
|
|
||||||
|
|
||||||
html = ed.to_html_with_embedded_images()
|
|
||||||
# Embedded data URL should appear for the image
|
|
||||||
assert "data:image" in html
|
|
||||||
# The link should still be present (retinted internally) without crashing
|
|
||||||
assert "example.com" in html
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot
|
|
||||||
from PySide6.QtGui import QImage, QMouseEvent, QTextCursor
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bouquin.editor import Editor
|
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_editor() -> Editor:
|
|
||||||
app = QApplication.instance()
|
|
||||||
tm = ThemeManager(app, ThemeConfig())
|
|
||||||
e = Editor(tm)
|
|
||||||
e.resize(700, 400)
|
|
||||||
e.show()
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
def _point_for_char(e: Editor, pos: int):
|
|
||||||
c = e.textCursor()
|
|
||||||
c.setPosition(pos)
|
|
||||||
r = e.cursorRect(c)
|
|
||||||
return r.center()
|
|
||||||
|
|
||||||
|
|
||||||
def test_trim_url_and_linkify_and_ctrl_mouse(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
assert e._trim_url_end("https://ex.com)") == "https://ex.com"
|
|
||||||
assert e._trim_url_end("www.mysite.org]") == "www.mysite.org"
|
|
||||||
|
|
||||||
url = "https://example.org/path"
|
|
||||||
QTest.keyClicks(e, url)
|
|
||||||
qtbot.waitUntil(lambda: url in e.toPlainText())
|
|
||||||
|
|
||||||
p = _point_for_char(e, 0)
|
|
||||||
move = QMouseEvent(
|
|
||||||
QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier
|
|
||||||
)
|
|
||||||
e.mouseMoveEvent(move)
|
|
||||||
assert e.viewport().cursor().shape() == Qt.PointingHandCursor
|
|
||||||
|
|
||||||
opened = {}
|
|
||||||
|
|
||||||
class Catcher(QObject):
|
|
||||||
@Slot(QUrl)
|
|
||||||
def handle(self, u: QUrl):
|
|
||||||
opened["u"] = u.toString()
|
|
||||||
|
|
||||||
from PySide6.QtGui import QDesktopServices
|
|
||||||
|
|
||||||
catcher = Catcher()
|
|
||||||
QDesktopServices.setUrlHandler("https", catcher, "handle")
|
|
||||||
try:
|
|
||||||
rel = QMouseEvent(
|
|
||||||
QEvent.MouseButtonRelease,
|
|
||||||
p,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.ControlModifier,
|
|
||||||
)
|
|
||||||
e.mouseReleaseEvent(rel)
|
|
||||||
got_signal = []
|
|
||||||
e.linkActivated.connect(lambda href: got_signal.append(href))
|
|
||||||
e.mouseReleaseEvent(rel)
|
|
||||||
assert opened or got_signal
|
|
||||||
finally:
|
|
||||||
QDesktopServices.unsetUrlHandler("https")
|
|
||||||
|
|
||||||
|
|
||||||
def test_insert_images_and_image_helpers(qtbot, tmp_path):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
|
|
||||||
# No image under cursor yet (412 guard)
|
|
||||||
tc, fmt, orig = e._image_info_at_cursor()
|
|
||||||
assert tc is None and fmt is None and orig is None
|
|
||||||
|
|
||||||
# Insert a real image file (574–584 path)
|
|
||||||
img_path = tmp_path / "tiny.png"
|
|
||||||
img = QImage(4, 4, QImage.Format_ARGB32)
|
|
||||||
img.fill(0xFF336699)
|
|
||||||
assert img.save(str(img_path), "PNG")
|
|
||||||
e.insert_images([str(img_path)], autoscale=False)
|
|
||||||
assert "<img" in e.toHtml()
|
|
||||||
|
|
||||||
# Guards when not on an image (453, 464)
|
|
||||||
e._scale_image_at_cursor(1.1)
|
|
||||||
e._fit_image_to_editor_width()
|
|
||||||
|
|
||||||
|
|
||||||
def test_checkbox_click_and_enter_continuation(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.setPlainText("☐ task one")
|
|
||||||
|
|
||||||
# Need it visible for mouse coords
|
|
||||||
e.resize(600, 300)
|
|
||||||
e.show()
|
|
||||||
qtbot.waitExposed(e)
|
|
||||||
|
|
||||||
# Click on the checkbox glyph to toggle (605–614)
|
|
||||||
start_point = _point_for_char(e, 0)
|
|
||||||
press = QMouseEvent(
|
|
||||||
QEvent.MouseButtonPress,
|
|
||||||
start_point,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.LeftButton,
|
|
||||||
Qt.NoModifier,
|
|
||||||
)
|
|
||||||
e.mousePressEvent(press)
|
|
||||||
assert e.toPlainText().startswith("☑ ")
|
|
||||||
|
|
||||||
# Press Enter at end -> new line with fresh checkbox (680–684)
|
|
||||||
c = e.textCursor()
|
|
||||||
c.movePosition(QTextCursor.End)
|
|
||||||
e.setTextCursor(c)
|
|
||||||
QTest.keyClick(e, Qt.Key_Return)
|
|
||||||
lines = e.toPlainText().splitlines()
|
|
||||||
assert len(lines) >= 2 and lines[1].startswith("☐ ")
|
|
||||||
|
|
||||||
|
|
||||||
def test_heading_and_lists_toggle_remove(qtbot):
|
|
||||||
e = _mk_editor()
|
|
||||||
qtbot.addWidget(e)
|
|
||||||
e.setPlainText("para")
|
|
||||||
|
|
||||||
# "Normal" path is size=0 (904…)
|
|
||||||
e.apply_heading(0)
|
|
||||||
|
|
||||||
# bullets twice -> second call removes (945–946)
|
|
||||||
e.toggle_bullets()
|
|
||||||
e.toggle_bullets()
|
|
||||||
# numbers twice -> second call removes (955–956)
|
|
||||||
e.toggle_numbers()
|
|
||||||
e.toggle_numbers()
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication, QListWidgetItem
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.history_dialog import HistoryDialog
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "h.db", key="k")
|
|
||||||
db = DBManager(cfg)
|
|
||||||
assert db.connect()
|
|
||||||
# Seed two versions for a date
|
|
||||||
db.save_new_version("2025-02-10", "<p>v1</p>", note="v1", set_current=True)
|
|
||||||
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def test_revert_early_returns(app, db, qtbot):
|
|
||||||
dlg = HistoryDialog(db, date_iso="2025-02-10")
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# (1) No current item -> returns immediately
|
|
||||||
dlg.list.setCurrentItem(None)
|
|
||||||
dlg._revert() # should not crash and should not accept
|
|
||||||
|
|
||||||
# (2) Selecting the current item -> still returns early
|
|
||||||
# Build an item with the *current* id as payload
|
|
||||||
cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"])
|
|
||||||
it = QListWidgetItem("current")
|
|
||||||
it.setData(Qt.UserRole, cur_id)
|
|
||||||
dlg.list.addItem(it)
|
|
||||||
dlg.list.setCurrentItem(it)
|
|
||||||
dlg._revert() # should return early (no accept called)
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import runpy
|
|
||||||
import types
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def test_dunder_main_executes_without_launching_qt(monkeypatch):
|
|
||||||
# Replace bouquin.main with a stub that records invocation and returns immediately
|
|
||||||
calls = {"called": False}
|
|
||||||
mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True))
|
|
||||||
monkeypatch.setitem(sys.modules, "bouquin.main", mod)
|
|
||||||
|
|
||||||
# Running the module as __main__ should call mod.main() but not start a Qt loop
|
|
||||||
runpy.run_module("bouquin.__main__", run_name="__main__")
|
|
||||||
assert calls["called"] is True
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
from PySide6.QtCore import QDate
|
|
||||||
from bouquin.theme import ThemeManager
|
|
||||||
from bouquin.main_window import MainWindow
|
|
||||||
from bouquin.settings import save_db_config
|
|
||||||
from bouquin.db import DBManager
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_window(qapp, cfg):
|
|
||||||
# Ensure DB exists and key is valid in settings
|
|
||||||
mgr = DBManager(cfg)
|
|
||||||
assert mgr.connect() is True
|
|
||||||
save_db_config(cfg)
|
|
||||||
|
|
||||||
themes = ThemeManager(qapp, cfg)
|
|
||||||
win = MainWindow(themes)
|
|
||||||
# Force an initial selected date
|
|
||||||
win.calendar.setSelectedDate(QDate.currentDate())
|
|
||||||
return win
|
|
||||||
|
|
||||||
|
|
||||||
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
|
|
||||||
cfg.move_todos = True
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
|
|
||||||
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
|
||||||
html = (
|
|
||||||
"<p><span>☐</span> Unchecked 1</p>"
|
|
||||||
"<p><span>☑</span> Checked 1</p>"
|
|
||||||
"<p><span>☐</span> Unchecked 2</p>"
|
|
||||||
)
|
|
||||||
win.db.save_new_version(y, html)
|
|
||||||
|
|
||||||
# Ensure today starts blank
|
|
||||||
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
|
||||||
win.editor.setHtml("<p></p>")
|
|
||||||
_html = win.editor.toHtml()
|
|
||||||
win.db.save_new_version(today_iso, _html)
|
|
||||||
|
|
||||||
# Invoke the move-todos logic
|
|
||||||
win._load_yesterday_todos()
|
|
||||||
|
|
||||||
# Verify today's entry now contains only the unchecked items
|
|
||||||
txt = win.db.get_entry(today_iso)
|
|
||||||
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
|
|
||||||
|
|
||||||
|
|
||||||
def test_adjust_and_save_paths(qapp, cfg):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Move date selection and jump to today
|
|
||||||
before = win.calendar.selectedDate()
|
|
||||||
win._adjust_day(-1)
|
|
||||||
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
|
|
||||||
"yyyy-MM-dd"
|
|
||||||
)
|
|
||||||
win._adjust_today()
|
|
||||||
assert win.calendar.selectedDate() == QDate.currentDate()
|
|
||||||
|
|
||||||
# Save path exercises success feedback + dirty flag reset
|
|
||||||
win.editor.setHtml("<p>content</p>")
|
|
||||||
win._dirty = True
|
|
||||||
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
|
|
||||||
assert win._dirty is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_window_position(qapp, cfg, tmp_path):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
|
|
||||||
geom = win.saveGeometry()
|
|
||||||
state = win.saveState()
|
|
||||||
s = win.settings
|
|
||||||
s.setValue("ui/geometry", geom)
|
|
||||||
s.setValue("ui/window_state", state)
|
|
||||||
s.sync()
|
|
||||||
|
|
||||||
win._restore_window_position() # should restore without error
|
|
||||||
|
|
||||||
|
|
||||||
def test_idle_lock_unlock_flow(qapp, cfg):
|
|
||||||
win = _bootstrap_window(qapp, cfg)
|
|
||||||
|
|
||||||
# Enter lock
|
|
||||||
win._enter_lock()
|
|
||||||
assert getattr(win, "_locked", False) is True
|
|
||||||
|
|
||||||
# Disabling idle minutes should unlock and hide overlay
|
|
||||||
win._apply_idle_minutes(0)
|
|
||||||
assert getattr(win, "_locked", False) is False
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
from bouquin.search import Search as SearchWidget
|
|
||||||
|
|
||||||
|
|
||||||
class DummyDB:
|
|
||||||
def search_entries(self, q):
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_no_match_triggers_start_window(qtbot):
|
|
||||||
w = SearchWidget(db=DummyDB())
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
html = "<p>" + ("x" * 300) + "</p>" # long text, no token present
|
|
||||||
frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80)
|
|
||||||
assert frag != ""
|
|
||||||
assert left is False and right is True
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.search import Search
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
# Ensure a single QApplication exists
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
yield a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fresh_db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
|
|
||||||
db = DBManager(cfg)
|
|
||||||
assert db.connect() is True
|
|
||||||
# Seed a couple of entries
|
|
||||||
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
|
|
||||||
db.save_new_version(
|
|
||||||
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
|
||||||
)
|
|
||||||
db.save_new_version(
|
|
||||||
"2025-01-03",
|
|
||||||
"<p>Long content begins "
|
|
||||||
+ ("x" * 200)
|
|
||||||
+ " middle token here "
|
|
||||||
+ ("y" * 200)
|
|
||||||
+ " ends.</p>",
|
|
||||||
)
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
|
|
||||||
# Close the DB to provoke an exception inside Search._search
|
|
||||||
fresh_db.close()
|
|
||||||
w = Search(fresh_db)
|
|
||||||
w.show()
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
|
|
||||||
# Typing should not raise; exception path returns empty results
|
|
||||||
w._search("anything")
|
|
||||||
assert w.results.isHidden() # remains hidden because there are no rows
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
|
|
||||||
w = Search(fresh_db)
|
|
||||||
# Choose a query so that the first match sits well inside a long string,
|
|
||||||
# forcing both left and right ellipses.
|
|
||||||
html = fresh_db.get_entry("2025-01-03")
|
|
||||||
snippet, left_ell, right_ell = w._make_html_snippet(html, "middle")
|
|
||||||
assert snippet # non-empty
|
|
||||||
assert left_ell is True
|
|
||||||
assert right_ell is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_results_middle(app, fresh_db, qtbot):
|
|
||||||
w = Search(fresh_db)
|
|
||||||
w.show()
|
|
||||||
qtbot.addWidget(w)
|
|
||||||
# Choose a query so that the first match sits well inside a long string,
|
|
||||||
# forcing both left and right ellipses.
|
|
||||||
assert fresh_db.connect()
|
|
||||||
|
|
||||||
w._search("middle")
|
|
||||||
assert w.results.isVisible()
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import pytest
|
|
||||||
from bouquin.search import Search
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def search_widget(qapp):
|
|
||||||
# We don't need a real DB for snippet generation – pass None
|
|
||||||
return Search(db=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_empty(search_widget: Search):
|
|
||||||
html = ""
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "", radius=10, maxlen=20
|
|
||||||
)
|
|
||||||
assert frag == "" and has_prev is False and has_next is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_phrase_preferred(search_widget: Search):
|
|
||||||
html = "<p>Alpha beta gamma delta</p>"
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "beta gamma", radius=1, maxlen=10
|
|
||||||
)
|
|
||||||
# We expect a window that includes the phrase and has previous text
|
|
||||||
assert "beta" in frag and "gamma" in frag
|
|
||||||
assert has_prev is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
|
|
||||||
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
|
|
||||||
# Use tokens such that the phrase doesn't exist, but individual tokens do
|
|
||||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
|
||||||
html, "eleven two", radius=3, maxlen=20
|
|
||||||
)
|
|
||||||
assert "two" in frag
|
|
||||||
# The snippet should be a slice within the text (has more following content)
|
|
||||||
assert has_next is True
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import pytest
|
|
||||||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
|
||||||
|
|
||||||
from bouquin.db import DBConfig, DBManager
|
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
|
||||||
from bouquin.settings import APP_NAME, APP_ORG
|
|
||||||
from bouquin.key_prompt import KeyPrompt
|
|
||||||
from bouquin.theme import Theme, ThemeManager, ThemeConfig
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def app():
|
|
||||||
a = QApplication.instance()
|
|
||||||
if a is None:
|
|
||||||
a = QApplication([])
|
|
||||||
a.setApplicationName(APP_NAME)
|
|
||||||
a.setOrganizationName(APP_ORG)
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def db(tmp_path):
|
|
||||||
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
|
|
||||||
m = DBManager(cfg)
|
|
||||||
assert m.connect()
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
|
|
||||||
# Dark preselection
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
dlg.theme_dark.setChecked(True)
|
|
||||||
dlg._save()
|
|
||||||
assert dlg.config.theme == Theme.DARK.value
|
|
||||||
|
|
||||||
# Light preselection
|
|
||||||
parent2 = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent2)
|
|
||||||
dlg2 = SettingsDialog(db.cfg, db, parent=parent2)
|
|
||||||
qtbot.addWidget(dlg2)
|
|
||||||
dlg2.theme_light.setChecked(True)
|
|
||||||
dlg2._save()
|
|
||||||
assert dlg2.config.theme == Theme.LIGHT.value
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# First prompt cancelled -> early return
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
|
||||||
dlg._change_key() # should just return without altering key
|
|
||||||
assert dlg.key == ""
|
|
||||||
|
|
||||||
# First OK, second cancelled -> early return at the second branch
|
|
||||||
state = {"calls": 0}
|
|
||||||
|
|
||||||
def _exec(self):
|
|
||||||
state["calls"] += 1
|
|
||||||
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
|
|
||||||
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", _exec)
|
|
||||||
# Also monkeypatch to control key() values
|
|
||||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
|
|
||||||
dlg._change_key()
|
|
||||||
# Because the second prompt was rejected, key should remain unchanged
|
|
||||||
assert dlg.key == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# Simulate user checking the box, but cancelling the prompt -> code unchecks it again
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
|
||||||
dlg.save_key_btn.setChecked(True)
|
|
||||||
# The slot toggled should run and revert it to unchecked
|
|
||||||
assert dlg.save_key_btn.isChecked() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_key_exception_path(app, db, monkeypatch, qtbot):
|
|
||||||
parent = _ParentWithThemes(app)
|
|
||||||
qtbot.addWidget(parent)
|
|
||||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
|
||||||
qtbot.addWidget(dlg)
|
|
||||||
|
|
||||||
# Accept both prompts and supply a key
|
|
||||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted)
|
|
||||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
|
|
||||||
|
|
||||||
# Force DB rekey to raise to exercise the except-branch
|
|
||||||
monkeypatch.setattr(
|
|
||||||
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not raise; error is handled internally
|
|
||||||
dlg._change_key()
|
|
||||||
|
|
||||||
|
|
||||||
class _ParentWithThemes(QWidget):
|
|
||||||
def __init__(self, app):
|
|
||||||
super().__init__()
|
|
||||||
self.themes = ThemeManager(app, ThemeConfig())
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue