bouquin 0.1.10__py3-none-any.whl → 0.2.1.2__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.
bouquin/db.py CHANGED
@@ -3,10 +3,8 @@ from __future__ import annotations
3
3
  import csv
4
4
  import html
5
5
  import json
6
- import os
7
6
 
8
7
  from dataclasses import dataclass
9
- from markdownify import markdownify as md
10
8
  from pathlib import Path
11
9
  from sqlcipher3 import dbapi2 as sqlite
12
10
  from typing import List, Sequence, Tuple
@@ -257,68 +255,31 @@ class DBManager:
257
255
  ).fetchall()
258
256
  return [dict(r) for r in rows]
259
257
 
260
- def get_version(
261
- self,
262
- *,
263
- date_iso: str | None = None,
264
- version_no: int | None = None,
265
- version_id: int | None = None,
266
- ) -> dict | None:
258
+ def get_version(self, *, version_id: int) -> dict | None:
267
259
  """
268
- Fetch a specific version by (date, version_no) OR by version_id.
260
+ Fetch a specific version by version_id.
269
261
  Returns a dict with keys: id, date, version_no, created_at, note, content.
270
262
  """
271
263
  cur = self.conn.cursor()
272
- if version_id is not None:
273
- row = cur.execute(
274
- "SELECT id, date, version_no, created_at, note, content "
275
- "FROM versions WHERE id=?;",
276
- (version_id,),
277
- ).fetchone()
278
- else:
279
- if date_iso is None or version_no is None:
280
- raise ValueError(
281
- "Provide either version_id OR (date_iso and version_no)"
282
- )
283
- row = cur.execute(
284
- "SELECT id, date, version_no, created_at, note, content "
285
- "FROM versions WHERE date=? AND version_no=?;",
286
- (date_iso, version_no),
287
- ).fetchone()
264
+ row = cur.execute(
265
+ "SELECT id, date, version_no, created_at, note, content "
266
+ "FROM versions WHERE id=?;",
267
+ (version_id,),
268
+ ).fetchone()
288
269
  return dict(row) if row else None
289
270
 
290
- def revert_to_version(
291
- self,
292
- date_iso: str,
293
- *,
294
- version_no: int | None = None,
295
- version_id: int | None = None,
296
- ) -> None:
271
+ def revert_to_version(self, date_iso: str, version_id: int) -> None:
297
272
  """
298
273
  Point the page head (pages.current_version_id) to an existing version.
299
- Fast revert: no content is rewritten.
300
274
  """
301
- if self.conn is None:
302
- raise RuntimeError("Database is not connected")
303
275
  cur = self.conn.cursor()
304
276
 
305
- if version_id is None:
306
- if version_no is None:
307
- raise ValueError("Provide version_no or version_id")
308
- row = cur.execute(
309
- "SELECT id FROM versions WHERE date=? AND version_no=?;",
310
- (date_iso, version_no),
311
- ).fetchone()
312
- if row is None:
313
- raise ValueError("Version not found for this date")
314
- version_id = int(row["id"])
315
- else:
316
- # Ensure that version_id belongs to the given date
317
- row = cur.execute(
318
- "SELECT date FROM versions WHERE id=?;", (version_id,)
319
- ).fetchone()
320
- if row is None or row["date"] != date_iso:
321
- raise ValueError("version_id does not belong to the given date")
277
+ # Ensure that version_id belongs to the given date
278
+ row = cur.execute(
279
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
280
+ ).fetchone()
281
+ if row is None or row["date"] != date_iso:
282
+ raise ValueError("version_id does not belong to the given date")
322
283
 
323
284
  with self.conn:
324
285
  cur.execute(
@@ -342,20 +303,18 @@ class DBManager:
342
303
  ).fetchall()
343
304
  return [(r[0], r[1]) for r in rows]
344
305
 
345
- def export_json(
346
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
347
- ) -> None:
306
+ def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
348
307
  """
349
308
  Export to json.
350
309
  """
351
310
  data = [{"date": d, "content": c} for d, c in entries]
352
311
  with open(file_path, "w", encoding="utf-8") as f:
