bouquin 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl

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.

bouquin/db.py CHANGED
@@ -1,9 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import csv
4
+ import html
5
+ import json
6
+ import os
7
+
3
8
  from dataclasses import dataclass
4
9
  from pathlib import Path
5
-
6
10
  from sqlcipher3 import dbapi2 as sqlite
11
+ from typing import List, Sequence, Tuple
12
+
13
+ Entry = Tuple[str, str]
7
14
 
8
15
 
9
16
  @dataclass
@@ -21,9 +28,9 @@ class DBManager:
21
28
  # Ensure parent dir exists
22
29
  self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
23
30
  self.conn = sqlite.connect(str(self.cfg.path))
31
+ self.conn.row_factory = sqlite.Row
24
32
  cur = self.conn.cursor()
25
33
  cur.execute(f"PRAGMA key = '{self.cfg.key}';")
26
- cur.execute("PRAGMA cipher_compatibility = 4;")
27
34
  cur.execute("PRAGMA journal_mode = WAL;")
28
35
  self.conn.commit()
29
36
  try:
@@ -103,14 +110,116 @@ class DBManager:
103
110
  def search_entries(self, text: str) -> list[str]:
104
111
  cur = self.conn.cursor()
105
112
  pattern = f"%{text}%"
106
- cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
107
- return [r for r in cur.fetchall()]
113
+ return cur.execute(
114
+ "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
115
+ ).fetchall()
108
116
 
109
117
  def dates_with_content(self) -> list[str]:
110
118
  cur = self.conn.cursor()
111
119
  cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
112
120
  return [r[0] for r in cur.fetchall()]
113
121
 
122
+ def get_all_entries(self) -> List[Entry]:
123
+ cur = self.conn.cursor()
124
+ rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
125
+ return [(row["date"], row["content"]) for row in rows]
126
+
127
+ def export_json(
128
+ self, entries: Sequence[Entry], file_path: str, pretty: bool = True
129
+ ) -> None:
130
+ data = [{"date": d, "content": c} for d, c in entries]
131
+ with open(file_path, "w", encoding="utf-8") as f:
132
+ if pretty:
133
+ json.dump(data, f, ensure_ascii=False, indent=2)
134
+ else:
135
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
136
+
137
+ def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
138
+ # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
139
+ with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
140
+ writer = csv.writer(f)
141
+ writer.writerow(["date", "content"]) # header
142
+ writer.writerows(entries)
143
+
144
+ def export_txt(
145
+ self,
146
+ entries: Sequence[Entry],
147
+ file_path: str,
148
+ separator: str = "\n\n— — — — —\n\n",
149
+ strip_html: bool = True,
150
+ ) -> None:
151
+ import re, html as _html
152
+
153
+ # Precompiled patterns
154
+ STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
155
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
156
+ BR_RE = re.compile(r"(?i)<br\\s*/?>")
157
+ BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
158
+ TAG_RE = re.compile(r"<[^>]+>")
159
+ WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
160
+ MULTINEWLINE_RE = re.compile(r"\\n{3,}")
161
+
162
+ def _strip(s: str) -> str:
163
+ # 1) Remove <style> and <script> blocks *including their contents*
164
+ s = STYLE_SCRIPT_RE.sub("", s)
165
+ # 2) Remove HTML comments
166
+ s = COMMENT_RE.sub("", s)
167
+ # 3) Turn some block-ish boundaries into newlines before removing tags
168
+ s = BR_RE.sub("\n", s)
169
+ s = BLOCK_END_RE.sub("\n", s)
170
+ # 4) Drop remaining tags
171
+ s = TAG_RE.sub("", s)
172
+ # 5) Unescape entities (&nbsp; etc.)
173
+ s = _html.unescape(s)
174
+ # 6) Tidy whitespace
175
+ s = WS_ENDS_RE.sub("\n", s)
176
+ s = MULTINEWLINE_RE.sub("\n\n", s)
177
+ return s.strip()
178
+
179
+ with open(file_path, "w", encoding="utf-8") as f:
180
+ for i, (d, c) in enumerate(entries):
181
+ body = _strip(c) if strip_html else c
182
+ f.write(f"{d}\n{body}\n")
183
+ if i < len(entries) - 1:
184
+ f.write(separator)
185
+
186
+ def export_html(
187
+ self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
188
+ ) -> None:
189
+ parts = [
190
+ "<!doctype html>",
191
+ '<html lang="en">',
192
+ '<meta charset="utf-8">',
193
+ f"<title>{html.escape(title)}</title>",
194
+ "<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
195
+ "article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
196
+ "<body>",
197
+ f"<h1>{html.escape(title)}</h1>",
198
+ ]
199
+ for d, c in entries:
200
+ parts.append(
201
+ f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
202
+ )
203
+ parts.append("</body></html>")
204
+
205
+ with open(file_path, "w", encoding="utf-8") as f:
206
+ f.write("\n".join(parts))
207
+
208
+ def export_by_extension(self, file_path: str) -> None:
209
+ entries = self.get_all_entries()
210
+ ext = os.path.splitext(file_path)[1].lower()
211
+
212
+ if ext == ".json":
213
+ self.export_json(entries, file_path)
214
+ elif ext == ".csv":
215
+ self.export_csv(entries, file_path)
216
+ elif ext == ".txt":
217
+ self.export_txt(entries, file_path)
218
+ elif ext in {".html", ".htm"}:
219
+ self.export_html(entries, file_path)
220
+ else:
221
+ raise ValueError(f"Unsupported extension: {ext}")
222
+
114
223
  def close(self) -> None:
115
224
  if self.conn is not None:
116
225
  self.conn.close()
bouquin/editor.py CHANGED
@@ -2,22 +2,144 @@ from __future__ import annotations
2
2
 
3
3
  from PySide6.QtGui import (
4
4
  QColor,
5
+ QDesktopServices,
5
6
  QFont,
6
7
  QFontDatabase,
7
8
  QTextCharFormat,
9
+ QTextCursor,
8
10
  QTextListFormat,
9
11
  QTextBlockFormat,
10
12
  )
11
- from PySide6.QtCore import Slot
13
+ from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
12
14
  from PySide6.QtWidgets import QTextEdit
13
15
 
14
16
 
15
17
  class Editor(QTextEdit):
16
- def __init__(self):
17
- super().__init__()
18
+ linkActivated = Signal(str)
19
+
20
+ _URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
21
+
22
+ def __init__(self, *args, **kwargs):
23
+ super().__init__(*args, **kwargs)
18
24
  tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
19
25
  self.setTabStopDistance(tab_w)
20
26
 
27
+ self.setTextInteractionFlags(
28
+ Qt.TextInteractionFlag.TextEditorInteraction
29
+ | Qt.TextInteractionFlag.LinksAccessibleByMouse
30
+ | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
31
+ )
32
+
33
+ self.setAcceptRichText(True)
34
+
35
+ # Turn raw URLs into anchors
36
+ self._linkifying = False
37
+ self.textChanged.connect(self._linkify_document)
38
+ self.viewport().setMouseTracking(True)
39
+
40
+ def _linkify_document(self):
41
+ if self._linkifying:
42
+ return
43
+ self._linkifying = True
44
+
45
+ doc = self.document()
46
+ cur = QTextCursor(doc)
47
+ cur.beginEditBlock()
48
+
49
+ block = doc.begin()
50
+ while block.isValid():
51
+ text = block.text()
52
+ it = self._URL_RX.globalMatch(text)
53
+ while it.hasNext():
54
+ m = it.next()
55
+ start = block.position() + m.capturedStart()
56
+ end = start + m.capturedLength()
57
+
58
+ cur.setPosition(start)
59
+ cur.setPosition(end, QTextCursor.KeepAnchor)
60
+
61
+ fmt = cur.charFormat()
62
+ if fmt.isAnchor(): # already linkified; skip
63
+ continue
64
+
65
+ href = m.captured(0)
66
+ if href.startswith("www."):
67
+ href = "https://" + href
68
+
69
+ fmt.setAnchor(True)
70
+ # Qt 6: use setAnchorHref; for compatibility, also set names.
71
+ try:
72
+ fmt.setAnchorHref(href)
73
+ except AttributeError:
74
+ fmt.setAnchorNames([href])
75
+
76
+ fmt.setFontUnderline(True)
77
+ fmt.setForeground(Qt.blue)
78
+ cur.setCharFormat(fmt)
79
+
80
+ block = block.next()
81
+
82
+ cur.endEditBlock()
83
+ self._linkifying = False
84
+
85
+ def mouseReleaseEvent(self, e):
86
+ if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
87
+ href = self.anchorAt(e.pos())
88
+ if href:
89
+ QDesktopServices.openUrl(QUrl.fromUserInput(href))
90
+ self.linkActivated.emit(href)
91
+ return
92
+ super().mouseReleaseEvent(e)
93
+
94
+ def mouseMoveEvent(self, e):
95
+ if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
96
+ self.viewport().setCursor(Qt.PointingHandCursor)
97
+ else:
98
+ self.viewport().setCursor(Qt.IBeamCursor)
99
+ super().mouseMoveEvent(e)
100
+
101
+ def keyPressEvent(self, e):
102
+ key = e.key()
103
+
104
+ # Pre-insert: stop link/format bleed for “word boundary” keys
105
+ if key in (Qt.Key_Space, Qt.Key_Tab):
106
+ self._break_anchor_for_next_char()
107
+ return super().keyPressEvent(e)
108
+
109
+ # When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
110
+ if key in (Qt.Key_Return, Qt.Key_Enter):
111
+ super().keyPressEvent(e) # create the new (possibly empty) paragraph
112
+
113
+ # If we're on an empty block, clear the insertion char format so the
114
+ # *next* Enter will create another new line (not consume the press to reset formatting).
115
+ c = self.textCursor()
116
+ block = c.block()
117
+ if block.length() == 1:
118
+ self._clear_insertion_char_format()
119
+ return
120
+
121
+ return super().keyPressEvent(e)
122
+
123
+ def _clear_insertion_char_format(self):
124
+ """Reset inline typing format (keeps lists, alignment, margins, etc.)."""
125
+ nf = QTextCharFormat()
126
+ self.setCurrentCharFormat(nf)
127
+
128
+ def _break_anchor_for_next_char(self):
129
+ c = self.textCursor()
130
+ fmt = c.charFormat()
131
+ if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
132
+ # clone, then strip just the link-specific bits so the next char is plain text
133
+ nf = QTextCharFormat(fmt)
134
+ nf.setAnchor(False)
135
+ nf.setFontUnderline(False)
136
+ nf.clearForeground()
137
+ try:
138
+ nf.setAnchorHref("")
139
+ except AttributeError:
140
+ nf.setAnchorNames([])
141
+ self.setCurrentCharFormat(nf)
142
+
21
143
  def merge_on_sel(self, fmt):
