bouquin 0.1.2__py3-none-any.whl → 0.1.4__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,15 +1,23 @@
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
10
17
  class DBConfig:
11
18
  path: Path
12
19
  key: str
20
+ idle_minutes: int = 15 # 0 = never lock
13
21
 
14
22
 
15
23
  class DBManager:
@@ -21,9 +29,9 @@ class DBManager:
21
29
  # Ensure parent dir exists
22
30
  self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
23
31
  self.conn = sqlite.connect(str(self.cfg.path))
32
+ self.conn.row_factory = sqlite.Row
24
33
  cur = self.conn.cursor()
25
34
  cur.execute(f"PRAGMA key = '{self.cfg.key}';")
26
- cur.execute("PRAGMA cipher_compatibility = 4;")
27
35
  cur.execute("PRAGMA journal_mode = WAL;")
28
36
  self.conn.commit()
29
37
  try:
@@ -103,14 +111,116 @@ class DBManager:
103
111
  def search_entries(self, text: str) -> list[str]:
104
112
  cur = self.conn.cursor()
105
113
  pattern = f"%{text}%"
106
- cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
107
- return [r for r in cur.fetchall()]
114
+ return cur.execute(
115
+ "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
116
+ ).fetchall()
108
117
 
109
118
  def dates_with_content(self) -> list[str]:
110
119
  cur = self.conn.cursor()
111
120
  cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
112
121
  return [r[0] for r in cur.fetchall()]
113
122
 
123
+ def get_all_entries(self) -> List[Entry]:
124
+ cur = self.conn.cursor()
125
+ rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
126
+ return [(row["date"], row["content"]) for row in rows]
127
+
128
+ def export_json(
129
+ self, entries: Sequence[Entry], file_path: str, pretty: bool = True
130
+ ) -> None:
131
+ data = [{"date": d, "content": c} for d, c in entries]
132
+ with open(file_path, "w", encoding="utf-8") as f:
133
+ if pretty:
134
+ json.dump(data, f, ensure_ascii=False, indent=2)
135
+ else:
136
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
137
+
138
+ def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
139
+ # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
140
+ with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
141
+ writer = csv.writer(f)
142
+ writer.writerow(["date", "content"]) # header
143
+ writer.writerows(entries)
144
+
145
+ def export_txt(
146
+ self,
147
+ entries: Sequence[Entry],
148
+ file_path: str,
149
+ separator: str = "\n\n— — — — —\n\n",
150
+ strip_html: bool = True,
151
+ ) -> None:
152
+ import re, html as _html
153
+
154
+ # Precompiled patterns
155
+ STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
156
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
157
+ BR_RE = re.compile(r"(?i)<br\\s*/?>")
158
+ BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
159
+ TAG_RE = re.compile(r"<[^>]+>")
160
+ WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
161
+ MULTINEWLINE_RE = re.compile(r"\\n{3,}")
162
+
163
+ def _strip(s: str) -> str:
164
+ # 1) Remove <style> and <script> blocks *including their contents*
165
+ s = STYLE_SCRIPT_RE.sub("", s)
166
+ # 2) Remove HTML comments
167
+ s = COMMENT_RE.sub("", s)
168
+ # 3) Turn some block-ish boundaries into newlines before removing tags
169
+ s = BR_RE.sub("\n", s)
170
+ s = BLOCK_END_RE.sub("\n", s)
171
+ # 4) Drop remaining tags
172
+ s = TAG_RE.sub("", s)
173
+ # 5) Unescape entities (&nbsp; etc.)
174
+ s = _html.unescape(s)
175
+ # 6) Tidy whitespace
176
+ s = WS_ENDS_RE.sub("\n", s)
177
+ s = MULTINEWLINE_RE.sub("\n\n", s)
178
+ return s.strip()
179
+
180
+ with open(file_path, "w", encoding="utf-8") as f:
181
+ for i, (d, c) in enumerate(entries):
182
+ body = _strip(c) if strip_html else c
183
+ f.write(f"{d}\n{body}\n")
184
+ if i < len(entries) - 1:
185
+ f.write(separator)
186
+
187
+ def export_html(
188
+ self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
189
+ ) -> None:
190
+ parts = [
191
+ "<!doctype html>",
192
+ '<html lang="en">',
193
+ '<meta charset="utf-8">',
194
+ f"<title>{html.escape(title)}</title>",
195
+ "<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
196
+ "article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
197
+ "<body>",
198
+ f"<h1>{html.escape(title)}</h1>",
199
+ ]
200
+ for d, c in entries:
201
+ parts.append(
202
+ f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
203
+ )
204
+ parts.append("</body></html>")
205
+
206
+ with open(file_path, "w", encoding="utf-8") as f:
207
+ f.write("\n".join(parts))
208
+
209
+ def export_by_extension(self, file_path: str) -> None:
210
+ entries = self.get_all_entries()
211
+ ext = os.path.splitext(file_path)[1].lower()
212
+
213
+ if ext == ".json":
214
+ self.export_json(entries, file_path)
215
+ elif ext == ".csv":
216
+ self.export_csv(entries, file_path)
217
+ elif ext == ".txt":
218
+ self.export_txt(entries, file_path)
219
+ elif ext in {".html", ".htm"}:
220
+ self.export_html(entries, file_path)
221
+ else:
222
+ raise ValueError(f"Unsupported extension: {ext}")
223
+
114
224
  def close(self) -> None:
