convert to markdown #1

Merged
mig5 merged 1 commit from markdown into main 2025-11-08 00:30:47 -06:00
54 changed files with 1616 additions and 4012 deletions

View file

@ -1,3 +1,7 @@
# 0.2.0
* Switch back to Markdown editor
# 0.1.12.1 # 0.1.12.1
* Fix newline after URL keeps URL style formatting * Fix newline after URL keeps URL style formatting

View file

@ -25,7 +25,7 @@ There is deliberately no network connectivity or syncing intended.
* Encryption key is prompted for and never stored, unless user chooses to via Settings * Encryption key is prompted for and never stored, unless user chooses to via Settings
* Every 'page' is linked to the calendar day * Every 'page' is linked to the calendar day
* All changes are version controlled, with ability to view/diff versions and revert * All changes are version controlled, with ability to view/diff versions and revert
* Text is HTML with basic styling * Text is Markdown with basic styling
* Images are supported * Images are supported
* Search * Search
* Automatic periodic saving (or explicitly save) * Automatic periodic saving (or explicitly save)

View file

@ -6,7 +6,6 @@ import json
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from markdownify import markdownify as md
from pathlib import Path from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite from sqlcipher3 import dbapi2 as sqlite
from typing import List, Sequence, Tuple from typing import List, Sequence, Tuple
@ -401,25 +400,13 @@ class DBManager:
Export to HTML, similar to export_html, but then convert to Markdown Export to HTML, similar to export_html, but then convert to Markdown
using markdownify, and finally save to file. using markdownify, and finally save to file.
""" """
parts = [ parts = []
"<!doctype html>",
'<html lang="en">',
"<body>",
f"<h1>{html.escape(title)}</h1>",
]
for d, c in entries: for d, c in entries:
parts.append( parts.append(f"# {d}")
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>" parts.append(c)
)
parts.append("</body></html>")
# Convert html to markdown
md_items = []
for item in parts:
md_items.append(md(item, heading_style="ATX"))
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write("\n".join(md_items)) f.write("\n".join(parts))
def export_sql(self, file_path: str) -> None: def export_sql(self, file_path: str) -> None:
""" """

File diff suppressed because it is too large Load diff

View file

