Compare commits

...

14 commits
0.1.1 ... main

15 changed files with 830 additions and 163 deletions

View file

@ -1,3 +1,18 @@
# 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 # 0.1.1
* Add ability to change the key * Add ability to change the key

View file

@ -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. 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 increase security, the SQLCipher key is requested when the app is opened, and is not written
to disk. to disk unless the user configures it to be in the settings.
There is deliberately no network connectivity or syncing intended. There is deliberately no network connectivity or syncing intended.
@ -20,22 +20,23 @@ There is deliberately no network connectivity or syncing intended.
## Features ## Features
* Every 'page' is linked to the calendar day * Every 'page' is linked to the calendar day
* Basic markdown * Text is HTML with basic styling
* Search
* Automatic periodic saving (or explicitly save) * 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 * Transparent integrity checking of the database when it opens
* Rekey the database (change the password)
## Yet to do ## Yet to do
* Search
* Taxonomy/tagging * Taxonomy/tagging
* Export to other formats (plaintext, json, sql etc) * Export to other formats (plaintext, json, sql etc)
## How to install ## How to install
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
### From source ### From source
* Clone this repo or download the tarball from the releases page * Clone this repo or download the tarball from the releases page
@ -47,7 +48,7 @@ There is deliberately no network connectivity or syncing intended.
* Download the whl and run it * Download the whl and run it
### From PyPi ### From PyPi/pip
* `pip install bouquin` * `pip install bouquin`

View file

@ -23,7 +23,6 @@ class DBManager:
self.conn = sqlite.connect(str(self.cfg.path)) self.conn = sqlite.connect(str(self.cfg.path))
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(f"PRAGMA key = '{self.cfg.key}';") cur.execute(f"PRAGMA key = '{self.cfg.key}';")
cur.execute("PRAGMA cipher_compatibility = 4;")
cur.execute("PRAGMA journal_mode = WAL;") cur.execute("PRAGMA journal_mode = WAL;")
self.conn.commit() self.conn.commit()
try: try:
@ -100,6 +99,12 @@ class DBManager:
) )
self.conn.commit() 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]: def dates_with_content(self) -> list[str]:
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';") cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")

248
bouquin/editor.py Normal file
View file

@ -0,0 +1,248 @@
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 blocks 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)

View file

@ -1,112 +0,0 @@
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)

View file

@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
def __init__( def __init__(
self, self,
parent=None, parent=None,
title: str = "Unlock database", title: str = "Enter key",
message: str = "Enter SQLCipher key", message: str = "Enter key",
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)

View file

@ -11,5 +11,6 @@ def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG) app.setOrganizationName(APP_ORG)
win = MainWindow(); win.show() win = MainWindow()
win.show()
sys.exit(app.exec()) sys.exit(app.exec())

View file