115
225
  if self.conn is not None:
116
226
  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,18 +1,26 @@
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, QEvent
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,
20
+ QLabel,
14
21
  QMainWindow,
15
22
  QMessageBox,
23
+ QPushButton,
16
24
  QSizePolicy,
17
25
  QSplitter,
18
26
  QVBoxLayout,
@@ -23,11 +31,66 @@ from .db import DBManager
23
31
  from .editor import Editor
24
32
  from .key_prompt import KeyPrompt
25
33
  from .search import Search
26
- from .settings import APP_NAME, load_db_config, save_db_config
34
+ from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
27
35
  from .settings_dialog import SettingsDialog
28
36
  from .toolbar import ToolBar
29
37
 
30
38
 
39
+ class _LockOverlay(QWidget):
40
+ def __init__(self, parent: QWidget, on_unlock: callable):
41
+ super().__init__(parent)
42
+ self.setObjectName("LockOverlay")
43
+ self.setAttribute(Qt.WA_StyledBackground, True)
44
+ self.setFocusPolicy(Qt.StrongFocus)
45
+ self.setGeometry(parent.rect())
46
+
47
+ self.setStyleSheet(
48
+ """
49
+ #LockOverlay { background-color: #ccc; }
50
+ #LockOverlay QLabel { color: #fff; font-size: 18px; }
51
+ #LockOverlay QPushButton {
52
+ background-color: #f2f2f2;
53
+ color: #000;
54
+ padding: 6px 14px;
55
+ border: 1px solid #808080;
56
+ border-radius: 6px;
57
+ font-size: 14px;
58
+ }
59
+ #LockOverlay QPushButton:hover { background-color: #ffffff; }
60
+ #LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
61
+ """
62
+ )
63
+
64
+ lay = QVBoxLayout(self)
65
+ lay.addStretch(1)
66
+
67
+ msg = QLabel("Locked due to inactivity")
68
+ msg.setAlignment(Qt.AlignCenter)
69
+
70
+ self._btn = QPushButton("Unlock")
71
+ self._btn.setFixedWidth(200)
72
+ self._btn.setCursor(Qt.PointingHandCursor)
73
+ self._btn.setAutoDefault(True)
74
+ self._btn.setDefault(True)
75
+ self._btn.clicked.connect(on_unlock)
76
+
77
+ lay.addWidget(msg, 0, Qt.AlignCenter)
78
+ lay.addWidget(self._btn, 0, Qt.AlignCenter)
79
+ lay.addStretch(1)
80
+
81
+ self.hide() # start hidden
82
+
83
+ # keep overlay sized with its parent
84
+ def eventFilter(self, obj, event):
85
+ if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
86
+ self.setGeometry(obj.rect())
87
+ return False
88
+
89
+ def showEvent(self, e):
90
+ super().showEvent(e)
91
+ self._btn.setFocus()
92
+
93
+
31
94
  class MainWindow(QMainWindow):