@ -16,31 +16,33 @@ from PySide6.QtWidgets import (
) )
def _html_to_text(s: str) -> str: def _markdown_to_text(s: str) -> str:
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks).""" """Convert markdown to plain text for diff comparison."""
IMG_RE = re.compile(r"(?is)<img\b[^>]*>") # Remove images
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>") s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
COMMENT_RE = re.compile(r"<!--.*?-->", re.S) # Remove inline code formatting
BR_RE = re.compile(r"(?i)<br\s*/?>") s = re.sub(r"`([^`]+)`", r"\1", s)
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>") # Remove bold/italic markers
TAG_RE = re.compile(r"<[^>]+>") s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
MULTINL_RE = re.compile(r"\n{3,}") s = re.sub(r"__([^_]+)__", r"\1", s)
s = re.sub(r"\*([^*]+)\*", r"\1", s)
s = IMG_RE.sub("[ Image changed - see Preview pane ]", s) s = re.sub(r"_([^_]+)_", r"\1", s)
s = STYLE_SCRIPT_RE.sub("", s) # Remove strikethrough
s = COMMENT_RE.sub("", s) s = re.sub(r"~~([^~]+)~~", r"\1", s)
s = BR_RE.sub("\n", s) # Remove heading markers
s = BLOCK_END_RE.sub("\n", s) s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
s = TAG_RE.sub("", s) # Remove list markers
s = _html.unescape(s) s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
s = MULTINL_RE.sub("\n\n", s) s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
# Remove checkbox markers
s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
return s.strip() return s.strip()
def _colored_unified_diff_html(old_html: str, new_html: str) -> str: def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray).""" """Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _html_to_text(old_html).splitlines() a = _markdown_to_text(old_md).splitlines()
b = _html_to_text(new_html).splitlines() b = _markdown_to_text(new_md).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="") ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
lines = [] lines = []
for line in ud: for line in ud:
@ -150,9 +152,13 @@ class HistoryDialog(QDialog):
self.btn_revert.setEnabled(False) self.btn_revert.setEnabled(False)
return return
sel_id = item.data(Qt.UserRole) sel_id = item.data(Qt.UserRole)
# Preview selected as HTML # Preview selected as plain text (markdown)
sel = self._db.get_version(version_id=sel_id) sel = self._db.get_version(version_id=sel_id)
self.preview.setHtml(sel["content"]) # Show markdown as plain text with monospace font for better readability
self.preview.setPlainText(sel["content"])
self.preview.setStyleSheet(
"font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
)
# 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"]))

View file

@ -44,7 +44,7 @@ from PySide6.QtWidgets import (
) )
from .db import DBManager from .db import DBManager
from .editor import Editor from .markdown_editor import MarkdownEditor
from .find_bar import FindBar from .find_bar import FindBar
from .history_dialog import HistoryDialog from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
@ -99,7 +99,7 @@ class MainWindow(QMainWindow):
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
# This is the note-taking editor # This is the note-taking editor
self.editor = Editor(self.themes) self.editor = MarkdownEditor(self.themes)
# Toolbar for controlling styling # Toolbar for controlling styling
self.toolBar = ToolBar() self.toolBar = ToolBar()
@ -107,14 +107,14 @@ class MainWindow(QMainWindow):
# Wire toolbar intents to editor methods # Wire toolbar intents to editor methods
self.toolBar.boldRequested.connect(self.editor.apply_weight) self.toolBar.boldRequested.connect(self.editor.apply_weight)
self.toolBar.italicRequested.connect(self.editor.apply_italic) self.toolBar.italicRequested.connect(self.editor.apply_italic)
self.toolBar.underlineRequested.connect(self.editor.apply_underline) # Note: Markdown doesn't support underline, so we skip underlineRequested
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough) self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
self.toolBar.codeRequested.connect(self.editor.apply_code) self.toolBar.codeRequested.connect(self.editor.apply_code)
self.toolBar.headingRequested.connect(self.editor.apply_heading) self.toolBar.headingRequested.connect(self.editor.apply_heading)
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets) self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes) self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
self.toolBar.alignRequested.connect(self.editor.setAlignment) # Note: Markdown doesn't natively support alignment, removing alignRequested
self.toolBar.historyRequested.connect(self._open_history) self.toolBar.historyRequested.connect(self._open_history)
self.toolBar.insertImageRequested.connect(self._on_insert_image) self.toolBar.insertImageRequested.connect(self._on_insert_image)
@ -450,17 +450,14 @@ class MainWindow(QMainWindow):
def _sync_toolbar(self): def _sync_toolbar(self):
fmt = self.editor.currentCharFormat() fmt = self.editor.currentCharFormat()
c = self.editor.textCursor() c = self.editor.textCursor()
bf = c.blockFormat()
# Block signals so setChecked() doesn't re-trigger actions # Block signals so setChecked() doesn't re-trigger actions
QSignalBlocker(self.toolBar.actBold) QSignalBlocker(self.toolBar.actBold)
QSignalBlocker(self.toolBar.actItalic) QSignalBlocker(self.toolBar.actItalic)
QSignalBlocker(self.toolBar.actUnderline)
QSignalBlocker(self.toolBar.actStrike) QSignalBlocker(self.toolBar.actStrike)
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
self.toolBar.actItalic.setChecked(fmt.fontItalic()) self.toolBar.actItalic.setChecked(fmt.fontItalic())
self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut()) self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
# Headings: decide which to check by current point size # Headings: decide which to check by current point size
@ -492,15 +489,6 @@ class MainWindow(QMainWindow):
self.toolBar.actBullets.setChecked(bool(bullets_on)) self.toolBar.actBullets.setChecked(bool(bullets_on))
self.toolBar.actNumbers.setChecked(bool(numbers_on)) self.toolBar.actNumbers.setChecked(bool(numbers_on))
# Alignment
align = bf.alignment() & Qt.AlignHorizontal_Mask
QSignalBlocker(self.toolBar.actAlignL)
self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
QSignalBlocker(self.toolBar.actAlignC)
self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
QSignalBlocker(self.toolBar.actAlignR)
self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
def _current_date_iso(self) -> str: def _current_date_iso(self) -> str:
d = self.calendar.selectedDate() d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}" return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
@ -511,14 +499,12 @@ class MainWindow(QMainWindow):
try: try:
text = self.db.get_entry(date_iso) text = self.db.get_entry(date_iso)
if extra_data: if extra_data:
# Wrap extra_data in a <p> tag for HTML rendering # Append extra data as markdown
extra_data_html = f"<p>{extra_data}</p>" if text and not text.endswith("\n"):
text += "\n"
# Inject the extra_data before the closing </body></html> text += extra_data
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
text = modified
# Force a save now so we don't lose it. # Force a save now so we don't lose it.
self._set_editor_html_preserve_view(text) self._set_editor_markdown_preserve_view(text)
self._dirty = True self._dirty = True
self._save_date(date_iso, True) self._save_date(date_iso, True)
@ -526,7 +512,7 @@ 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._set_editor_markdown_preserve_view(text)
self._dirty = False self._dirty = False
# track which date the editor currently represents # track which date the editor currently represents
@ -556,39 +542,33 @@ class MainWindow(QMainWindow):
text = self.db.get_entry(yesterday_str) text = self.db.get_entry(yesterday_str)
unchecked_items = [] unchecked_items = []
# Regex to match the unchecked checkboxes and their associated text # Split into lines and find unchecked checkbox items
checkbox_pattern = re.compile( lines = text.split("\n")
r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL remaining_lines = []
)
# Find unchecked items and store them for line in lines:
for match in checkbox_pattern.finditer(text): # Check for unchecked markdown checkboxes: - [ ] or - [☐]
checkbox = match.group(1) # Either ☐ or ☑ if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
item_text = match.group(2).strip() # The text after the checkbox r"^\s*-\s*\[☐\]\s+", line
if checkbox == "": # If it's an unchecked checkbox (☐) ):
unchecked_items.append("" + item_text) # Store the unchecked item # Extract the text after the checkbox
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
unchecked_items.append(f"- [ ] {item_text}")
else:
# Keep all other lines
remaining_lines.append(line)
# Remove the unchecked items from yesterday's HTML content # Save modified content back if we moved items
if unchecked_items: if unchecked_items:
# This regex will find the entire checkbox line and remove it from the HTML content modified_text = "\n".join(remaining_lines)
uncheckbox_pattern = re.compile(
r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
)
modified_text = re.sub(
uncheckbox_pattern, "", text
) # Remove the checkbox lines
# Save the modified HTML back to the database
self.db.save_new_version( self.db.save_new_version(
yesterday_str, yesterday_str,
modified_text, modified_text,
"Unchecked checkbox items moved to next day", "Unchecked checkbox items moved to next day",
) )
# Join unchecked items into a formatted string # Join unchecked items into markdown format
unchecked_str = "\n".join( unchecked_str = "\n".join(unchecked_items) + "\n"
[f"<p>{item}</p>" for item in unchecked_items]
)
# Load the unchecked items into the current editor # Load the unchecked items into the current editor
self._load_selected_date(False, unchecked_str) self._load_selected_date(False, unchecked_str)
@ -621,7 +601,7 @@ class MainWindow(QMainWindow):
""" """
if not self._dirty and not explicit: if not self._dirty and not explicit:
return return
text = self.editor.to_html_with_embedded_images() text = self.editor.to_markdown()
try: try:
self.db.save_new_version(date_iso, text, note) self.db.save_new_version(date_iso, text, note)
except Exception as e: except Exception as e:
@ -674,7 +654,9 @@ class MainWindow(QMainWindow):
) )
if not paths: if not paths:
return return
self.editor.insert_images(paths) # call into the editor # Insert each image
for path_str in paths:
self.editor.insert_image_from_path(Path(path_str))
# ----------- Settings handler ------------# # ----------- Settings handler ------------#
def _open_settings(self): def _open_settings(self):
@ -975,7 +957,7 @@ If you want an encrypted backup, choose Backup instead of Export.
if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
QTimer.singleShot(0, self._focus_editor_now) QTimer.singleShot(0, self._focus_editor_now)
def _set_editor_html_preserve_view(self, html: str): def _set_editor_markdown_preserve_view(self, markdown: str):
ed = self.editor ed = self.editor
# Save caret/selection and scroll # Save caret/selection and scroll
@ -986,15 +968,19 @@ If you want an encrypted backup, choose Backup instead of Export.
# Only touch the doc if it actually changed # Only touch the doc if it actually changed
ed.blockSignals(True) ed.blockSignals(True)
if ed.toHtml() != html: if ed.to_markdown() != markdown:
ed.setHtml(html) ed.from_markdown(markdown)
ed.blockSignals(False) ed.blockSignals(False)
# Restore scroll first # Restore scroll first
ed.verticalScrollBar().setValue(v) ed.verticalScrollBar().setValue(v)
ed.horizontalScrollBar().setValue(h) ed.horizontalScrollBar().setValue(h)
# Restore caret/selection # Restore caret/selection (bounded to new doc length)
doc_length = ed.document().characterCount() - 1
old_pos = min(old_pos, doc_length)
old_anchor = min(old_anchor, doc_length)
cur = ed.textCursor() cur = ed.textCursor()
cur.setPosition(old_anchor) cur.setPosition(old_anchor)
mode = ( mode = (

795
bouquin/markdown_editor.py Normal file
View file

@ -0,0 +1,795 @@
from __future__ import annotations
import base64
import re
from pathlib import Path
from PySide6.QtGui import (
QColor,
QFont,
QFontDatabase,
QImage,
QPalette,
QGuiApplication,
QTextCharFormat,
QTextCursor,
QTextDocument,
QSyntaxHighlighter,
QTextImageFormat,
)
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QTextEdit
from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type."""
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
super().__init__(document)
self.theme_manager = theme_manager
self._setup_formats()
# Recompute formats whenever the app theme changes
try:
self.theme_manager.themeChanged.connect(self._on_theme_changed)
except Exception:
pass
def _on_theme_changed(self, *_):
self._setup_formats()
self.rehighlight()
def _setup_formats(self):
"""Setup text formats for different markdown elements."""
# Bold: **text** or __text__
self.bold_format = QTextCharFormat()
self.bold_format.setFontWeight(QFont.Weight.Bold)
# Italic: *text* or _text_
self.italic_format = QTextCharFormat()
self.italic_format.setFontItalic(True)
# Strikethrough: ~~text~~
self.strike_format = QTextCharFormat()
self.strike_format.setFontStrikeOut(True)
# Code: `code`
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self.code_format = QTextCharFormat()
self.code_format.setFont(mono)
self.code_format.setFontFixedPitch(True)
# Code block: ```
self.code_block_format = QTextCharFormat()
self.code_block_format.setFont(mono)
self.code_block_format.setFontFixedPitch(True)
pal = QGuiApplication.palette()
if self.theme_manager.current() == Theme.DARK:
# In dark mode, use a darker panel-like background
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = pal.color(QPalette.Text)
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)
# Headings
self.h1_format = QTextCharFormat()
self.h1_format.setFontPointSize(24.0)
self.h1_format.setFontWeight(QFont.Weight.Bold)
self.h2_format = QTextCharFormat()
self.h2_format.setFontPointSize(18.0)
self.h2_format.setFontWeight(QFont.Weight.Bold)
self.h3_format = QTextCharFormat()
self.h3_format.setFontPointSize(14.0)
self.h3_format.setFontWeight(QFont.Weight.Bold)
# Markdown syntax (the markers themselves) - make invisible
self.syntax_format = QTextCharFormat()
# Make the markers invisible by setting font size to 0.1 points
self.syntax_format.setFontPointSize(0.1)
# Also make them very faint in case they still show
self.syntax_format.setForeground(QColor(250, 250, 250))
def highlightBlock(self, text: str):
"""Apply formatting to a block of text based on markdown syntax."""
if not text:
return
# Track if we're in a code block (multiline)
prev_state = self.previousBlockState()
in_code_block = prev_state == 1
# Check for code block fences
if text.strip().startswith("```"):
# Toggle code block state
in_code_block = not in_code_block
self.setCurrentBlockState(1 if in_code_block else 0)
# Format the fence markers - but keep them somewhat visible for editing
# Use code format instead of syntax format so cursor is visible
self.setFormat(0, len(text), self.code_block_format)
return
if in_code_block:
# Format entire line as code
self.setFormat(0, len(text), self.code_block_format)
self.setCurrentBlockState(1)
return
self.setCurrentBlockState(0)
# Headings (must be at start of line)
heading_match = re.match(r"^(#{1,3})\s+", text)
if heading_match:
level = len(heading_match.group(1))
marker_len = len(heading_match.group(0))
# Format the # markers
self.setFormat(0, marker_len, self.syntax_format)
# Format the heading text
heading_fmt = (
self.h1_format
if level == 1
else self.h2_format if level == 2 else self.h3_format
)
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
return
# Bold: **text** or __text__
for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text):
start, end = match.span()
content_start = start + 2
content_end = end - 2
# Gray out the markers
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
# Bold the content
self.setFormat(content_start, content_end - content_start, self.bold_format)
# Italic: *text* or _text_ (but not part of bold)
for match in re.finditer(
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
):
start, end = match.span()
# Skip if this is part of a bold pattern
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
continue
if end < len(text) and text[end : end + 1] in ("*", "_"):
continue
content_start = start + 1
content_end = end - 1
# Gray out markers
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
# Italicize content
self.setFormat(
content_start, content_end - content_start, self.italic_format
)
# Strikethrough: ~~text~~
for match in re.finditer(r"~~(.+?)~~", text):
start, end = match.span()
content_start = start + 2
content_end = end - 2
self.setFormat(start, 2, self.syntax_format)
self.setFormat(end - 2, 2, self.syntax_format)
self.setFormat(
content_start, content_end - content_start, self.strike_format
)
# Inline code: `code`
for match in re.finditer(r"`([^`]+)`", text):
start, end = match.span()
content_start = start + 1
content_end = end - 1
self.setFormat(start, 1, self.syntax_format)
self.setFormat(end - 1, 1, self.syntax_format)
self.setFormat(content_start, content_end - content_start, self.code_format)
class MarkdownEditor(QTextEdit):
"""A QTextEdit that stores/loads markdown and provides live rendering."""
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
# Checkbox characters (Unicode for display, markdown for storage)
_CHECK_UNCHECKED_DISPLAY = ""
_CHECK_CHECKED_DISPLAY = ""
_CHECK_UNCHECKED_STORAGE = "[ ]"
_CHECK_CHECKED_STORAGE = "[x]"
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs)
self.theme_manager = theme_manager
# Setup tab width
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
self.setTabStopDistance(tab_w)
# We accept plain text, not rich text (markdown is plain text)
self.setAcceptRichText(False)
# Install syntax highlighter
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
# Track current list type for smart enter handling
self._last_enter_was_empty = False
# Track if we're currently updating text programmatically
self._updating = False
# Connect to text changes for smart formatting
self.textChanged.connect(self._on_text_changed)
# Enable mouse tracking for checkbox clicking
self.viewport().setMouseTracking(True)
def _on_text_changed(self):
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
if self._updating:
return
self._updating = True
try:
# Convert checkbox markdown to Unicode for display
cursor = self.textCursor()
pos = cursor.position()
text = self.toPlainText()
# Convert lines that START with "TODO " into an unchecked checkbox.
# Keeps any leading indentation.
todo_re = re.compile(r"(?m)^([ \t]*)TODO\s")
if todo_re.search(text):
modified_text = todo_re.sub(
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
text,
)
else:
modified_text = text
# Replace checkbox markdown with Unicode (for display only)
modified_text = modified_text.replace(
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
)
modified_text = modified_text.replace(
"- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
)
if modified_text != text:
# Count replacements before cursor to adjust position
text_before = text[:pos]
x_count = text_before.count("- [x] ")
space_count = text_before.count("- [ ] ")
# Each markdown checkbox -> unicode shortens by 2 chars ([x]/[ ] -> ☑/☐)
checkbox_delta = (x_count + space_count) * 2
# Each "TODO " -> "- ☐ " shortens by 1 char
todo_count = len(list(todo_re.finditer(text_before)))
todo_delta = todo_count * 1
new_pos = pos - checkbox_delta - todo_delta
# Update the text
self.blockSignals(True)
self.setPlainText(modified_text)
self.blockSignals(False)
# Restore cursor position
cursor = self.textCursor()
cursor.setPosition(max(0, min(new_pos, len(modified_text))))
self.setTextCursor(cursor)
finally:
self._updating = False
def to_markdown(self) -> str:
"""Export current content as markdown (convert Unicode checkboxes back to markdown)."""
# First, extract any embedded images and convert to markdown
text = self._extract_images_to_markdown()
# Convert Unicode checkboxes back to markdown syntax
text = text.replace(f"- {self._CHECK_CHECKED_DISPLAY} ", "- [x] ")
text = text.replace(f"- {self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
return text
def _extract_images_to_markdown(self) -> str:
"""Extract embedded images and convert them back to markdown format."""
doc = self.document()
cursor = QTextCursor(doc)
# Build the output text with images as markdown
result = []
cursor.movePosition(QTextCursor.MoveOperation.Start)
block = doc.begin()
while block.isValid():
it = block.begin()
block_text = ""
while not it.atEnd():
fragment = it.fragment()
if fragment.isValid():
if fragment.charFormat().isImageFormat():
# This is an image - convert to markdown
img_format = fragment.charFormat().toImageFormat()
img_name = img_format.name()
# The name contains the data URI
if img_name.startswith("data:image/"):
block_text += f"![image]({img_name})"
else:
# Regular text
block_text += fragment.text()
it += 1
result.append(block_text)
block = block.next()
return "\n".join(result)
def from_markdown(self, markdown_text: str):
"""Load markdown text into the editor (convert markdown checkboxes to Unicode)."""
# Convert markdown checkboxes to Unicode for display
display_text = markdown_text.replace(
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
)
display_text = display_text.replace(
"- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
)
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
display_text = re.sub(
r"(?m)^([ \t]*)TODO\s",
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
display_text,
)
self._updating = True
try:
self.setPlainText(display_text)
finally:
self._updating = False
# Render any embedded images
self._render_images()
def _render_images(self):
"""Find and render base64 images in the document."""
text = self.toPlainText()
# Pattern for markdown images with base64 data
img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)"
matches = list(re.finditer(img_pattern, text))
if not matches:
return
# Process matches in reverse to preserve positions
for match in reversed(matches):
mime_type = match.group(2)
b64_data = match.group(3)
try:
# Decode base64 to image
img_bytes = base64.b64decode(b64_data)
image = QImage.fromData(img_bytes)
if image.isNull():
continue
# Use original image size - no scaling
original_width = image.width()
original_height = image.height()
# Create image format with original base64
img_format = QTextImageFormat()
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
img_format.setWidth(original_width)
img_format.setHeight(original_height)
# Add image to document resources
self.document().addResource(
QTextDocument.ResourceType.ImageResource, img_format.name(), image
)
# Replace markdown with rendered image
cursor = QTextCursor(self.document())
cursor.setPosition(match.start())
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
cursor.insertImage(img_format)
except Exception as e:
# If image fails to render, leave the markdown as-is
print(f"Failed to render image: {e}")
continue
def _get_current_line(self) -> str:
"""Get the text of the current line."""
cursor = self.textCursor()
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
return cursor.selectedText()
def _detect_list_type(self, line: str) -> tuple[str | None, str]:
"""
Detect if line is a list item. Returns (list_type, prefix).
list_type: 'bullet', 'number', 'checkbox', or None
prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ')
"""
line = line.lstrip()
# Checkbox list (Unicode display format)
if line.startswith(f"- {self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith(
f"- {self._CHECK_CHECKED_DISPLAY} "
):
return ("checkbox", f"- {self._CHECK_UNCHECKED_DISPLAY} ")
# Bullet list
if re.match(r"^[-*+]\s", line):
match = re.match(r"^([-*+]\s)", line)
return ("bullet", match.group(1))
# Numbered list
if re.match(r"^\d+\.\s", line):
# Extract the number and increment
match = re.match(r"^(\d+)\.\s", line)
num = int(match.group(1))
return ("number", f"{num + 1}. ")
return (None, "")
def keyPressEvent(self, event):
"""Handle special key events for markdown editing."""
# Handle Enter key for smart list continuation AND code blocks
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
cursor = self.textCursor()
current_line = self._get_current_line()
# Check if we're in a code block
current_block = cursor.block()
block_state = current_block.userState()
# If current line is opening code fence, or we're inside a code block
if current_line.strip().startswith("```") or block_state == 1:
# Just insert a regular newline - the highlighter will format it as code
super().keyPressEvent(event)
return
# Check for list continuation
list_type, prefix = self._detect_list_type(current_line)
if list_type:
# Check if the line is empty (just the prefix)
content = current_line.lstrip()
is_empty = (
content == prefix.strip() or not content.replace(prefix, "").strip()
)
if is_empty and self._last_enter_was_empty:
# Second enter on empty list item - remove the list formatting
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText("\n")
self._last_enter_was_empty = False
return
elif is_empty:
# First enter on empty list item - remember this
self._last_enter_was_empty = True
else:
# Not empty - continue the list
self._last_enter_was_empty = False
# Insert newline and continue the list
super().keyPressEvent(event)
cursor = self.textCursor()
cursor.insertText(prefix)
return
else:
self._last_enter_was_empty = False
else:
# Any other key resets the empty enter flag
self._last_enter_was_empty = False
# Default handling
super().keyPressEvent(event)
def mousePressEvent(self, event):
"""Handle mouse clicks - check for checkbox clicking."""
if event.button() == Qt.MouseButton.LeftButton:
cursor = self.cursorForPosition(event.pos())
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
line = cursor.selectedText()
# Check if clicking on a checkbox line
if (
f"- {self._CHECK_UNCHECKED_DISPLAY} " in line
or f"- {self._CHECK_CHECKED_DISPLAY} " in line
):
# Toggle the checkbox
if f"- {self._CHECK_UNCHECKED_DISPLAY} " in line:
new_line = line.replace(
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
f"- {self._CHECK_CHECKED_DISPLAY} ",
)
else:
new_line = line.replace(
f"- {self._CHECK_CHECKED_DISPLAY} ",
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
)
cursor.insertText(new_line)
# Don't call super() - we handled the click
return
# Default handling for non-checkbox clicks
super().mousePressEvent(event)
# ------------------------ Toolbar action handlers ------------------------
def apply_weight(self):
"""Toggle bold formatting."""
cursor = self.textCursor()
if cursor.hasSelection():
selected = cursor.selectedText()
# Check if already bold
if selected.startswith("**") and selected.endswith("**"):
# Remove bold
new_text = selected[2:-2]
else:
# Add bold
new_text = f"**{selected}**"
cursor.insertText(new_text)
else:
# No selection - just insert markers
cursor.insertText("****")
cursor.movePosition(
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
)
self.setTextCursor(cursor)
# Return focus to editor
self.setFocus()
def apply_italic(self):
"""Toggle italic formatting."""
cursor = self.textCursor()
if cursor.hasSelection():
selected = cursor.selectedText()
if (
selected.startswith("*")
and selected.endswith("*")
and not selected.startswith("**")
):
new_text = selected[1:-1]
else:
new_text = f"*{selected}*"
cursor.insertText(new_text)
else:
cursor.insertText("**")
cursor.movePosition(
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
)
self.setTextCursor(cursor)
# Return focus to editor
self.setFocus()
def apply_strikethrough(self):
"""Toggle strikethrough formatting."""
cursor = self.textCursor()
if cursor.hasSelection():
selected = cursor.selectedText()
if selected.startswith("~~") and selected.endswith("~~"):
new_text = selected[2:-2]
else:
new_text = f"~~{selected}~~"
cursor.insertText(new_text)
else:
cursor.insertText("~~~~")
cursor.movePosition(
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
)
self.setTextCursor(cursor)
# Return focus to editor
self.setFocus()
def apply_code(self):
"""Insert or toggle code block."""
cursor = self.textCursor()
if cursor.hasSelection():
# Wrap selection in code fence
selected = cursor.selectedText()
# Note: selectedText() uses Unicode paragraph separator, replace with newline
selected = selected.replace("\u2029", "\n")
new_text = f"```\n{selected}\n```"
cursor.insertText(new_text)
else:
# Insert code block template
cursor.insertText("```\n\n```")
cursor.movePosition(
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
)
self.setTextCursor(cursor)
# Return focus to editor
self.setFocus()
def apply_heading(self, size: int):
"""Apply heading formatting to current line."""
cursor = self.textCursor()
# Determine heading level from size
if size >= 24:
level = 1
elif size >= 18:
level = 2
elif size >= 14:
level = 3
else:
level = 0 # Normal text
# Get current line
cursor.movePosition(
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
)
cursor.movePosition(
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
)
line = cursor.selectedText()
# Remove existing heading markers
line = re.sub(r"^#{1,6}\s+", "", line)
# Add new heading markers if not normal
if level > 0:
new_line = "#" * level + " " + line
else:
new_line = line
cursor.insertText(new_line)
# Return focus to editor
self.setFocus()
def toggle_bullets(self):
"""Toggle bullet list on current line."""
cursor = self.textCursor()
cursor.movePosition(
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
)
cursor.movePosition(
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
)
line = cursor.selectedText()
# Check if already a bullet
if line.lstrip().startswith("- ") or line.lstrip().startswith("* "):
# Remove bullet
new_line = re.sub(r"^\s*[-*]\s+", "", line)
else:
# Add bullet
new_line = "- " + line.lstrip()
cursor.insertText(new_line)
# Return focus to editor
self.setFocus()
def toggle_numbers(self):
"""Toggle numbered list on current line."""
cursor = self.textCursor()
cursor.movePosition(
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
)
cursor.movePosition(
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
)
line = cursor.selectedText()
# Check if already numbered
if re.match(r"^\s*\d+\.\s", line):
# Remove number
new_line = re.sub(r"^\s*\d+\.\s+", "", line)
else:
# Add number
new_line = "1. " + line.lstrip()
cursor.insertText(new_line)
# Return focus to editor
self.setFocus()
def toggle_checkboxes(self):
"""Toggle checkbox on current line."""
cursor = self.textCursor()
cursor.movePosition(
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
)
cursor.movePosition(
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
)
line = cursor.selectedText()
# Check if already has checkbox (Unicode display format)
if (
f"- {self._CHECK_UNCHECKED_DISPLAY} " in line
or f"- {self._CHECK_CHECKED_DISPLAY} " in line
):
# Remove checkbox - use raw string to avoid escape sequence warning
new_line = re.sub(
rf"^\s*-\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+",
"",
line,
)
else:
# Add checkbox (Unicode display format)
new_line = f"- {self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip()
cursor.insertText(new_line)
# Return focus to editor
self.setFocus()
def insert_image_from_path(self, path: Path):
"""Insert an image as rendered image (but save as base64 markdown)."""
if not path.exists():
return
# Read the ORIGINAL image file bytes for base64 encoding
with open(path, "rb") as f:
img_data = f.read()
# Encode ORIGINAL file bytes to base64
b64_data = base64.b64encode(img_data).decode("ascii")
# Determine mime type
ext = path.suffix.lower()
mime_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
}
mime_type = mime_map.get(ext, "image/png")
# Load the image
image = QImage(str(path))
if image.isNull():
return
# Use ORIGINAL size - no scaling!
original_width = image.width()
original_height = image.height()
# Create image format with original base64
img_format = QTextImageFormat()
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
img_format.setWidth(original_width)
img_format.setHeight(original_height)
# Add ORIGINAL image to document resources
self.document().addResource(
QTextDocument.ResourceType.ImageResource, img_format.name(), image
)
# Insert the image at original size
cursor = self.textCursor()
cursor.insertImage(img_format)
cursor.insertText("\n") # Add newline after image

