diff --git a/CHANGELOG.md b/CHANGELOG.md
index b45483f..07944f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,3 @@
-# 0.1.3
-
- * Fix bold toggle
- * Improvements to preview size in search results
- * Make URLs highlighted and clickable (Ctrl+click)
- * Explain the purpose of the encryption key for first-time use
- * Support saving the encryption key to the settings file to avoid being prompted (off by default)
- * Abbreviated toolbar symbols to keep things tidier. Add tooltips
-
-# 0.1.2
-
- * Switch from Markdown to HTML via QTextEdit, with a toolbar
- * Add search ability
- * Fix Settings shortcut and change nav menu from 'File' to 'Application'
-
# 0.1.1
* Add ability to change the key
diff --git a/README.md b/README.md
index 94d0648..b874668 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
To increase security, the SQLCipher key is requested when the app is opened, and is not written
-to disk unless the user configures it to be in the settings.
+to disk.
There is deliberately no network connectivity or syncing intended.
@@ -20,23 +20,22 @@ There is deliberately no network connectivity or syncing intended.
## Features
* Every 'page' is linked to the calendar day
- * Text is HTML with basic styling
- * Search
+ * Basic markdown
* Automatic periodic saving (or explicitly save)
+ * Navigating from one day to the next automatically saves
+ * Basic keyboard shortcuts
* Transparent integrity checking of the database when it opens
- * Rekey the database (change the password)
## Yet to do
+ * Search
* Taxonomy/tagging
* Export to other formats (plaintext, json, sql etc)
## How to install
-Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
-
### From source
* Clone this repo or download the tarball from the releases page
@@ -48,7 +47,7 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
* Download the whl and run it
-### From PyPi/pip
+### From PyPi
* `pip install bouquin`
diff --git a/bouquin/db.py b/bouquin/db.py
index 0073903..15cc4c9 100644
--- a/bouquin/db.py
+++ b/bouquin/db.py
@@ -23,6 +23,7 @@ class DBManager:
self.conn = sqlite.connect(str(self.cfg.path))
cur = self.conn.cursor()
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
+ cur.execute("PRAGMA cipher_compatibility = 4;")
cur.execute("PRAGMA journal_mode = WAL;")
self.conn.commit()
try:
@@ -99,12 +100,6 @@ class DBManager:
)
self.conn.commit()
- def search_entries(self, text: str) -> list[str]:
- cur = self.conn.cursor()
- pattern = f"%{text}%"
- cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
- return [r for r in cur.fetchall()]
-
def dates_with_content(self) -> list[str]:
cur = self.conn.cursor()
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
diff --git a/bouquin/editor.py b/bouquin/editor.py
deleted file mode 100644
index eb3b664..0000000
--- a/bouquin/editor.py
+++ /dev/null
@@ -1,248 +0,0 @@
-from __future__ import annotations
-
-from PySide6.QtGui import (
- QColor,
- QDesktopServices,
- QFont,
- QFontDatabase,
- QTextCharFormat,
- QTextCursor,
- QTextListFormat,
- QTextBlockFormat,
-)
-from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
-from PySide6.QtWidgets import QTextEdit
-
-
-class Editor(QTextEdit):
- linkActivated = Signal(str)
-
- _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
- self.setTabStopDistance(tab_w)
-
- self.setTextInteractionFlags(
- Qt.TextInteractionFlag.TextEditorInteraction
- | Qt.TextInteractionFlag.LinksAccessibleByMouse
- | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
- )
-
- self.setAcceptRichText(True)
-
- # Turn raw URLs into anchors
- self._linkifying = False
- self.textChanged.connect(self._linkify_document)
- self.viewport().setMouseTracking(True)
-
- def _linkify_document(self):
- if self._linkifying:
- return
- self._linkifying = True
-
- doc = self.document()
- cur = QTextCursor(doc)
- cur.beginEditBlock()
-
- block = doc.begin()
- while block.isValid():
- text = block.text()
- it = self._URL_RX.globalMatch(text)
- while it.hasNext():
- m = it.next()
- start = block.position() + m.capturedStart()
- end = start + m.capturedLength()
-
- cur.setPosition(start)
- cur.setPosition(end, QTextCursor.KeepAnchor)
-
- fmt = cur.charFormat()
- if fmt.isAnchor(): # already linkified; skip
- continue
-
- href = m.captured(0)
- if href.startswith("www."):
- href = "https://" + href
-
- fmt.setAnchor(True)
- # Qt 6: use setAnchorHref; for compatibility, also set names.
- try:
- fmt.setAnchorHref(href)
- except AttributeError:
- fmt.setAnchorNames([href])
-
- fmt.setFontUnderline(True)
- fmt.setForeground(Qt.blue)
- cur.setCharFormat(fmt)
-
- block = block.next()
-
- cur.endEditBlock()
- self._linkifying = False
-
- def mouseReleaseEvent(self, e):
- if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
- href = self.anchorAt(e.pos())
- if href:
- QDesktopServices.openUrl(QUrl.fromUserInput(href))
- self.linkActivated.emit(href)
- return
- super().mouseReleaseEvent(e)
-
- def mouseMoveEvent(self, e):
- if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
- self.viewport().setCursor(Qt.PointingHandCursor)
- else:
- self.viewport().setCursor(Qt.IBeamCursor)
- super().mouseMoveEvent(e)
-
- def keyPressEvent(self, e):
- key = e.key()
-
- # Pre-insert: stop link/format bleed for “word boundary” keys
- if key in (Qt.Key_Space, Qt.Key_Tab):
- self._break_anchor_for_next_char()
- return super().keyPressEvent(e)
-
- # When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
- if key in (Qt.Key_Return, Qt.Key_Enter):
- super().keyPressEvent(e) # create the new (possibly empty) paragraph
-
- # If we're on an empty block, clear the insertion char format so the
- # *next* Enter will create another new line (not consume the press to reset formatting).
- c = self.textCursor()
- block = c.block()
- if block.length() == 1:
- self._clear_insertion_char_format()
- return
-
- return super().keyPressEvent(e)
-
- def _clear_insertion_char_format(self):
- """Reset inline typing format (keeps lists, alignment, margins, etc.)."""
- nf = QTextCharFormat()
- self.setCurrentCharFormat(nf)
-
- def _break_anchor_for_next_char(self):
- c = self.textCursor()
- fmt = c.charFormat()
- if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
- # clone, then strip just the link-specific bits so the next char is plain text
- nf = QTextCharFormat(fmt)
- nf.setAnchor(False)
- nf.setFontUnderline(False)
- nf.clearForeground()
- try:
- nf.setAnchorHref("")
- except AttributeError:
- nf.setAnchorNames([])
- self.setCurrentCharFormat(nf)
-
- def merge_on_sel(self, fmt):
- """
- Sets the styling on the selected characters.
- """
- cursor = self.textCursor()
- if not cursor.hasSelection():
- cursor.select(cursor.SelectionType.WordUnderCursor)
- cursor.mergeCharFormat(fmt)
- self.mergeCurrentCharFormat(fmt)
-
- @Slot()
- def apply_weight(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- weight = (
- QFont.Weight.Normal
- if cur.fontWeight() == QFont.Weight.Bold
- else QFont.Weight.Bold
- )
- fmt.setFontWeight(weight)
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_italic(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontItalic(not cur.fontItalic())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_underline(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontUnderline(not cur.fontUnderline())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_strikethrough(self):
- cur = self.currentCharFormat()
- fmt = QTextCharFormat()
- fmt.setFontStrikeOut(not cur.fontStrikeOut())
- self.merge_on_sel(fmt)
-
- @Slot()
- def apply_code(self):
- c = self.textCursor()
- if not c.hasSelection():
- c.select(c.SelectionType.BlockUnderCursor)
-
- bf = QTextBlockFormat()
- bf.setLeftMargin(12)
- bf.setRightMargin(12)
- bf.setTopMargin(6)
- bf.setBottomMargin(6)
- bf.setBackground(QColor(245, 245, 245))
- bf.setNonBreakableLines(True)
-
- cf = QTextCharFormat()
- mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
- cf.setFont(mono)
- cf.setFontFixedPitch(True)
-
- # If the current block already looks like a code block, remove styling
- cur_bf = c.blockFormat()
- is_code = (
- cur_bf.nonBreakableLines()
- and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
- )
- if is_code:
- # clear: margins/background/wrapping
- bf = QTextBlockFormat()
- cf = QTextCharFormat()
-
- c.mergeBlockFormat(bf)
- c.mergeBlockCharFormat(cf)
-
- @Slot(int)
- def apply_heading(self, size):
- fmt = QTextCharFormat()
- if size:
- fmt.setFontWeight(QFont.Weight.Bold)
- fmt.setFontPointSize(size)
- else:
- fmt.setFontWeight(QFont.Weight.Normal)
- fmt.setFontPointSize(self.font().pointSizeF())
- self.merge_on_sel(fmt)
-
- def toggle_bullets(self):
- c = self.textCursor()
- lst = c.currentList()
- if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
- lst.remove(c.block())
- return
- fmt = QTextListFormat()
- fmt.setStyle(QTextListFormat.Style.ListDisc)
- c.createList(fmt)
-
- def toggle_numbers(self):
- c = self.textCursor()
- lst = c.currentList()
- if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
- lst.remove(c.block())
- return
- fmt = QTextListFormat()
- fmt.setStyle(QTextListFormat.Style.ListDecimal)
- c.createList(fmt)
diff --git a/bouquin/highlighter.py b/bouquin/highlighter.py
new file mode 100644
index 0000000..456dfa2
--- /dev/null
+++ b/bouquin/highlighter.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import re
+from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
+
+
+class MarkdownHighlighter(QSyntaxHighlighter):
+ ST_NORMAL = 0
+ ST_CODE = 1
+
+ FENCE = re.compile(r"^```")
+
+ def __init__(self, document):
+ super().__init__(document)
+
+ base_size = document.defaultFont().pointSizeF() or 12.0
+
+ # Monospace for code
+ self.mono = QFont("Monospace")
+ self.mono.setStyleHint(QFont.TypeWriter)
+
+ # Light, high-contrast scheme for code
+ self.col_bg = QColor("#eef2f6") # light code bg
+ self.col_fg = QColor("#1f2328") # dark text
+
+ # Formats
+ self.fmt_h = [QTextCharFormat() for _ in range(6)]
+ for i, f in enumerate(self.fmt_h, start=1):
+ f.setFontWeight(QFont.Weight.Bold)
+ f.setFontPointSize(base_size + (7 - i))
+ self.fmt_bold = QTextCharFormat()
+ self.fmt_bold.setFontWeight(QFont.Weight.Bold)
+ self.fmt_italic = QTextCharFormat()
+ self.fmt_italic.setFontItalic(True)
+ self.fmt_quote = QTextCharFormat()
+ self.fmt_quote.setForeground(QColor("#6a737d"))
+ self.fmt_link = QTextCharFormat()
+ self.fmt_link.setFontUnderline(True)
+ self.fmt_list = QTextCharFormat()
+ self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
+ self.fmt_strike = QTextCharFormat()
+ self.fmt_strike.setFontStrikeOut(True)
+
+ # Uniform code style
+ self.fmt_code = QTextCharFormat()
+ self.fmt_code.setFont(self.mono)
+ self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
+ self.fmt_code.setBackground(self.col_bg)
+ self.fmt_code.setForeground(self.col_fg)
+
+ # Simple patterns
+ self.re_heading = re.compile(r"^(#{1,6}) +.*$")
+ self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
+ self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
+ self.re_strike = re.compile(r"~~(.+?)~~")
+ self.re_inline_code = re.compile(r"`([^`]+)`")
+ self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
+ self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
+ self.re_quote = re.compile(r"^> ?.*$")
+
+ def highlightBlock(self, text: str) -> None:
+ prev = self.previousBlockState()
+ in_code = prev == self.ST_CODE
+
+ if in_code:
+ # Entire line is code
+ self.setFormat(0, len(text), self.fmt_code)
+ if self.FENCE.match(text):
+ self.setCurrentBlockState(self.ST_NORMAL)
+ else:
+ self.setCurrentBlockState(self.ST_CODE)
+ return
+
+ # Starting/ending a fenced block?
+ if self.FENCE.match(text):
+ self.setFormat(0, len(text), self.fmt_code)
+ self.setCurrentBlockState(self.ST_CODE)
+ return
+
+ # --- Normal markdown styling ---
+ m = self.re_heading.match(text)
+ if m:
+ level = min(len(m.group(1)), 6)
+ self.setFormat(0, len(text), self.fmt_h[level - 1])
+ self.setCurrentBlockState(self.ST_NORMAL)
+ return
+
+ m = self.re_list.match(text)
+ if m:
+ self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
+
+ if self.re_quote.match(text):
+ self.setFormat(0, len(text), self.fmt_quote)
+
+ for m in self.re_inline_code.finditer(text):
+ self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
+
+ for m in self.re_bold.finditer(text):
+ self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
+
+ for m in self.re_italic.finditer(text):
+ self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
+
+ for m in self.re_strike.finditer(text):
+ self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
+
+ for m in self.re_link.finditer(text):
+ start = m.start(1) - 1
+ length = len(m.group(1)) + 2
+ self.setFormat(start, length, self.fmt_link)
+
+ self.setCurrentBlockState(self.ST_NORMAL)
diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py
index 095093c..1fe8dee 100644
--- a/bouquin/key_prompt.py
+++ b/bouquin/key_prompt.py
@@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
def __init__(
self,
parent=None,
- title: str = "Enter key",
- message: str = "Enter key",
+ title: str = "Unlock database",
+ message: str = "Enter SQLCipher key",
):
super().__init__(parent)
self.setWindowTitle(title)
diff --git a/bouquin/main.py b/bouquin/main.py
index 3e5f90b..9beb4d9 100644
--- a/bouquin/main.py
+++ b/bouquin/main.py
@@ -11,6 +11,5 @@ def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
- win = MainWindow()
- win.show()
+ win = MainWindow(); win.show()
sys.exit(app.exec())
diff --git a/bouquin/main_window.py b/bouquin/main_window.py
index 0f90197..6b0451c 100644
--- a/bouquin/main_window.py
+++ b/bouquin/main_window.py
@@ -1,34 +1,26 @@
from __future__ import annotations
-import os
import sys
-from PySide6.QtCore import QDate, QTimer, Qt, QSettings
-from PySide6.QtGui import (
- QAction,
- QCursor,
- QFont,
- QGuiApplication,
- QTextCharFormat,
-)
+from PySide6.QtCore import QDate, QTimer, Qt
+from PySide6.QtGui import QAction, QFont, QTextCharFormat
from PySide6.QtWidgets import (
- QCalendarWidget,
QDialog,
+ QCalendarWidget,
QMainWindow,
QMessageBox,
- QSizePolicy,
+ QPlainTextEdit,
QSplitter,
QVBoxLayout,
QWidget,
+ QSizePolicy,
)
from .db import DBManager
-from .editor import Editor
+from .settings import APP_NAME, load_db_config, save_db_config
from .key_prompt import KeyPrompt
-from .search import Search
-from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
+from .highlighter import MarkdownHighlighter
from .settings_dialog import SettingsDialog
-from .toolbar import ToolBar
class MainWindow(QMainWindow):
@@ -38,18 +30,9 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650)
self.cfg = load_db_config()
- if not os.path.exists(self.cfg.path):
- # Fresh database/first time use, so guide the user re: setting a key
- first_time = True
- else:
- first_time = False
-
- # Prompt for the key unless it is found in config
- if not self.cfg.key:
- if not self._prompt_for_key_until_valid(first_time):
- sys.exit(1)
- else:
- self._try_connect()
+ # Always prompt for the key (we never store it)
+ if not self._prompt_for_key_until_valid():
+ sys.exit(1)
# ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget()
@@ -57,35 +40,17 @@ class MainWindow(QMainWindow):
self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed)
- self.search = Search(self.db)
- self.search.openDateRequested.connect(self._load_selected_date)
-
- # Lock the calendar to the left panel at the top to stop it stretching
- # when the main window is resized.
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
- left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
- # This is the note-taking editor
- self.editor = Editor()
-
- # Toolbar for controlling styling
- tb = ToolBar()
- self.addToolBar(tb)
- # Wire toolbar intents to editor methods
- tb.boldRequested.connect(self.editor.apply_weight)
- tb.italicRequested.connect(self.editor.apply_italic)
- tb.underlineRequested.connect(self.editor.apply_underline)
- tb.strikeRequested.connect(self.editor.apply_strikethrough)
- tb.codeRequested.connect(self.editor.apply_code)
- tb.headingRequested.connect(self.editor.apply_heading)
- tb.bulletsRequested.connect(self.editor.toggle_bullets)
- tb.numbersRequested.connect(self.editor.toggle_numbers)
- tb.alignRequested.connect(self.editor.setAlignment)
+ self.editor = QPlainTextEdit()
+ tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ")
+ self.editor.setTabStopDistance(tab_w)
+ self.highlighter = MarkdownHighlighter(self.editor.document())
split = QSplitter()
split.addWidget(left_panel)
@@ -102,13 +67,13 @@ class MainWindow(QMainWindow):
# Menu bar (File)
mb = self.menuBar()
- file_menu = mb.addMenu("&Application")
+ file_menu = mb.addMenu("&File")
act_save = QAction("&Save", self)
act_save.setShortcut("Ctrl+S")
act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save)
act_settings = QAction("S&ettings", self)
- act_settings.setShortcut("Ctrl+E")
+ act_save.setShortcut("Ctrl+E")
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
file_menu.addSeparator()
@@ -117,7 +82,7 @@ class MainWindow(QMainWindow):
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
- # Navigate menu with next/previous/today
+ # Navigate menu with next/previous day
nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P")
@@ -147,18 +112,12 @@ class MainWindow(QMainWindow):
self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed)
- # First load + mark dates in calendar with content
+ # First load + mark dates with content
self._load_selected_date()
self._refresh_calendar_marks()
- # Restore window position from settings
- self.settings = QSettings(APP_ORG, APP_NAME)
- self._restore_window_position()
-
+ # --- DB lifecycle
def _try_connect(self) -> bool:
- """
- Try to connect to the database.
- """
try:
self.db = DBManager(self.cfg)
ok = self.db.connect()
@@ -171,29 +130,17 @@ class MainWindow(QMainWindow):
return False
return ok
- def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
- """
- Prompt for the SQLCipher key.
- """
- if first_time:
- title = "Set an encryption key"
- message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
- else:
- title = "Unlock encrypted notebook"
- message = "Enter your key to unlock the notebook"
+ def _prompt_for_key_until_valid(self) -> bool:
while True:
- dlg = KeyPrompt(self, title, message)
+ dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
if dlg.exec() != QDialog.Accepted:
return False
self.cfg.key = dlg.key()
if self._try_connect():
return True
+ # --- Calendar marks to indicate text exists for htat day -----------------
def _refresh_calendar_marks(self):
- """
- Sets a bold marker on the day to indicate that text exists
- for that day.
- """
fmt_bold = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks
@@ -214,22 +161,19 @@ class MainWindow(QMainWindow):
d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
- def _load_selected_date(self, date_iso=False):
- if not date_iso:
- date_iso = self._current_date_iso()
+ def _load_selected_date(self):
+ date_iso = self._current_date_iso()
try:
text = self.db.get_entry(date_iso)
except Exception as e:
QMessageBox.critical(self, "Read Error", str(e))
return
self.editor.blockSignals(True)
- self.editor.setHtml(text)
+ self.editor.setPlainText(text)
self.editor.blockSignals(False)
self._dirty = False
# track which date the editor currently represents
self._active_date_iso = date_iso
- qd = QDate.fromString(date_iso, "yyyy-MM-dd")
- self.calendar.setSelectedDate(qd)
def _on_text_changed(self):
self._dirty = True
@@ -268,7 +212,7 @@ class MainWindow(QMainWindow):
"""
if not self._dirty and not explicit:
return
- text = self.editor.toHtml()
+ text = self.editor.toPlainText()
try:
self.db.upsert_entry(date_iso, text)
except Exception as e:
@@ -305,46 +249,8 @@ class MainWindow(QMainWindow):
self._load_selected_date()
self._refresh_calendar_marks()
- def _restore_window_position(self):
- geom = self.settings.value("main/geometry", None)
- state = self.settings.value("main/windowState", None)
- was_max = self.settings.value("main/maximized", False, type=bool)
-
- if geom is not None:
- self.restoreGeometry(geom)
- if state is not None:
- self.restoreState(state)
- if not self._rect_on_any_screen(self.frameGeometry()):
- self._move_to_cursor_screen_center()
- else:
- # First run: place window on the screen where the mouse cursor is.
- self._move_to_cursor_screen_center()
-
- # If it was maximized, do that AFTER the window exists in the event loop.
- if was_max:
- QTimer.singleShot(0, self.showMaximized)
-
- def _rect_on_any_screen(self, rect):
- for sc in QGuiApplication.screens():
- if sc.availableGeometry().intersects(rect):
- return True
- return False
-
- def _move_to_cursor_screen_center(self):
- screen = (
- QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
- )
- r = screen.availableGeometry()
- # Center the window in that screen’s available area
- self.move(r.center() - self.rect().center())
-
- def closeEvent(self, event):
+ def closeEvent(self, event): # noqa: N802
try:
- # Save window position
- self.settings.setValue("main/geometry", self.saveGeometry())
- self.settings.setValue("main/windowState", self.saveState())
- self.settings.setValue("main/maximized", self.isMaximized())
- # Ensure we save any last pending edits to the db
self._save_current()
self.db.close()
except Exception:
diff --git a/bouquin/search.py b/bouquin/search.py
deleted file mode 100644
index 8cd2fd5..0000000
--- a/bouquin/search.py
+++ /dev/null
@@ -1,195 +0,0 @@
-from __future__ import annotations
-
-import re
-from typing import Iterable, Tuple
-
-from PySide6.QtCore import Qt, Signal
-from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
-from PySide6.QtWidgets import (
- QLabel,
- QLineEdit,
- QListWidget,
- QListWidgetItem,
- QHBoxLayout,
- QVBoxLayout,
- QWidget,
-)
-
-# type: rows are (date_iso, content)
-Row = Tuple[str, str]
-
-
-class Search(QWidget):
- """Encapsulates the search UI + logic and emits a signal when a result is chosen."""
-
- openDateRequested = Signal(str)
-
- def __init__(self, db, parent: QWidget | None = None):
- super().__init__(parent)
- self._db = db
-
- self.search = QLineEdit()
- self.search.setPlaceholderText("Search for notes here")
- self.search.textChanged.connect(self._search)
-
- self.results = QListWidget()
- self.results.setUniformItemSizes(False)
- self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
- self.results.itemClicked.connect(self._open_selected)
- self.results.hide()
-
- lay = QVBoxLayout(self)
- lay.setContentsMargins(0, 0, 0, 0)
- lay.setSpacing(6)
- lay.addWidget(self.search)
- lay.addWidget(self.results)
-
- def _open_selected(self, item: QListWidgetItem):
- date_str = item.data(Qt.ItemDataRole.UserRole)
- if date_str:
- self.openDateRequested.emit(date_str)
-
- def _search(self, text: str):
- """
- Search for the supplied text in the database.
- For all rows found, populate the results widget with a clickable preview.
- """
- q = text.strip()
- if not q:
- self.results.clear()
- self.results.hide()
- return
-
- try:
- rows: Iterable[Row] = self._db.search_entries(q)
- except Exception:
- # be quiet on DB errors here; caller can surface if desired
- rows = []
-
- self._populate_results(q, rows)
-
- def _populate_results(self, query: str, rows: Iterable[Row]):
- self.results.clear()
- rows = list(rows)
- if not rows:
- self.results.hide()
- return
-
- self.results.show()
-
- for date_str, content in rows:
- # Build an HTML fragment around the match and whether to show ellipses
- frag_html, left_ell, right_ell = self._make_html_snippet(
- content, query, radius=30, maxlen=90
- )
-
- # ---- Per-item widget: date on top, preview row below (with ellipses) ----
- container = QWidget()
- outer = QVBoxLayout(container)
- outer.setContentsMargins(8, 6, 8, 6)
- outer.setSpacing(2)
-
- # Date label (plain text)
- date_lbl = QLabel(date_str)
- date_lbl.setTextFormat(Qt.TextFormat.PlainText)
- date_f = date_lbl.font()
- date_f.setPointSizeF(date_f.pointSizeF() - 1)
- date_lbl.setFont(date_f)
- date_lbl.setStyleSheet("color:#666;")
- outer.addWidget(date_lbl)
-
- # Preview row with optional ellipses
- row = QWidget()
- h = QHBoxLayout(row)
- h.setContentsMargins(0, 0, 0, 0)
- h.setSpacing(4)
-
- if left_ell:
- left = QLabel("…")
- left.setStyleSheet("color:#888;")
- h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
-
- preview = QLabel()
- preview.setTextFormat(Qt.TextFormat.RichText)
- preview.setWordWrap(True)
- preview.setOpenExternalLinks(True)
- preview.setText(
- frag_html
- if frag_html
- else "(no preview)"
- )
- h.addWidget(preview, 1)
-
- if right_ell:
- right = QLabel("…")
- right.setStyleSheet("color:#888;")
- h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
-
- outer.addWidget(row)
-
- # ---- Add to list ----
- item = QListWidgetItem()
- item.setData(Qt.ItemDataRole.UserRole, date_str)
- item.setSizeHint(container.sizeHint())
-
- self.results.addItem(item)
- self.results.setItemWidget(item, container)
-
- # --- Snippet/highlight helpers -----------------------------------------
- def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
- doc = QTextDocument()
- doc.setHtml(html_src)
- plain = doc.toPlainText()
- if not plain:
- return "", False, False
-
- tokens = [t for t in re.split(r"\s+", query.strip()) if t]
- L = len(plain)
-
- # Find first occurrence (phrase first, then earliest token)
- idx, mlen = -1, 0
- if tokens:
- lower = plain.lower()
- phrase = " ".join(tokens).lower()
- j = lower.find(phrase)
- if j >= 0:
- idx, mlen = j, len(phrase)
- else:
- for t in tokens:
- tj = lower.find(t.lower())
- if tj >= 0 and (idx < 0 or tj < idx):
- idx, mlen = tj, len(t)
- # Compute window
- if idx < 0:
- start, end = 0, min(L, maxlen)
- else:
- start = max(0, min(idx - radius, max(0, L - maxlen)))
- end = min(L, max(idx + mlen + radius, start + maxlen))
-
- # Bold all token matches that fall inside [start, end)
- if tokens:
- lower = plain.lower()
- fmt = QTextCharFormat()
- fmt.setFontWeight(QFont.Weight.Bold)
- for t in tokens:
- t_low = t.lower()
- pos = start
- while True:
- k = lower.find(t_low, pos)
- 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
- 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
diff --git a/bouquin/settings.py b/bouquin/settings.py
index ec45094..508e12f 100644
--- a/bouquin/settings.py
+++ b/bouquin/settings.py
@@ -21,11 +21,9 @@ def get_settings() -> QSettings:
def load_db_config() -> DBConfig:
s = get_settings()
path = Path(s.value("db/path", str(default_db_path())))
- key = s.value("db/key", "")
- return DBConfig(path=path, key=key)
+ return DBConfig(path=path, key="")
def save_db_config(cfg: DBConfig) -> None:
s = get_settings()
s.setValue("db/path", str(cfg.path))
- s.setValue("db/key", str(cfg.key))
diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py
index 70ae8f6..ca2514c 100644
--- a/bouquin/settings_dialog.py
+++ b/bouquin/settings_dialog.py
@@ -3,12 +3,8 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
- QCheckBox,
QDialog,
QFormLayout,
- QFrame,
- QGroupBox,
- QLabel,
QHBoxLayout,
QVBoxLayout,
QWidget,
@@ -19,12 +15,9 @@ from PySide6.QtWidgets import (
QSizePolicy,
QMessageBox,
)
-from PySide6.QtCore import Qt, Slot
-from PySide6.QtGui import QPalette
-
from .db import DBConfig, DBManager
-from .settings import load_db_config, save_db_config
+from .settings import save_db_config
from .key_prompt import KeyPrompt
@@ -34,11 +27,10 @@ class SettingsDialog(QDialog):
self.setWindowTitle("Settings")
self._cfg = DBConfig(path=cfg.path, key="")
self._db = db
- self.key = ""
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
- self.setMinimumWidth(560)
+ self.setMinimumWidth(520)
self.setSizeGripEnabled(True)
self.path_edit = QLineEdit(str(self._cfg.path))
@@ -55,65 +47,18 @@ class SettingsDialog(QDialog):
h.setStretch(1, 0)
form.addRow("Database path", path_row)
- # Encryption settings
- enc_group = QGroupBox("Encryption")
- enc = QVBoxLayout(enc_group)
- enc.setContentsMargins(12, 8, 12, 12)
- enc.setSpacing(6)
-
- # Checkbox to remember key
- self.save_key_btn = QCheckBox("Remember key")
- current_settings = load_db_config()
- if current_settings.key:
- self.save_key_btn.setChecked(True)
- else:
- self.save_key_btn.setChecked(False)
- self.save_key_btn.setCursor(Qt.PointingHandCursor)
- self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
- enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
-
- # Explanation for remembering key
- self.save_key_label = QLabel(
- "If you don't want to be prompted for your encryption key, check this to remember it. "
- "WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
- )
- self.save_key_label.setWordWrap(True)
- self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
- # make it look secondary
- pal = self.save_key_label.palette()
- pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
- self.save_key_label.setPalette(pal)
-
- exp_row = QHBoxLayout()
- exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
- exp_row.addWidget(self.save_key_label)
- enc.addLayout(exp_row)
-
- line = QFrame()
- line.setFrameShape(QFrame.HLine)
- line.setFrameShadow(QFrame.Sunken)
- enc.addWidget(line)
-
# Change key button
self.rekey_btn = QPushButton("Change key")
- self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key)
- enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
- # Put the group into the form so it spans the full width nicely
- form.addRow(enc_group)
-
- # Buttons
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
- # Root layout (adjust margins/spacing a bit)
v = QVBoxLayout(self)
- v.setContentsMargins(12, 12, 12, 12)
- v.setSpacing(10)
v.addLayout(form)
- v.addWidget(bb, 0, Qt.AlignRight)
+ v.addWidget(self.rekey_btn)
+ v.addWidget(bb)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
@@ -126,16 +71,16 @@ class SettingsDialog(QDialog):
self.path_edit.setText(p)
def _save(self):
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
+ self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
save_db_config(self._cfg)
self.accept()
def _change_key(self):
- p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
+ p1 = KeyPrompt(self, title="Change key", message="Enter new key")
if p1.exec() != QDialog.Accepted:
return
new_key = p1.key()
- p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
if p2.exec() != QDialog.Accepted:
return
if new_key != p2.key():
@@ -146,24 +91,10 @@ class SettingsDialog(QDialog):
return
try:
self._db.rekey(new_key)
- QMessageBox.information(
- self, "Key changed", "The notebook was re-encrypted with the new key!"
- )
+ QMessageBox.information(self, "Key changed", "The database key was updated.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
- @Slot(bool)
- def save_key_btn_clicked(self, checked: bool):
- if checked:
- p1 = KeyPrompt(
- self, title="Enter your key", message="Enter the encryption key"
- )
- if p1.exec() != QDialog.Accepted:
- return
- self.key = p1.key()
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
- save_db_config(self._cfg)
-
@property
def config(self) -> DBConfig:
return self._cfg
diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py
deleted file mode 100644
index 182b527..0000000
--- a/bouquin/toolbar.py
+++ /dev/null
@@ -1,148 +0,0 @@
-from __future__ import annotations
-
-from PySide6.QtCore import Signal, Qt
-from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
-from PySide6.QtWidgets import QToolBar
-
-
-class ToolBar(QToolBar):
- boldRequested = Signal()
- italicRequested = Signal()
- underlineRequested = Signal()
- strikeRequested = Signal()
- codeRequested = Signal()
- headingRequested = Signal(int)
- bulletsRequested = Signal()
- numbersRequested = Signal()
- alignRequested = Signal(Qt.AlignmentFlag)
-
- def __init__(self, parent=None):
- super().__init__("Format", parent)
- self.setObjectName("Format")
- self.setToolButtonStyle(Qt.ToolButtonTextOnly)
- self._build_actions()
- self._apply_toolbar_styles()
-
- def _build_actions(self):
- self.actBold = QAction("Bold", self)
- self.actBold.setShortcut(QKeySequence.Bold)
- self.actBold.triggered.connect(self.boldRequested)
-
- self.actItalic = QAction("Italic", self)
- self.actItalic.setShortcut(QKeySequence.Italic)
- self.actItalic.triggered.connect(self.italicRequested)
-
- self.actUnderline = QAction("Underline", self)
- self.actUnderline.setShortcut(QKeySequence.Underline)
- self.actUnderline.triggered.connect(self.underlineRequested)
-
- self.actStrike = QAction("Strikethrough", self)
- self.actStrike.setShortcut("Ctrl+-")
- self.actStrike.triggered.connect(self.strikeRequested)
-
- self.actCode = QAction("Inline code", self)
- self.actCode.setShortcut("Ctrl+`")
- self.actCode.triggered.connect(self.codeRequested)
-
- # Headings
- self.actH1 = QAction("Heading 1", self)
- self.actH2 = QAction("Heading 2", self)
- self.actH3 = QAction("Heading 3", self)
- self.actNormal = QAction("Normal text", self)
- self.actH1.setShortcut("Ctrl+1")
- self.actH2.setShortcut("Ctrl+2")
- self.actH3.setShortcut("Ctrl+3")
- self.actNormal.setShortcut("Ctrl+N")
- self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
- self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
- self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
- self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
-
- # Lists
- self.actBullets = QAction("Bulleted list", self)
- self.actBullets.triggered.connect(self.bulletsRequested)
- self.actNumbers = QAction("Numbered list", self)
- self.actNumbers.triggered.connect(self.numbersRequested)
-
- # Alignment
- self.actAlignL = QAction("Align left", self)
- self.actAlignC = QAction("Align center", self)
- self.actAlignR = QAction("Align right", self)
- self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
- self.actAlignC.triggered.connect(
- lambda: self.alignRequested.emit(Qt.AlignHCenter)
- )
- self.actAlignR.triggered.connect(
- lambda: self.alignRequested.emit(Qt.AlignRight)
- )
-
- self.addActions(
- [
- self.actBold,
- self.actItalic,
- self.actUnderline,
- self.actStrike,
- self.actCode,
- self.actH1,
- self.actH2,
- self.actH3,
- self.actNormal,
- self.actBullets,
- self.actNumbers,
- self.actAlignL,
- self.actAlignC,
- self.actAlignR,
- ]
- )
-
- def _apply_toolbar_styles(self):
- self._style_letter_button(self.actBold, "B", bold=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)
-
- # Monospace look for code; use a fixed font
- code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
- self._style_letter_button(self.actCode, ">", custom_font=code_font)
-
- # Headings
- self._style_letter_button(self.actH1, "H1")
- self._style_letter_button(self.actH2, "H2")
- self._style_letter_button(self.actH3, "H3")
- self._style_letter_button(self.actNormal, "N")
-
- # Lists
- self._style_letter_button(self.actBullets, "•")
- 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")
-
- def _style_letter_button(
- self,
- action: QAction,
- text: str,
- *,
- bold: bool = False,
- italic: bool = False,
- underline: bool = False,
- strike: bool = False,
- custom_font: QFont | None = None,
- ):
- btn = self.widgetForAction(action)
- if not btn:
- return
- btn.setText(text)
- f = custom_font if custom_font is not None else QFont(btn.font())
- if custom_font is None:
- f.setBold(bold)
- f.setItalic(italic)
- f.setUnderline(underline)
- f.setStrikeOut(strike)
- btn.setFont(f)
-
- # Keep accessibility/tooltip readable
- btn.setToolTip(action.text())
- btn.setAccessibleName(action.text())
diff --git a/pyproject.toml b/pyproject.toml
index 0e7310f..2be1386 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,11 +1,10 @@
[tool.poetry]
name = "bouquin"
-version = "0.1.2"
+version = "0.1.1"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq "]
readme = "README.md"
license = "GPL-3.0-or-later"
-repository = "https://git.mig5.net/mig5/bouquin"
[tool.poetry.dependencies]
python = ">=3.9,<3.14"
diff --git a/screenshot.png b/screenshot.png
index 85b8e83..a0d47bf 100644
Binary files a/screenshot.png and b/screenshot.png differ
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 280a01a..5df04bc 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot):
win, *_ = patched_main_window
# Type into the editor and save
- win.editor.setHtml("Test note")
+ win.editor.setPlainText("Test note")
win._save_current(explicit=True) # call directly to avoid waiting timers
day = win._current_date_iso()
- assert "Test note" in win.db.get_entry(day)
+ assert win.db.get_entry(day) == "Test note"
def test_switch_day_saves_previous(patched_main_window, qtbot):
@@ -45,13 +45,13 @@ def test_switch_day_saves_previous(patched_main_window, qtbot):
# Write on Day 1
d1 = win.calendar.selectedDate()
d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
- win.editor.setHtml("Notes day 1")
+ win.editor.setPlainText("Notes day 1")
# Trigger a day change (this path calls _on_date_changed via signal)
d2 = d1.addDays(1)
win.calendar.setSelectedDate(d2)
# After changing, previous day should be saved; editor now shows day 2 content (empty)
- assert "Notes day 1" in win.db.get_entry(d1_iso)
+ assert win.db.get_entry(d1_iso) == "Notes day 1"
assert win.editor.toPlainText() == ""