32
95
  def __init__(self):
33
96
  super().__init__()
@@ -35,9 +98,18 @@ class MainWindow(QMainWindow):
35
98
  self.setMinimumSize(1000, 650)
36
99
 
37
100
  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)
101
+ if not os.path.exists(self.cfg.path):
102
+ # Fresh database/first time use, so guide the user re: setting a key
103
+ first_time = True
104
+ else:
105
+ first_time = False
106
+
107
+ # Prompt for the key unless it is found in config
108
+ if not self.cfg.key:
109
+ if not self._prompt_for_key_until_valid(first_time):
110
+ sys.exit(1)
111
+ else:
112
+ self._try_connect()
41
113
 
42
114
  # ---- UI: Left fixed panel (calendar) + right editor -----------------
43
115
  self.calendar = QCalendarWidget()
@@ -62,18 +134,18 @@ class MainWindow(QMainWindow):
62
134
  self.editor = Editor()
63
135
 
64
136
  # Toolbar for controlling styling
65
- tb = ToolBar()
66
- self.addToolBar(tb)
137
+ self.toolBar = ToolBar()
138
+ self.addToolBar(self.toolBar)
67
139
  # 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)
140
+ self.toolBar.boldRequested.connect(self.editor.apply_weight)
141
+ self.toolBar.italicRequested.connect(self.editor.apply_italic)
142
+ self.toolBar.underlineRequested.connect(self.editor.apply_underline)
143
+ self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
144
+ self.toolBar.codeRequested.connect(self.editor.apply_code)
145
+ self.toolBar.headingRequested.connect(self.editor.apply_heading)
146
+ self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
147
+ self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
148
+ self.toolBar.alignRequested.connect(self.editor.setAlignment)
77
149
 
78
150
  split = QSplitter()
79
151
  split.addWidget(left_panel)
@@ -85,20 +157,42 @@ class MainWindow(QMainWindow):
85
157
  lay.addWidget(split)
86
158
  self.setCentralWidget(container)
87
159
 
160
+ # Idle lock setup
161
+ self._idle_timer = QTimer(self)
162
+ self._idle_timer.setSingleShot(True)
163
+ self._idle_timer.timeout.connect(self._enter_lock)
164
+ self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
165
+ self._idle_timer.start()
166
+
167
+ # full-window overlay that sits on top of the central widget
168
+ self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
169
+ self.centralWidget().installEventFilter(self._lock_overlay)
170
+
171
+ self._locked = False
172
+
173
+ # reset idle timer on any key press anywhere in the app
174
+ from PySide6.QtWidgets import QApplication
175
+
176
+ QApplication.instance().installEventFilter(self)
177
+
88
178
  # Status bar for feedback
89
179
  self.statusBar().showMessage("Ready", 800)
90
180
 
91
181
  # Menu bar (File)
92
182
  mb = self.menuBar()
93
- file_menu = mb.addMenu("&Application")
183
+ file_menu = mb.addMenu("&File")
94
184
  act_save = QAction("&Save", self)
95
185
  act_save.setShortcut("Ctrl+S")
96
186
  act_save.triggered.connect(lambda: self._save_current(explicit=True))
97
187
  file_menu.addAction(act_save)
98
- act_settings = QAction("S&ettings", self)
99
- act_settings.setShortcut("Ctrl+E")
188
+ act_settings = QAction("Settin&gs", self)
189
+ act_settings.setShortcut("Ctrl+G")
100
190
  act_settings.triggered.connect(self._open_settings)
