bouquin 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bouquin might be problematic. Click here for more details.

@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
+ Home-page: https://git.mig5.net/mig5/bouquin
5
6
  License: GPL-3.0-or-later
6
7
  Author: Miguel Jacq
7
8
  Author-email: mig@mig5.net
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
16
17
  Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
18
+ Project-URL: Repository, https://git.mig5.net/mig5/bouquin
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  # Bouquin
@@ -38,18 +40,16 @@ There is deliberately no network connectivity or syncing intended.
38
40
  ## Features
39
41
 
40
42
  * Every 'page' is linked to the calendar day
41
- * Basic markdown
43
+ * Text is HTML with basic styling
44
+ * Search
42
45
  * Automatic periodic saving (or explicitly save)
43
- * Navigating from one day to the next automatically saves
44
- * Basic keyboard shortcuts
45
46
  * Transparent integrity checking of the database when it opens
47
+ * Rekey the database (change the password)
46
48
 
47
49
 
48
50
  ## Yet to do
49
51
 
50
- * Search
51
52
  * Taxonomy/tagging
52
- * Ability to change the SQLCipher key
53
53
  * Export to other formats (plaintext, json, sql etc)
54
54
 
55
55
 
@@ -66,7 +66,7 @@ There is deliberately no network connectivity or syncing intended.
66
66
 
67
67
  * Download the whl and run it
68
68
 
69
- ### From PyPi
69
+ ### From PyPi/pip
70
70
 
71
71
  * `pip install bouquin`
72
72
 
@@ -20,18 +20,16 @@ There is deliberately no network connectivity or syncing intended.
20
20
  ## Features
21
21
 
22
22
  * Every 'page' is linked to the calendar day
23
- * Basic markdown
23
+ * Text is HTML with basic styling
24
+ * Search
24
25
  * Automatic periodic saving (or explicitly save)
25
- * Navigating from one day to the next automatically saves
26
- * Basic keyboard shortcuts
27
26
  * Transparent integrity checking of the database when it opens
27
+ * Rekey the database (change the password)
28
28
 
29
29
 
30
30
  ## Yet to do
31
31
 
32
- * Search
33
32
  * Taxonomy/tagging
34
- * Ability to change the SQLCipher key
35
33
  * Export to other formats (plaintext, json, sql etc)
36
34
 
37
35
 
@@ -48,7 +46,7 @@ There is deliberately no network connectivity or syncing intended.
48
46
 
49
47
  * Download the whl and run it
50
48
 
51
- ### From PyPi
49
+ ### From PyPi/pip
52
50
 
53
51
  * `pip install bouquin`
54
52
 
@@ -64,6 +64,25 @@ class DBManager:
64
64
  cur.execute("PRAGMA user_version = 1;")
65
65
  self.conn.commit()
66
66
 
67
+ def rekey(self, new_key: str) -> None:
68
+ """
69
+ Change the SQLCipher passphrase in-place, then reopen the connection
70
+ with the new key to verify.
71
+ """
72
+ if self.conn is None:
73
+ raise RuntimeError("Database is not connected")
74
+ cur = self.conn.cursor()
75
+ # Change the encryption key of the currently open database
76
+ cur.execute(f"PRAGMA rekey = '{new_key}';")
77
+ self.conn.commit()
78
+
79
+ # Close and reopen with the new key to verify and restore PRAGMAs
80
+ self.conn.close()
81
+ self.conn = None
82
+ self.cfg.key = new_key
83
+ if not self.connect():
84
+ raise sqlite.Error("Re-open failed after rekey")
85
+
67
86
  def get_entry(self, date_iso: str) -> str:
68
87
  cur = self.conn.cursor()
69
88
  cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
@@ -81,6 +100,12 @@ class DBManager:
81
100
  )
82
101
  self.conn.commit()
83
102
 
103
+ def search_entries(self, text: str) -> list[str]:
104
+ cur = self.conn.cursor()
105
+ pattern = f"%{text}%"
106
+ cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
107
+ return [r for r in cur.fetchall()]
108
+
84
109
  def dates_with_content(self) -> list[str]:
85
110
  cur = self.conn.cursor()
