Compare commits

..

8 commits

10 changed files with 406 additions and 87 deletions

View file

@ -1,3 +1,12 @@
# 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

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.
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.
@ -35,6 +35,8 @@ There is deliberately no network connectivity or syncing intended.
## 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

View file

@ -23,7 +23,6 @@ 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:

View file

@ -2,22 +2,144 @@ from __future__ import annotations
from PySide6.QtGui import (
QColor,
QDesktopServices,
QFont,
QFontDatabase,
QTextCharFormat,
QTextCursor,
QTextListFormat,
QTextBlockFormat,
)
from PySide6.QtCore import Slot
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
from PySide6.QtWidgets import QTextEdit
class Editor(QTextEdit):
def __init__(self):
super().__init__()
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.
@ -28,9 +150,15 @@ class Editor(QTextEdit):
cursor.mergeCharFormat(fmt)
self.mergeCurrentCharFormat(fmt)
@Slot(QFont.Weight)
def apply_weight(self, weight):
@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)

View file

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

View file

@ -1,11 +1,14 @@
from __future__ import annotations
import os
import sys
from PySide6.QtCore import QDate, QTimer, Qt
from PySide6.QtCore import QDate, QTimer, Qt, QSettings
from PySide6.QtGui import (
QAction,
QCursor,
QFont,
QGuiApplication,
QTextCharFormat,
)
from PySide6.QtWidgets import (
@ -23,7 +26,7 @@ from .db import DBManager
from .editor import Editor
from .key_prompt import KeyPrompt
from .search import Search
from .settings import APP_NAME, load_db_config, save_db_config
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .toolbar import ToolBar
@ -35,9 +38,18 @@ class MainWindow(QMainWindow):
self.setMinimumSize(1000, 650)
self.cfg = load_db_config()
# Always prompt for the key (we never store it)
if not self._prompt_for_key_until_valid():
sys.exit(1)
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()
# ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget()
@ -139,6 +151,10 @@ class MainWindow(QMainWindow):
self._load_selected_date()
self._refresh_calendar_marks()
# Restore window position from settings
self.settings = QSettings(APP_ORG, APP_NAME)
self._restore_window_position()
def _try_connect(self) -> bool:
"""
Try to connect to the database.
@ -155,12 +171,18 @@ class MainWindow(QMainWindow):
return False
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:
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
dlg = KeyPrompt(self, title, message)
if dlg.exec() != QDialog.Accepted:
return False
self.cfg.key = dlg.key()
@ -206,6 +228,8 @@ class MainWindow(QMainWindow):
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
@ -281,8 +305,46 @@ 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 screens available area
self.move(r.center() - self.rect().center())
def closeEvent(self, event):
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:

View file

@ -80,7 +80,7 @@ class Search(QWidget):
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=60, maxlen=180
content, query, radius=30, maxlen=90
)
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
@ -112,7 +112,7 @@ class Search(QWidget):
preview = QLabel()
preview.setTextFormat(Qt.TextFormat.RichText)
preview.setWordWrap(True)
preview.setOpenExternalLinks(True) # keep links in your HTML clickable
preview.setOpenExternalLinks(True)
preview.setText(
frag_html
if frag_html

View file

@ -21,9 +21,11 @@ def get_settings() -> QSettings:
def load_db_config() -> DBConfig:
s = get_settings()
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:
s = get_settings()
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 PySide6.QtWidgets import (
QCheckBox,
QDialog,
QFormLayout,
QFrame,
QGroupBox,
QLabel,
QHBoxLayout,
QVBoxLayout,
QWidget,
@ -15,9 +19,12 @@ 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 save_db_config
from .settings import load_db_config, save_db_config
from .key_prompt import KeyPrompt
@ -27,10 +34,11 @@ 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(520)
self.setMinimumWidth(560)
self.setSizeGripEnabled(True)
self.path_edit = QLineEdit(str(self._cfg.path))
@ -47,18 +55,65 @@ 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(self.rekey_btn)
v.addWidget(bb)
v.addWidget(bb, 0, Qt.AlignRight)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
self.path_edit.setText(p)
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)
self.accept()
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:
return
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:
return
if new_key != p2.key():
@ -92,11 +147,23 @@ class SettingsDialog(QDialog):
try:
self._db.rekey(new_key)
QMessageBox.information(
self, "Key changed", "The database key was updated."
self, "Key changed", "The notebook was re-encrypted with the new key!"
)
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

View file

@ -1,12 +1,12 @@
from __future__ import annotations
from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QFont, QAction
from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
from PySide6.QtWidgets import QToolBar
class ToolBar(QToolBar):
boldRequested = Signal(QFont.Weight)
boldRequested = Signal()
italicRequested = Signal()
underlineRequested = Signal()
strikeRequested = Signal()
@ -18,81 +18,131 @@ class ToolBar(QToolBar):
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):
# Bold
bold = QAction("Bold", self)
bold.setShortcut("Ctrl+B")
bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
self.actBold = QAction("Bold", self)
self.actBold.setShortcut(QKeySequence.Bold)
self.actBold.triggered.connect(self.boldRequested)
italic = QAction("Italic", self)
italic.setShortcut("Ctrl+I")
italic.triggered.connect(self.italicRequested)
self.actItalic = QAction("Italic", self)
self.actItalic.setShortcut(QKeySequence.Italic)
self.actItalic.triggered.connect(self.italicRequested)
underline = QAction("Underline", self)
underline.setShortcut("Ctrl+U")
underline.triggered.connect(self.underlineRequested)
self.actUnderline = QAction("Underline", self)
self.actUnderline.setShortcut(QKeySequence.Underline)
self.actUnderline.triggered.connect(self.underlineRequested)
strike = QAction("Strikethrough", self)
strike.setShortcut("Ctrl+-")
strike.triggered.connect(self.strikeRequested)
self.actStrike = QAction("Strikethrough", self)
self.actStrike.setShortcut("Ctrl+-")
self.actStrike.triggered.connect(self.strikeRequested)
code = QAction("<code>", self)
code.setShortcut("Ctrl+`")
code.triggered.connect(self.codeRequested)
self.actCode = QAction("Inline code", self)
self.actCode.setShortcut("Ctrl+`")
self.actCode.triggered.connect(self.codeRequested)
# Headings
h1 = QAction("H1", self)
h1.setShortcut("Ctrl+1")
h2 = QAction("H2", self)
h2.setShortcut("Ctrl+2")
h3 = QAction("H3", self)
h3.setShortcut("Ctrl+3")
normal = QAction("Normal", self)
normal.setShortcut("Ctrl+P")
h1.triggered.connect(lambda: self.headingRequested.emit(24))
h2.triggered.connect(lambda: self.headingRequested.emit(18))
h3.triggered.connect(lambda: self.headingRequested.emit(14))
normal.triggered.connect(lambda: self.headingRequested.emit(0))
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
bullets = QAction("• Bullets", self)
bullets.triggered.connect(self.bulletsRequested)
numbers = QAction("1. Numbered", self)
numbers.triggered.connect(self.numbersRequested)
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
left = QAction("Align Left", self)
center = QAction("Align Center", self)
right = QAction("Align Right", self)
left.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
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)
)
center.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
)
right.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
self.actAlignR.triggered.connect(
lambda: self.alignRequested.emit(Qt.AlignRight)
)
self.addActions(
[
bold,
italic,
underline,
strike,
code,
h1,
h2,
h3,
normal,
bullets,
numbers,
left,
center,
right,
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())