@ -1,26 +1,34 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from PySide6.QtCore import QDate, QTimer, Qt from PySide6.QtCore import QDate, QTimer, Qt, QSettings
from PySide6.QtGui import QAction, QFont, QTextCharFormat from PySide6.QtGui import (
QAction,
QCursor,
QFont,
QGuiApplication,
QTextCharFormat,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog,
QCalendarWidget, QCalendarWidget,
QDialog,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPlainTextEdit, QSizePolicy,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QSizePolicy,
) )
from .db import DBManager from .db import DBManager
from .settings import APP_NAME, load_db_config, save_db_config from .editor import Editor
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
from .highlighter import MarkdownHighlighter from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog from .settings_dialog import SettingsDialog
from .toolbar import ToolBar
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@ -30,9 +38,18 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650) self.setMinimumSize(1000, 650)
self.cfg = load_db_config() self.cfg = load_db_config()
# Always prompt for the key (we never store it) if not os.path.exists(self.cfg.path):
if not self._prompt_for_key_until_valid(): # 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) sys.exit(1)
else:
self._try_connect()
# ---- UI: Left fixed panel (calendar) + right editor ----------------- # ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget() self.calendar = QCalendarWidget()
@ -40,17 +57,35 @@ class MainWindow(QMainWindow):
self.calendar.setGridVisible(True) self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed) 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_panel = QWidget()
left_layout = QVBoxLayout(left_panel) left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(8, 8, 8, 8) left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop) left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
left_layout.addStretch(1) left_layout.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
self.editor = QPlainTextEdit() # This is the note-taking editor
tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ") self.editor = Editor()
self.editor.setTabStopDistance(tab_w)
self.highlighter = MarkdownHighlighter(self.editor.document()) # 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)
split = QSplitter() split = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
@ -67,13 +102,13 @@ class MainWindow(QMainWindow):
# Menu bar (File) # Menu bar (File)
mb = self.menuBar() mb = self.menuBar()
file_menu = mb.addMenu("&File") file_menu = mb.addMenu("&Application")
act_save = QAction("&Save", self) act_save = QAction("&Save", self)
act_save.setShortcut("Ctrl+S") act_save.setShortcut("Ctrl+S")
act_save.triggered.connect(lambda: self._save_current(explicit=True)) act_save.triggered.connect(lambda: self._save_current(explicit=True))
file_menu.addAction(act_save) file_menu.addAction(act_save)
act_settings = QAction("S&ettings", self) act_settings = QAction("S&ettings", self)
act_save.setShortcut("Ctrl+E") act_settings.setShortcut("Ctrl+E")
act_settings.triggered.connect(self._open_settings) act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings) file_menu.addAction(act_settings)
file_menu.addSeparator() file_menu.addSeparator()
@ -82,7 +117,7 @@ class MainWindow(QMainWindow):
act_quit.triggered.connect(self.close) act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit) file_menu.addAction(act_quit)
# Navigate menu with next/previous day # Navigate menu with next/previous/today
nav_menu = mb.addMenu("&Navigate") nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self) act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P") act_prev.setShortcut("Ctrl+P")
@ -112,12 +147,18 @@ class MainWindow(QMainWindow):
self._save_timer.timeout.connect(self._save_current) self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed) self.editor.textChanged.connect(self._on_text_changed)
# First load + mark dates with content # First load + mark dates in calendar with content
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
# --- DB lifecycle # Restore window position from settings
self.settings = QSettings(APP_ORG, APP_NAME)
self._restore_window_position()
def _try_connect(self) -> bool: def _try_connect(self) -> bool:
"""
Try to connect to the database.
"""
try: try:
self.db = DBManager(self.cfg) self.db = DBManager(self.cfg)
ok = self.db.connect() ok = self.db.connect()
@ -130,17 +171,29 @@ class MainWindow(QMainWindow):
return False return False
return ok return ok
def _prompt_for_key_until_valid(self) -> bool: 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"
while True: while True:
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook") dlg = KeyPrompt(self, title, message)
if dlg.exec() != QDialog.Accepted: if dlg.exec() != QDialog.Accepted:
return False return False
self.cfg.key = dlg.key() self.cfg.key = dlg.key()
if self._try_connect(): if self._try_connect():
return True return True
# --- Calendar marks to indicate text exists for htat day -----------------
def _refresh_calendar_marks(self): 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 = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold) fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks # Clear previous marks
@ -161,7 +214,8 @@ class MainWindow(QMainWindow):
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}"
def _load_selected_date(self): def _load_selected_date(self, date_iso=False):
if not date_iso:
date_iso = self._current_date_iso() date_iso = self._current_date_iso()
try: try:
text = self.db.get_entry(date_iso) text = self.db.get_entry(date_iso)
@ -169,11 +223,13 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self.editor.blockSignals(True) self.editor.blockSignals(True)
self.editor.setPlainText(text) self.editor.setHtml(text)
self.editor.blockSignals(False) self.editor.blockSignals(False)
self._dirty = False self._dirty = False
# track which date the editor currently represents # track which date the editor currently represents
self._active_date_iso = date_iso self._active_date_iso = date_iso
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
self.calendar.setSelectedDate(qd)
def _on_text_changed(self): def _on_text_changed(self):
self._dirty = True self._dirty = True
@ -212,7 +268,7 @@ class MainWindow(QMainWindow):
""" """
if not self._dirty and not explicit: if not self._dirty and not explicit:
return return
text = self.editor.toPlainText() text = self.editor.toHtml()
try: try:
self.db.upsert_entry(date_iso, text) self.db.upsert_entry(date_iso, text)
except Exception as e: except Exception as e:
@ -249,8 +305,46 @@ class MainWindow(QMainWindow):
self._load_selected_date() self._load_selected_date()
self._refresh_calendar_marks() self._refresh_calendar_marks()
def closeEvent(self, event): # noqa: N802 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 screens available area
self.move(r.center() - self.rect().center())
def closeEvent(self, event):
try: 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._save_current()
self.db.close() self.db.close()
except Exception: except Exception:

195
bouquin/search.py Normal file
View file

@ -0,0 +1,195 @@
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 "<span style='color:#888'>(no preview)</span>"
)
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

View file

@ -21,9 +21,11 @@ def get_settings() -> QSettings:
def load_db_config() -> DBConfig: def load_db_config() -> DBConfig:
s = get_settings() s = get_settings()
path = Path(s.value("db/path", str(default_db_path()))) path = Path(s.value("db/path", str(default_db_path())))
return DBConfig(path=path, key="") key = s.value("db/key", "")
return DBConfig(path=path, key=key)
def save_db_config(cfg: DBConfig) -> None: def save_db_config(cfg: DBConfig) -> None:
s = get_settings() s = get_settings()
s.setValue("db/path", str(cfg.path)) s.setValue("db/path", str(cfg.path))
s.setValue("db/key", str(cfg.key))

View file