86
111
  cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtGui import (
4
+ QColor,
5
+ QFont,
6
+ QFontDatabase,
7
+ QTextCharFormat,
8
+ QTextListFormat,
9
+ QTextBlockFormat,
10
+ )
11
+ from PySide6.QtCore import Slot
12
+ from PySide6.QtWidgets import QTextEdit
13
+
14
+
15
+ class Editor(QTextEdit):
16
+ def __init__(self):
17
+ super().__init__()
18
+ tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
19
+ self.setTabStopDistance(tab_w)
20
+
21
+ def merge_on_sel(self, fmt):
22
+ """
23
+ Sets the styling on the selected characters.
24
+ """
25
+ cursor = self.textCursor()
26
+ if not cursor.hasSelection():
27
+ cursor.select(cursor.SelectionType.WordUnderCursor)
28
+ cursor.mergeCharFormat(fmt)
29
+ self.mergeCurrentCharFormat(fmt)
30
+
31
+ @Slot(QFont.Weight)
32
+ def apply_weight(self, weight):
33
+ fmt = QTextCharFormat()
34
+ fmt.setFontWeight(weight)
35
+ self.merge_on_sel(fmt)
36
+
37
+ @Slot()
38
+ def apply_italic(self):
39
+ cur = self.currentCharFormat()
40
+ fmt = QTextCharFormat()
41
+ fmt.setFontItalic(not cur.fontItalic())
42
+ self.merge_on_sel(fmt)
43
+
44
+ @Slot()
45
+ def apply_underline(self):
46
+ cur = self.currentCharFormat()
47
+ fmt = QTextCharFormat()
48
+ fmt.setFontUnderline(not cur.fontUnderline())
49
+ self.merge_on_sel(fmt)
50
+
51
+ @Slot()
52
+ def apply_strikethrough(self):
53
+ cur = self.currentCharFormat()
54
+ fmt = QTextCharFormat()
55
+ fmt.setFontStrikeOut(not cur.fontStrikeOut())
56
+ self.merge_on_sel(fmt)
57
+
58
+ @Slot()
59
+ def apply_code(self):
60
+ c = self.textCursor()
61
+ if not c.hasSelection():
62
+ c.select(c.SelectionType.BlockUnderCursor)
63
+
64
+ bf = QTextBlockFormat()
65
+ bf.setLeftMargin(12)
66
+ bf.setRightMargin(12)
67
+ bf.setTopMargin(6)
68
+ bf.setBottomMargin(6)
69
+ bf.setBackground(QColor(245, 245, 245))
70
+ bf.setNonBreakableLines(True)
71
+
72
+ cf = QTextCharFormat()
73
+ mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
74
+ cf.setFont(mono)
75
+ cf.setFontFixedPitch(True)
76
+
77
+ # If the current block already looks like a code block, remove styling
78
+ cur_bf = c.blockFormat()
79
+ is_code = (
80
+ cur_bf.nonBreakableLines()
81
+ and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
82
+ )
83
+ if is_code:
84
+ # clear: margins/background/wrapping
85
+ bf = QTextBlockFormat()
86
+ cf = QTextCharFormat()
87
+
88
+ c.mergeBlockFormat(bf)
89
+ c.mergeBlockCharFormat(cf)
90
+
91
+ @Slot(int)
92
+ def apply_heading(self, size):
93
+ fmt = QTextCharFormat()
94
+ if size:
95
+ fmt.setFontWeight(QFont.Weight.Bold)
96
+ fmt.setFontPointSize(size)
97
+ else:
98
+ fmt.setFontWeight(QFont.Weight.Normal)
99
+ fmt.setFontPointSize(self.font().pointSizeF())
100
+ self.merge_on_sel(fmt)
101
+
102
+ def toggle_bullets(self):
103
+ c = self.textCursor()
104
+ lst = c.currentList()
105
+ if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
106
+ lst.remove(c.block())
107
+ return
108
+ fmt = QTextListFormat()
109
+ fmt.setStyle(QTextListFormat.Style.ListDisc)
110
+ c.createList(fmt)
111
+
112
+ def toggle_numbers(self):
113
+ c = self.textCursor()
114
+ lst = c.currentList()
115
+ if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
116
+ lst.remove(c.block())
117
+ return
118
+ fmt = QTextListFormat()
119
+ fmt.setStyle(QTextListFormat.Style.ListDecimal)
120
+ c.createList(fmt)
@@ -11,5 +11,6 @@ def main():
11
11
  app = QApplication(sys.argv)
12
12
  app.setApplicationName(APP_NAME)
13
13
  app.setOrganizationName(APP_ORG)
14
- win = MainWindow(); win.show()
14
+ win = MainWindow()
15
+ win.show()
15
16
  sys.exit(app.exec())
