Initial commit

This commit is contained in:
Miguel Jacq 2025-10-31 16:00:54 +11:00
commit 3e6a08231c
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
17 changed files with 2054 additions and 0 deletions

1
bouquin/__init__.py Normal file
View file

@ -0,0 +1 @@
from .main import main

4
bouquin/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from .main import main
if __name__ == "__main__":
main()

92
bouquin/db.py Normal file
View file

@ -0,0 +1,92 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from sqlcipher3 import dbapi2 as sqlite
@dataclass
class DBConfig:
path: Path
key: str
class DBManager:
def __init__(self, cfg: DBConfig):
self.cfg = cfg
self.conn: sqlite.Connection | None = None
def connect(self) -> bool:
# Ensure parent dir exists
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
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:
self._integrity_ok()
except Exception:
self.conn.close()
self.conn = None
return False
self._ensure_schema()
return True
def _integrity_ok(self) -> bool:
cur = self.conn.cursor()
cur.execute("PRAGMA cipher_integrity_check;")
rows = cur.fetchall()
# OK
if not rows:
return
# Not OK
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
raise sqlite.IntegrityError(
"SQLCipher integrity check failed"
+ (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
)
def _ensure_schema(self) -> None:
cur = self.conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
content TEXT NOT NULL
);
"""
)
cur.execute("PRAGMA user_version = 1;")
self.conn.commit()
def get_entry(self, date_iso: str) -> str:
cur = self.conn.cursor()
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
row = cur.fetchone()
return row[0] if row else ""
def upsert_entry(self, date_iso: str, content: str) -> None:
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO entries(date, content) VALUES(?, ?)
ON CONFLICT(date) DO UPDATE SET content = excluded.content;
""",
(date_iso, content),
)
self.conn.commit()
def dates_with_content(self) -> list[str]:
cur = self.conn.cursor()
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
return [r[0] for r in cur.fetchall()]
def close(self) -> None:
if self.conn is not None:
self.conn.close()
self.conn = None

112
bouquin/highlighter.py Normal file
View file

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

41
bouquin/key_prompt.py Normal file
View file

@ -0,0 +1,41 @@
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QDialogButtonBox,
)
class KeyPrompt(QDialog):
def __init__(
self,
parent=None,
title: str = "Unlock database",
message: str = "Enter SQLCipher key",
):
super().__init__(parent)
self.setWindowTitle(title)
v = QVBoxLayout(self)
v.addWidget(QLabel(message))
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.Password)
v.addWidget(self.edit)
toggle = QPushButton("Show")
toggle.setCheckable(True)
toggle.toggled.connect(
lambda c: self.edit.setEchoMode(
QLineEdit.Normal if c else QLineEdit.Password
)
)
v.addWidget(toggle)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
v.addWidget(bb)
def key(self) -> str:
return self.edit.text()

15
bouquin/main.py Normal file
View file

@ -0,0 +1,15 @@
from __future__ import annotations
import sys
from PySide6.QtWidgets import QApplication
from .settings import APP_NAME, APP_ORG
from .main_window import MainWindow
def main():
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(APP_ORG)
win = MainWindow(); win.show()
sys.exit(app.exec())

245
bouquin/main_window.py Normal file
View file

