bouquin 0.1.1__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:
@@ -100,11 +107,119 @@ class DBManager:
100
107
  )
101
108
  self.conn.commit()
102
109
 
110
+ def search_entries(self, text: str) -> list[str]:
111
+ cur = self.conn.cursor()
112
+ pattern = f"%{text}%"
113
+ return cur.execute(
114
+ "SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
115
+ ).fetchall()
116
+
103
117
  def dates_with_content(self) -> list[str]:
104
118
  cur = self.conn.cursor()
105
119
  cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
106
120
  return [r[0] for r in cur.fetchall()]
107
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
+
108
223
  def close(self) -> None:
109
224
  if self.conn is not None:
110
225
  self.conn.close()
bouquin/editor.py ADDED
@@ -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)
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.py CHANGED
@@ -11,5 +11,6 @@ def main():
11
11
  app = QApplication(sys.argv)
12
12
  app.setApplicationName(APP_NAME)
13
13
  app.setOrganizationName(APP_ORG)
14
- win = MainWindow(); win.show()
14
+ win = MainWindow()
15
+ win.show()
15
16
  sys.exit(app.exec())