@@ -3,24 +3,29 @@ from __future__ import annotations
3
3
  import sys
4
4
 
5
5
  from PySide6.QtCore import QDate, QTimer, Qt
6
- from PySide6.QtGui import QAction, QFont, QTextCharFormat
6
+ from PySide6.QtGui import (
7
+ QAction,
8
+ QFont,
9
+ QTextCharFormat,
10
+ )
7
11
  from PySide6.QtWidgets import (
8
- QDialog,
9
12
  QCalendarWidget,
13
+ QDialog,
10
14
  QMainWindow,
11
15
  QMessageBox,
12
- QPlainTextEdit,
16
+ QSizePolicy,
13
17
  QSplitter,
14
18
  QVBoxLayout,
15
19
  QWidget,
16
- QSizePolicy,
17
20
  )
18
21
 
19
22
  from .db import DBManager
20
- from .settings import APP_NAME, load_db_config, save_db_config
23
+ from .editor import Editor
21
24
  from .key_prompt import KeyPrompt
22
- from .highlighter import MarkdownHighlighter
25
+ from .search import Search
26
+ from .settings import APP_NAME, load_db_config, save_db_config
23
27
  from .settings_dialog import SettingsDialog
28
+ from .toolbar import ToolBar
24
29
 
25
30
 
26
31
  class MainWindow(QMainWindow):
@@ -40,17 +45,35 @@ class MainWindow(QMainWindow):
40
45
  self.calendar.setGridVisible(True)
41
46
  self.calendar.selectionChanged.connect(self._on_date_changed)
42
47
 
48
+ self.search = Search(self.db)
49
+ self.search.openDateRequested.connect(self._load_selected_date)
50
+
51
+ # Lock the calendar to the left panel at the top to stop it stretching
52
+ # when the main window is resized.
43
53
  left_panel = QWidget()
44
54
  left_layout = QVBoxLayout(left_panel)
45
55
  left_layout.setContentsMargins(8, 8, 8, 8)
46
56
  left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
57
+ left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
47
58
  left_layout.addStretch(1)
48
59
  left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
49
60
 
50
- self.editor = QPlainTextEdit()
51
- tab_w = 4 * self.editor.fontMetrics().horizontalAdvance(" ")
52
- self.editor.setTabStopDistance(tab_w)
53
- self.highlighter = MarkdownHighlighter(self.editor.document())
61
+ # This is the note-taking editor
62
+ self.editor = Editor()
63
+
64
+ # Toolbar for controlling styling
65
+ tb = ToolBar()
66
+ self.addToolBar(tb)
67
+ # Wire toolbar intents to editor methods
68
+ tb.boldRequested.connect(self.editor.apply_weight)
69
+ tb.italicRequested.connect(self.editor.apply_italic)
70
+ tb.underlineRequested.connect(self.editor.apply_underline)
71
+ tb.strikeRequested.connect(self.editor.apply_strikethrough)
72
+ tb.codeRequested.connect(self.editor.apply_code)
73
+ tb.headingRequested.connect(self.editor.apply_heading)
74
+ tb.bulletsRequested.connect(self.editor.toggle_bullets)
75
+ tb.numbersRequested.connect(self.editor.toggle_numbers)
76
+ tb.alignRequested.connect(self.editor.setAlignment)
54
77
 
55
78
  split = QSplitter()
56
79
  split.addWidget(left_panel)
@@ -67,12 +90,13 @@ class MainWindow(QMainWindow):
67
90
 
68
91
  # Menu bar (File)
69
92
  mb = self.menuBar()
70
- file_menu = mb.addMenu("&File")
93
+ file_menu = mb.addMenu("&Application")
71
94
  act_save = QAction("&Save", self)
72
95
  act_save.setShortcut("Ctrl+S")
73
96
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
74
97
  file_menu.addAction(act_save)
75
- act_settings = QAction("&Settings", self)
98
+ act_settings = QAction("S&ettings", self)
99
+ act_settings.setShortcut("Ctrl+E")
76
100
  act_settings.triggered.connect(self._open_settings)
77
101
  file_menu.addAction(act_settings)
78
102
  file_menu.addSeparator()
@@ -81,7 +105,7 @@ class MainWindow(QMainWindow):
81
105
  act_quit.triggered.connect(self.close)
82
106
  file_menu.addAction(act_quit)
83
107
 
84
- # Navigate menu with next/previous day
108
+ # Navigate menu with next/previous/today
85
109
  nav_menu = mb.addMenu("&Navigate")
