bouquin 0.1.2__tar.gz → 0.1.4__tar.gz

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

Potentially problematic release.


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

@@ -1,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
@@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
9
9
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
10
10
 
11
11
  To increase security, the SQLCipher key is requested when the app is opened, and is not written
12
- to disk.
12
+ to disk unless the user configures it to be in the settings.
13
13
 
14
14
  There is deliberately no network connectivity or syncing intended.
15
15
 
@@ -19,22 +19,21 @@ There is deliberately no network connectivity or syncing intended.
19
19
 
20
20
  ## Features
21
21
 
22
+ * Data is encrypted at rest
23
+ * Encryption key is prompted for and never stored, unless user chooses to via Settings
22
24
  * Every 'page' is linked to the calendar day
23
25
  * Text is HTML with basic styling
24
26
  * Search
25
27
  * Automatic periodic saving (or explicitly save)
26
28
  * Transparent integrity checking of the database when it opens
27
29
  * Rekey the database (change the password)
28
-
29
-
30
- ## Yet to do
31
-
32
- * Taxonomy/tagging
33
- * Export to other formats (plaintext, json, sql etc)
30
+ * Export the database to json, txt, html or csv
34
31
 
35
32
 
36
33
  ## How to install
37
34
 
35
+ Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
36
+
38
37
  ### From source
39
38
 