101
191
  file_menu.addAction(act_settings)
192
+ act_export = QAction("&Export", self)
193
+ act_export.setShortcut("Ctrl+E")
194
+ act_export.triggered.connect(self._export)
195
+ file_menu.addAction(act_export)
102
196
  file_menu.addSeparator()
103
197
  act_quit = QAction("&Quit", self)
104
198
  act_quit.setShortcut("Ctrl+Q")
@@ -128,6 +222,21 @@ class MainWindow(QMainWindow):
128
222
  nav_menu.addAction(act_today)
129
223
  self.addAction(act_today)
130
224
 
225
+ # Help menu with drop-down
226
+ help_menu = mb.addMenu("&Help")
227
+ act_docs = QAction("Documentation", self)
228
+ act_docs.setShortcut("Ctrl+D")
229
+ act_docs.setShortcutContext(Qt.ApplicationShortcut)
230
+ act_docs.triggered.connect(self._open_docs)
231
+ help_menu.addAction(act_docs)
232
+ self.addAction(act_docs)
233
+ act_bugs = QAction("Report a bug", self)
234
+ act_bugs.setShortcut("Ctrl+R")
235
+ act_bugs.setShortcutContext(Qt.ApplicationShortcut)
236
+ act_bugs.triggered.connect(self._open_bugs)
237
+ help_menu.addAction(act_bugs)
238
+ self.addAction(act_bugs)
239
+
131
240
  # Autosave
132
241
  self._dirty = False
133
242
  self._save_timer = QTimer(self)
@@ -139,6 +248,10 @@ class MainWindow(QMainWindow):
139
248
  self._load_selected_date()
140
249
  self._refresh_calendar_marks()
141
250
 
251
+ # Restore window position from settings
252
+ self.settings = QSettings(APP_ORG, APP_NAME)
253
+ self._restore_window_position()
254
+
142
255
  def _try_connect(self) -> bool:
143
256
  """
144
257
  Try to connect to the database.
@@ -155,12 +268,18 @@ class MainWindow(QMainWindow):
155
268
  return False
156
269
  return ok
157
270
 
158
- def _prompt_for_key_until_valid(self) -> bool:
271
+ def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
159
272
  """
160
273
  Prompt for the SQLCipher key.