View file

@ -4,7 +4,6 @@ import re
from typing import Iterable, Tuple from typing import Iterable, Tuple
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QLabel, QLabel,
@ -149,10 +148,12 @@ class Search(QWidget):
self.results.setItemWidget(item, container) self.results.setItemWidget(item, container)
# --- Snippet/highlight helpers ----------------------------------------- # --- Snippet/highlight helpers -----------------------------------------
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180): def _make_html_snippet(
doc = QTextDocument() self, markdown_src: str, query: str, *, radius=60, maxlen=180
doc.setHtml(html_src) ):
plain = doc.toPlainText() # For markdown, we can work directly with the text
# Strip markdown formatting for display
plain = self._strip_markdown(markdown_src)
if not plain: if not plain:
return "", False, False return "", False, False
@ -179,30 +180,45 @@ class Search(QWidget):
start = max(0, min(idx - radius, max(0, L - maxlen))) start = max(0, min(idx - radius, max(0, L - maxlen)))
end = min(L, max(idx + mlen + radius, start + maxlen)) end = min(L, max(idx + mlen + radius, start + maxlen))
# Bold all token matches that fall inside [start, end) # Extract snippet and highlight matches
snippet = plain[start:end]
# Escape HTML and bold matches
import html as _html
snippet_html = _html.escape(snippet)
if tokens: if tokens:
lower = plain.lower()
fmt = QTextCharFormat()
fmt.setFontWeight(QFont.Weight.Bold)
for t in tokens: for t in tokens:
t_low = t.lower() # Case-insensitive replacement
pos = start pattern = re.compile(re.escape(t), re.IGNORECASE)
while True: snippet_html = pattern.sub(
k = lower.find(t_low, pos) lambda m: f"<b>{m.group(0)}</b>", snippet_html
if k == -1 or k >= end: )
break
c = QTextCursor(doc)
c.setPosition(k)
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
c.mergeCharFormat(fmt)
pos = k + len(t)
# Select the window and export as HTML fragment return snippet_html, start > 0, end < L
c = QTextCursor(doc)
c.setPosition(start)
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
fragment_html = (
c.selection().toHtml()
) # preserves original styles + our bolding
return fragment_html, start > 0, end < L def _strip_markdown(self, markdown: str) -> str:
"""Strip markdown formatting for plain text display."""
# Remove images
text = re.sub(r"!\[.*?\]\(.*?\)", "[Image]", markdown)
# Remove links but keep text
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
# Remove inline code backticks
text = re.sub(r"`([^`]+)`", r"\1", text)
# Remove bold/italic markers
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"\*([^*]+)\*", r"\1", text)
text = re.sub(r"_([^_]+)_", r"\1", text)
# Remove strikethrough
text = re.sub(r"~~([^~]+)~~", r"\1", text)
# Remove heading markers
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
# Remove list markers
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
# Remove checkbox markers
text = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", text, flags=re.MULTILINE)
# Remove code block fences
text = re.sub(r"```[^\n]*\n", "", text)
return text.strip()

View file

@ -8,14 +8,12 @@ from PySide6.QtWidgets import QToolBar
class ToolBar(QToolBar): class ToolBar(QToolBar):
boldRequested = Signal() boldRequested = Signal()
italicRequested = Signal() italicRequested = Signal()
underlineRequested = Signal()
strikeRequested = Signal() strikeRequested = Signal()
codeRequested = Signal() codeRequested = Signal()
headingRequested = Signal(int) headingRequested = Signal(int)
bulletsRequested = Signal() bulletsRequested = Signal()
numbersRequested = Signal() numbersRequested = Signal()
checkboxesRequested = Signal() checkboxesRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal() historyRequested = Signal()
insertImageRequested = Signal() insertImageRequested = Signal()
@ -39,12 +37,6 @@ class ToolBar(QToolBar):
self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested) self.actItalic.triggered.connect(self.italicRequested)
self.actUnderline = QAction("U", self)
self.actUnderline.setToolTip("Underline")
self.actUnderline.setCheckable(True)
self.actUnderline.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested)
self.actStrike = QAction("S", self) self.actStrike = QAction("S", self)
self.actStrike.setToolTip("Strikethrough") self.actStrike.setToolTip("Strikethrough")
self.actStrike.setCheckable(True) self.actStrike.setCheckable(True)
@ -97,24 +89,6 @@ class ToolBar(QToolBar):
self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.setShortcut("Ctrl+Shift+I")
self.actInsertImg.triggered.connect(self.insertImageRequested) self.actInsertImg.triggered.connect(self.insertImageRequested)
# Alignment
self.actAlignL = QAction("L", self)
self.actAlignL.setToolTip("Align Left")
self.actAlignL.setCheckable(True)
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
self.actAlignC = QAction("C", self)
self.actAlignC.setToolTip("Align Center")
self.actAlignC.setCheckable(True)
self.actAlignC.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignHCenter)
)
self.actAlignR = QAction("R", self)
self.actAlignR.setToolTip("Align Right")
self.actAlignR.setCheckable(True)
self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight)
)
# History button # History button
self.actHistory = QAction("History", self) self.actHistory = QAction("History", self)
self.actHistory.triggered.connect(self.historyRequested) self.actHistory.triggered.connect(self.historyRequested)
@ -125,7 +99,6 @@ class ToolBar(QToolBar):
for a in ( for a in (
self.actBold, self.actBold,
self.actItalic, self.actItalic,
self.actUnderline,
self.actStrike, self.actStrike,
self.actH1, self.actH1,
self.actH2, self.actH2,
@ -135,11 +108,6 @@ class ToolBar(QToolBar):
a.setCheckable(True) a.setCheckable(True)
a.setActionGroup(self.grpHeadings) a.setActionGroup(self.grpHeadings)
self.grpAlign = QActionGroup(self)
self.grpAlign.setExclusive(True)
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
a.setActionGroup(self.grpAlign)
self.grpLists = QActionGroup(self) self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True) self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes): for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
@ -150,7 +118,6 @@ class ToolBar(QToolBar):
[ [
self.actBold, self.actBold,
self.actItalic, self.actItalic,
self.actUnderline,
self.actStrike, self.actStrike,
self.actCode, self.actCode,
self.actH1, self.actH1,
@ -161,9 +128,6 @@ class ToolBar(QToolBar):
self.actNumbers, self.actNumbers,
self.actCheckboxes, self.actCheckboxes,
self.actInsertImg, self.actInsertImg,
self.actAlignL,
self.actAlignC,
self.actAlignR,
self.actHistory, self.actHistory,
] ]
) )
@ -171,7 +135,6 @@ class ToolBar(QToolBar):
def _apply_toolbar_styles(self): def _apply_toolbar_styles(self):
self._style_letter_button(self.actBold, "B", bold=True) self._style_letter_button(self.actBold, "B", bold=True)
self._style_letter_button(self.actItalic, "I", italic=True) self._style_letter_button(self.actItalic, "I", italic=True)
self._style_letter_button(self.actUnderline, "U", underline=True)
self._style_letter_button(self.actStrike, "S", strike=True) self._style_letter_button(self.actStrike, "S", strike=True)
# Monospace look for code; use a fixed font # Monospace look for code; use a fixed font
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont) code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
@ -187,11 +150,6 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actBullets, "") self._style_letter_button(self.actBullets, "")
self._style_letter_button(self.actNumbers, "1.") self._style_letter_button(self.actNumbers, "1.")
# Alignment
self._style_letter_button(self.actAlignL, "L")
self._style_letter_button(self.actAlignC, "C")
self._style_letter_button(self.actAlignR, "R")
# History # History
self._style_letter_button(self.actHistory, "View History") self._style_letter_button(self.actHistory, "View History")

61
poetry.lock generated
View file