@ -3,8 +3,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QDialog, QDialog,
QFormLayout, QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout, QHBoxLayout,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -15,9 +19,12 @@ from PySide6.QtWidgets import (
QSizePolicy, QSizePolicy,
QMessageBox, QMessageBox,
) )
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPalette
from .db import DBConfig, DBManager from .db import DBConfig, DBManager
from .settings import save_db_config from .settings import load_db_config, save_db_config
from .key_prompt import KeyPrompt from .key_prompt import KeyPrompt
@ -27,10 +34,11 @@ class SettingsDialog(QDialog):
self.setWindowTitle("Settings") self.setWindowTitle("Settings")
self._cfg = DBConfig(path=cfg.path, key="") self._cfg = DBConfig(path=cfg.path, key="")
self._db = db self._db = db
self.key = ""
form = QFormLayout() form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(520) self.setMinimumWidth(560)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
self.path_edit = QLineEdit(str(self._cfg.path)) self.path_edit = QLineEdit(str(self._cfg.path))
@ -47,18 +55,65 @@ class SettingsDialog(QDialog):
h.setStretch(1, 0) h.setStretch(1, 0)
form.addRow("Database path", path_row) 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 # Change key button
self.rekey_btn = QPushButton("Change key") self.rekey_btn = QPushButton("Change key")
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.rekey_btn.clicked.connect(self._change_key) 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 = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save) bb.accepted.connect(self._save)
bb.rejected.connect(self.reject) bb.rejected.connect(self.reject)
# Root layout (adjust margins/spacing a bit)
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.setContentsMargins(12, 12, 12, 12)
v.setSpacing(10)
v.addLayout(form) v.addLayout(form)
v.addWidget(self.rekey_btn) v.addWidget(bb, 0, Qt.AlignRight)
v.addWidget(bb)
def _browse(self): def _browse(self):
p, _ = QFileDialog.getSaveFileName( p, _ = QFileDialog.getSaveFileName(
@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
self.path_edit.setText(p) self.path_edit.setText(p)
def _save(self): def _save(self):
self._cfg = DBConfig(path=Path(self.path_edit.text()), key="") self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
save_db_config(self._cfg) save_db_config(self._cfg)
self.accept() self.accept()
def _change_key(self): def _change_key(self):
p1 = KeyPrompt(self, title="Change key", message="Enter new key") p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
if p1.exec() != QDialog.Accepted: if p1.exec() != QDialog.Accepted:
return return
new_key = p1.key() new_key = p1.key()
p2 = KeyPrompt(self, title="Change key", message="Re-enter new key") p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
if p2.exec() != QDialog.Accepted: if p2.exec() != QDialog.Accepted:
return return
if new_key != p2.key(): if new_key != p2.key():
@ -91,10 +146,24 @@ class SettingsDialog(QDialog):
return return
try: try:
self._db.rekey(new_key) self._db.rekey(new_key)
QMessageBox.information(self, "Key changed", "The database key was updated.") QMessageBox.information(
self, "Key changed", "The notebook was re-encrypted with the new key!"
)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{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 @property
def config(self) -> DBConfig: def config(self) -> DBConfig:
return self._cfg return self._cfg

148
bouquin/toolbar.py Normal file
View file

@ -0,0 +1,148 @@
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())

View file

@ -1,10 +1,11 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.1.1" version = "0.1.2"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://git.mig5.net/mig5/bouquin"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.9,<3.14" python = ">=3.9,<3.14"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before After
Before After

View file

@ -30,11 +30,11 @@ def test_manual_save_current_day(patched_main_window, qtbot):
win, *_ = patched_main_window win, *_ = patched_main_window
# Type into the editor and save # Type into the editor and save
win.editor.setPlainText("Test note") win.editor.setHtml("Test note")
win._save_current(explicit=True) # call directly to avoid waiting timers win._save_current(explicit=True) # call directly to avoid waiting timers
day = win._current_date_iso() day = win._current_date_iso()
assert win.db.get_entry(day) == "Test note" assert "Test note" in win.db.get_entry(day)
def test_switch_day_saves_previous(patched_main_window, qtbot): 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 # Write on Day 1
d1 = win.calendar.selectedDate() d1 = win.calendar.selectedDate()
d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}" d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}"
win.editor.setPlainText("Notes day 1") win.editor.setHtml("Notes day 1")
# Trigger a day change (this path calls _on_date_changed via signal) # Trigger a day change (this path calls _on_date_changed via signal)
d2 = d1.addDays(1) d2 = d1.addDays(1)
win.calendar.setSelectedDate(d2) win.calendar.setSelectedDate(d2)
# After changing, previous day should be saved; editor now shows day 2 content (empty) # After changing, previous day should be saved; editor now shows day 2 content (empty)
assert win.db.get_entry(d1_iso) == "Notes day 1" assert "Notes day 1" in win.db.get_entry(d1_iso)
assert win.editor.toPlainText() == "" assert win.editor.toPlainText() == ""