22
144
  """
23
145
  Sets the styling on the selected characters.
@@ -28,9 +150,15 @@ class Editor(QTextEdit):
28
150
  cursor.mergeCharFormat(fmt)
29
151
  self.mergeCurrentCharFormat(fmt)
30
152
 
31
- @Slot(QFont.Weight)
32
- def apply_weight(self, weight):
153
+ @Slot()
154
+ def apply_weight(self):
155
+ cur = self.currentCharFormat()
33
156
  fmt = QTextCharFormat()
157
+ weight = (
158
+ QFont.Weight.Normal
159
+ if cur.fontWeight() == QFont.Weight.Bold
160
+ else QFont.Weight.Bold
161
+ )
34
162
  fmt.setFontWeight(weight)
35
163
  self.merge_on_sel(fmt)
36
164
 
bouquin/key_prompt.py CHANGED
@@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
14
14
  def __init__(
15
15
  self,
16
16
  parent=None,
17
- title: str = "Unlock database",
18
- message: str = "Enter SQLCipher key",
17
+ title: str = "Enter key",
18
+ message: str = "Enter key",
19
19
  ):
20
20
  super().__init__(parent)
21
21
  self.setWindowTitle(title)
bouquin/main_window.py CHANGED
@@ -1,16 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import sys
4
5
 
5
- from PySide6.QtCore import QDate, QTimer, Qt
6
+ from pathlib import Path
7
+ from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
6
8
  from PySide6.QtGui import (
7
9
  QAction,
10
+ QCursor,
11
+ QDesktopServices,
8
12
  QFont,
13
+ QGuiApplication,
9
14
  QTextCharFormat,
10
15
  )
11
16
  from PySide6.QtWidgets import (
12
17
  QCalendarWidget,
13
18
  QDialog,
19
+ QFileDialog,
14
20
  QMainWindow,
15
21
  QMessageBox,
16
22
  QSizePolicy,
@@ -23,7 +29,7 @@ from .db import DBManager
23
29
  from .editor import Editor
24
30
  from .key_prompt import KeyPrompt
25
31
  from .search import Search
26
- from .settings import APP_NAME, load_db_config, save_db_config
32
+ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
27
33
  from .settings_dialog import SettingsDialog
28
34
  from .toolbar import ToolBar
29
35
 
@@ -35,9 +41,18 @@ class MainWindow(QMainWindow):
35
41
  self.setMinimumSize(1000, 650)
36
42
 
37
43
  self.cfg = load_db_config()
38
- # Always prompt for the key (we never store it)
39
- if not self._prompt_for_key_until_valid():
40
- sys.exit(1)
44
+ if not os.path.exists(self.cfg.path):
45
+ # Fresh database/first time use, so guide the user re: setting a key
46
+ first_time = True
47
+ else:
48
+ first_time = False
49
+
50
+ # Prompt for the key unless it is found in config
51
+ if not self.cfg.key:
52
+ if not self._prompt_for_key_until_valid(first_time):
53
+ sys.exit(1)
54
+ else:
55
+ self._try_connect()
41
56
 
42
57
  # ---- UI: Left fixed panel (calendar) + right editor -----------------
43
58
  self.calendar = QCalendarWidget()
@@ -90,15 +105,19 @@ class MainWindow(QMainWindow):
90
105
 
91
106
  # Menu bar (File)
92
107
  mb = self.menuBar()
93
- file_menu = mb.addMenu("&Application")
108
+ file_menu = mb.addMenu("&File")
94
109
  act_save = QAction("&Save", self)
95
110
  act_save.setShortcut("Ctrl+S")
96
111
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
97
112
  file_menu.addAction(act_save)
98
- act_settings = QAction("S&ettings", self)
99
- act_settings.setShortcut("Ctrl+E")
113
+ act_settings = QAction("Settin&gs", self)
114
+ act_settings.setShortcut("Ctrl+G")
100
115
  act_settings.triggered.connect(self._open_settings)
101
116
  file_menu.addAction(act_settings)
117
+ act_export = QAction("&Export", self)
118
+ act_export.setShortcut("Ctrl+E")
119
+ act_export.triggered.connect(self._export)
120
+ file_menu.addAction(act_export)
102
121
  file_menu.addSeparator()
103
122
  act_quit = QAction("&Quit", self)
104
123
  act_quit.setShortcut("Ctrl+Q")
@@ -128,6 +147,15 @@ class MainWindow(QMainWindow):
128
147
  nav_menu.addAction(act_today)
129
148
  self.addAction(act_today)
130
149
 
150
+ # Help menu with drop-down
151
+ help_menu = mb.addMenu("&Help")
152
+ act_docs = QAction("Documentation", self)
153
+ act_docs.setShortcut("Ctrl+D")
154
+ act_docs.setShortcutContext(Qt.ApplicationShortcut)
155
+ act_docs.triggered.connect(self._open_docs)
156
+ help_menu.addAction(act_docs)
157
+ self.addAction(act_docs)
158
+
131
159
  # Autosave
132
160
  self._dirty = False
133
161
  self._save_timer = QTimer(self)
@@ -139,6 +167,10 @@ class MainWindow(QMainWindow):
139
167
  self._load_selected_date()
140
168
  self._refresh_calendar_marks()
141
169
 
170
+ # Restore window position from settings
171
+ self.settings = QSettings(APP_ORG, APP_NAME)
172
+ self._restore_window_position()
173
+
142
174
  def _try_connect(self) -> bool:
143
175
  """