@ -1,27 +1,5 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "beautifulsoup4"
version = "4.14.2"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515"},
{file = "beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e"},
]
[package.dependencies]
soupsieve = ">1.2"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@ -180,21 +158,6 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
] ]
[[package]]
name = "markdownify"
version = "1.2.0"
description = "Convert HTML to markdown."
optional = false
python-versions = "*"
files = [
{file = "markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351"},
{file = "markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c"},
]
[package.dependencies]
beautifulsoup4 = ">=4.9,<5"
six = ">=1.15,<2"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "25.0"
@ -382,28 +345,6 @@ files = [
{file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"},
] ]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "soupsieve"
version = "2.8"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
files = [
{file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"},
{file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"},
]
[[package]] [[package]]
name = "sqlcipher3-wheels" name = "sqlcipher3-wheels"
version = "0.5.5.post0" version = "0.5.5.post0"
@ -600,4 +541,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.9,<3.14" python-versions = ">=3.9,<3.14"
content-hash = "939d9d62fbe685cc74a64d6cca3584b0876142c655093f1c18da4d21fbb0718d" content-hash = "f5a670c96c370ce7d70dd76c7e2ebf98f7443e307b446779ea0c748db1019dd4"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.1.12.1" version = "0.2.0"
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"
@ -11,7 +11,6 @@ repository = "https://git.mig5.net/mig5/bouquin"
python = ">=3.9,<3.14" python = ">=3.9,<3.14"
pyside6 = ">=6.8.1,<7.0.0" pyside6 = ">=6.8.1,<7.0.0"
sqlcipher3-wheels = "^0.5.5.post0" sqlcipher3-wheels = "^0.5.5.post0"
markdownify = "^1.2.0"
[tool.poetry.scripts] [tool.poetry.scripts]
bouquin = "bouquin.__main__:main" bouquin = "bouquin.__main__:main"

View file

View file

@ -1,133 +1,51 @@
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
from PySide6.QtCore import QStandardPaths
from tests.qt_helpers import AutoResponder
# Force Qt *non-native* file dialog so we can type a filename programmatically. import pytest
os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") from PySide6.QtWidgets import QApplication
# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env
# Ensure the nested package directory (repo_root/bouquin) is on sys.path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PKG_PARENT = PROJECT_ROOT / "bouquin"
if str(PKG_PARENT) not in sys.path:
sys.path.insert(0, str(PKG_PARENT))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# Make project importable @pytest.fixture(scope="session")
from PySide6.QtWidgets import QApplication, QWidget def app():
from bouquin.theme import ThemeManager, ThemeConfig, Theme app = QApplication.instance()
if app is None:
PROJECT_ROOT = Path(__file__).resolve().parents[1] app = QApplication([])
if str(PROJECT_ROOT) not in sys.path: return app
sys.path.insert(0, str(PROJECT_ROOT))
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def enable_qstandardpaths_test_mode(): def isolate_qsettings(tmp_path_factory):
QStandardPaths.setTestModeEnabled(True) cfgdir = tmp_path_factory.mktemp("qt_cfg")
os.environ["XDG_CONFIG_HOME"] = str(cfgdir)
@pytest.fixture()
def temp_home(tmp_path, monkeypatch):
home = tmp_path / "home"
(home / "Documents").mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
return home
@pytest.fixture()
def clean_settings():
try:
from bouquin.settings import APP_NAME, APP_ORG
from PySide6.QtCore import QSettings
except Exception:
yield yield
return
s = QSettings(APP_ORG, APP_NAME)
s.clear()
yield
s.clear()
@pytest.fixture(autouse=True)
def auto_accept_common_dialogs(qtbot):
ar = AutoResponder()
ar.start()
try:
yield
finally:
ar.stop()
@pytest.fixture()
def open_window(qtbot, temp_home, clean_settings):
"""Launch the app and immediately satisfy first-run/unlock key prompts."""
from bouquin.main_window import MainWindow
app = QApplication.instance()
themes = ThemeManager(app, ThemeConfig())
themes.apply(Theme.SYSTEM)
win = MainWindow(themes=themes)
qtbot.addWidget(win)
win.show()
qtbot.waitExposed(win)
# Immediately satisfy first-run 'Set key' or 'Unlock' prompts if already visible
AutoResponder().prehandle_key_prompts_if_present()
return win
@pytest.fixture()
def today_iso():
from datetime import date
d = date.today()
return f"{d.year:04d}-{d.month:02d}-{d.day:02d}"
@pytest.fixture @pytest.fixture
def theme_parent_widget(qtbot): def tmp_db_cfg(tmp_path):
"""A minimal parent that provides .themes.apply(...) like MainWindow."""
class _ThemesStub:
def __init__(self):
self.applied = []
def apply(self, theme):
self.applied.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemesStub()
parent = _Parent()
qtbot.addWidget(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 from bouquin.db import DBConfig
db_path = tmp_path / "notebook.db"
key = "test-secret-key"
return DBConfig( return DBConfig(
path=Path(temp_db_path), path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
key="testkey",
idle_minutes=0,
theme="system",
move_todos=True,
) )
@pytest.fixture
def fresh_db(tmp_db_cfg):
from bouquin.db import DBManager
db = DBManager(tmp_db_cfg)
ok = db.connect()
assert ok, "DB connect() should succeed"
yield db
db.close()

View file

@ -1,287 +0,0 @@
import time
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QWidget,
QDialog,
QFileDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QAbstractButton,
QListWidget,
)
# ---------- robust widget finders ----------
def _visible_widgets():
for w in QApplication.topLevelWidgets():
if w.isVisible():
yield w
for c in w.findChildren(QWidget):
if c.isWindow() and c.isVisible():
yield c
def wait_for_widget(cls=None, predicate=lambda w: True, timeout_ms: int = 15000):
deadline = time.time() + timeout_ms / 1000.0
while time.time() < deadline:
for w in _visible_widgets():
if (cls is None or isinstance(w, cls)) and predicate(w):
return w
QTest.qWait(25)
raise TimeoutError(f"Timed out waiting for {cls} matching predicate")
# ---------- generic ui helpers ----------
def click_button_by_text(container: QWidget, contains: str) -> bool:
"""Click any QAbstractButton whose label contains the substring."""
target = contains.lower()
for btn in container.findChildren(QAbstractButton):
text = (btn.text() or "").lower()
if target in text:
from PySide6.QtTest import QTest
if not btn.isEnabled():
QTest.qWait(50) # give UI a tick to enable
QTest.mouseClick(btn, Qt.LeftButton)
return True
return False
def _first_line_edit(dlg: QDialog) -> QLineEdit | None:
edits = dlg.findChildren(QLineEdit)
return edits[0] if edits else None
def fill_first_line_edit_and_accept(dlg: QDialog, text: str | None):
le = _first_line_edit(dlg)
assert le is not None, "Expected a QLineEdit in the dialog"
if text is not None:
le.clear()
QTest.keyClicks(le, text)
# Prefer 'OK'; fallback to Return
ok = None
for btn in dlg.findChildren(QPushButton):
t = btn.text().lower().lstrip("&")
if t == "ok" or btn.isDefault():
ok = btn
break
if ok:
QTest.mouseClick(ok, Qt.LeftButton)
else:
QTest.keyClick(le, Qt.Key_Return)
def accept_all_message_boxes(limit: int = 5) -> bool:
"""
Accept every visible QMessageBox, preferring Yes/Accept/Ok.
Returns True if at least one box was accepted.
"""
accepted_any = False
for _ in range(limit):
accepted_this_round = False
for w in _visible_widgets():
if isinstance(w, QMessageBox) and w.isVisible():
# Prefer "Yes", then any Accept/Apply role, then Ok, then default/first.
btn = (
w.button(QMessageBox.Yes)
or next(
(
b
for b in w.buttons()
if w.buttonRole(b)
in (
QMessageBox.YesRole,
QMessageBox.AcceptRole,
QMessageBox.ApplyRole,
)
),
None,
)
or w.button(QMessageBox.Ok)
or w.defaultButton()
or (w.buttons()[0] if w.buttons() else None)
)
if btn:
QTest.mouseClick(btn, Qt.LeftButton)
accepted_this_round = True
accepted_any = True
if not accepted_this_round:
break
QTest.qWait(30) # give the next box a tick to appear
return accepted_any
def trigger_menu_action(win, text_contains: str) -> QAction:
for act in win.findChildren(QAction):
if text_contains in act.text():
act.trigger()
return act
raise AssertionError(f"Action containing '{text_contains}' not found")
def find_line_edit_by_placeholder(container: QWidget, needle: str) -> QLineEdit | None:
n = needle.lower()
for le in container.findChildren(QLineEdit):
if n in (le.placeholderText() or "").lower():
return le
return None
class AutoResponder:
def __init__(self):
self._seen: set[int] = set()
self._timer = QTimer()
self._timer.setInterval(50)
self._timer.timeout.connect(self._tick)
def start(self):
self._timer.start()
def stop(self):
self._timer.stop()
def prehandle_key_prompts_if_present(self):
for w in _visible_widgets():
if isinstance(w, QDialog) and (
_looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w)
):
fill_first_line_edit_and_accept(w, "ci-secret-key")
def _tick(self):
if accept_all_message_boxes(limit=3):
return
for w in _visible_widgets():
if not isinstance(w, QDialog) or not w.isVisible():
continue
wid = id(w)
# Handle first-run / unlock / save-name prompts
if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w):
fill_first_line_edit_and_accept(w, "ci-secret-key")
self._seen.add(wid)
continue
if _looks_like_save_version_dialog(w):
fill_first_line_edit_and_accept(w, None)
self._seen.add(wid)
continue
if _is_history_dialog(w):
# Don't mark as seen until we've actually clicked the button.
if _click_revert_in_history(w):
accept_all_message_boxes(limit=5)
self._seen.add(wid)
continue
# ---------- dialog classifiers ----------
def _looks_like_set_key_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and (
"set an encryption key" in title
or "create a strong passphrase" in labels
or "encrypts your data" in labels
)
def _looks_like_unlock_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and ("unlock" in labels or "unlock" in title) and "key" in labels
# ---------- version prompt ----------
def _looks_like_save_version_dialog(dlg: QDialog) -> bool:
labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower()
title = (dlg.windowTitle() or "").lower()
has_line = bool(dlg.findChildren(QLineEdit))
return has_line and (
"enter a name" in labels or "name for this version" in labels or "save" in title
)
# ---------- QFileDialog driver ----------
def drive_qfiledialog_save(path: Path, name_filter: str | None = None):
dlg = wait_for_widget(QFileDialog, timeout_ms=20000)
if name_filter:
try:
dlg.selectNameFilter(name_filter)
except Exception:
pass
# Prefer typing in the filename edit so Save enables on all styles
filename_edit = None
for le in dlg.findChildren(QLineEdit):
if le.echoMode() == QLineEdit.Normal:
filename_edit = le
break
if filename_edit is not None:
filename_edit.clear()
QTest.keyClicks(filename_edit, str(path))
# Return usually triggers Save in non-native dialogs
QTest.keyClick(filename_edit, Qt.Key_Return)
else:
dlg.selectFile(str(path))
QTimer.singleShot(0, dlg.accept)
# Some themes still need an explicit Save click
_ = click_button_by_text(dlg, "save")
def _is_history_dialog(dlg: QDialog) -> bool:
if not isinstance(dlg, QDialog) or not dlg.isVisible():
return False
title = (dlg.windowTitle() or "").lower()
if "history" in title:
return True
return bool(dlg.findChildren(QListWidget))
def _click_revert_in_history(dlg: QDialog) -> bool:
"""
Returns True if we successfully clicked an enabled 'Revert' button.
Ensures a row is actually clicked first so the button enables.
"""
lists = dlg.findChildren(QListWidget)
if not lists:
return False
versions = max(lists, key=lambda lw: lw.count())
if versions.count() < 2:
return False
# Click the older row (index 1); real click so the dialog enables the button.
from PySide6.QtTest import QTest
from PySide6.QtCore import Qt
rect = versions.visualItemRect(versions.item(1))
QTest.mouseClick(versions.viewport(), Qt.LeftButton, pos=rect.center())
QTest.qWait(60)
# Find any enabled button that looks like "revert"
for btn in dlg.findChildren(QAbstractButton):
meta = " ".join(
[(btn.text() or ""), (btn.toolTip() or ""), (btn.objectName() or "")]
).lower()
if "revert" in meta and btn.isEnabled():
QTest.mouseClick(btn, Qt.LeftButton)
return True
return False

127
tests/test_db.py Normal file
View file

@ -0,0 +1,127 @@
import json, csv
import datetime as dt
def _today():
return dt.date.today().isoformat()
def _yesterday():
return (dt.date.today() - dt.timedelta(days=1)).isoformat()
def _tomorrow():
return (dt.date.today() + dt.timedelta(days=1)).isoformat()
def _entry(text, i=0):
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
def test_connect_integrity_and_schema(fresh_db):
d = _today()
fresh_db.save_new_version(d, _entry("hello world"), "initial")
vlist = fresh_db.list_versions(d)
assert vlist
v = fresh_db.get_version(version_id=vlist[0]["id"])
assert v and "created_at" in v
def test_save_and_get_entry_versions(fresh_db):
d = _today()
fresh_db.save_new_version(d, _entry("hello world"), "initial")
txt = fresh_db.get_entry(d)
assert "hello world" in txt
fresh_db.save_new_version(d, _entry("hello again"), "second")
versions = fresh_db.list_versions(d)
assert len(versions) >= 2
assert any(v["is_current"] for v in versions)
first = sorted(versions, key=lambda v: v["version_no"])[0]
fresh_db.revert_to_version(d, version_id=first["id"])
txt2 = fresh_db.get_entry(d)
assert "hello world" in txt2 and "again" not in txt2
def test_dates_with_content_and_search(fresh_db):
fresh_db.save_new_version(_today(), _entry("alpha bravo"), "t1")
fresh_db.save_new_version(_yesterday(), _entry("bravo charlie"), "t2")
fresh_db.save_new_version(_tomorrow(), _entry("delta alpha"), "t3")
dates = set(fresh_db.dates_with_content())
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
hits = list(fresh_db.search_entries("alpha"))
assert any(d == _today() for d, _ in hits)
assert any(d == _tomorrow() for d, _ in hits)
def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path):
for i in range(3):
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
entries = fresh_db.get_all_entries()
assert entries and all(len(t) == 2 for t in entries)
json_path = tmp_path / "export.json"
fresh_db.export_json(entries, str(json_path))
assert json_path.exists() and json.load(open(json_path)) is not None
csv_path = tmp_path / "export.csv"
fresh_db.export_csv(entries, str(csv_path))
assert csv_path.exists() and list(csv.reader(open(csv_path)))
txt_path = tmp_path / "export.txt"
fresh_db.export_txt(entries, str(txt_path))
assert txt_path.exists() and txt_path.read_text().strip()
md_path = tmp_path / "export.md"
fresh_db.export_markdown(entries, str(md_path))
md_text = md_path.read_text()
assert md_path.exists() and entries[0][0] in md_text
html_path = tmp_path / "export.html"
fresh_db.export_html(entries, str(html_path), title="My Notebook")
assert html_path.exists() and "<html" in html_path.read_text().lower()
sql_path = tmp_path / "export.sql"
fresh_db.export_sql(str(sql_path))
assert sql_path.exists() and sql_path.read_bytes()
sqlc_path = tmp_path / "export.db"
fresh_db.export_sqlcipher(str(sqlc_path))
assert sqlc_path.exists() and sqlc_path.read_bytes()
for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]:
path.unlink(missing_ok=True)
fresh_db.export_by_extension(str(path))
assert path.exists()
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
fresh_db.rekey("new-key-123")
fresh_db.close()
from bouquin.db import DBManager
tmp_db_cfg.key = "new-key-123"
db2 = DBManager(tmp_db_cfg)
assert db2.connect()
assert "secure" in db2.get_entry(_today())
db2.close()
def test_compact_and_close_dont_crash(fresh_db):
fresh_db.compact()
fresh_db.close()
import pytest
def test_export_by_extension_unsupported(fresh_db, tmp_path):
p = tmp_path / "export.xyz"
with pytest.raises(ValueError):
fresh_db.export_by_extension(str(p))