161
274
  """
275
+ if first_time:
276
+ title = "Set an encryption key"
277
+ message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
278
+ else:
279
+ title = "Unlock encrypted notebook"
280
+ message = "Enter your key to unlock the notebook"
162
281
  while True:
163
- dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
282
+ dlg = KeyPrompt(self, title, message)
164
283
  if dlg.exec() != QDialog.Accepted:
165
284
  return False
166
285
  self.cfg.key = dlg.key()
@@ -206,6 +325,8 @@ class MainWindow(QMainWindow):
206
325
  self._dirty = False
207
326
  # track which date the editor currently represents
208
327
  self._active_date_iso = date_iso
328
+ qd = QDate.fromString(date_iso, "yyyy-MM-dd")
329
+ self.calendar.setSelectedDate(qd)
209
330
 
210
331
  def _on_text_changed(self):
211
332
  self._dirty = True
@@ -265,24 +386,193 @@ class MainWindow(QMainWindow):
265
386
 
266
387
  def _open_settings(self):
267
388
  dlg = SettingsDialog(self.cfg, self.db, self)
268
- if dlg.exec() == QDialog.Accepted:
269
- new_cfg = dlg.config
270
- if new_cfg.path != self.cfg.path:
271
- # Save the new path to the notebook
272
- self.cfg.path = new_cfg.path
273
- save_db_config(self.cfg)
274
- self.db.close()
275
- # Prompt again for the key for the new path
276
- if not self._prompt_for_key_until_valid():
277
- QMessageBox.warning(
278
- self, "Reopen failed", "Could not unlock database at new path."
279
- )
280
- return
281
- self._load_selected_date()
282
- self._refresh_calendar_marks()
389
+ if dlg.exec() != QDialog.Accepted:
390
+ return
391
+
392
+ new_cfg = dlg.config
393
+ old_path = self.cfg.path
394
+
395
+ # Update in-memory config from the dialog
396
+ self.cfg.path = new_cfg.path
397
+ self.cfg.key = new_cfg.key
398
+ self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
399
+
400
+ # Persist once
401
+ save_db_config(self.cfg)
402
+
403
+ # Apply idle setting immediately (restart the timer with new interval if it changed)
404
+ self._apply_idle_minutes(self.cfg.idle_minutes)
283
405
 
406
+ # If the DB path changed, reconnect
407
+ if self.cfg.path != old_path:
408
+ self.db.close()
409
+ if not self._prompt_for_key_until_valid(first_time=False):
410
+ QMessageBox.warning(
411
+ self, "Reopen failed", "Could not unlock database at new path."
412
+ )
413
+ return
414
+ self._load_selected_date()
415
+ self._refresh_calendar_marks()
416
+
417
+ def _restore_window_position(self):
418
+ geom = self.settings.value("main/geometry", None)
419
+ state = self.settings.value("main/windowState", None)
420
+ was_max = self.settings.value("main/maximized", False, type=bool)
421
+
422
+ if geom is not None:
423
+ self.restoreGeometry(geom)
424
+ if state is not None:
425
+ self.restoreState(state)
426
+ if not self._rect_on_any_screen(self.frameGeometry()):
427
+ self._move_to_cursor_screen_center()
428
+ else:
429
+ # First run: place window on the screen where the mouse cursor is.
430
+ self._move_to_cursor_screen_center()
431
+
432
+ # If it was maximized, do that AFTER the window exists in the event loop.
433
+ if was_max:
434
+ QTimer.singleShot(0, self.showMaximized)
435
+
436
+ def _rect_on_any_screen(self, rect):
437
+ for sc in QGuiApplication.screens():
438
+ if sc.availableGeometry().intersects(rect):
439
+ return True
440
+ return False
441
+
442
+ def _move_to_cursor_screen_center(self):
443
+ screen = (
444
+ QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
445
+ )
446
+ r = screen.availableGeometry()
447
+ # Center the window in that screen’s available area
448
+ self.move(r.center() - self.rect().center())
449
+
450
+ @Slot()
451
+ def _export(self):
452
+ try:
453
+ self.export_dialog()
454
+ except Exception as e:
455
+ QMessageBox.critical(self, "Export failed", str(e))
456
+
457
+ def export_dialog(self) -> None:
458
+ filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
459
+
460
+ start_dir = os.path.join(os.path.expanduser("~"), "Documents")
461
+ filename, selected_filter = QFileDialog.getSaveFileName(
462
+ self, "Export entries", start_dir, filters
463
+ )
464
+ if not filename:
465
+ return # user cancelled
466
+
467
+ default_ext = {
468
+ "Text (*.txt)": ".txt",
469
+ "JSON (*.json)": ".json",
470
+ "CSV (*.csv)": ".csv",
471
+ "HTML (*.html)": ".html",
472
+ }.get(selected_filter, ".txt")
473
+
474
+ if not Path(filename).suffix:
475
+ filename += default_ext
476
+
477
+ try:
478
+ entries = self.db.get_all_entries()
479
+ if selected_filter.startswith("Text"):
480
+ self.db.export_txt(entries, filename)
481
+ elif selected_filter.startswith("JSON"):
482
+ self.db.export_json(entries, filename)
483
+ elif selected_filter.startswith("CSV"):
484
+ self.db.export_csv(entries, filename)
485
+ elif selected_filter.startswith("HTML"):
486
+ self.bd.export_html(entries, filename)
487
+ else:
488
+ self.bd.export_by_extension(entries, filename)
489
+
490
+ QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
491
+ except Exception as e:
492
+ QMessageBox.critical(self, "Export failed", str(e))
493
+
494
+ def _open_docs(self):
495
+ url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
496
+ url = QUrl.fromUserInput(url_str)
497
+ if not QDesktopServices.openUrl(url):
498
+ QMessageBox.warning(
499
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
500
+ )
501
+
502
+ def _open_bugs(self):
503
+ url_str = "https://nr.mig5.net/forms/mig5/contact"
504
+ url = QUrl.fromUserInput(url_str)
505
+ if not QDesktopServices.openUrl(url):
506
+ QMessageBox.warning(
507
+ self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
508
+ )
509
+
510
+ # Idle handlers
511
+ def _apply_idle_minutes(self, minutes: int):
512
+ minutes = max(0, int(minutes))
513
+ if not hasattr(self, "_idle_timer"):
514
+ return
515
+ if minutes == 0:
516
+ self._idle_timer.stop()
517
+ # If you’re currently locked, unlock when user disables the timer:
518
+ if getattr(self, "_locked", False):
519
+ try:
520
+ self._locked = False
521
+ if hasattr(self, "_lock_overlay"):
522
+ self._lock_overlay.hide()
523
+ except Exception:
524
+ pass
525
+ else:
526
+ self._idle_timer.setInterval(minutes * 60 * 1000)
527
+ if not getattr(self, "_locked", False):
528
+ self._idle_timer.start()
529
+
530
+ def eventFilter(self, obj, event):
531
+ if event.type() == QEvent.KeyPress and not self._locked:
532
+ self._idle_timer.start()
533
+ return super().eventFilter(obj, event)
534
+
535
+ def _enter_lock(self):
536
+ if self._locked:
537
+ return
538
+ self._locked = True
539
+ if self.menuBar():
540
+ self.menuBar().setEnabled(False)
541
+ if self.statusBar():
542
+ self.statusBar().setEnabled(False)
543
+ tb = getattr(self, "toolBar", None)
544
+ if tb:
545
+ tb.setEnabled(False)
546
+ self._lock_overlay.show()
547
+ self._lock_overlay.raise_()
548
+
549
+ @Slot()
550
+ def _on_unlock_clicked(self):
551
+ try:
552
+ ok = self._prompt_for_key_until_valid(first_time=False)
553
+ except Exception as e:
554
+ QMessageBox.critical(self, "Unlock failed", str(e))
555
+ return
556
+ if ok:
557
+ self._locked = False
558
+ self._lock_overlay.hide()
559
+ if self.menuBar():
560
+ self.menuBar().setEnabled(True)
561
+ if self.statusBar():
562
+ self.statusBar().setEnabled(True)
563
+ tb = getattr(self, "toolBar", None)
564
+ if tb:
565
+ tb.setEnabled(True)
566
+ self._idle_timer.start()
567
+
568
+ # Close app handler - save window position and database
284
569
  def closeEvent(self, event):
285
570
  try:
571
+ # Save window position
572
+ self.settings.setValue("main/geometry", self.saveGeometry())
573
+ self.settings.setValue("main/windowState", self.saveState())
574
+ self.settings.setValue("main/maximized", self.isMaximized())
575
+ # Ensure we save any last pending edits to the db
286
576
  self._save_current()
287
577
  self.db.close()
288
578
  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,13 @@ 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
+ idle = s.value("db/idle_minutes", 15, type=int)
26
+ return DBConfig(path=path, key=key, idle_minutes=idle)
25
27
 
26
28
 
27
29
  def save_db_config(cfg: DBConfig) -> None:
28
30
  s = get_settings()
29
31
  s.setValue("db/path", str(cfg.path))
32
+ s.setValue("db/key", str(cfg.key))
33
+ s.setValue("db/idle_minutes", str(cfg.idle_minutes))
@@ -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,
@@ -13,11 +17,15 @@ from PySide6.QtWidgets import (
13
17
  QFileDialog,
14
18
  QDialogButtonBox,
15
19
  QSizePolicy,
20
+ QSpinBox,
16
21
  QMessageBox,
17
22
  )
23
+ from PySide6.QtCore import Qt, Slot
24
+ from PySide6.QtGui import QPalette
25
+
18
26
 
19
27
  from .db import DBConfig, DBManager
20
- from .settings import save_db_config
28
+ from .settings import load_db_config, save_db_config
21
29
  from .key_prompt import KeyPrompt
22
30
 
23
31
 
@@ -27,10 +35,11 @@ class SettingsDialog(QDialog):
27
35
  self.setWindowTitle("Settings")
28
36
  self._cfg = DBConfig(path=cfg.path, key="")
29
37
  self._db = db
38
+ self.key = ""
30
39
 
31
40
  form = QFormLayout()
32
41
  form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
33
- self.setMinimumWidth(520)
42
+ self.setMinimumWidth(560)
34
43
  self.setSizeGripEnabled(True)
35
44
 
36
45
  self.path_edit = QLineEdit(str(self._cfg.path))
@@ -47,18 +56,88 @@ class SettingsDialog(QDialog):
47
56
  h.setStretch(1, 0)
48
57
  form.addRow("Database path", path_row)
49
58
 
59
+ # Encryption settings
60
+ enc_group = QGroupBox("Encryption and Privacy")
61
+ enc = QVBoxLayout(enc_group)
62
+ enc.setContentsMargins(12, 8, 12, 12)
63
+ enc.setSpacing(6)
64
+
65
+ # Checkbox to remember key
66
+ self.save_key_btn = QCheckBox("Remember key")
67
+ current_settings = load_db_config()
68
+ self.key = current_settings.key or ""
69
+ self.save_key_btn.setChecked(bool(self.key))
70
+ self.save_key_btn.setCursor(Qt.PointingHandCursor)
71
+ self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
72
+ enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
73
+
74
+ # Explanation for remembering key
75
+ self.save_key_label = QLabel(
76
+ "If you don't want to be prompted for your encryption key, check this to remember it. "
77
+ "WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
78
+ )
79
+ self.save_key_label.setWordWrap(True)
80
+ self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
81
+ # make it look secondary
82
+ pal = self.save_key_label.palette()
83
+ pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
84
+ self.save_key_label.setPalette(pal)
85
+
86
+ exp_row = QHBoxLayout()
87
+ exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
88
+ exp_row.addWidget(self.save_key_label)
89
+ enc.addLayout(exp_row)
90
+
91
+ line = QFrame()
92
+ line.setFrameShape(QFrame.HLine)
93
+ line.setFrameShadow(QFrame.Sunken)
94
+ enc.addWidget(line)
95
+
50
96
  # Change key button
51
97
  self.rekey_btn = QPushButton("Change key")
98
+ self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
52
99
  self.rekey_btn.clicked.connect(self._change_key)
100
+ enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
53
101
 
102
+ self.idle_spin = QSpinBox()
103
+ self.idle_spin.setRange(0, 240)
104
+ self.idle_spin.setSingleStep(1)
105
+ self.idle_spin.setAccelerated(True)
106
+ self.idle_spin.setSuffix(" min")
107
+ self.idle_spin.setSpecialValueText("Never")
108
+ self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
109
+ enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
110
+ # Explanation for idle option (autolock)
111
+ self.idle_spin_label = QLabel(
112
+ "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
113
+ "Set to 0 (never) to never lock."
114
+ )
115
+ self.idle_spin_label.setWordWrap(True)
116
+ self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
117
+ # make it look secondary
118
+ spal = self.idle_spin_label.palette()
119
+ spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
120
+ self.idle_spin_label.setPalette(spal)
121
+
122
+ spin_row = QHBoxLayout()
123
+ spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
124
+ spin_row.addWidget(self.idle_spin_label)
125
+ enc.addLayout(spin_row)
126
+
127
+ # Put the group into the form so it spans the full width nicely
128
+ form.addRow(enc_group)
129
+
130
+ # Buttons
54
131
  bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
55
132
  bb.accepted.connect(self._save)
56
133
  bb.rejected.connect(self.reject)
57
134
 
135
+ # Root layout (adjust margins/spacing a bit)
58
136
  v = QVBoxLayout(self)
137
+ v.setContentsMargins(12, 12, 12, 12)
138
+ v.setSpacing(10)
59
139
  v.addLayout(form)
60
- v.addWidget(self.rekey_btn)
61
- v.addWidget(bb)
140
+ v.addWidget(bb, 0, Qt.AlignRight)
62
141
 
63
142
  def _browse(self):
64
143
  p, _ = QFileDialog.getSaveFileName(
@@ -71,16 +150,21 @@ class SettingsDialog(QDialog):
71
150
  self.path_edit.setText(p)
72
151
 
73
152
  def _save(self):
74
- self._cfg = DBConfig(path=Path(self.path_edit.text()), key="")
153
+ key_to_save = self.key if self.save_key_btn.isChecked() else ""
154
+ self._cfg = DBConfig(
155
+ path=Path(self.path_edit.text()),
156
+ key=key_to_save,
157
+ idle_minutes=self.idle_spin.value(),
158
+ )
75
159
  save_db_config(self._cfg)
76
160
  self.accept()
77
161
 
78
162
  def _change_key(self):
79
- p1 = KeyPrompt(self, title="Change key", message="Enter new key")
163
+ p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
80
164
  if p1.exec() != QDialog.Accepted:
81
165
  return
82
166
  new_key = p1.key()
83
- p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
167
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
84
168
  if p2.exec() != QDialog.Accepted:
85
169
  return
86
170
  if new_key != p2.key():
@@ -92,11 +176,27 @@ class SettingsDialog(QDialog):
92
176
  try:
93
177
  self._db.rekey(new_key)
94
178
  QMessageBox.information(
95
- self, "Key changed", "The database key was updated."
179
+ self, "Key changed", "The notebook was re-encrypted with the new key!"
96
180
  )
97
181
  except Exception as e:
98
182
  QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
99
183
 
184
+ @Slot(bool)
185
+ def save_key_btn_clicked(self, checked: bool):
186
+ if checked:
187
+ if not self.key:
188
+ p1 = KeyPrompt(
189
+ self, title="Enter your key", message="Enter the encryption key"
190
+ )
191
+ if p1.exec() != QDialog.Accepted:
192
+ self.save_key_btn.blockSignals(True)
193
+ self.save_key_btn.setChecked(False)
194
+ self.save_key_btn.blockSignals(False)
195
+ return
196
+ self.key = p1.key() or ""
197
+ else:
198
+ self.key = ""
199
+
100
200
  @property
101
201
  def config(self) -> DBConfig:
102
202
  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.4
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=kvwZRP9fcG8V8paAU0iVR7qTYO8gGCq1qb2Wuog0dKE,7922
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=Q6HJjUU4kY05GeaxNEEoqDY79MN4tu2rlBH7qgEYjuY,20835
8
+ bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
9
+ bouquin/settings.py,sha256=GpMeJcTjdL1PFumeqdlSOi7nlgGdPTOeRbFadWYFcA0,870
10
+ bouquin/settings_dialog.py,sha256=pgIg2G5O092mPn5EmkKrEgtl-Tyc8dwwCyNSNEAOidA,7256
11
+ bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
12
+ bouquin-0.1.4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
+ bouquin-0.1.4.dist-info/METADATA,sha256=mJ_ZtOmlBZjkwq4ecGlCmzU3JVOBp_CwXoPscm37Tag,2468
14
+ bouquin-0.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
15
+ bouquin-0.1.4.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
16
+ bouquin-0.1.4.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,,