40
39
  * Clone this repo or download the tarball from the releases page
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import html
5
+ import json
6
+ import os
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from sqlcipher3 import dbapi2 as sqlite
11
+ from typing import List, Sequence, Tuple
12
+
13
+ Entry = Tuple[str, str]
14
+
15
+
16
+ @dataclass
17
+ class DBConfig:
18
+ path: Path
19
+ key: str
20
+ idle_minutes: int = 15 # 0 = never lock
21
+
22
+
23
+ class DBManager:
24
+ def __init__(self, cfg: DBConfig):
25
+ self.cfg = cfg
26
+ self.conn: sqlite.Connection | None = None
27
+
28
+ def connect(self) -> bool:
29
+ # Ensure parent dir exists
30
+ self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
31
+ self.conn = sqlite.connect(str(self.cfg.path))
32
+ self.conn.row_factory = sqlite.Row
33
+ cur = self.conn.cursor()
34
+ cur.execute(f"PRAGMA key = '{self.cfg.key}';")
35
+ cur.execute("PRAGMA journal_mode = WAL;")
36
+ self.conn.commit()
37
+ try:
38
+ self._integrity_ok()
39
+ except Exception:
40
+ self.conn.close()
41
+ self.conn = None
42
+ return False
43
+ self._ensure_schema()
44
+ return True
45
+
46
+ def _integrity_ok(self) -> bool:
47
+ cur = self.conn.cursor()
48
+ cur.execute("PRAGMA cipher_integrity_check;")
49
+ rows = cur.fetchall()
50
+
51
+ # OK
52
+ if not rows:
53
+ return
54
+
55
+ # Not OK
56
+ details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
57
+ raise sqlite.IntegrityError(
58
+ "SQLCipher integrity check failed"
59
+ + (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
60
+ )
61
+
62
+ def _ensure_schema(self) -> None:
63
+ cur = self.conn.cursor()
64
+ cur.execute(
65
+ """
66
+ CREATE TABLE IF NOT EXISTS entries (
67
+ date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
68
+ content TEXT NOT NULL
69
+ );
70
+ """
71
+ )
72
+ cur.execute("PRAGMA user_version = 1;")
73
+ self.conn.commit()
74
+
75
+ def rekey(self, new_key: str) -> None:
76
+ """
77
+ Change the SQLCipher passphrase in-place, then reopen the connection
78
+ with the new key to verify.
79
+ """
80
+ if self.conn is None:
81
+ raise RuntimeError("Database is not connected")
82
+ cur = self.conn.cursor()
83
+ # Change the encryption key of the currently open database
84
+ cur.execute(f"PRAGMA rekey = '{new_key}';")
85
+ self.conn.commit()
86
+
87
+ # Close and reopen with the new key to verify and restore PRAGMAs
88
+ self.conn.close()
89
+ self.conn = None
90
+ self.cfg.key = new_key
91
+ if not self.connect():
92
+ raise sqlite.Error("Re-open failed after rekey")
93
+
94
+ def get_entry(self, date_iso: str) -> str:
95
+ cur = self.conn.cursor()
96
+ cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
97
+ row = cur.fetchone()
98
+ return row[0] if row else ""
99
+
100
+ def upsert_entry(self, date_iso: str, content: str) -> None:
101
+ cur = self.conn.cursor()
102
+ cur.execute(
103
+ """
104
+ INSERT INTO entries(date, content) VALUES(?, ?)
105
+ ON CONFLICT(date) DO UPDATE SET content = excluded.content;
106
+ """,
107
+ (date_iso, content),
108
+ )
109
+ self.conn.commit()
110
+
111
+ def search_entries(self, text: str) -> list[str]:
112
+ cur = self.conn.cursor()
113
+ pattern = f"%{text}%"
114
+ return cur.execute(
115
+ "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
116
+ ).fetchall()
117
+
118
+ def dates_with_content(self) -> list[str]:
119
+ cur = self.conn.cursor()
120
+ cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
121
+ return [r[0] for r in cur.fetchall()]
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
+
224
+ def close(self) -> None:
225
+ if self.conn is not None:
226
+ self.conn.close()
227
+ self.conn = None
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from PySide6.QtGui import (
4
+ QColor,
5
+ QDesktopServices,
6
+ QFont,
7
+ QFontDatabase,
8
+ QTextCharFormat,
9
+ QTextCursor,
10
+ QTextListFormat,
11
+ QTextBlockFormat,
12
+ )
13
+ from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
14
+ from PySide6.QtWidgets import QTextEdit
15
+
16
+
17
+ class Editor(QTextEdit):
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)
24
+ tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
25
+ self.setTabStopDistance(tab_w)
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
+
143
+ def merge_on_sel(self, fmt):
144
+ """
145
+ Sets the styling on the selected characters.
146
+ """
147
+ cursor = self.textCursor()
148
+ if not cursor.hasSelection():
149
+ cursor.select(cursor.SelectionType.WordUnderCursor)
150
+ cursor.mergeCharFormat(fmt)
151
+ self.mergeCurrentCharFormat(fmt)
152
+
153
+ @Slot()
154
+ def apply_weight(self):
155
+ cur = self.currentCharFormat()
156
+ fmt = QTextCharFormat()
157
+ weight = (
158
+ QFont.Weight.Normal
159
+ if cur.fontWeight() == QFont.Weight.Bold
160
+ else QFont.Weight.Bold
161
+ )
162
+ fmt.setFontWeight(weight)
163
+ self.merge_on_sel(fmt)
164
+
165
+ @Slot()
166
+ def apply_italic(self):
167
+ cur = self.currentCharFormat()
168
+ fmt = QTextCharFormat()
169
+ fmt.setFontItalic(not cur.fontItalic())
170
+ self.merge_on_sel(fmt)
171
+
172
+ @Slot()
173
+ def apply_underline(self):
174
+ cur = self.currentCharFormat()
175
+ fmt = QTextCharFormat()
176
+ fmt.setFontUnderline(not cur.fontUnderline())
177
+ self.merge_on_sel(fmt)
178
+
179
+ @Slot()
180
+ def apply_strikethrough(self):
181
+ cur = self.currentCharFormat()
182
+ fmt = QTextCharFormat()
183
+ fmt.setFontStrikeOut(not cur.fontStrikeOut())
184
+ self.merge_on_sel(fmt)
185
+
186
+ @Slot()
187
+ def apply_code(self):
188
+ c = self.textCursor()
189
+ if not c.hasSelection():
190
+ c.select(c.SelectionType.BlockUnderCursor)
191
+
192
+ bf = QTextBlockFormat()
193
+ bf.setLeftMargin(12)
194
+ bf.setRightMargin(12)
195
+ bf.setTopMargin(6)
196
+ bf.setBottomMargin(6)
197
+ bf.setBackground(QColor(245, 245, 245))
198
+ bf.setNonBreakableLines(True)
199
+
200
+ cf = QTextCharFormat()
201
+ mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
202
+ cf.setFont(mono)
203
+ cf.setFontFixedPitch(True)
204
+
205
+ # If the current block already looks like a code block, remove styling
206
+ cur_bf = c.blockFormat()
207
+ is_code = (
208
+ cur_bf.nonBreakableLines()
209
+ and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
210
+ )
211
+ if is_code:
212
+ # clear: margins/background/wrapping
213
+ bf = QTextBlockFormat()
214
+ cf = QTextCharFormat()
215
+
216
+ c.mergeBlockFormat(bf)
217
+ c.mergeBlockCharFormat(cf)
218
+
219
+ @Slot(int)
220
+ def apply_heading(self, size):
221
+ fmt = QTextCharFormat()
222
+ if size:
223
+ fmt.setFontWeight(QFont.Weight.Bold)
224
+ fmt.setFontPointSize(size)
225
+ else:
226
+ fmt.setFontWeight(QFont.Weight.Normal)
227
+ fmt.setFontPointSize(self.font().pointSizeF())
228
+ self.merge_on_sel(fmt)
229
+
230
+ def toggle_bullets(self):
231
+ c = self.textCursor()
232
+ lst = c.currentList()
233
+ if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
234
+ lst.remove(c.block())
235
+ return
236
+ fmt = QTextListFormat()
237
+ fmt.setStyle(QTextListFormat.Style.ListDisc)
238
+ c.createList(fmt)
239
+
240
+ def toggle_numbers(self):
241
+ c = self.textCursor()
242
+ lst = c.currentList()
243
+ if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
244
+ lst.remove(c.block())
245
+ return
246
+ fmt = QTextListFormat()
247
+ fmt.setStyle(QTextListFormat.Style.ListDecimal)
248
+ c.createList(fmt)
@@ -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)