86
110
  act_prev = QAction("Previous Day", self)
87
111
  act_prev.setShortcut("Ctrl+P")
@@ -97,6 +121,13 @@ class MainWindow(QMainWindow):
97
121
  nav_menu.addAction(act_next)
98
122
  self.addAction(act_next)
99
123
 
124
+ act_today = QAction("Today", self)
125
+ act_today.setShortcut("Ctrl+T")
126
+ act_today.setShortcutContext(Qt.ApplicationShortcut)
127
+ act_today.triggered.connect(self._adjust_today)
128
+ nav_menu.addAction(act_today)
129
+ self.addAction(act_today)
130
+
100
131
  # Autosave
101
132
  self._dirty = False
102
133
  self._save_timer = QTimer(self)
@@ -104,12 +135,14 @@ class MainWindow(QMainWindow):
104
135
  self._save_timer.timeout.connect(self._save_current)
105
136
  self.editor.textChanged.connect(self._on_text_changed)
106
137
 
107
- # First load + mark dates with content
138
+ # First load + mark dates in calendar with content
108
139
  self._load_selected_date()
109
140
  self._refresh_calendar_marks()
110
141
 
111
- # --- DB lifecycle
112
142
  def _try_connect(self) -> bool:
143
+ """
144
+ Try to connect to the database.
145
+ """
113
146
  try:
114
147
  self.db = DBManager(self.cfg)
115
148
  ok = self.db.connect()
@@ -123,6 +156,9 @@ class MainWindow(QMainWindow):
123
156
  return ok
124
157
 
125
158
  def _prompt_for_key_until_valid(self) -> bool:
159
+ """
160
+ Prompt for the SQLCipher key.
161
+ """
126
162
  while True:
127
163
  dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
128
164
  if dlg.exec() != QDialog.Accepted:
@@ -131,8 +167,11 @@ class MainWindow(QMainWindow):
131
167
  if self._try_connect():
132
168
  return True
133
169
 
134
- # --- Calendar marks to indicate text exists for htat day -----------------
135
170
  def _refresh_calendar_marks(self):
171
+ """
172
+ Sets a bold marker on the day to indicate that text exists
173
+ for that day.
174
+ """
136
175
  fmt_bold = QTextCharFormat()
137
176
  fmt_bold.setFontWeight(QFont.Weight.Bold)
138
177
  # Clear previous marks
@@ -153,15 +192,16 @@ class MainWindow(QMainWindow):
153
192
  d = self.calendar.selectedDate()
154
193
  return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
155
194
 
156
- def _load_selected_date(self):
157
- date_iso = self._current_date_iso()
195
+ def _load_selected_date(self, date_iso=False):
196
+ if not date_iso:
197
+ date_iso = self._current_date_iso()
158
198
  try:
159
199
  text = self.db.get_entry(date_iso)
160
200
  except Exception as e:
161
201
  QMessageBox.critical(self, "Read Error", str(e))
162
202
  return
163
203
  self.editor.blockSignals(True)
164
- self.editor.setPlainText(text)
204
+ self.editor.setHtml(text)
165
205
  self.editor.blockSignals(False)
166
206
  self._dirty = False
167
207
  # track which date the editor currently represents
@@ -176,6 +216,11 @@ class MainWindow(QMainWindow):
176
216
  d = self.calendar.selectedDate().addDays(delta)
177
217
  self.calendar.setSelectedDate(d)
178
218
 
219
+ def _adjust_today(self):
220
+ """Jump to today."""
221
+ today = QDate.currentDate()
222
+ self.calendar.setSelectedDate(today)
223
+
179
224
  def _on_date_changed(self):
180
225
  """
181
226
  When the calendar selection changes, save the previous day's note if dirty,
@@ -199,7 +244,7 @@ class MainWindow(QMainWindow):
199
244
  """
200
245
  if not self._dirty and not explicit:
201
246
  return
202
- text = self.editor.toPlainText()
247
+ text = self.editor.toHtml()
203
248
  try:
204
249
  self.db.upsert_entry(date_iso, text)
205
250
  except Exception as e:
@@ -219,7 +264,7 @@ class MainWindow(QMainWindow):
219
264
  self._save_date(self._current_date_iso(), explicit)
220
265
 
221
266
  def _open_settings(self):
222
- dlg = SettingsDialog(self.cfg, self)
267
+ dlg = SettingsDialog(self.cfg, self.db, self)
223
268
  if dlg.exec() == QDialog.Accepted:
224
269
  new_cfg = dlg.config
225
270
  if new_cfg.path != self.cfg.path:
@@ -236,7 +281,7 @@ class MainWindow(QMainWindow):
236
281
  self._load_selected_date()
237
282
  self._refresh_calendar_marks()
238
283
 
239
- def closeEvent(self, event): # noqa: N802
284
+ def closeEvent(self, event):
240
285
  try:
241
286
  self._save_current()
242
287
  self.db.close()
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Iterable, Tuple
5
+
6
+ from PySide6.QtCore import Qt, Signal
7
+ from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
8
+ from PySide6.QtWidgets import (
9
+ QLabel,
10
+ QLineEdit,
11
+ QListWidget,
12
+ QListWidgetItem,
13
+ QHBoxLayout,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ # type: rows are (date_iso, content)
19
+ Row = Tuple[str, str]
20
+
21
+
22
+ class Search(QWidget):
23
+ """Encapsulates the search UI + logic and emits a signal when a result is chosen."""
24
+
25
+ openDateRequested = Signal(str)
26
+
27
+ def __init__(self, db, parent: QWidget | None = None):
28
+ super().__init__(parent)
29
+ self._db = db
30
+
31
+ self.search = QLineEdit()
32
+ self.search.setPlaceholderText("Search for notes here")
33
+ self.search.textChanged.connect(self._search)
34
+
35
+ self.results = QListWidget()
36
+ self.results.setUniformItemSizes(False)
37
+ self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
38
+ self.results.itemClicked.connect(self._open_selected)
39
+ self.results.hide()
40
+
41
+ lay = QVBoxLayout(self)
42
+ lay.setContentsMargins(0, 0, 0, 0)
43
+ lay.setSpacing(6)
44
+ lay.addWidget(self.search)
45
+ lay.addWidget(self.results)
46
+
47
+ def _open_selected(self, item: QListWidgetItem):
48
+ date_str = item.data(Qt.ItemDataRole.UserRole)
49
+ if date_str:
50
+ self.openDateRequested.emit(date_str)
51
+
52
+ def _search(self, text: str):
53
+ """
54
+ Search for the supplied text in the database.
55
+ For all rows found, populate the results widget with a clickable preview.
56
+ """
57
+ q = text.strip()
58
+ if not q:
59
+ self.results.clear()
60
+ self.results.hide()
61
+ return
62
+
63
+ try:
64
+ rows: Iterable[Row] = self._db.search_entries(q)
65
+ except Exception:
66
+ # be quiet on DB errors here; caller can surface if desired
67
+ rows = []
68
+
69
+ self._populate_results(q, rows)
70
+
71
+ def _populate_results(self, query: str, rows: Iterable[Row]):
72
+ self.results.clear()
73
+ rows = list(rows)
74
+ if not rows:
75
+ self.results.hide()
76
+ return
77
+
78
+ self.results.show()
79
+
80
+ for date_str, content in rows:
81
+ # Build an HTML fragment around the match and whether to show ellipses
82
+ frag_html, left_ell, right_ell = self._make_html_snippet(
83
+ content, query, radius=60, maxlen=180
84
+ )
85
+
86
+ # ---- Per-item widget: date on top, preview row below (with ellipses) ----
87
+ container = QWidget()
88
+ outer = QVBoxLayout(container)
89
+ outer.setContentsMargins(8, 6, 8, 6)
90
+ outer.setSpacing(2)
91
+
92
+ # Date label (plain text)
93
+ date_lbl = QLabel(date_str)
94
+ date_lbl.setTextFormat(Qt.TextFormat.PlainText)
95
+ date_f = date_lbl.font()
96
+ date_f.setPointSizeF(date_f.pointSizeF() - 1)
97
+ date_lbl.setFont(date_f)
98
+ date_lbl.setStyleSheet("color:#666;")
99
+ outer.addWidget(date_lbl)
100
+
101
+ # Preview row with optional ellipses
102
+ row = QWidget()
103
+ h = QHBoxLayout(row)
104
+ h.setContentsMargins(0, 0, 0, 0)
105
+ h.setSpacing(4)
106
+
107
+ if left_ell:
108
+ left = QLabel("…")
109
+ left.setStyleSheet("color:#888;")
110
+ h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
111
+
112
+ preview = QLabel()
113
+ preview.setTextFormat(Qt.TextFormat.RichText)
114
+ preview.setWordWrap(True)
115
+ preview.setOpenExternalLinks(True) # keep links in your HTML clickable
116
+ preview.setText(
117
+ frag_html
118
+ if frag_html
119
+ else "<span style='color:#888'>(no preview)</span>"
120
+ )
121
+ h.addWidget(preview, 1)
122
+
123
+ if right_ell:
124
+ right = QLabel("…")
125
+ right.setStyleSheet("color:#888;")
126
+ h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
127
+
128
+ outer.addWidget(row)
129
+
130
+ # ---- Add to list ----
131
+ item = QListWidgetItem()
132
+ item.setData(Qt.ItemDataRole.UserRole, date_str)
133
+ item.setSizeHint(container.sizeHint())
134
+
135
+ self.results.addItem(item)
136
+ self.results.setItemWidget(item, container)
137
+
138
+ # --- Snippet/highlight helpers -----------------------------------------
139
+ def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
140
+ doc = QTextDocument()
141
+ doc.setHtml(html_src)
142
+ plain = doc.toPlainText()
143
+ if not plain:
144
+ return "", False, False
145
+
146
+ tokens = [t for t in re.split(r"\s+", query.strip()) if t]
147
+ L = len(plain)
148
+
149
+ # Find first occurrence (phrase first, then earliest token)
150
+ idx, mlen = -1, 0
151
+ if tokens:
152
+ lower = plain.lower()
153
+ phrase = " ".join(tokens).lower()
154
+ j = lower.find(phrase)
155
+ if j >= 0:
156
+ idx, mlen = j, len(phrase)
157
+ else:
158
+ for t in tokens:
159
+ tj = lower.find(t.lower())
160
+ if tj >= 0 and (idx < 0 or tj < idx):
161
+ idx, mlen = tj, len(t)
162
+ # Compute window
163
+ if idx < 0:
164
+ start, end = 0, min(L, maxlen)
165
+ else:
166
+ start = max(0, min(idx - radius, max(0, L - maxlen)))
167
+ end = min(L, max(idx + mlen + radius, start + maxlen))
168
+
169
+ # Bold all token matches that fall inside [start, end)
170
+ if tokens:
171
+ lower = plain.lower()
172
+ fmt = QTextCharFormat()
173
+ fmt.setFontWeight(QFont.Weight.Bold)
174
+ for t in tokens:
175
+ t_low = t.lower()
176
+ pos = start
177
+ while True:
178
+ k = lower.find(t_low, pos)
179
+ if k == -1 or k >= end:
180
+ break
181
+ c = QTextCursor(doc)
182
+ c.setPosition(k)
183
+ c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
184
+ c.mergeCharFormat(fmt)
185
+ pos = k + len(t)
186
+
187
+ # Select the window and export as HTML fragment
188
+ c = QTextCursor(doc)
189
+ c.setPosition(start)
190
+ c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
191
+ fragment_html = (
192
+ c.selection().toHtml()
193
+ ) # preserves original styles + our bolding
194
+
195
+ return fragment_html, start > 0, end < L
@@ -13,17 +13,20 @@ from PySide6.QtWidgets import (
13
13
  QFileDialog,
14
14
  QDialogButtonBox,
15
15
  QSizePolicy,
16
+ QMessageBox,
16
17
  )
17
18
 
18
- from .db import DBConfig
19
+ from .db import DBConfig, DBManager
19
20
  from .settings import save_db_config
21
+ from .key_prompt import KeyPrompt
20
22
 
21
23
 
22
24
  class SettingsDialog(QDialog):
23
- def __init__(self, cfg: DBConfig, parent=None):
25
+ def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
24
26
  super().__init__(parent)
25
27
  self.setWindowTitle("Settings")
26
28
  self._cfg = DBConfig(path=cfg.path, key="")
29
+ self._db = db
27
30
 
28
31
  form = QFormLayout()
29
32
  form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
@@ -44,12 +47,17 @@ class SettingsDialog(QDialog):
44
47
  h.setStretch(1, 0)
45
48
  form.addRow("Database path", path_row)
46
49
 
50
+ # Change key button
51
+ self.rekey_btn = QPushButton("Change key")
52
+ self.rekey_btn.clicked.connect(self._change_key)
53
+
47
54
  bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
48
55
  bb.accepted.connect(self._save)
49
56
  bb.rejected.connect(self.reject)
50
57
 
51
58
  v = QVBoxLayout(self)
52
59
  v.addLayout(form)
60
+ v.addWidget(self.rekey_btn)
53
61
  v.addWidget(bb)
54
62
 
55
63
  def _browse(self):
@@ -67,6 +75,28 @@ class SettingsDialog(QDialog):
67
75
  save_db_config(self._cfg)
68
76
  self.accept()
69
77
 
78
+ def _change_key(self):
79
+ p1 = KeyPrompt(self, title="Change key", message="Enter new key")
80
+ if p1.exec() != QDialog.Accepted:
81
+ return
82
+ new_key = p1.key()
83
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
84
+ if p2.exec() != QDialog.Accepted:
85
+ return
86
+ if new_key != p2.key():
87
+ QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
88
+ return
89
+ if not new_key:
90
+ QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
91
+ return
92
+ try:
93
+ self._db.rekey(new_key)
94
+ QMessageBox.information(
95
+ self, "Key changed", "The database key was updated."
96
+ )
97
+ except Exception as e:
98
+ QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
99
+
70
100
  @property
71
101
  def config(self) -> DBConfig:
72
102
  return self._cfg
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtCore import Signal, Qt
4
+ from PySide6.QtGui import QFont, QAction
5
+ from PySide6.QtWidgets import QToolBar
6
+
7
+
8
+ class ToolBar(QToolBar):
9
+ boldRequested = Signal(QFont.Weight)
10
+ italicRequested = Signal()
11
+ underlineRequested = Signal()
12
+ strikeRequested = Signal()
13
+ codeRequested = Signal()
14
+ headingRequested = Signal(int)
15
+ bulletsRequested = Signal()
16
+ numbersRequested = Signal()
17
+ alignRequested = Signal(Qt.AlignmentFlag)
18
+
19
+ def __init__(self, parent=None):
20
+ super().__init__("Format", parent)
21
+ self._build_actions()
22
+
23
+ def _build_actions(self):
24
+ # Bold
25
+ bold = QAction("Bold", self)
26
+ bold.setShortcut("Ctrl+B")
27
+ bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
28
+
29
+ italic = QAction("Italic", self)
30
+ italic.setShortcut("Ctrl+I")
31
+ italic.triggered.connect(self.italicRequested)
32
+
33
+ underline = QAction("Underline", self)
34
+ underline.setShortcut("Ctrl+U")
35
+ underline.triggered.connect(self.underlineRequested)
36
+
37
+ strike = QAction("Strikethrough", self)
38
+ strike.setShortcut("Ctrl+-")
39
+ strike.triggered.connect(self.strikeRequested)
40
+
41
+ code = QAction("<code>", self)
42
+ code.setShortcut("Ctrl+`")
43
+ code.triggered.connect(self.codeRequested)
44
+
45
+ # Headings
46
+ h1 = QAction("H1", self)
47
+ h1.setShortcut("Ctrl+1")
48
+ h2 = QAction("H2", self)
49
+ h2.setShortcut("Ctrl+2")
50
+ h3 = QAction("H3", self)
51
+ h3.setShortcut("Ctrl+3")
52
+ normal = QAction("Normal", self)
53
+ normal.setShortcut("Ctrl+P")
54
+
55
+ h1.triggered.connect(lambda: self.headingRequested.emit(24))
56
+ h2.triggered.connect(lambda: self.headingRequested.emit(18))
57
+ h3.triggered.connect(lambda: self.headingRequested.emit(14))
58
+ normal.triggered.connect(lambda: self.headingRequested.emit(0))
59
+
60
+ # Lists
61
+ bullets = QAction("• Bullets", self)
62
+ bullets.triggered.connect(self.bulletsRequested)
63
+ numbers = QAction("1. Numbered", self)
64
+ numbers.triggered.connect(self.numbersRequested)
65
+
66
+ # Alignment
67
+ left = QAction("Align Left", self)
68
+ center = QAction("Align Center", self)
69
+ right = QAction("Align Right", self)
70
+
71
+ left.triggered.connect(
72
+ lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
73
+ )
74
+ center.triggered.connect(
75
+ lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
76
+ )
77
+ right.triggered.connect(
78
+ lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
79
+ )
80
+
81
+ self.addActions(
82
+ [
83
+ bold,
84
+ italic,
85
+ underline,
86
+ strike,
87
+ code,
88
+ h1,
89
+ h2,
90
+ h3,
91
+ normal,
92
+ bullets,
93
+ numbers,
94
+ left,
95
+ center,
96
+ right,
97
+ ]
98
+ )
@@ -1,10 +1,11 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
7
7
  license = "GPL-3.0-or-later"
8
+ repository = "https://git.mig5.net/mig5/bouquin"
8
9
 
9
10
  [tool.poetry.dependencies]
10
11
  python = ">=3.9,<3.14"
@@ -1,112 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
5
-
6
-
7
- class MarkdownHighlighter(QSyntaxHighlighter):
8
- ST_NORMAL = 0
9
- ST_CODE = 1
10
-
11
- FENCE = re.compile(r"^```")
12
-
13
- def __init__(self, document):
14
- super().__init__(document)
15
-
16
- base_size = document.defaultFont().pointSizeF() or 12.0
17
-
18
- # Monospace for code
19
- self.mono = QFont("Monospace")
20
- self.mono.setStyleHint(QFont.TypeWriter)
21
-
22
- # Light, high-contrast scheme for code
23
- self.col_bg = QColor("#eef2f6") # light code bg
24
- self.col_fg = QColor("#1f2328") # dark text
25
-
26
- # Formats
27
- self.fmt_h = [QTextCharFormat() for _ in range(6)]
28
- for i, f in enumerate(self.fmt_h, start=1):
29
- f.setFontWeight(QFont.Weight.Bold)
30
- f.setFontPointSize(base_size + (7 - i))
31
- self.fmt_bold = QTextCharFormat()
32
- self.fmt_bold.setFontWeight(QFont.Weight.Bold)
33
- self.fmt_italic = QTextCharFormat()
34
- self.fmt_italic.setFontItalic(True)
35
- self.fmt_quote = QTextCharFormat()
36
- self.fmt_quote.setForeground(QColor("#6a737d"))
37
- self.fmt_link = QTextCharFormat()
38
- self.fmt_link.setFontUnderline(True)
39
- self.fmt_list = QTextCharFormat()
40
- self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
41
- self.fmt_strike = QTextCharFormat()
42
- self.fmt_strike.setFontStrikeOut(True)
43
-
44
- # Uniform code style
45
- self.fmt_code = QTextCharFormat()
46
- self.fmt_code.setFont(self.mono)
47
- self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
48
- self.fmt_code.setBackground(self.col_bg)
49
- self.fmt_code.setForeground(self.col_fg)
50
-
51
- # Simple patterns
52
- self.re_heading = re.compile(r"^(#{1,6}) +.*$")
53
- self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
54
- self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
55
- self.re_strike = re.compile(r"~~(.+?)~~")
56
- self.re_inline_code = re.compile(r"`([^`]+)`")
57
- self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
58
- self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
59
- self.re_quote = re.compile(r"^> ?.*$")
60
-
61
- def highlightBlock(self, text: str) -> None:
62
- prev = self.previousBlockState()
63
- in_code = prev == self.ST_CODE
64
-
65
- if in_code:
66
- # Entire line is code
67
- self.setFormat(0, len(text), self.fmt_code)
68
- if self.FENCE.match(text):
69
- self.setCurrentBlockState(self.ST_NORMAL)
70
- else:
71
- self.setCurrentBlockState(self.ST_CODE)
72
- return
73
-
74
- # Starting/ending a fenced block?
75
- if self.FENCE.match(text):
76
- self.setFormat(0, len(text), self.fmt_code)
77
- self.setCurrentBlockState(self.ST_CODE)
78
- return
79
-
80
- # --- Normal markdown styling ---
81
- m = self.re_heading.match(text)
82
- if m:
83
- level = min(len(m.group(1)), 6)
84
- self.setFormat(0, len(text), self.fmt_h[level - 1])
85
- self.setCurrentBlockState(self.ST_NORMAL)
86
- return
87
-
88
- m = self.re_list.match(text)
89
- if m:
90
- self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
91
-
92
- if self.re_quote.match(text):
93
- self.setFormat(0, len(text), self.fmt_quote)
94
-
95
- for m in self.re_inline_code.finditer(text):
96
- self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
97
-
98
- for m in self.re_bold.finditer(text):
99
- self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
100
-
101
- for m in self.re_italic.finditer(text):
102
- self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
103
-
104
- for m in self.re_strike.finditer(text):
105
- self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
106
-
107
- for m in self.re_link.finditer(text):
108
- start = m.start(1) - 1
109
- length = len(m.group(1)) + 2
110
- self.setFormat(start, length, self.fmt_link)
111
-
112
- self.setCurrentBlockState(self.ST_NORMAL)
File without changes
File without changes
File without changes
File without changes
File without changes