@ -0,0 +1,245 @@
from __future__ import annotations
import sys
from PySide6.QtCore import QDate, QTimer, Qt
from PySide6.QtGui import QAction, QFont, QTextCharFormat
from PySide6.QtWidgets import (
QDialog,
QCalendarWidget,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QSplitter,
QVBoxLayout,
QWidget,
QSizePolicy,
)
from .db import DBManager
from .settings import APP_NAME, load_db_config, save_db_config
from .key_prompt import KeyPrompt
from .highlighter import MarkdownHighlighter
from .settings_dialog import SettingsDialog
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
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)
# ---- UI: Left fixed panel (calendar) + right editor -----------------
self.calendar = QCalendarWidget()
self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed)
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.addStretch(1)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
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)
split.addWidget(self.editor)
split.setStretchFactor(1, 1) # editor grows
container = QWidget()
lay = QVBoxLayout(container)
lay.addWidget(split)
self.setCentralWidget(container)
# Status bar for feedback
self.statusBar().showMessage("Ready", 800)
# Menu bar (File)
mb = self.menuBar()
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("&Settings", self)
act_settings.triggered.connect(self._open_settings)
file_menu.addAction(act_settings)
file_menu.addSeparator()
act_quit = QAction("&Quit", self)
act_quit.setShortcut("Ctrl+Q")
act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit)
# Navigate menu with next/previous day
nav_menu = mb.addMenu("&Navigate")
act_prev = QAction("Previous Day", self)
act_prev.setShortcut("Ctrl+P")
act_prev.setShortcutContext(Qt.ApplicationShortcut)
act_prev.triggered.connect(lambda: self._adjust_day(-1))
nav_menu.addAction(act_prev)
self.addAction(act_prev)
act_next = QAction("Next Day", self)
act_next.setShortcut("Ctrl+N")
act_next.setShortcutContext(Qt.ApplicationShortcut)
act_next.triggered.connect(lambda: self._adjust_day(1))
nav_menu.addAction(act_next)
self.addAction(act_next)
# Autosave
self._dirty = False
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.timeout.connect(self._save_current)
self.editor.textChanged.connect(self._on_text_changed)
# First load + mark dates with content
self._load_selected_date()
self._refresh_calendar_marks()
# --- DB lifecycle
def _try_connect(self) -> bool:
try:
self.db = DBManager(self.cfg)
ok = self.db.connect()
except Exception as e:
if str(e) == "file is not a database":
error = "The key is probably incorrect."
else:
error = str(e)
QMessageBox.critical(self, "Database Error", error)
return False
return ok
def _prompt_for_key_until_valid(self) -> bool:
while True:
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):
fmt_bold = QTextCharFormat()
fmt_bold.setFontWeight(QFont.Weight.Bold)
# Clear previous marks
for d in getattr(self, "_marked_dates", set()):
self.calendar.setDateTextFormat(d, QTextCharFormat())
self._marked_dates = set()
try:
for date_iso in self.db.dates_with_content():
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
if qd.isValid():
self.calendar.setDateTextFormat(qd, fmt_bold)
self._marked_dates.add(qd)
except Exception:
pass
# --- UI handlers ---------------------------------------------------------
def _current_date_iso(self) -> str:
d = self.calendar.selectedDate()
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
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.setPlainText(text)
self.editor.blockSignals(False)
self._dirty = False
# track which date the editor currently represents
self._active_date_iso = date_iso
def _on_text_changed(self):
self._dirty = True
self._save_timer.start(1200) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""
d = self.calendar.selectedDate().addDays(delta)
self.calendar.setSelectedDate(d)
def _on_date_changed(self):
"""
When the calendar selection changes, save the previous day's note if dirty,
so we don't lose that text, then load the newly selected day.
"""
# Stop pending autosave and persist current buffer if needed
try:
self._save_timer.stop()
except Exception:
pass
prev = getattr(self, "_active_date_iso", None)
if prev and self._dirty:
self._save_date(prev, explicit=False)
# Now load the newly selected date
self._load_selected_date()
def _save_date(self, date_iso: str, explicit: bool = False):
"""
Save editor contents into the given date. Shows status on success.
explicit=True means user invoked Save: show feedback even if nothing changed.
"""
if not self._dirty and not explicit:
return
text = self.editor.toPlainText()
try:
self.db.upsert_entry(date_iso, text)
except Exception as e:
QMessageBox.critical(self, "Save Error", str(e))
return
self._dirty = False
self._refresh_calendar_marks()
# Feedback in the status bar
from datetime import datetime as _dt
self.statusBar().showMessage(
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
)
def _save_current(self, explicit: bool = False):
# Delegate to _save_date for the currently selected date
self._save_date(self._current_date_iso(), explicit)
def _open_settings(self):
dlg = SettingsDialog(self.cfg, self)
if dlg.exec() == QDialog.Accepted:
new_cfg = dlg.config
if new_cfg.path != self.cfg.path:
# Save the new path to the notebook
self.cfg.path = new_cfg.path
save_db_config(self.cfg)
self.db.close()
# Prompt again for the key for the new path
if not self._prompt_for_key_until_valid():
QMessageBox.warning(
self, "Reopen failed", "Could not unlock database at new path."
)
return
self._load_selected_date()
self._refresh_calendar_marks()
def closeEvent(self, event): # noqa: N802
try:
self._save_current()
self.db.close()
except Exception:
pass
super().closeEvent(event)

29
bouquin/settings.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QSettings, QStandardPaths
from .db import DBConfig
APP_ORG = "Bouquin"
APP_NAME = "Bouquin"
def default_db_path() -> Path:
base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
return base / "notebook.db"
def get_settings() -> QSettings:
return QSettings(APP_ORG, APP_NAME)
def load_db_config() -> DBConfig:
s = get_settings()
path = Path(s.value("db/path", str(default_db_path())))
return DBConfig(path=path, key="")
def save_db_config(cfg: DBConfig) -> None:
s = get_settings()
s.setValue("db/path", str(cfg.path))

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
from PySide6.QtWidgets import (
QDialog,
QFormLayout,
QHBoxLayout,
QVBoxLayout,
QWidget,
QLineEdit,
QPushButton,
QFileDialog,
QDialogButtonBox,
QSizePolicy,
)
from .db import DBConfig
from .settings import save_db_config
class SettingsDialog(QDialog):
def __init__(self, cfg: DBConfig, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self._cfg = DBConfig(path=cfg.path, key="")
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.setMinimumWidth(520)
self.setSizeGripEnabled(True)
self.path_edit = QLineEdit(str(self._cfg.path))
self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
browse_btn = QPushButton("Browse…")
browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
browse_btn.clicked.connect(self._browse)
path_row = QWidget()
h = QHBoxLayout(path_row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(self.path_edit, 1)
h.addWidget(browse_btn, 0)
h.setStretch(0, 1)
h.setStretch(1, 0)
form.addRow("Database path", path_row)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self._save)
bb.rejected.connect(self.reject)
v = QVBoxLayout(self)
v.addLayout(form)
v.addWidget(bb)
def _browse(self):
p, _ = QFileDialog.getSaveFileName(
self,
"Choose database file",
self.path_edit.text(),
"DB Files (*.db);;All Files (*)",
)
if p:
self.path_edit.setText(p)
def _save(self):
self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
save_db_config(self._cfg)
self.accept()
@property
def config(self) -> DBConfig:
return self._cfg