View file

@ -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

View file

@ -1,137 +0,0 @@
import bouquin.db as dbmod
from bouquin.db import DBConfig, DBManager
class FakeCursor:
def __init__(self, rows=None):
self._rows = rows or []
self.executed = []
def execute(self, sql, params=None):
self.executed.append((sql, tuple(params) if params else None))
return self
def fetchall(self):
return list(self._rows)
def fetchone(self):
return self._rows[0] if self._rows else None
class FakeConn:
def __init__(self, rows=None):
self._rows = rows or []
self.closed = False
self.cursors = []
self.row_factory = None
def cursor(self):
c = FakeCursor(rows=self._rows)
self.cursors.append(c)
return c
def close(self):
self.closed = True
def commit(self):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
def test_integrity_ok_ok(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
mgr.conn = FakeConn(rows=[])
assert mgr._integrity_ok() is None
def test_integrity_ok_raises(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
mgr.conn = FakeConn(rows=[("oops",), (None,)])
try:
mgr._integrity_ok()
except Exception as e:
assert isinstance(e, dbmod.sqlite.IntegrityError)
def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path):
# Use a non-empty key to avoid SQLCipher complaining before our patch runs
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
# Make the integrity check raise so connect() takes the failure path
monkeypatch.setattr(
DBManager,
"_integrity_ok",
lambda self: (_ for _ in ()).throw(RuntimeError("bad")),
)
ok = mgr.connect()
assert ok is False
assert mgr.conn is None
def test_rekey_not_connected_raises(tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
mgr.conn = None
import pytest
with pytest.raises(RuntimeError):
mgr.rekey("new")
def test_rekey_reopen_failure(monkeypatch, tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
mgr.conn = FakeConn(rows=[(None,)])
monkeypatch.setattr(DBManager, "connect", lambda self: False)
import pytest
with pytest.raises(Exception):
mgr.rekey("new")
def test_export_by_extension_and_unknown(tmp_path):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
entries = [("2025-01-01", "<b>Hi</b>")]
# Test each exporter writes the file
p = tmp_path / "out.json"
mgr.export_json(entries, str(p))
assert p.exists() and p.stat().st_size > 0
p = tmp_path / "out.csv"
mgr.export_csv(entries, str(p))
assert p.exists()
p = tmp_path / "out.txt"
mgr.export_txt(entries, str(p))
assert p.exists()
p = tmp_path / "out.html"
mgr.export_html(entries, str(p))
assert p.exists()
p = tmp_path / "out.md"
mgr.export_markdown(entries, str(p))
assert p.exists()
# Router
import types
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
for ext in [".json", ".csv", ".txt", ".html", ".md"]:
path = tmp_path / f"route{ext}"
mgr.export_by_extension(str(path))
assert path.exists()
import pytest
with pytest.raises(ValueError):
mgr.export_by_extension(str(tmp_path / "x.zzz"))
def test_compact_error_prints(monkeypatch, tmp_path, capsys):
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
class BadConn:
def cursor(self):
raise RuntimeError("no")
mgr.conn = BadConn()
mgr.compact()
out = capsys.readouterr().out
assert "Error:" in out

View file

@ -1,55 +0,0 @@
from PySide6.QtCore import QUrl, QObject, Slot
from PySide6.QtGui import QDesktopServices
from PySide6.QtTest import QTest
from tests.qt_helpers import trigger_menu_action
def test_launch_write_save_and_navigate(open_window, qtbot, today_iso):
win = open_window
win.editor.setPlainText("Hello Bouquin")
qtbot.waitUntil(lambda: win.editor.toPlainText() == "Hello Bouquin", timeout=15000)
trigger_menu_action(win, "Save a version") # AutoResponder clicks OK
versions = win.db.list_versions(today_iso)
assert versions and versions[0]["is_current"] == 1
selected = win.calendar.selectedDate()
trigger_menu_action(win, "Next Day")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected.addDays(1))
trigger_menu_action(win, "Previous Day")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
win.calendar.setSelectedDate(selected.addDays(3))
trigger_menu_action(win, "Today")
qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected)
def test_help_menu_opens_urls(open_window, qtbot):
opened: list[str] = []
class UrlCatcher(QObject):
@Slot(QUrl)
def handle(self, url: QUrl):
opened.append(url.toString())
catcher = UrlCatcher()
# Qt6/PySide6: setUrlHandler(scheme, receiver, methodName)
QDesktopServices.setUrlHandler("https", catcher, "handle")
QDesktopServices.setUrlHandler("http", catcher, "handle")
try:
win = open_window
trigger_menu_action(win, "Documentation")
trigger_menu_action(win, "Report a bug")
QTest.qWait(150)
assert len(opened) >= 2
finally:
QDesktopServices.unsetUrlHandler("https")
QDesktopServices.unsetUrlHandler("http")
def test_idle_lock_and_unlock(open_window, qtbot):
win = open_window
win._enter_lock()
assert getattr(win, "_locked", False) is True
win._on_unlock_clicked() # AutoResponder types 'ci-secret-key'
qtbot.waitUntil(lambda: getattr(win, "_locked", True) is False, timeout=15000)

View file

@ -1,339 +0,0 @@
from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl
from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from bouquin.editor import Editor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
import re
def _mk_editor() -> Editor:
# pytest-qt ensures a QApplication exists
app = QApplication.instance()
tm = ThemeManager(app, ThemeConfig())
return Editor(tm)
def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None:
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
while True:
c2 = QTextCursor(c)
c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
if c2.position() == c.position():
break
fmt = c2.charFormat()
if fmt.isImageFormat():
editor.setTextCursor(c2)
return QTextImageFormat(fmt)
c.movePosition(QTextCursor.Right)
return None
def _fmt_at(editor: Editor, pos: int):
c = editor.textCursor()
c.setPosition(pos)
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
return c.charFormat()
def test_space_breaks_link_anchor_and_styling(qtbot):
e = _mk_editor()
e.resize(600, 300)
e.show()
qtbot.waitExposed(e)
# Type a URL, which should be linkified (anchor + underline + blue)
url = "https://mig5.net"
QTest.keyClicks(e, url)
qtbot.waitUntil(lambda: e.toPlainText() == url)
# Sanity: characters within the URL are anchors
for i in range(len(url)):
assert _fmt_at(e, i).isAnchor()
# Hit Space Editor.keyPressEvent() should call _break_anchor_for_next_char()
QTest.keyClick(e, Qt.Key_Space)
# Type some normal text; it must not inherit the link formatting
tail = "this is a test"
QTest.keyClicks(e, tail)
qtbot.waitUntil(lambda: e.toPlainText().endswith(tail))
txt = e.toPlainText()
# Find where our 'tail' starts
start = txt.index(tail)
end = start + len(tail)
# None of the trailing characters should be part of an anchor or visually underlined
for i in range(start, end):
fmt = _fmt_at(e, i)
assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor"
assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined"
# Optional: ensure the HTML only wraps the URL in <a>, not the trailing text
html = e.document().toHtml()
assert re.search(
r'<a [^>]*href="https?://mig5\.net"[^>]*>(?:<span[^>]*>)?https?://mig5\.net(?:</span>)?</a>\s+this is a test',
html,
re.S,
), html
assert "this is a test</a>" not in html
def test_embed_qimage_saved_as_data_url(qtbot):
e = _mk_editor()
e.resize(600, 400)
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
img = QImage(60, 40, QImage.Format_ARGB32)
img.fill(0xFF336699)
e._insert_qimage_at_cursor(img, autoscale=False)
html = e.to_html_with_embedded_images()
assert "data:image/png;base64," in html
def test_insert_images_autoscale_and_fit(qtbot, tmp_path):
# Create a very wide image so autoscale triggers
big = QImage(2000, 800, QImage.Format_ARGB32)
big.fill(0xFF00FF00)
big_path = tmp_path / "big.png"
big.save(str(big_path))
e = _mk_editor()
e.resize(420, 300) # known viewport width
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.insert_images([str(big_path)], autoscale=True)
# Cursor lands after the image + a blank block; helper will select the image char
fmt = _move_cursor_to_first_image(e)
assert fmt is not None
# After autoscale, width should be <= ~92% of viewport
max_w = int(e.viewport().width() * 0.92)
assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding
# Now exercise "fit to editor width"
e._fit_image_to_editor_width()
_tc, fmt2, _orig = e._image_info_at_cursor()
assert fmt2 is not None
assert abs(fmt2.width() - max_w) <= 1
def test_linkify_trims_trailing_punctuation(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("See (https://example.com).")
# Wait until linkification runs (connected to textChanged)
qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
html = e.document().toHtml()
# Anchor should *not* include the closing ')'
assert 'href="https://example.com"' in html
assert 'href="https://example.com)."' not in html
def test_code_block_enter_exits_on_empty_line(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("code")
c = e.textCursor()
c.select(QTextCursor.BlockUnderCursor)
e.setTextCursor(c)
e.apply_code()
# Put caret at end of the code block, then Enter to create an empty line *inside* the frame
c = e.textCursor()
c.movePosition(QTextCursor.EndOfBlock)
e.setTextCursor(c)
QTest.keyClick(e, Qt.Key_Return)
# Ensure we are on an empty block *inside* the code frame
qtbot.waitUntil(
lambda: e._nearest_code_frame(e.textCursor(), tolerant=False) is not None
and e.textCursor().block().length() == 1
)
# Second Enter should jump *out* of the frame
QTest.keyClick(e, Qt.Key_Return)
class DummyMenu:
def __init__(self):
self.seps = 0
self.subs = []
self.exec_called = False
def addSeparator(self):
self.seps += 1
def addMenu(self, title):
m = DummyMenu()
self.subs.append((title, m))
return m
def addAction(self, *a, **k):
pass
def exec(self, *a, **k):
self.exec_called = True
def _themes():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def test_context_menu_adds_image_actions(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Fake an image at cursor
qi = QImage(10, 10, QImage.Format_ARGB32)
qi.fill(0xFF00FF00)
imgfmt = QTextImageFormat()
imgfmt.setName("x")
imgfmt.setWidth(10)
imgfmt.setHeight(10)
tc = e.textCursor()
monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi))
dummy = DummyMenu()
monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy)
class Evt:
def globalPos(self):
return QPoint(0, 0)
e.contextMenuEvent(Evt())
assert dummy.exec_called
assert dummy.seps == 1
assert any(t == "Image size" for t, _ in dummy.subs)
def test_insert_from_mime_image_and_urls(tmp_path, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Build a mime with an image
mime = QMimeData()
img = QImage(6, 6, QImage.Format_ARGB32)
img.fill(0xFF0000FF)
mime.setImageData(img)
e.insertFromMimeData(mime)
html = e.document().toHtml()
assert "<img" in html
# Now with urls: local non-image + local image + remote url
png = tmp_path / "t.png"
img.save(str(png))
txt = tmp_path / "x.txt"
txt.write_text("hi", encoding="utf-8")
mime2 = QMimeData()
mime2.setUrls(
[
QUrl.fromLocalFile(str(txt)),
QUrl.fromLocalFile(str(png)),
QUrl("https://example.com/file"),
]
)
e.insertFromMimeData(mime2)
h2 = e.document().toHtml()
assert 'href="file://' in h2 # local file link inserted
assert "<img" in h2 # image inserted
assert 'href="https://example.com/file"' in h2 # remote url link
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
# Anchor under cursor
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
opened = {}
from PySide6.QtGui import QDesktopServices as DS
monkeypatch.setattr(
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
)
ev = QMouseEvent(
QMouseEvent.MouseButtonRelease,
QPoint(1, 1),
Qt.LeftButton,
Qt.LeftButton,
Qt.ControlModifier,
)
e.mouseReleaseEvent(ev)
assert opened.get("u") == "https://example.com"
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
called = {}
monkeypatch.setattr(
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
)
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
e.keyPressEvent(ev)
assert called.get("x") is True
def test_enter_leaves_code_frame(qtbot):
e = Editor(_themes())
qtbot.addWidget(e)
e.setPlainText("")
# Insert a code block frame
e.apply_code()
# Place cursor inside the empty code block
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
# Press Enter; should jump outside the frame and start normal paragraph
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
e.keyPressEvent(ev)
# After enter, the cursor should not be inside a code frame
assert e._nearest_code_frame(e.textCursor(), tolerant=False) is None
def test_space_does_not_bleed_anchor_format(qtbot):
e = _mk_editor()
qtbot.addWidget(e)
e.show()
qtbot.waitExposed(e)
e.setPlainText("https://a.example")
qtbot.waitUntil(lambda: 'href="' in e.document().toHtml())
c = e.textCursor()
c.movePosition(QTextCursor.End)
e.setTextCursor(c)
# Press Space; keyPressEvent should break the anchor for the next char
QTest.keyClick(e, Qt.Key_Space)
assert e.currentCharFormat().isAnchor() is False
def test_editor_small_helpers(qtbot):
app = QApplication.instance()
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
e = Editor(themes)
qtbot.addWidget(e)
# _approx returns True when |a-b| <= eps
assert e._approx(1.0, 1.25, eps=0.3) is True
assert e._approx(1.0, 1.6, eps=0.3) is False
# Exercise helpers
_ = e._is_heading_typing()
e._apply_normal_typing()

View file

@ -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

View file

@ -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

View file

@ -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 (574584 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 (605614)
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 (680684)
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 (945946)
e.toggle_bullets()
e.toggle_bullets()
# numbers twice -> second call removes (955956)
e.toggle_numbers()
e.toggle_numbers()

View file

@ -1,69 +0,0 @@
import importlib
def test___main___exports_main():
entry_mod = importlib.import_module("bouquin.__main__")
main_mod = importlib.import_module("bouquin.main")
assert entry_mod.main is main_mod.main
def test_main_entry_initializes_qt(monkeypatch):
main_mod = importlib.import_module("bouquin.main")
# Fakes to avoid real Qt event loop
class FakeApp:
def __init__(self, argv):
self.argv = argv
self.name = None
self.org = None
def setApplicationName(self, n):
self.name = n
def setOrganizationName(self, n):
self.org = n
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.themes = themes
self.shown = False
def show(self):
self.shown = True
class FakeThemes:
def __init__(self, app, cfg):
self._applied = None
self.app = app
self.cfg = cfg
def apply(self, t):
self._applied = t
class FakeSettings:
def __init__(self):
self._map = {"ui/theme": "dark"}
def value(self, k, default=None, type=None):
return self._map.get(k, default)
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
exits = {}
def fake_exit(code):
exits["code"] = code
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
main_mod.main()
assert exits.get("code", None) == 0

View file

@ -1,112 +0,0 @@
import csv, json, sqlite3
import pytest
from tests.qt_helpers import trigger_menu_action, accept_all_message_boxes
# Export filters used by the app (format is chosen by this name filter, not by extension)
EXPORT_FILTERS = {
".txt": "Text (*.txt)",
".json": "JSON (*.json)",
".csv": "CSV (*.csv)",
".html": "HTML (*.html)",
".sql": "SQL (*.sql)", # app writes a SQLite DB here
}
BACKUP_FILTER = "SQLCipher (*.db)"
def _write_sample_entries(win, qtbot):
win.editor.setPlainText("alpha <b>bold</b>")
win._save_current(explicit=True)
d = win.calendar.selectedDate().addDays(1)
win.calendar.setSelectedDate(d)
win.editor.setPlainText("beta text")
win._save_current(explicit=True)
@pytest.mark.parametrize(
"ext,verifier",
[
(".txt", lambda p: p.read_text(encoding="utf-8").strip()),
(".json", lambda p: json.loads(p.read_text(encoding="utf-8"))),
(".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))),
(".html", lambda p: p.read_text(encoding="utf-8")),
(".sql", lambda p: p),
],
)
def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch):
win = open_window
_write_sample_entries(win, qtbot)
out = tmp_path / f"export_test{ext}"
# 1) Short-circuit the file dialog so it returns our path + the filter we want.
from PySide6.QtWidgets import QFileDialog
def fake_getSaveFileName(*args, **kwargs):
return (str(out), EXPORT_FILTERS[ext])
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
)
# 2) Kick off the export
trigger_menu_action(win, "Export")
# 3) Click through the "unencrypted export" warning
accept_all_message_boxes()
# 4) Wait for the file to appear (export happens synchronously after the stub)
qtbot.waitUntil(out.exists, timeout=5000)
# 5) Dismiss the "Export complete" info box so it can't block later tests
accept_all_message_boxes()
# 6) Assert as before
val = verifier(out)
if ext == ".json":
assert isinstance(val, list) and all(
"date" in d and "content" in d for d in val
)
elif ext == ".csv":
flat = [cell for row in val for cell in row]
assert any("alpha" in c for c in flat) and any("beta" in c for c in flat)
elif ext == ".html":
lower = val.lower()
assert "<html" in lower and ("<article" in lower or "<body" in lower)
elif ext == ".txt":
assert "alpha" in val and "beta" in val
elif ext == ".sql":
con = sqlite3.connect(str(out))
cur = con.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
names = {r[0] for r in cur.fetchall()}
assert {"pages", "versions"} <= names
con.close()
def test_backup_encrypted_database(open_window, qtbot, tmp_path, monkeypatch):
win = open_window
_write_sample_entries(win, qtbot)
from PySide6.QtWidgets import QFileDialog
def fake_getSaveFileName(*args, **kwargs):
return (str(tmp_path / "backup.db"), BACKUP_FILTER)
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
)
trigger_menu_action(win, "Backup")
backup = tmp_path / "backup.db"
qtbot.waitUntil(backup.exists, timeout=5000)
# The backup path is now ready; proceed as before...
sqlcipher3 = pytest.importorskip("sqlcipher3")
con = sqlcipher3.dbapi2.connect(str(backup))
cur = con.cursor()
cur.execute("PRAGMA key = 'ci-secret-key';")
ok = cur.execute("PRAGMA cipher_integrity_check;").fetchall()
assert ok == []
con.close()

View file

@ -1,100 +1,57 @@
from PySide6.QtCore import Qt import pytest
from PySide6.QtGui import QKeySequence, QTextCursor
from PySide6.QtTest import QTest
from tests.qt_helpers import trigger_menu_action from PySide6.QtGui import QTextCursor
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def _cursor_info(editor): @pytest.fixture
"""Return (start, end, selectedText) for the current selection.""" def editor(app, qtbot):
tc: QTextCursor = editor.textCursor() themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
start = min(tc.anchor(), tc.position()) ed = MarkdownEditor(themes)
end = max(tc.anchor(), tc.position()) qtbot.addWidget(ed)
return start, end, tc.selectedText() ed.show()
return ed
def test_find_actions_and_shortcuts(open_window, qtbot): from bouquin.find_bar import FindBar
win = open_window
# Actions should be present under Navigate and advertise canonical shortcuts
act_find = trigger_menu_action(win, "Find on page")
assert act_find.shortcut().matches(QKeySequence.Find) == QKeySequence.ExactMatch
act_next = trigger_menu_action(win, "Find Next")
assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch
act_prev = trigger_menu_action(win, "Find Previous")
assert (
act_prev.shortcut().matches(QKeySequence.FindPrevious)
== QKeySequence.ExactMatch
)
# "Find on page" should open the bar and focus the input
act_find.trigger()
qtbot.waitUntil(lambda: win.findBar.isVisible())
qtbot.waitUntil(lambda: win.findBar.edit.hasFocus())
def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot): @pytest.mark.gui
win = open_window def test_findbar_basic_navigation(qtbot, editor):
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start)
# Mixed-case content with three matches fb = FindBar(editor, parent=editor)
text = "alpha … ALPHA … alpha" qtbot.addWidget(fb)
win.editor.setPlainText(text) fb.show_bar()
qtbot.waitUntil(lambda: win.editor.toPlainText() == text) fb.edit.setText("alpha")
fb.find_next()
pos1 = editor.textCursor().position()
fb.find_next()
pos2 = editor.textCursor().position()
assert pos2 > pos1
# Open the find bar from the menu fb.find_prev()
trigger_menu_action(win, "Find on page").trigger() pos3 = editor.textCursor().position()
qtbot.waitUntil(lambda: win.findBar.isVisible()) assert pos3 <= pos2
win.findBar.edit.clear()
QTest.keyClicks(win.findBar.edit, "alpha")
# 1) First hit (case-insensitive default) fb.case.setChecked(True)
QTest.keyClick(win.findBar.edit, Qt.Key_Return) fb.refresh()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) fb.hide_bar()
s0, e0, sel0 = _cursor_info(win.editor)
assert sel0.lower() == "alpha"
# 2) Next → uppercase ALPHA (case-insensitive)
trigger_menu_action(win, "Find Next").trigger()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s1, e1, sel1 = _cursor_info(win.editor)
assert sel1.upper() == "ALPHA"
# 3) Next → the *other* lowercase "alpha" def test_show_bar_seeds_selection(qtbot, editor):
trigger_menu_action(win, "Find Next").trigger() from PySide6.QtGui import QTextCursor
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s2, e2, sel2 = _cursor_info(win.editor)
assert sel2.lower() == "alpha"
# Ensure we didn't wrap back to the very first "alpha"
assert s2 != s0
# 4) Case-sensitive: skip ALPHA and only hit lowercase editor.from_markdown("alpha beta")
win.findBar.case.setChecked(True) c = editor.textCursor()
# Put the caret at start to make the next search deterministic c.movePosition(QTextCursor.Start)
tc = win.editor.textCursor() c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
tc.setPosition(0) editor.setTextCursor(c)
win.editor.setTextCursor(tc)
win.findBar.find_next() fb = FindBar(editor, parent=editor)
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection()) qtbot.addWidget(fb)
s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor) fb.show_bar()
assert sel_cs1 == "alpha" assert fb.edit.text().lower() == "alpha"
fb.hide_bar()
win.findBar.find_next()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor)
assert sel_cs2 == "alpha"
assert s_cs2 != s_cs1 # it's the other lowercase match
# 5) Previous goes back to the earlier lowercase match
win.findBar.find_prev()
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
s_prev, e_prev, sel_prev = _cursor_info(win.editor)
assert sel_prev == "alpha"
assert s_prev == s_cs1
# 6) Close returns focus to editor
win.findBar.closeBtn.click()
qtbot.waitUntil(lambda: not win.findBar.isVisible())
qtbot.waitUntil(lambda: win.editor.hasFocus())

