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 +117 -2
- bouquin/editor.py +248 -0
- bouquin/key_prompt.py +2 -2
- bouquin/main.py +2 -1
- bouquin/main_window.py +188 -27
- bouquin/search.py +195 -0
- bouquin/settings.py +3 -1
- bouquin/settings_dialog.py +77 -8
- bouquin/toolbar.py +148 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/METADATA +13 -13
- bouquin-0.1.3.dist-info/RECORD +16 -0
- bouquin/highlighter.py +0 -112
- bouquin-0.1.1.dist-info/RECORD +0 -14
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/LICENSE +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/WHEEL +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.3.dist-info}/entry_points.txt +0 -0
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 ( 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 = "
|
|
18
|
-
message: str = "Enter
|
|
17
|
+
title: str = "Enter key",
|
|
18
|
+
message: str = "Enter key",
|
|
19
19
|
):
|
|
20
20
|
super().__init__(parent)
|
|
21
21
|
self.setWindowTitle(title)
|