353
- if pretty:
354
- json.dump(data, f, ensure_ascii=False, indent=2)
355
- else:
356
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
312
+ json.dump(data, f, ensure_ascii=False, indent=2)
357
313
 
358
314
  def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
315
+ """
316
+ Export pages to CSV.
317
+ """
359
318
  # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
360
319
  with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
361
320
  writer = csv.writer(f)
@@ -369,6 +328,10 @@ class DBManager:
369
328
  separator: str = "\n\n— — — — —\n\n",
370
329
  strip_html: bool = True,
371
330
  ) -> None:
331
+ """
332
+ Strip the HTML from the latest version of the pages
333
+ and save to a text file.
334
+ """
372
335
  import re, html as _html
373
336
 
374
337
  # Precompiled patterns
@@ -407,6 +370,9 @@ class DBManager:
407
370
  def export_html(
408
371
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
409
372
  ) -> None:
373
+ """
374
+ Export to HTML with a heading.
375
+ """
410
376
  parts = [
411
377
  "<!doctype html>",
412
378
  '<html lang="en">',
@@ -429,25 +395,17 @@ class DBManager:
429
395
  def export_markdown(
430
396
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
431
397
  ) -> None:
432
- parts = [
433
- "<!doctype html>",
434
- '<html lang="en">',
435
- "<body>",
436
- f"<h1>{html.escape(title)}</h1>",
437
- ]
398
+ """
399
+ Export to HTML, similar to export_html, but then convert to Markdown
400
+ using markdownify, and finally save to file.
401
+ """
402
+ parts = []
438
403
  for d, c in entries:
439
- parts.append(
440
- f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
441
- )
442
- parts.append("</body></html>")
443
-
444
- # Convert html to markdown
445
- md_items = []
446
- for item in parts:
447
- md_items.append(md(item, heading_style="ATX"))
404
+ parts.append(f"# {d}")
405
+ parts.append(c)
448
406
 
449
407
  with open(file_path, "w", encoding="utf-8") as f:
450
- f.write("\n".join(md_items))
408
+ f.write("\n".join(parts))
451
409
 
452
410
  def export_sql(self, file_path: str) -> None:
453
411
  """
@@ -468,21 +426,6 @@ class DBManager:
468
426
  cur.execute("SELECT sqlcipher_export('backup')")
469
427
  cur.execute("DETACH DATABASE backup")
470
428
 
471
- def export_by_extension(self, file_path: str) -> None:
472
- entries = self.get_all_entries()
473
- ext = os.path.splitext(file_path)[1].lower()
474
-
475
- if ext == ".json":
476
- self.export_json(entries, file_path)
477
- elif ext == ".csv":
478
- self.export_csv(entries, file_path)
479
- elif ext == ".txt":
480
- self.export_txt(entries, file_path)
481
- elif ext in {".html", ".htm"}:
482
- self.export_html(entries, file_path)
483
- else:
484
- raise ValueError(f"Unsupported extension: {ext}")
485
-
486
429
  def compact(self) -> None:
487
430
  """
488
431
  Runs VACUUM on the db.
bouquin/find_bar.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtGui import (
5
+ QShortcut,
6
+ QTextCursor,
7
+ QTextCharFormat,
8
+ QTextDocument,
9
+ )
10
+ from PySide6.QtWidgets import (
11
+ QWidget,
12
+ QHBoxLayout,
13
+ QLineEdit,
14
+ QLabel,
15
+ QPushButton,
16
+ QCheckBox,
17
+ QTextEdit,
18
+ )
19
+
20
+
21
+ class FindBar(QWidget):
22
+ """Widget for finding text in the Editor"""
23
+
24
+ # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
25
+ closed = Signal()
26
+
27
+ def __init__(
28
+ self,
29
+ editor: QTextEdit,
30
+ shortcut_parent: QWidget | None = None,
31
+ parent: QWidget | None = None,
32
+ ):
33
+
34
+ super().__init__(parent)
35
+
36
+ # store how to get the current editor
37
+ self._editor_getter = editor if callable(editor) else (lambda: editor)
38
+ self.shortcut_parent = shortcut_parent
39
+
40
+ # UI (build ONCE)
41
+ layout = QHBoxLayout(self)
42
+ layout.setContentsMargins(6, 0, 6, 0)
43
+
44
+ layout.addWidget(QLabel("Find:"))
45
+
46
+ self.edit = QLineEdit(self)
47
+ self.edit.setPlaceholderText("Type to search")
48
+ layout.addWidget(self.edit)
49
+
50
+ self.case = QCheckBox("Match case", self)
51
+ layout.addWidget(self.case)
52
+
53
+ self.prevBtn = QPushButton("Prev", self)
54
+ self.nextBtn = QPushButton("Next", self)
55
+ self.closeBtn = QPushButton("✕", self)
56
+ self.closeBtn.setFlat(True)
57
+ layout.addWidget(self.prevBtn)
58
+ layout.addWidget(self.nextBtn)
59
+ layout.addWidget(self.closeBtn)
60
+
61
+ self.setVisible(False)
62
+
63
+ # Shortcut (press Esc to hide bar)
64
+ sp = (
65
+ self.shortcut_parent
66
+ if self.shortcut_parent is not None
67
+ else (self.parent() or self)
68
+ )
69
+ QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
70
+
71
+ # Signals (connect ONCE)
72
+ self.edit.returnPressed.connect(self.find_next)
73
+ self.edit.textChanged.connect(self._update_highlight)
74
+ self.case.toggled.connect(self._update_highlight)
75
+ self.nextBtn.clicked.connect(self.find_next)
76
+ self.prevBtn.clicked.connect(self.find_prev)
77
+ self.closeBtn.clicked.connect(self.hide_bar)
78
+
79
+ @property
80
+ def editor(self) -> QTextEdit | None:
81
+ """Get the current editor"""
82
+ return self._editor_getter()
83
+
84
+ # ----- Public API -----
85
+
86
+ def show_bar(self):
87
+ """Show the bar, seed with current selection if sensible, focus the line edit."""
88
+ if not self.editor:
89
+ return
90
+ tc = self.editor.textCursor()
91
+ sel = tc.selectedText().strip()
92
+ if sel and "\u2029" not in sel: # ignore multi-paragraph selections
93
+ self.edit.setText(sel)
94
+ self.setVisible(True)
95
+ self.edit.setFocus(Qt.ShortcutFocusReason)
96
+ self.edit.selectAll()
97
+ self._update_highlight()
98
+
99
+ def hide_bar(self):
100
+ self.setVisible(False)
101
+ self._clear_highlight()
102
+ self.closed.emit()
103
+
104
+ def refresh(self):
105
+ """Recompute highlights"""
106
+ self._update_highlight()
107
+
108
+ # ----- Internals -----
109
+
110
+ def _maybe_hide(self):
111
+ if self.isVisible():
112
+ self.hide_bar()
113
+
114
+ def _flags(self, backward: bool = False) -> QTextDocument.FindFlags:
115
+ flags = QTextDocument.FindFlags()
116
+ if backward:
117
+ flags |= QTextDocument.FindBackward
118
+ if self.case.isChecked():
119
+ flags |= QTextDocument.FindCaseSensitively
120
+ return flags
121
+
122
+ def find_next(self):
123
+ if not self.editor:
124
+ return
125
+ txt = self.edit.text()
126
+ if not txt:
127
+ return
128
+ # If current selection == query, bump caret to the end so we don't re-match it.
129
+ c = self.editor.textCursor()
130
+ if c.hasSelection():
131
+ sel = c.selectedText()
132
+ same = (
133
+ (sel == txt)
134
+ if self.case.isChecked()
135
+ else (sel.casefold() == txt.casefold())
136
+ )
137
+ if same:
138
+ end = max(c.position(), c.anchor())
139
+ c.setPosition(end, QTextCursor.MoveAnchor)
140
+ self.editor.setTextCursor(c)
141
+ if not self.editor.find(txt, self._flags(False)):
142
+ cur = self.editor.textCursor()
143
+ cur.movePosition(QTextCursor.Start)
144
+ self.editor.setTextCursor(cur)
145
+ self.editor.find(txt, self._flags(False))
146
+ self.editor.ensureCursorVisible()
147
+ self._update_highlight()
148
+
149
+ def find_prev(self):
150
+ if not self.editor:
151
+ return
152
+ txt = self.edit.text()
153
+ if not txt:
154
+ return
155
+ # If current selection == query, bump caret to the start so we don't re-match it.
156
+ c = self.editor.textCursor()
157
+ if c.hasSelection():
158
+ sel = c.selectedText()
159
+ same = (
160
+ (sel == txt)
161
+ if self.case.isChecked()
162
+ else (sel.casefold() == txt.casefold())
163
+ )
164
+ if same:
165
+ start = min(c.position(), c.anchor())
166
+ c.setPosition(start, QTextCursor.MoveAnchor)
167
+ self.editor.setTextCursor(c)
168
+ if not self.editor.find(txt, self._flags(True)):
169
+ cur = self.editor.textCursor()
170
+ cur.movePosition(QTextCursor.End)
171
+ self.editor.setTextCursor(cur)
172
+ self.editor.find(txt, self._flags(True))
173
+ self.editor.ensureCursorVisible()
174
+ self._update_highlight()
175
+
176
+ def _update_highlight(self):
177
+ if not self.editor:
178
+ return
179
+ txt = self.edit.text()
180
+ if not txt:
181
+ self._clear_highlight()
182
+ return
183
+
184
+ doc = self.editor.document()
185
+ flags = self._flags(False)
186
+ cur = QTextCursor(doc)
187
+ cur.movePosition(QTextCursor.Start)
188
+
189
+ fmt = QTextCharFormat()
190
+ hl = self.palette().highlight().color()
191
+ hl.setAlpha(90)
192
+ fmt.setBackground(hl)
193
+
194
+ selections = []
195
+ while True:
196
+ cur = doc.find(txt, cur, flags)
197
+ if cur.isNull():
198
+ break
199
+ sel = QTextEdit.ExtraSelection()
200
+ sel.cursor = cur
201
+ sel.format = fmt
202
+ selections.append(sel)
203
+
204
+ self.editor.setExtraSelections(selections)
205
+
206
+ def _clear_highlight(self):
207
+ if self.editor:
208
+ self.editor.setExtraSelections([])
bouquin/history_dialog.py CHANGED
@@ -16,31 +16,33 @@ from PySide6.QtWidgets import (
16
16
  )
17
17
 
18
18
 
19
- def _html_to_text(s: str) -> str:
20
- """Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
21
- IMG_RE = re.compile(r"(?is)<img\b[^>]*>")
22
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
23
- COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
24
- BR_RE = re.compile(r"(?i)<br\s*/?>")
25
- BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
26
- TAG_RE = re.compile(r"<[^>]+>")
27
- MULTINL_RE = re.compile(r"\n{3,}")
28
-
29
- s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
30
- s = STYLE_SCRIPT_RE.sub("", s)
31
- s = COMMENT_RE.sub("", s)
32
- s = BR_RE.sub("\n", s)
33
- s = BLOCK_END_RE.sub("\n", s)
34
- s = TAG_RE.sub("", s)
35
- s = _html.unescape(s)
36
- s = MULTINL_RE.sub("\n\n", s)
19
+ def _markdown_to_text(s: str) -> str:
20
+ """Convert markdown to plain text for diff comparison."""
21
+ # Remove images
22
+ s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
23
+ # Remove inline code formatting
24
+ s = re.sub(r"`([^`]+)`", r"\1", s)
25
+ # Remove bold/italic markers
26
+ s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
27
+ s = re.sub(r"__([^_]+)__", r"\1", s)
28
+ s = re.sub(r"\*([^*]+)\*", r"\1", s)
29
+ s = re.sub(r"_([^_]+)_", r"\1", s)
30
+ # Remove strikethrough
31
+ s = re.sub(r"~~([^~]+)~~", r"\1", s)
32
+ # Remove heading markers
33
+ s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
34
+ # Remove list markers
35
+ s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
36
+ s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
37
+ # Remove checkbox markers
38
+ s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
37
39
  return s.strip()
38
40
 
39
41
 
40
- def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
42
+ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
41
43
  """Return HTML with colored unified diff (+ green, - red, context gray)."""
42
- a = _html_to_text(old_html).splitlines()
43
- b = _html_to_text(new_html).splitlines()
44
+ a = _markdown_to_text(old_md).splitlines()
45
+ b = _markdown_to_text(new_md).splitlines()
44
46
  ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
45
47
  lines = []
46
48
  for line in ud:
@@ -116,9 +118,9 @@ class HistoryDialog(QDialog):
116
118
  return local.strftime("%Y-%m-%d %H:%M:%S %Z")
117
119
 
118
120
  def _load_versions(self):
119
- self._versions = self._db.list_versions(
120
- self._date
121
- ) # [{id,version_no,created_at,note,is_current}]
121
+ # [{id,version_no,created_at,note,is_current}]
122
+ self._versions = self._db.list_versions(self._date)
123
+
122
124
  self._current_id = next(
123
125
  (v["id"] for v in self._versions if v["is_current"]), None
124
126
  )
@@ -150,13 +152,12 @@ class HistoryDialog(QDialog):
150
152
  self.btn_revert.setEnabled(False)
151
153
  return
152
154
  sel_id = item.data(Qt.UserRole)
153
- # Preview selected as HTML
154
155
  sel = self._db.get_version(version_id=sel_id)
155
- self.preview.setHtml(sel["content"])
156
+ self.preview.setMarkdown(sel["content"])
156
157
  # Diff vs current (textual diff)
157
158
  cur = self._db.get_version(version_id=self._current_id)
158
159
  self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
159
- # Enable revert only if selecting a non-current
160
+ # Enable revert only if selecting a non-current version
160
161
  self.btn_revert.setEnabled(sel_id != self._current_id)
161
162
 
162
163
  @Slot()
@@ -167,7 +168,7 @@ class HistoryDialog(QDialog):
167
168
  sel_id = item.data(Qt.UserRole)
168
169
  if sel_id == self._current_id:
169
170
  return
170
- # Flip head pointer
171
+ # Flip head pointer to the older version
171
172
  try:
172
173
  self._db.revert_to_version(self._date, version_id=sel_id)
173
174
  except Exception as e:
bouquin/key_prompt.py CHANGED
@@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
17
17
  title: str = "Enter key",
18
18
  message: str = "Enter key",
19
19
  ):
20
+ """
21
+ Prompt the user for the key required to decrypt the database.
22
+
23
+ Used when opening the app, unlocking the idle locked screen,
24
+ or when rekeying.
25
+ """
20
26
  super().__init__(parent)
21
27
  self.setWindowTitle(title)
22
28
  v = QVBoxLayout(self)
bouquin/lock_overlay.py CHANGED
@@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
7
7
 
8
8
  class LockOverlay(QWidget):
9
9
  def __init__(self, parent: QWidget, on_unlock: callable):
10
+ """
11
+ Widget that 'locks' the screen after a configured idle time.
12
+ """
10
13
  super().__init__(parent)
11
14
  self.setObjectName("LockOverlay")
12
15
  self.setAttribute(Qt.WA_StyledBackground, True)
@@ -39,6 +42,9 @@ class LockOverlay(QWidget):
39
42
  self.hide()
40
43
 
41
44
  def _is_dark(self, pal: QPalette) -> bool:
45
+ """
46
+ Detect if dark mode is in use.
47
+ """
42
48
  c = pal.color(QPalette.Window)
43
49
  luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
44
50
  return luma < 0.5
@@ -58,7 +64,7 @@ class LockOverlay(QWidget):
58
64
 
59
65
  self.setStyleSheet(
60
66
  f"""
61
- #LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
67
+ #LockOverlay {{ background-color: rgb(0,0,0); }}
62
68
  #LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
63
69
 
64
70
  #LockOverlay QPushButton#unlockButton {{
@@ -113,7 +119,7 @@ class LockOverlay(QWidget):
113
119
 
114
120
  def changeEvent(self, ev):
115
121
  super().changeEvent(ev)
116
- # Only re-style on palette flips
122
+ # Only re-style on palette flips (user changed theme)
117
123
  if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
118
124
  self._apply_overlay_style()
119
125