View file

@ -0,0 +1,19 @@
from PySide6.QtWidgets import QWidget
from PySide6.QtCore import Qt
from bouquin.history_dialog import HistoryDialog
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
d = "2001-01-01"
fresh_db.save_new_version(d, "v1", "first")
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
dlg.list.setCurrentRow(1)
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
assert fresh_db.get_entry(d) == "v1"

View file

@ -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)

View file

@ -1,66 +0,0 @@
from PySide6.QtWidgets import QListWidgetItem
from PySide6.QtCore import Qt
from bouquin.history_dialog import HistoryDialog
class FakeDB:
def __init__(self):
self.fail_revert = False
def list_versions(self, date_iso):
# Simulate two versions; mark second as current
return [
{
"id": 1,
"version_no": 1,
"created_at": "2025-01-01T10:00:00Z",
"note": None,
"is_current": False,
"content": "<p>a</p>",
},
{
"id": 2,
"version_no": 2,
"created_at": "2025-01-02T10:00:00Z",
"note": None,
"is_current": True,
"content": "<p>b</p>",
},
]
def get_version(self, version_id):
if version_id == 2:
return {"content": "<p>b</p>"}
return {"content": "<p>a</p>"}
def revert_to_version(self, date, version_id=None, version_no=None):
if self.fail_revert:
raise RuntimeError("boom")
def test_on_select_no_item(qtbot):
dlg = HistoryDialog(FakeDB(), "2025-01-01")
qtbot.addWidget(dlg)
dlg.list.clear()
dlg._on_select()
def test_revert_failure_shows_critical(qtbot, monkeypatch):
from PySide6.QtWidgets import QMessageBox
fake = FakeDB()
fake.fail_revert = True
dlg = HistoryDialog(fake, "2025-01-01")
qtbot.addWidget(dlg)
item = QListWidgetItem("v1")
item.setData(Qt.UserRole, 1) # different from current 2
dlg.list.addItem(item)
dlg.list.setCurrentItem(item)
msgs = {}
def fake_crit(parent, title, text):
msgs["t"] = (title, text)
monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit))
dlg._revert()
assert "Revert failed" in msgs["t"][0]

9
tests/test_key_prompt.py Normal file
View file

@ -0,0 +1,9 @@
from bouquin.key_prompt import KeyPrompt
def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt()
qtbot.addWidget(kp)
kp.show()
kp.edit.setText("swordfish")
assert kp.key() == "swordfish"

View file

@ -0,0 +1,18 @@
import pytest
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
@pytest.mark.gui
def test_lock_overlay_reacts_to_theme(qtbot):
host = QWidget()
qtbot.addWidget(host)
host.show()
ol = LockOverlay(host, on_unlock=lambda: None)
qtbot.addWidget(ol)
ol.show()
ev = QEvent(QEvent.Type.PaletteChange)
ol.changeEvent(ev)

11
tests/test_main.py Normal file
View file

@ -0,0 +1,11 @@
import importlib
def test_main_module_has_main():
m = importlib.import_module("bouquin.main")
assert hasattr(m, "main")
def test_dunder_main_imports_main():
m = importlib.import_module("bouquin.__main__")
assert hasattr(m, "main")

View file

@ -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

79
tests/test_main_window.py Normal file
View file