144
176
  Try to connect to the database.
@@ -155,12 +187,18 @@ class MainWindow(QMainWindow):
155
187
  return False
156
188
  return ok
157
189
 
158
- def _prompt_for_key_until_valid(self) -> bool:
190
+ def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
159
191
  """
160
192
  Prompt for the SQLCipher key.
161
193
  """
194
+ if first_time:
195
+ title = "Set an encryption key"
196
+ message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
197
+ else:
198
+ title = "Unlock encrypted notebook"
199
+ message = "Enter your key to unlock the notebook"
162
200
  while True:
163
- dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
201
+ dlg = KeyPrompt(self, title, message)
164
202
  if dlg.exec() != QDialog.Accepted:
165
203
  return False
166
204
  self.cfg.key = dlg.key()
@@ -206,6 +244,8 @@ class MainWindow(QMainWindow):
206
244
  self._dirty = False
207
245
  # track which date the editor currently represents
208
246
  self._active_date_iso = date_iso
247
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
248
+ self.calendar.setSelectedDate(qd)
209
249
 
210
250
  def _on_text_changed(self):
211
251
  self._dirty = True
@@ -281,8 +321,97 @@ class MainWindow(QMainWindow):
281
321
  self._load_selected_date()
282
322
  self._refresh_calendar_marks()
283
323
 
324
+ def _restore_window_position(self):
325
+ geom = self.settings.value("main/geometry", None)
326
+ state = self.settings.value("main/windowState", None)
327
+ was_max = self.settings.value("main/maximized", False, type=bool)
328
+
329
+ if geom is not None:
330
+ self.restoreGeometry(geom)
331
+ if state is not None:
332
+ self.restoreState(state)
333
+ if not self._rect_on_any_screen(self.frameGeometry()):
334
+ self._move_to_cursor_screen_center()
335
+ else:
336
+ # First run: place window on the screen where the mouse cursor is.
337
+ self._move_to_cursor_screen_center()
338
+
339
+ # If it was maximized, do that AFTER the window exists in the event loop.
340
+ if was_max:
341
+ QTimer.singleShot(0, self.showMaximized)
342
+
343
+ def _rect_on_any_screen(self, rect):
344
+ for sc in QGuiApplication.screens():
345
+ if sc.availableGeometry().intersects(rect):
346
+ return True
347
+ return False
348
+
349
+ def _move_to_cursor_screen_center(self):
350
+ screen = (
351
+ QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
352
+ )
353
+ r = screen.availableGeometry()
354
+ # Center the window in that screen’s available area
355
+ self.move(r.center() - self.rect().center())
356
+
357
+ @Slot()
358
+ def _export(self):
359
+ try:
360
+ self.export_dialog()
361
+ except Exception as e:
362
+ QMessageBox.critical(self, "Export failed", str(e))
363
+
364
+ def export_dialog(self) -> None:
365
+ filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
366
+
367
+ start_dir = os.path.join(os.path.expanduser("~"), "Documents")
368
+ filename, selected_filter = QFileDialog.getSaveFileName(
369
+ self, "Export entries", start_dir, filters
370
+ )
371
+ if not filename:
372
+ return # user cancelled
373
+
374
+ default_ext = {
375
+ "Text (*.txt)": ".txt",
376
+ "JSON (*.json)": ".json",
377
+ "CSV (*.csv)": ".csv",
378
+ "HTML (*.html)": ".html",
379
+ }.get(selected_filter, ".txt")
380
+
381
+ if not Path(filename).suffix:
382
+ filename += default_ext
383
+
384
+ try:
385
+ entries = self.db.get_all_entries()
386
+ if selected_filter.startswith("Text"):
387
+ self.db.export_txt(entries, filename)
388
+ elif selected_filter.startswith("JSON"):
389
+ self.db.export_json(entries, filename)
390
+ elif selected_filter.startswith("CSV"):
391
+ self.db.export_csv(entries, filename)
392
+ elif selected_filter.startswith("HTML"):
393
+ self.bd.export_html(entries, filename)
394
+ else:
395
+ self.bd.export_by_extension(entries, filename)
396
+
397
+ QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
398
+ except Exception as e:
399
+ QMessageBox.critical(self, "Export failed", str(e))
400
+
401
+ def _open_docs(self):
402
+ url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
403
+ url = QUrl.fromUserInput(url_str)
404
+ if not QDesktopServices.openUrl(url):
405
+ QMessageBox.warning(self, "Open Documentation",
406
+ f"Couldn't open:\n{url.toDisplayString()}")
407
+
284
408
  def closeEvent(self, event):