@ -0,0 +1,79 @@
import pytest
from PySide6.QtCore import QDate
from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
@pytest.mark.gui
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
date = QDate.currentDate().toString("yyyy-MM-dd")
w._load_selected_date(date)
w.editor.from_markdown("hello **world**")
w._on_text_changed()
qtbot.wait(5500) # let the 5s autosave QTimer fire
assert "world" in fresh_db.get_entry(date)
w.search.search.setText("world")
qtbot.wait(50)
assert not w.search.results.isHidden()
w._sync_toolbar()
w._adjust_day(-1) # previous day
w._adjust_day(+1) # next day
# Auto-accept the unlock KeyPrompt with the correct key
def _auto_accept_keyprompt():
for wdg in QApplication.topLevelWidgets():
if isinstance(wdg, KeyPrompt):
wdg.edit.setText(tmp_db_cfg.key)
wdg.accept()
w._enter_lock()
QTimer.singleShot(0, _auto_accept_keyprompt)
w._on_unlock_clicked()
qtbot.wait(50) # let the nested event loop process the acceptance
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
from PySide6.QtCore import QDate
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
s = get_settings()
s.setValue("db/path", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/move_todos", True)
s.setValue("ui/theme", "light")
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed")
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
from bouquin.main_window import MainWindow
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
w._load_yesterday_todos()
assert "carry me" in w.editor.to_markdown()
y_txt = fresh_db.get_entry(y)
assert "carry me" not in y_txt or "- [ ]" not in y_txt

View file

@ -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

View file

@ -0,0 +1,63 @@
import pytest
from PySide6.QtGui import QImage, QColor
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
ed.show()
return ed
def test_from_and_to_markdown_roundtrip(editor):
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
editor.from_markdown(md)
out = editor.to_markdown()
assert "Title" in out and "task" in out and "code" in out
def test_apply_styles_and_headings(editor, qtbot):
editor.from_markdown("hello world")
editor.selectAll()
editor.apply_weight()
editor.apply_italic()
editor.apply_strikethrough()
editor.apply_heading(24)
md = editor.to_markdown()
assert "**" in md and "*~~~~*" in md
def test_toggle_lists_and_checkboxes(editor):
editor.from_markdown("item one\nitem two\n")
editor.toggle_bullets()
assert "- " in editor.to_markdown()
editor.toggle_numbers()
assert "1. " in editor.to_markdown()
editor.toggle_checkboxes()
md = editor.to_markdown()
assert "- [ ]" in md or "- [x]" in md
def test_insert_image_from_path(editor, tmp_path):
img = tmp_path / "pic.png"
qimg = QImage(2, 2, QImage.Format_RGBA8888)
qimg.fill(QColor(255, 0, 0))
assert qimg.save(str(img)) # ensure a valid PNG on disk
editor.insert_image_from_path(img)
md = editor.to_markdown()
# Images are saved as base64 data URIs in markdown
assert "data:image/image/png;base64" in md
def test_apply_code_inline(editor):
editor.from_markdown("alpha beta")
editor.selectAll()
editor.apply_code()
md = editor.to_markdown()
assert ("`" in md) or ("```" in md)

View file

@ -1,113 +0,0 @@
from PySide6.QtWidgets import QApplication, QMessageBox
from bouquin.main_window import MainWindow
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.db import DBConfig
def _themes_light():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def _themes_dark():
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=Theme.DARK))
class FakeDBErr:
def __init__(self, cfg):
pass
def connect(self):
raise Exception("file is not a database")
class FakeDBOk:
def __init__(self, cfg):
pass
def connect(self):
return True
def save_new_version(self, date, text, note):
raise RuntimeError("nope")
def get_entry(self, date):
return "<p>hi</p>"
def get_entries_days(self):
return []
def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path):
# Config with a key so __init__ calls _try_connect immediately
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr)
msgs = {}
monkeypatch.setattr(
QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m))
)
w = MainWindow(_themes_light()) # auto-calls _try_connect
qtbot.addWidget(w)
assert "incorrect" in msgs.get("m", "").lower()
def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_dark())
qtbot.addWidget(w)
w._apply_link_css()
css = w.editor.document().defaultStyleSheet()
assert "a {" in css
def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_light())
qtbot.addWidget(w)
called = {}
class FakeSettings:
def value(self, key, default=None, type=None):
if key == "main/geometry":
return None
if key == "main/windowState":
return None
if key == "main/maximized":
return False
return default
w.settings = FakeSettings()
monkeypatch.setattr(
w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True)
)
w._restore_window_position()
assert called.get("x") is True
def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path):
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
w = MainWindow(_themes_light())
qtbot.addWidget(w)
captured = {}
monkeypatch.setattr(
w.editor, "insert_images", lambda paths: captured.setdefault("p", paths)
)
# Simulate file dialog returning paths
monkeypatch.setattr(
"bouquin.main_window.QFileDialog.getOpenFileNames",
staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")),
)
w._on_insert_image()
assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"]

View file

@ -0,0 +1,8 @@
from bouquin.save_dialog import SaveDialog
def test_save_dialog_note_text(qtbot):
dlg = SaveDialog()
qtbot.addWidget(dlg)
dlg.show()
assert dlg.note_text()

22
tests/test_search.py Normal file
View file

@ -0,0 +1,22 @@
from bouquin.search import Search
def test_search_widget_populates_results(qtbot, fresh_db):
fresh_db.save_new_version("2024-01-01", "alpha bravo", "seed")
fresh_db.save_new_version("2024-01-02", "bravo charlie", "seed")
fresh_db.save_new_version("2024-01-03", "delta alpha bravo", "seed")
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
emitted = []
s.resultDatesChanged.connect(lambda ds: emitted.append(tuple(ds)))
s.search.setText("alpha")
qtbot.wait(50)
assert s.results.count() >= 2
assert emitted and {"2024-01-01", "2024-01-03"}.issubset(set(emitted[-1]))
s.search.setText("")
qtbot.wait(50)
assert s.results.isHidden()

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1,11 @@
from bouquin.search import Search
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
s = Search(fresh_db)
long = (
"This is **bold** text with alpha in the middle and some more trailing content."
)
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
assert "alpha" in frag
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")

View file

@ -1,110 +0,0 @@
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QListWidget, QWidget, QAbstractButton
from tests.qt_helpers import (
trigger_menu_action,
wait_for_widget,
find_line_edit_by_placeholder,
)
def test_search_and_open_date(open_window, qtbot):
win = open_window
win.editor.setPlainText("lorem ipsum target")
win._save_current(explicit=True)
base = win.calendar.selectedDate()
d2 = base.addDays(7)
win.calendar.setSelectedDate(d2)
win.editor.setPlainText("target appears here, too")
win._save_current(explicit=True)
search_box = find_line_edit_by_placeholder(win, "search")
assert search_box is not None, "Search input not found"
search_box.setText("target")
QTest.qWait(150)
results = getattr(getattr(win, "search", None), "results", None)
if isinstance(results, QListWidget) and results.count() > 0:
# Click until we land on d2
landed = False
for i in range(results.count()):
item = results.item(i)
rect = results.visualItemRect(item)
QTest.mouseDClick(results.viewport(), Qt.LeftButton, pos=rect.center())
qtbot.wait(120)
if win.calendar.selectedDate() == d2:
landed = True
break
assert landed, "Search results did not navigate to the expected date"
else:
assert "target" in win.editor.toPlainText().lower()
def test_history_dialog_revert(open_window, qtbot):
win = open_window
# Create two versions on the current day
win.editor.setPlainText("v1 text")
win._save_current(explicit=True)
win.editor.setPlainText("v2 text")
win._save_current(explicit=True)
# Open the History UI (label varies)
try:
trigger_menu_action(win, "View History")
except AssertionError:
trigger_menu_action(win, "History")
# Find ANY top-level window that looks like the History dialog
def _is_history(w: QWidget):
if not w.isWindow() or not w.isVisible():
return False
title = (w.windowTitle() or "").lower()
return "history" in title or bool(w.findChildren(QListWidget))
hist = wait_for_widget(QWidget, predicate=_is_history, timeout_ms=15000)
# Wait for population and pick the list with the most items
chosen = None
for _ in range(120): # up to ~3s
lists = hist.findChildren(QListWidget)
if lists:
chosen = max(lists, key=lambda lw: lw.count())
if chosen.count() >= 2:
break
QTest.qWait(25)
assert (
chosen is not None and chosen.count() >= 2
), "History list never populated with 2+ versions"
# Click the older version row so the Revert button enables
idx = 1 # row 0 is most-recent "v2 text", row 1 is "v1 text"
rect = chosen.visualItemRect(chosen.item(idx))
QTest.mouseClick(chosen.viewport(), Qt.LeftButton, pos=rect.center())
QTest.qWait(100)
# Find any enabled button whose text/tooltip/objectName contains 'revert'
revert_btn = None
for _ in range(120): # wait until it enables
for btn in hist.findChildren(QAbstractButton):
meta = " ".join(
[btn.text() or "", btn.toolTip() or "", btn.objectName() or ""]
).lower()
if "revert" in meta:
revert_btn = btn
break
if revert_btn and revert_btn.isEnabled():
break
QTest.qWait(25)
assert (
revert_btn is not None and revert_btn.isEnabled()
), "Revert button not found/enabled"
QTest.mouseClick(revert_btn, Qt.LeftButton)
# AutoResponder will accept confirm/success boxes
QTest.qWait(150)
assert "v1 text" in win.editor.toPlainText()

View file