285
409
  try:
410
+ # Save window position
411
+ self.settings.setValue("main/geometry", self.saveGeometry())
412
+ self.settings.setValue("main/windowState", self.saveState())
413
+ self.settings.setValue("main/maximized", self.isMaximized())
414
+ # Ensure we save any last pending edits to the db
286
415
  self._save_current()
287
416
  self.db.close()
288
417
  except Exception:
bouquin/search.py CHANGED
@@ -80,7 +80,7 @@ class Search(QWidget):
80
80
  for date_str, content in rows:
81
81
  # Build an HTML fragment around the match and whether to show ellipses
82
82
  frag_html, left_ell, right_ell = self._make_html_snippet(
83
- content, query, radius=60, maxlen=180
83
+ content, query, radius=30, maxlen=90
84
84
  )
85
85
 
86
86
  # ---- Per-item widget: date on top, preview row below (with ellipses) ----
@@ -112,7 +112,7 @@ class Search(QWidget):
112
112
  preview = QLabel()
113
113
  preview.setTextFormat(Qt.TextFormat.RichText)
114
114
  preview.setWordWrap(True)
115
- preview.setOpenExternalLinks(True) # keep links in your HTML clickable
115
+ preview.setOpenExternalLinks(True)
116
116
  preview.setText(
117
117
  frag_html
118
118
  if frag_html
bouquin/settings.py CHANGED
@@ -21,9 +21,11 @@ def get_settings() -> QSettings:
21
21
  def load_db_config() -> DBConfig:
22
22
  s = get_settings()
23
23
  path = Path(s.value("db/path", str(default_db_path())))
24
- return DBConfig(path=path, key="")
24
+ key = s.value("db/key", "")
25
+ return DBConfig(path=path, key=key)
25
26
 
26
27
 
27
28
  def save_db_config(cfg: DBConfig) -> None:
28
29
  s = get_settings()
29
30
  s.setValue("db/path", str(cfg.path))
31
+ s.setValue("db/key", str(cfg.key))
@@ -3,8 +3,12 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from PySide6.QtWidgets import (
6
+ QCheckBox,
6
7
  QDialog,
7
8
  QFormLayout,
9
+ QFrame,
10
+ QGroupBox,
11
+ QLabel,
8
12
  QHBoxLayout,
9
13
  QVBoxLayout,
10
14
  QWidget,
@@ -15,9 +19,12 @@ from PySide6.QtWidgets import (
15
19
  QSizePolicy,
16
20
  QMessageBox,
17
21
  )
22
+ from PySide6.QtCore import Qt, Slot
23
+ from PySide6.QtGui import QPalette
24
+
18
25
 
19
26
  from .db import DBConfig, DBManager
20
- from .settings import save_db_config
27
+ from .settings import load_db_config, save_db_config
21
28
  from .key_prompt import KeyPrompt
22
29
 
23
30
 
@@ -27,10 +34,11 @@ class SettingsDialog(QDialog):
27
34
  self.setWindowTitle("Settings")
28
35
  self._cfg = DBConfig(path=cfg.path, key="")
29
36
  self._db = db
37
+ self.key = ""
30
38
 
31
39
  form = QFormLayout()
32
40
  form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
33
- self.setMinimumWidth(520)
41
+ self.setMinimumWidth(560)
34
42
  self.setSizeGripEnabled(True)
35
43
 
36
44
  self.path_edit = QLineEdit(str(self._cfg.path))
@@ -47,18 +55,65 @@ class SettingsDialog(QDialog):
47
55
  h.setStretch(1, 0)
48
56
  form.addRow("Database path", path_row)
49
57
 
58
+ # Encryption settings
59
+ enc_group = QGroupBox("Encryption")
60
+ enc = QVBoxLayout(enc_group)
61
+ enc.setContentsMargins(12, 8, 12, 12)
62
+ enc.setSpacing(6)
63
+
64
+ # Checkbox to remember key
65
+ self.save_key_btn = QCheckBox("Remember key")
66
+ current_settings = load_db_config()
67
+ if current_settings.key:
68
+ self.save_key_btn.setChecked(True)
69
+ else:
70
+ self.save_key_btn.setChecked(False)
71
+ self.save_key_btn.setCursor(Qt.PointingHandCursor)
72
+ self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
73
+ enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
74
+
75
+ # Explanation for remembering key
76
+ self.save_key_label = QLabel(
77
+ "If you don't want to be prompted for your encryption key, check this to remember it. "
78
+ "WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
79
+ )
80
+ self.save_key_label.setWordWrap(True)
81
+ self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
82
+ # make it look secondary
83
+ pal = self.save_key_label.palette()
84
+ pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
85
+ self.save_key_label.setPalette(pal)
86
+
87
+ exp_row = QHBoxLayout()
88
+ exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
89
+ exp_row.addWidget(self.save_key_label)
90
+ enc.addLayout(exp_row)
91
+
92
+ line = QFrame()
93
+ line.setFrameShape(QFrame.HLine)
94
+ line.setFrameShadow(QFrame.Sunken)
95
+ enc.addWidget(line)
96
+
50
97
  # Change key button
51
98
  self.rekey_btn = QPushButton("Change key")
99
+ self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
52
100
  self.rekey_btn.clicked.connect(self._change_key)
101
+ enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
102
+
103
+ # Put the group into the form so it spans the full width nicely
104
+ form.addRow(enc_group)
53
105
 
106
+ # Buttons
54
107
  bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
55
108
  bb.accepted.connect(self._save)
56
109
  bb.rejected.connect(self.reject)
57
110
 
111
+ # Root layout (adjust margins/spacing a bit)
58
112
  v = QVBoxLayout(self)
113
+ v.setContentsMargins(12, 12, 12, 12)
114
+ v.setSpacing(10)
59
115
  v.addLayout(form)
60
- v.addWidget(self.rekey_btn)
61
- v.addWidget(bb)
116
+ v.addWidget(bb, 0, Qt.AlignRight)
62
117
 
63
118
  def _browse(self):
64
119
  p, _ = QFileDialog.getSaveFileName(
@@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
71
126
  self.path_edit.setText(p)
72
127
 
73
128
  def _save(self):
74
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
129
+ self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
75
130
  save_db_config(self._cfg)
76
131
  self.accept()
77
132
 
78
133
  def _change_key(self):
79
- p1 = KeyPrompt(self, title="Change key", message="Enter new key")
134
+ p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
80
135
  if p1.exec() != QDialog.Accepted:
81
136
  return
82
137
  new_key = p1.key()
83
- p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
138
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
84
139
  if p2.exec() != QDialog.Accepted:
85
140
  return
86
141
  if new_key != p2.key():
@@ -92,11 +147,23 @@ class SettingsDialog(QDialog):
92
147
  try:
93
148
  self._db.rekey(new_key)
94
149
  QMessageBox.information(
95
- self, "Key changed", "The database key was updated."
150
+ self, "Key changed", "The notebook was re-encrypted with the new key!"
96
151
  )
97
152
  except Exception as e:
98
153
  QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
99
154
 
155
+ @Slot(bool)
156
+ def save_key_btn_clicked(self, checked: bool):
157
+ if checked:
158
+ p1 = KeyPrompt(
159
+ self, title="Enter your key", message="Enter the encryption key"
160
+ )
161
+ if p1.exec() != QDialog.Accepted:
162
+ return
163
+ self.key = p1.key()
164
+ self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
165
+ save_db_config(self._cfg)
166
+
100
167
  @property
101
168
  def config(self) -> DBConfig:
102
169
  return self._cfg
bouquin/toolbar.py CHANGED
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from PySide6.QtCore import Signal, Qt
4
- from PySide6.QtGui import QFont, QAction
4
+ from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
5
5
  from PySide6.QtWidgets import QToolBar
6
6
 
7
7
 
8
8
  class ToolBar(QToolBar):
9
- boldRequested = Signal(QFont.Weight)
9
+ boldRequested = Signal()
10
10
  italicRequested = Signal()
11
11
  underlineRequested = Signal()
12
12
  strikeRequested = Signal()
@@ -18,81 +18,131 @@ class ToolBar(QToolBar):
18
18
 
19
19
  def __init__(self, parent=None):
20
20
  super().__init__("Format", parent)
21
+ self.setObjectName("Format")
22
+ self.setToolButtonStyle(Qt.ToolButtonTextOnly)
21
23
  self._build_actions()
24
+ self._apply_toolbar_styles()
22
25
 
23
26
  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))
27
+ self.actBold = QAction("Bold", self)
28
+ self.actBold.setShortcut(QKeySequence.Bold)
29
+ self.actBold.triggered.connect(self.boldRequested)
28
30
 
29
- italic = QAction("Italic", self)
30
- italic.setShortcut("Ctrl+I")
31
- italic.triggered.connect(self.italicRequested)
31
+ self.actItalic = QAction("Italic", self)
32
+ self.actItalic.setShortcut(QKeySequence.Italic)
33
+ self.actItalic.triggered.connect(self.italicRequested)
32
34
 
33
- underline = QAction("Underline", self)
34
- underline.setShortcut("Ctrl+U")
35
- underline.triggered.connect(self.underlineRequested)
35
+ self.actUnderline = QAction("Underline", self)
36
+ self.actUnderline.setShortcut(QKeySequence.Underline)
37
+ self.actUnderline.triggered.connect(self.underlineRequested)
36
38
 
37
- strike = QAction("Strikethrough", self)
38
- strike.setShortcut("Ctrl+-")
39
- strike.triggered.connect(self.strikeRequested)
39
+ self.actStrike = QAction("Strikethrough", self)
40
+ self.actStrike.setShortcut("Ctrl+-")
41
+ self.actStrike.triggered.connect(self.strikeRequested)
40
42
 
41
- code = QAction("<code>", self)
42
- code.setShortcut("Ctrl+`")
43
- code.triggered.connect(self.codeRequested)
43
+ self.actCode = QAction("Inline code", self)
44
+ self.actCode.setShortcut("Ctrl+`")
45
+ self.actCode.triggered.connect(self.codeRequested)
44
46
 
45
47
  # 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))
48
+ self.actH1 = QAction("Heading 1", self)
49
+ self.actH2 = QAction("Heading 2", self)
50
+ self.actH3 = QAction("Heading 3", self)
51
+ self.actNormal = QAction("Normal text", self)
52
+ self.actH1.setShortcut("Ctrl+1")
53
+ self.actH2.setShortcut("Ctrl+2")
54
+ self.actH3.setShortcut("Ctrl+3")
55
+ self.actNormal.setShortcut("Ctrl+N")
56
+ self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
57
+ self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
58
+ self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
59
+ self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
59
60
 
60
61
  # Lists
61
- bullets = QAction(" Bullets", self)
62
- bullets.triggered.connect(self.bulletsRequested)
63
- numbers = QAction("1. Numbered", self)
64
- numbers.triggered.connect(self.numbersRequested)
62
+ self.actBullets = QAction("Bulleted list", self)
63
+ self.actBullets.triggered.connect(self.bulletsRequested)
64
+ self.actNumbers = QAction("Numbered list", self)
65
+ self.actNumbers.triggered.connect(self.numbersRequested)
65
66
 
66
67
  # 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)
68
+ self.actAlignL = QAction("Align left", self)
69
+ self.actAlignC = QAction("Align center", self)
70
+ self.actAlignR = QAction("Align right", self)
71
+ self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
72
+ self.actAlignC.triggered.connect(
73
+ lambda: self.alignRequested.emit(Qt.AlignHCenter)
76
74
  )
77
- right.triggered.connect(
78
- lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
75
+ self.actAlignR.triggered.connect(
76
+ lambda: self.alignRequested.emit(Qt.AlignRight)
79
77
  )
80
78
 
81
79
  self.addActions(
82
80
  [
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,
81
+ self.actBold,
82
+ self.actItalic,
83
+ self.actUnderline,
84
+ self.actStrike,
85
+ self.actCode,
86
+ self.actH1,
87
+ self.actH2,
88
+ self.actH3,
89
+ self.actNormal,
90
+ self.actBullets,
91
+ self.actNumbers,
92
+ self.actAlignL,
93
+ self.actAlignC,
94
+ self.actAlignR,
97
95
  ]
98
96
  )
97
+
98
+ def _apply_toolbar_styles(self):
99
+ self._style_letter_button(self.actBold, "B", bold=True)
100
+ self._style_letter_button(self.actItalic, "I", italic=True)
101
+ self._style_letter_button(self.actUnderline, "U", underline=True)
102
+ self._style_letter_button(self.actStrike, "S", strike=True)
103
+
104
+ # Monospace look for code; use a fixed font
105
+ code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
106
+ self._style_letter_button(self.actCode, "</>", custom_font=code_font)
107
+
108
+ # Headings
109
+ self._style_letter_button(self.actH1, "H1")
110
+ self._style_letter_button(self.actH2, "H2")
111
+ self._style_letter_button(self.actH3, "H3")
112
+ self._style_letter_button(self.actNormal, "N")
113
+
114
+ # Lists
115
+ self._style_letter_button(self.actBullets, "•")
116
+ self._style_letter_button(self.actNumbers, "1.")
117
+
118
+ # Alignment
119
+ self._style_letter_button(self.actAlignL, "L")
120
+ self._style_letter_button(self.actAlignC, "C")
121
+ self._style_letter_button(self.actAlignR, "R")
122
+
123
+ def _style_letter_button(
124
+ self,
125
+ action: QAction,
126
+ text: str,
127
+ *,
128
+ bold: bool = False,
129
+ italic: bool = False,
130
+ underline: bool = False,
131
+ strike: bool = False,
132
+ custom_font: QFont | None = None,
133
+ ):
134
+ btn = self.widgetForAction(action)
135
+ if not btn:
136
+ return
137
+ btn.setText(text)
138
+ f = custom_font if custom_font is not None else QFont(btn.font())
139
+ if custom_font is None:
140
+ f.setBold(bold)
141
+ f.setItalic(italic)
142
+ f.setUnderline(underline)
143
+ f.setStrikeOut(strike)
144
+ btn.setFont(f)
145
+
146
+ # Keep accessibility/tooltip readable
147
+ btn.setToolTip(action.text())
148
+ btn.setAccessibleName(action.text())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
@@ -29,7 +29,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
29
29
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
30
30
 
31
31
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
32
- to disk.
32
+ to disk unless the user configures it to be in the settings.
33
33
 
34
34
  There is deliberately no network connectivity or syncing intended.
35
35
 
@@ -39,22 +39,21 @@ There is deliberately no network connectivity or syncing intended.
39
39
 
40
40
  ## Features
41
41
 
42
+ * Data is encrypted at rest
43
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
42
44
  * Every 'page' is linked to the calendar day
43
45
  * Text is HTML with basic styling
44
46
  * Search
45
47
  * Automatic periodic saving (or explicitly save)
46
48
  * Transparent integrity checking of the database when it opens
47
49
  * Rekey the database (change the password)
48
-
49
-
50
- ## Yet to do
51
-
52
- * Taxonomy/tagging
53
- * Export to other formats (plaintext, json, sql etc)
50
+ * Export the database to json, txt, html or csv
54
51
 
55
52
 
56
53
  ## How to install
57
54
 
55
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
56
+
58
57
  ### From source
59
58
 
60
59
  * Clone this repo or download the tarball from the releases page
@@ -0,0 +1,16 @@
1
+ bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
2
+ bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
+ bouquin/db.py,sha256=s3FDphbi6zxpHEFHnz44saZ9qAV4wU4WEiE6V95PkmI,7877
4
+ bouquin/editor.py,sha256=vPLqysUNinUO6gtJQ8uDxJ_BL-lcaq0IXLStlG63k4E,8042
5
+ bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
6
+ bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
7
+ bouquin/main_window.py,sha256=48lq5trwORpGWko6jWLGBk-_7PrtaQfZT1l-jbz67rY,15427
8
+ bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
9
+ bouquin/settings.py,sha256=aEsIIlYGwSxCVXXMpo98192QzatIIP6OvQDtcKrYWW4,742
10
+ bouquin/settings_dialog.py,sha256=kWR4OeeHd5uQZ6lfHtuYx3UIh_MCb-nhjHcDyhQhpKM,5747
11
+ bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
12
+ bouquin-0.1.3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
+ bouquin-0.1.3.dist-info/METADATA,sha256=y2FvqLWDTEYj1E2LCYAezvRsbycKVV71pVy9AaZv9EY,2468
14
+ bouquin-0.1.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
15
+ bouquin-0.1.3.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
16
+ bouquin-0.1.3.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
2
- bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
- bouquin/db.py,sha256=LlKf_AzaJpzgN3cjxUshsHLybaIATgfQF1g9G92yYEw,3641
4
- bouquin/editor.py,sha256=HY5ASmSTiwb_pQzEdqyMBhKFOojw1bppuCk4FacE660,3540
5
- bouquin/key_prompt.py,sha256=RNrW0bN4xnwDGeBlgbmFaBSs_2iQyYrBYpKOQhe4E0c,1092
6
- bouquin/main.py,sha256=u7Wm5-9LRZDKkzKkK0W6P4oTtDorrrmtwIJWmQCqsRs,351
7
- bouquin/main_window.py,sha256=LOu80m5r6bg-tjY1R-Ol5H4bLUCVJbOR6nN2ykN7Q1M,10363
8
- bouquin/search.py,sha256=uTHkxsKrcWqVpXEbOMqCkqrAfVsQvIvgvDV6YNH06lA,6516
9
- bouquin/settings.py,sha256=bJYQXbTqX_r_DfOKuGnah6IVZLiNwZAuBuz2OgdhA_E,670
10
- bouquin/settings_dialog.py,sha256=HV7IERazYBjvMXyVkm9FmZqu3gVHlceNrfFaW_fQJHE,3150
11
- bouquin/toolbar.py,sha256=jPsix5f8VErO-P_cjRo_ZWHPW7KGGAAlHbC5S-2uStg,3050
12
- bouquin-0.1.2.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
- bouquin-0.1.2.dist-info/METADATA,sha256=XLFFF4yUWZVjEVAliPz4XdP_qP1yUkhtr7CBGYCgSy0,2230
14
- bouquin-0.1.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
15
- bouquin-0.1.2.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
16
- bouquin-0.1.2.dist-info/RECORD,,