@ -1,57 +0,0 @@
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QListWidgetItem
# The widget class is named `Search` in bouquin.search
from bouquin.search import Search as SearchWidget
class FakeDB:
def __init__(self, rows):
self.rows = rows
def search_entries(self, q):
return list(self.rows)
def test_search_empty_clears_and_hides(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
w.show()
qtbot.waitExposed(w)
dates = []
w.resultDatesChanged.connect(lambda ds: dates.extend(ds))
w._search(" ")
assert w.results.isHidden()
assert dates == []
def test_populate_empty_hides(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
w._populate_results("x", [])
assert w.results.isHidden()
def test_open_selected_emits_when_present(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
got = {}
w.openDateRequested.connect(lambda d: got.setdefault("d", d))
it = QListWidgetItem("x")
it.setData(Qt.ItemDataRole.UserRole, "")
w._open_selected(it)
assert "d" not in got
it.setData(Qt.ItemDataRole.UserRole, "2025-01-02")
w._open_selected(it)
assert got["d"] == "2025-01-02"
def test_make_html_snippet_edge_cases(qtbot):
w = SearchWidget(db=FakeDB([]))
qtbot.addWidget(w)
# Empty HTML -> empty fragment, no ellipses
frag, l, r = w._make_html_snippet("", "hello")
assert frag == "" and not l and not r
# Small doc around token -> should not show ellipses
frag, l, r = w._make_html_snippet("<p>Hello world</p>", "world")
assert "<b>world</b>" in frag or "world" in frag

View file

@ -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

36
tests/test_settings.py Normal file
View file

@ -0,0 +1,36 @@
from pathlib import Path
from bouquin.settings import (
default_db_path,
get_settings,
load_db_config,
save_db_config,
)
from bouquin.db import DBConfig
def test_default_db_path_returns_writable_path(app, tmp_path):
p = default_db_path()
assert isinstance(p, Path)
p.parent.mkdir(parents=True, exist_ok=True)
def test_load_and_save_db_config_roundtrip(app, tmp_path):
s = get_settings()
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
s.remove(k)
cfg = DBConfig(
path=tmp_path / "notes.db",
key="abc123",
idle_minutes=7,
theme="dark",
move_todos=True,
)
save_db_config(cfg)
loaded = load_db_config()
assert loaded.path == cfg.path
assert loaded.key == cfg.key
assert loaded.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos

View file

@ -1,296 +1,180 @@
from pathlib import Path import pytest
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
from bouquin.db import DBConfig
from bouquin.settings_dialog import SettingsDialog from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
class _ThemeSpy: @pytest.mark.gui
def __init__(self): def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
self.calls = [] # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
app = QApplication.instance()
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
def set(self, t): dlg.path_edit.setText(str(tmp_path / "alt.db"))
self.calls.append(t) dlg.idle_spin.setValue(3)
dlg.theme_light.setChecked(True)
dlg.move_todos.setChecked(True)
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
def _auto_accept_msgbox():
for w in QApplication.topLevelWidgets():
if isinstance(w, QMessageBox):
w.accept()
QTimer.singleShot(0, _auto_accept_msgbox)
dlg._compact_btn_clicked()
qtbot.wait(50)
dlg._save()
cfg = dlg.config
assert cfg.path.name == "alt.db"
assert cfg.idle_minutes == 3
assert cfg.theme in ("light", "dark", "system")
class _Parent(QWidget): def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
def __init__(self): from PySide6.QtCore import QTimer
super().__init__() from PySide6.QtWidgets import QApplication, QMessageBox
self.themes = _ThemeSpy() from bouquin.key_prompt import KeyPrompt
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtWidgets import QWidget
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
# Ensure a clean starting state (suite may leave settings toggled on)
dlg.save_key_btn.setChecked(False)
dlg.key = ""
# Robust popup pump so we never miss late dialogs
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.edit.setText("supersecret")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump)
timer.start()
try:
dlg.save_key_btn.setChecked(True)
qtbot.waitUntil(lambda: dlg.key == "supersecret", timeout=1000)
assert dlg.save_key_btn.isChecked()
dlg.save_key_btn.setChecked(False)
qtbot.waitUntil(lambda: dlg.key == "", timeout=1000)
assert dlg.key == ""
finally:
timer.stop()
class FakeDB: def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
def __init__(self): from PySide6.QtCore import QTimer
self.rekey_called_with = None from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
self.compact_called = False from bouquin.key_prompt import KeyPrompt
self.fail_compact = False from bouquin.db import DBManager, DBConfig
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def rekey(self, key: str): cfg = DBConfig(
self.rekey_called_with = key path=tmp_path / "iso.db",
key="oldkey",
idle_minutes=0,
theme="light",
move_todos=True,
)
db = DBManager(cfg)
assert db.connect()
db.save_new_version("2000-01-01", "seed", "seed")
def compact(self): parent = QWidget()
if self.fail_compact: parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
raise RuntimeError("boom")
self.compact_called = True
class AcceptingPrompt:
def __init__(self, parent=None, title="", message=""):
self._key = ""
self._accepted = True
def set_key(self, k: str):
self._key = k
return self
def exec(self):
return QDialog.Accepted if self._accepted else QDialog.Rejected
def key(self):
return self._key
class RejectingPrompt(AcceptingPrompt):
def __init__(self, *a, **k):
super().__init__()
self._accepted = False
def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path):
db = FakeDB()
cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15)
saved = {}
def fake_save(cfg2):
saved["cfg"] = cfg2
monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save)
# Drive the "remember key" checkbox via the prompt (no pre-set key)
p = AcceptingPrompt().set_key("sekrit")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
# Provide a lightweight parent that mimics MainWindows `themes` API
class _ThemeSpy:
def __init__(self):
self.calls = []
def set(self, theme):
self.calls.append(theme)
class _Parent(QWidget):
def __init__(self):
super().__init__()
self.themes = _ThemeSpy()
parent = _Parent()
qtbot.addWidget(parent)
dlg = SettingsDialog(cfg, db, parent=parent) dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() dlg.show()
qtbot.waitExposed(dlg)
# Change fields keys = ["one", "two"]
new_path = tmp_path / "new.sqlite"
dlg.path_edit.setText(str(new_path))
dlg.idle_spin.setValue(0)
# User toggles "Remember key" -> stores prompted key def _pump_popups():
dlg.save_key_btn.setChecked(True) for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
dlg._save() w.edit.setText(keys.pop(0) if keys else "zzz")
w.accept()
out = saved["cfg"] elif isinstance(w, QMessageBox):
assert out.path == new_path w.accept()
assert out.idle_minutes == 0
assert out.key == "sekrit"
assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot):
# When toggled on with no key yet, it prompts; cancelling should revert the check
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
assert dlg.key == ""
dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects
assert dlg.save_key_btn.isChecked() is False
assert dlg.key == ""
def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot):
# Toggling on with an accepting prompt should store the typed key
p = AcceptingPrompt().set_key("remember-me")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg.save_key_btn.click()
assert dlg.save_key_btn.isChecked() is True
assert dlg.key == "remember-me"
def test_change_key_success(monkeypatch, qtbot):
# Two prompts returning the same non-empty key -> rekey() and info message
p1 = AcceptingPrompt().set_key("newkey")
p2 = AcceptingPrompt().set_key("newkey")
seq = [p1, p2]
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
shown = {"info": 0}
monkeypatch.setattr(
QMessageBox,
"information",
lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_popups)
timer.start()
try:
dlg._change_key() dlg._change_key()
finally:
assert db.rekey_called_with == "newkey" timer.stop()
assert shown["info"] >= 1 db.close()
assert dlg.key == "newkey" db2 = DBManager(cfg)
assert db2.connect()
db2.close()
def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot): def test_change_key_success(qtbot, tmp_path, app):
p1 = AcceptingPrompt().set_key("a") from PySide6.QtCore import QTimer
p2 = AcceptingPrompt().set_key("b") from PySide6.QtWidgets import QApplication, QWidget, QMessageBox
seq = [p1, p2] from bouquin.key_prompt import KeyPrompt
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) from bouquin.db import DBManager, DBConfig
from bouquin.theme import ThemeManager, ThemeConfig, Theme
called = {"warn": 0} cfg = DBConfig(
monkeypatch.setattr( path=tmp_path / "iso2.db",
QMessageBox, key="oldkey",
"warning", idle_minutes=0,
lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), theme="light",
move_todos=True,
) )
db = DBManager(cfg)
assert db.connect()
db.save_new_version("2001-01-01", "seed", "seed")
db = FakeDB() parent = QWidget()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(cfg, db, parent=parent)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg.show() dlg.show()
qtbot.waitExposed(dlg)
keys = ["newkey", "newkey"]
def _pump():
for w in QApplication.topLevelWidgets():
if isinstance(w, KeyPrompt):
w.edit.setText(keys.pop(0) if keys else "newkey")
w.accept()
elif isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump)
timer.start()
try:
dlg._change_key() dlg._change_key()
finally:
timer.stop()
qtbot.wait(50)
assert db.rekey_called_with is None db.close()
assert called["warn"] >= 1 cfg.key = "newkey"
db2 = DBManager(cfg)
assert db2.connect()
def test_change_key_empty_shows_warning(monkeypatch, qtbot): assert "seed" in db2.get_entry("2001-01-01")
p1 = AcceptingPrompt().set_key("") db2.close()
p2 = AcceptingPrompt().set_key("")
seq = [p1, p2]
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0))
called = {"warn": 0}
monkeypatch.setattr(
QMessageBox,
"warning",
lambda *a, **k: called.__setitem__("warn", called["warn"] + 1),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._change_key()
assert db.rekey_called_with is None
assert called["warn"] >= 1
def test_browse_sets_path(monkeypatch, qtbot, tmp_path):
def fake_get_save_file_name(*a, **k):
return (str(tmp_path / "picked.sqlite"), "")
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name)
)
dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._browse()
assert dlg.path_edit.text().endswith("picked.sqlite")
def test_compact_success_and_failure(monkeypatch, qtbot):
shown = {"info": 0, "crit": 0}
monkeypatch.setattr(
QMessageBox,
"information",
lambda *a, **k: shown.__setitem__("info", shown["info"] + 1),
)
monkeypatch.setattr(
QMessageBox,
"critical",
lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1),
)
db = FakeDB()
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg._compact_btn_clicked()
assert db.compact_called is True
assert shown["info"] >= 1
# Failure path
db2 = FakeDB()
db2.fail_compact = True
dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2)
qtbot.addWidget(dlg2)
dlg2.show()
qtbot.waitExposed(dlg2)
dlg2._compact_btn_clicked()
assert shown["crit"] >= 1
def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot):
p = AcceptingPrompt().set_key("already")
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p)
dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB())
qtbot.addWidget(dlg)
dlg.show()
qtbot.waitExposed(dlg)
dlg.save_key_btn.setChecked(True)
# We should reach here with the original key preserved.
assert dlg.key == "already"
def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path):
parent = _Parent()
qtbot.addWidget(parent)
cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5)
dlg = SettingsDialog(cfg, FakeDB(), parent=parent)
qtbot.addWidget(dlg)
dlg.save_key_btn.setChecked(False)
# Trigger save
dlg._save()
assert dlg.config.key == "" # cleared
assert parent.themes.calls # applied some theme

View file

@ -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())

View file

@ -1,28 +0,0 @@
from bouquin.db import DBConfig
import bouquin.settings as settings
class FakeSettings:
def __init__(self):
self.store = {}
def value(self, key, default=None, type=None):
return self.store.get(key, default)
def setValue(self, key, value):
self.store[key] = value
def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path):
fake = FakeSettings()
monkeypatch.setattr(settings, "get_settings", lambda: fake)
cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark")
settings.save_db_config(cfg)
# Now read back into a new DBConfig
cfg2 = settings.load_db_config()
assert cfg2.path == cfg.path
assert cfg2.key == "k"
assert cfg2.idle_minutes == "7"
assert cfg2.theme == "dark"

21
tests/test_theme.py Normal file
View file

@ -0,0 +1,21 @@
import pytest
from PySide6.QtGui import QPalette
from bouquin.theme import Theme, ThemeConfig, ThemeManager
def test_theme_manager_apply_light_and_dark(app):
cfg = ThemeConfig(theme=Theme.LIGHT)
mgr = ThemeManager(app, cfg)
mgr.apply(Theme.LIGHT)
assert isinstance(app.palette(), QPalette)
mgr.set(Theme.DARK)
assert isinstance(app.palette(), QPalette)
@pytest.mark.gui
def test_theme_manager_system_roundtrip(app, qtbot):
cfg = ThemeConfig(theme=Theme.SYSTEM)
mgr = ThemeManager(app, cfg)
mgr.apply(cfg.theme)

View file

@ -1,19 +0,0 @@
from bouquin.theme import Theme
def test_apply_link_css_dark_theme(open_window, qtbot):
win = open_window
# Switch to dark and apply link CSS
win.themes.set(Theme.DARK)
win._apply_link_css()
css = win.editor.document().defaultStyleSheet()
assert "#FFA500" in css and "a:visited" in css
def test_apply_link_css_light_theme(open_window, qtbot):
win = open_window
# Switch to light and apply link CSS
win.themes.set(Theme.LIGHT)
win._apply_link_css()
css = win.editor.document().defaultStyleSheet()
assert css == "" or "a {" not in css

View file

@ -1,19 +0,0 @@
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPalette, QColor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
def test_theme_manager_applies_palettes(qtbot):
app = QApplication.instance()
tm = ThemeManager(app, ThemeConfig())
# Light palette should set Link to the light blue
tm.apply(Theme.LIGHT)
pal = app.palette()
assert pal.color(QPalette.Link) == QColor("#1a73e8")
# Dark palette should set Link to lavender-ish
tm.apply(Theme.DARK)
pal = app.palette()
assert pal.color(QPalette.Link) == QColor("#FFA500")

44
tests/test_toolbar.py Normal file
View file

@ -0,0 +1,44 @@
import pytest
from PySide6.QtWidgets import QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
ed.show()
return ed
from bouquin.toolbar import ToolBar
@pytest.mark.gui
def test_toolbar_signals_and_styling(qtbot, editor):
host = QWidget()
qtbot.addWidget(host)
host.show()
tb = ToolBar(parent=host)
qtbot.addWidget(tb)
tb.show()
tb.boldRequested.connect(editor.apply_weight)
tb.italicRequested.connect(editor.apply_italic)
tb.strikeRequested.connect(editor.apply_strikethrough)
tb.codeRequested.connect(lambda: editor.apply_code())
tb.headingRequested.connect(lambda s: editor.apply_heading(s))
tb.bulletsRequested.connect(lambda: editor.toggle_bullets())
tb.numbersRequested.connect(lambda: editor.toggle_numbers())
tb.checkboxesRequested.connect(lambda: editor.toggle_checkboxes())
editor.from_markdown("hello")
editor.selectAll()
tb.boldRequested.emit()
tb.italicRequested.emit()
tb.strikeRequested.emit()
tb.headingRequested.emit(24)
assert editor.to_markdown()

View file

@ -1,23 +0,0 @@
from bouquin.toolbar import ToolBar
def test_style_letter_button_handles_missing_widget(qtbot):
tb = ToolBar()
qtbot.addWidget(tb)
# Create a dummy action detached from toolbar to force widgetForAction->None
from PySide6.QtGui import QAction
act = QAction("X", tb)
# No crash and early return
tb._style_letter_button(act, "X")
def test_style_letter_button_sets_tooltip_and_accessible(qtbot):
tb = ToolBar()
qtbot.addWidget(tb)
# Use an existing action so widgetForAction returns a button
act = tb.actBold
tb._style_letter_button(act, "B", bold=True, tooltip="Bold")
btn = tb.widgetForAction(act)
assert btn.toolTip() == "Bold"
assert btn.accessibleName() == "Bold"

View file

@ -1,55 +0,0 @@
from PySide6.QtGui import QTextCursor, QFont
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
def test_toggle_basic_char_styles(open_window, qtbot):
win = open_window
win.editor.setPlainText("style")
c = win.editor.textCursor()
c.select(QTextCursor.Document)
win.editor.setTextCursor(c)
win.toolBar.actBold.trigger()
assert win.editor.currentCharFormat().fontWeight() == QFont.Weight.Bold
win.toolBar.actItalic.trigger()
assert win.editor.currentCharFormat().fontItalic() is True
win.toolBar.actUnderline.trigger()
assert win.editor.currentCharFormat().fontUnderline() is True
win.toolBar.actStrike.trigger()
assert win.editor.currentCharFormat().fontStrikeOut() is True
def test_headings_lists_and_alignment(open_window, qtbot):
win = open_window
win.editor.setPlainText("Heading\nSecond line")
c = win.editor.textCursor()
c.select(QTextCursor.LineUnderCursor)
win.editor.setTextCursor(c)
sizes = []
for attr in ("actH1", "actH2", "actH3"):
if hasattr(win.toolBar, attr):
getattr(win.toolBar, attr).trigger()
QTest.qWait(45) # let the format settle to avoid segfaults on some styles
sizes.append(win.editor.currentCharFormat().fontPointSize())
assert len(sizes) >= 2 and all(
a > b for a, b in zip(sizes, sizes[1:])
), f"Heading sizes not decreasing: {sizes}"
win.toolBar.actCode.trigger()
QTest.qWait(45)
win.toolBar.actBullets.trigger()
QTest.qWait(45)
win.toolBar.actNumbers.trigger()
QTest.qWait(45)
win.toolBar.actAlignC.trigger()
QTest.qWait(45)
assert int(win.editor.alignment()) & int(Qt.AlignHCenter)
win.toolBar.actAlignR.trigger()
QTest.qWait(45)
assert int(win.editor.alignment()) & int(Qt.AlignRight)
win.toolBar.actAlignL.trigger()
QTest.qWait(45)
assert int(win.editor.alignment()) & int(Qt.AlignLeft)