bouquin 0.1.2__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 +113 -4
- bouquin/editor.py +133 -5
- bouquin/key_prompt.py +2 -2
- bouquin/main_window.py +139 -10
- bouquin/search.py +2 -2
- bouquin/settings.py +3 -1
- bouquin/settings_dialog.py +75 -8
- bouquin/toolbar.py +110 -60
- {bouquin-0.1.2.dist-info → bouquin-0.1.3.dist-info}/METADATA +7 -8
- bouquin-0.1.3.dist-info/RECORD +16 -0
- bouquin-0.1.2.dist-info/RECORD +0 -16
- {bouquin-0.1.2.dist-info → bouquin-0.1.3.dist-info}/LICENSE +0 -0
- {bouquin-0.1.2.dist-info → bouquin-0.1.3.dist-info}/WHEEL +0 -0
- {bouquin-0.1.2.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:
|
|
@@ -103,14 +110,116 @@ class DBManager:
|
|
|
103
110
|
def search_entries(self, text: str) -> list[str]:
|
|
104
111
|
cur = self.conn.cursor()
|
|
105
112
|
pattern = f"%{text}%"
|
|
106
|
-
cur.execute(
|
|
107
|
-
|
|
113
|
+
return cur.execute(
|
|
114
|
+
"SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
|
|
115
|
+
).fetchall()
|
|
108
116
|
|
|
109
117
|
def dates_with_content(self) -> list[str]:
|
|
110
118
|
cur = self.conn.cursor()
|
|
111
119
|
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
|
112
120
|
return [r[0] for r in cur.fetchall()]
|
|
113
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
|
+
|
|
114
223
|
def close(self) -> None:
|
|
115
224
|
if self.conn is not None:
|
|
116
225
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
32
|
-
def apply_weight(self
|
|
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 = "
|
|
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)
|
bouquin/main_window.py
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sys
|
|
4
5
|
|
|
5
|
-
from
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
|
|
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,
|
|
14
20
|
QMainWindow,
|
|
15
21
|
QMessageBox,
|
|
16
22
|
QSizePolicy,
|
|
@@ -23,7 +29,7 @@ from .db import DBManager
|
|
|
23
29
|
from .editor import Editor
|
|
24
30
|
from .key_prompt import KeyPrompt
|
|
25
31
|
from .search import Search
|
|
26
|
-
from .settings import APP_NAME, load_db_config, save_db_config
|
|
32
|
+
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
|
27
33
|
from .settings_dialog import SettingsDialog
|
|
28
34
|
from .toolbar import ToolBar
|
|
29
35
|
|
|
@@ -35,9 +41,18 @@ class MainWindow(QMainWindow):
|
|
|
35
41
|
self.setMinimumSize(1000, 650)
|
|
36
42
|
|
|
37
43
|
self.cfg = load_db_config()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
if not os.path.exists(self.cfg.path):
|
|
45
|
+
# Fresh database/first time use, so guide the user re: setting a key
|
|
46
|
+
first_time = True
|
|
47
|
+
else:
|
|
48
|
+
first_time = False
|
|
49
|
+
|
|
50
|
+
# Prompt for the key unless it is found in config
|
|
51
|
+
if not self.cfg.key:
|
|
52
|
+
if not self._prompt_for_key_until_valid(first_time):
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
else:
|
|
55
|
+
self._try_connect()
|
|
41
56
|
|
|
42
57
|
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
|
43
58
|
self.calendar = QCalendarWidget()
|
|
@@ -90,15 +105,19 @@ class MainWindow(QMainWindow):
|
|
|
90
105
|
|
|
91
106
|
# Menu bar (File)
|
|
92
107
|
mb = self.menuBar()
|
|
93
|
-
file_menu = mb.addMenu("&
|
|
108
|
+
file_menu = mb.addMenu("&File")
|
|
94
109
|
act_save = QAction("&Save", self)
|
|
95
110
|
act_save.setShortcut("Ctrl+S")
|
|
96
111
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
97
112
|
file_menu.addAction(act_save)
|
|
98
|
-
act_settings = QAction("
|
|
99
|
-
act_settings.setShortcut("Ctrl+
|
|
113
|
+
act_settings = QAction("Settin&gs", self)
|
|
114
|
+
act_settings.setShortcut("Ctrl+G")
|
|
100
115
|
act_settings.triggered.connect(self._open_settings)
|
|
101
116
|
file_menu.addAction(act_settings)
|
|
117
|
+
act_export = QAction("&Export", self)
|
|
118
|
+
act_export.setShortcut("Ctrl+E")
|
|
119
|
+
act_export.triggered.connect(self._export)
|
|
120
|
+
file_menu.addAction(act_export)
|
|
102
121
|
file_menu.addSeparator()
|
|
103
122
|
act_quit = QAction("&Quit", self)
|
|
104
123
|
act_quit.setShortcut("Ctrl+Q")
|
|
@@ -128,6 +147,15 @@ class MainWindow(QMainWindow):
|
|
|
128
147
|
nav_menu.addAction(act_today)
|
|
129
148
|
self.addAction(act_today)
|
|
130
149
|
|
|
150
|
+
# Help menu with drop-down
|
|
151
|
+
help_menu = mb.addMenu("&Help")
|
|
152
|
+
act_docs = QAction("Documentation", self)
|
|
153
|
+
act_docs.setShortcut("Ctrl+D")
|
|
154
|
+
act_docs.setShortcutContext(Qt.ApplicationShortcut)
|
|
155
|
+
act_docs.triggered.connect(self._open_docs)
|
|
156
|
+
help_menu.addAction(act_docs)
|
|
157
|
+
self.addAction(act_docs)
|
|
158
|
+
|
|
131
159
|
# Autosave
|
|
132
160
|
self._dirty = False
|
|
133
161
|
self._save_timer = QTimer(self)
|
|
@@ -139,6 +167,10 @@ class MainWindow(QMainWindow):
|
|
|
139
167
|
self._load_selected_date()
|
|
140
168
|
self._refresh_calendar_marks()
|
|
141
169
|
|
|
170
|
+
# Restore window position from settings
|
|
171
|
+
self.settings = QSettings(APP_ORG, APP_NAME)
|
|
172
|
+
self._restore_window_position()
|
|
173
|
+
|
|
142
174
|
def _try_connect(self) -> bool:
|
|
143
175
|
"""
|
|
144
176
|
Try to connect to the database.
|
|
@@ -155,12 +187,18 @@ class MainWindow(QMainWindow):
|
|
|
155
187
|
return False
|
|
156
188
|
return ok
|
|
157
189
|
|
|
158
|
-
def _prompt_for_key_until_valid(self) -> bool:
|
|
190
|
+
def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
|
|
159
191
|
"""
|
|
160
192
|
Prompt for the SQLCipher key.
|
|
161
193
|
"""
|
|
194
|
+
if first_time:
|
|
195
|
+
title = "Set an encryption key"
|
|
196
|
+
message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
|
|
197
|
+
else:
|
|
198
|
+
title = "Unlock encrypted notebook"
|
|
199
|
+
message = "Enter your key to unlock the notebook"
|
|
162
200
|
while True:
|
|
163
|
-
dlg = KeyPrompt(self, message
|
|
201
|
+
dlg = KeyPrompt(self, title, message)
|
|
164
202
|
if dlg.exec() != QDialog.Accepted:
|
|
165
203
|
return False
|
|
166
204
|
self.cfg.key = dlg.key()
|
|
@@ -206,6 +244,8 @@ class MainWindow(QMainWindow):
|
|
|
206
244
|
self._dirty = False
|
|
207
245
|
# track which date the editor currently represents
|
|
208
246
|
self._active_date_iso = date_iso
|
|
247
|
+
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
248
|
+
self.calendar.setSelectedDate(qd)
|
|
209
249
|
|
|
210
250
|
def _on_text_changed(self):
|
|
211
251
|
self._dirty = True
|
|
@@ -281,8 +321,97 @@ class MainWindow(QMainWindow):
|
|
|
281
321
|
self._load_selected_date()
|
|
282
322
|
self._refresh_calendar_marks()
|
|
283
323
|
|
|
324
|
+
def _restore_window_position(self):
|
|
325
|
+
geom = self.settings.value("main/geometry", None)
|
|
326
|
+
state = self.settings.value("main/windowState", None)
|
|
327
|
+
was_max = self.settings.value("main/maximized", False, type=bool)
|
|
328
|
+
|
|
329
|
+
if geom is not None:
|
|
330
|
+
self.restoreGeometry(geom)
|
|
331
|
+
if state is not None:
|
|
332
|
+
self.restoreState(state)
|
|
333
|
+
if not self._rect_on_any_screen(self.frameGeometry()):
|
|
334
|
+
self._move_to_cursor_screen_center()
|
|
335
|
+
else:
|
|
336
|
+
# First run: place window on the screen where the mouse cursor is.
|
|
337
|
+
self._move_to_cursor_screen_center()
|
|
338
|
+
|
|
339
|
+
# If it was maximized, do that AFTER the window exists in the event loop.
|
|
340
|
+
if was_max:
|
|
341
|
+
QTimer.singleShot(0, self.showMaximized)
|
|
342
|
+
|
|
343
|
+
def _rect_on_any_screen(self, rect):
|
|
344
|
+
for sc in QGuiApplication.screens():
|
|
345
|
+
if sc.availableGeometry().intersects(rect):
|
|
346
|
+
return True
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _move_to_cursor_screen_center(self):
|
|
350
|
+
screen = (
|
|
351
|
+
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
|
|
352
|
+
)
|
|
353
|
+
r = screen.availableGeometry()
|
|
354
|
+
# Center the window in that screen’s available area
|
|
355
|
+
self.move(r.center() - self.rect().center())
|
|
356
|
+
|
|
357
|
+
@Slot()
|
|
358
|
+
def _export(self):
|
|
359
|
+
try:
|
|
360
|
+
self.export_dialog()
|
|
361
|
+
except Exception as e:
|
|
362
|
+
QMessageBox.critical(self, "Export failed", str(e))
|
|
363
|
+
|
|
364
|
+
def export_dialog(self) -> None:
|
|
365
|
+
filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
|
|
366
|
+
|
|
367
|
+
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
|
|
368
|
+
filename, selected_filter = QFileDialog.getSaveFileName(
|
|
369
|
+
self, "Export entries", start_dir, filters
|
|
370
|
+
)
|
|
371
|
+
if not filename:
|
|
372
|
+
return # user cancelled
|
|
373
|
+
|
|
374
|
+
default_ext = {
|
|
375
|
+
"Text (*.txt)": ".txt",
|
|
376
|
+
"JSON (*.json)": ".json",
|
|
377
|
+
"CSV (*.csv)": ".csv",
|
|
378
|
+
"HTML (*.html)": ".html",
|
|
379
|
+
}.get(selected_filter, ".txt")
|
|
380
|
+
|
|
381
|
+
if not Path(filename).suffix:
|
|
382
|
+
filename += default_ext
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
entries = self.db.get_all_entries()
|
|
386
|
+
if selected_filter.startswith("Text"):
|
|
387
|
+
self.db.export_txt(entries, filename)
|
|
388
|
+
elif selected_filter.startswith("JSON"):
|
|
389
|
+
self.db.export_json(entries, filename)
|
|
390
|
+
elif selected_filter.startswith("CSV"):
|
|
391
|
+
self.db.export_csv(entries, filename)
|
|
392
|
+
elif selected_filter.startswith("HTML"):
|
|
393
|
+
self.bd.export_html(entries, filename)
|
|
394
|
+
else:
|
|
395
|
+
self.bd.export_by_extension(entries, filename)
|
|
396
|
+
|
|
397
|
+
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
|
|
398
|
+
except Exception as e:
|
|
399
|
+
QMessageBox.critical(self, "Export failed", str(e))
|
|
400
|
+
|
|
401
|
+
def _open_docs(self):
|
|
402
|
+
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
|
403
|
+
url = QUrl.fromUserInput(url_str)
|
|
404
|
+
if not QDesktopServices.openUrl(url):
|
|
405
|
+
QMessageBox.warning(self, "Open Documentation",
|
|
406
|
+
f"Couldn't open:\n{url.toDisplayString()}")
|
|
407
|
+
|
|
284
408
|
def closeEvent(self, event):
|
|
285
409
|
try:
|
|
410
|
+
# Save window position
|
|
411
|
+
self.settings.setValue("main/geometry", self.saveGeometry())
|
|
412
|
+
self.settings.setValue("main/windowState", self.saveState())
|
|
413
|
+
self.settings.setValue("main/maximized", self.isMaximized())
|
|
414
|
+
# Ensure we save any last pending edits to the db
|
|
286
415
|
self._save_current()
|
|
287
416
|
self.db.close()
|
|
288
417
|
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=
|
|
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)
|
|
115
|
+
preview.setOpenExternalLinks(True)
|
|
116
116
|
preview.setText(
|
|
117
117
|
frag_html
|
|
118
118
|
if frag_html
|
bouquin/settings.py
CHANGED
|
@@ -21,9 +21,11 @@ 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
|
-
|
|
24
|
+
key = s.value("db/key", "")
|
|
25
|
+
return DBConfig(path=path, key=key)
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def save_db_config(cfg: DBConfig) -> None:
|
|
28
29
|
s = get_settings()
|
|
29
30
|
s.setValue("db/path", str(cfg.path))
|
|
31
|
+
s.setValue("db/key", str(cfg.key))
|
bouquin/settings_dialog.py
CHANGED
|
@@ -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,
|
|
@@ -15,9 +19,12 @@ from PySide6.QtWidgets import (
|
|
|
15
19
|
QSizePolicy,
|
|
16
20
|
QMessageBox,
|
|
17
21
|
)
|
|
22
|
+
from PySide6.QtCore import Qt, Slot
|
|
23
|
+
from PySide6.QtGui import QPalette
|
|
24
|
+
|
|
18
25
|
|
|
19
26
|
from .db import DBConfig, DBManager
|
|
20
|
-
from .settings import save_db_config
|
|
27
|
+
from .settings import load_db_config, save_db_config
|
|
21
28
|
from .key_prompt import KeyPrompt
|
|
22
29
|
|
|
23
30
|
|
|
@@ -27,10 +34,11 @@ class SettingsDialog(QDialog):
|
|
|
27
34
|
self.setWindowTitle("Settings")
|
|
28
35
|
self._cfg = DBConfig(path=cfg.path, key="")
|
|
29
36
|
self._db = db
|
|
37
|
+
self.key = ""
|
|
30
38
|
|
|
31
39
|
form = QFormLayout()
|
|
32
40
|
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
|
33
|
-
self.setMinimumWidth(
|
|
41
|
+
self.setMinimumWidth(560)
|
|
34
42
|
self.setSizeGripEnabled(True)
|
|
35
43
|
|
|
36
44
|
self.path_edit = QLineEdit(str(self._cfg.path))
|
|
@@ -47,18 +55,65 @@ class SettingsDialog(QDialog):
|
|
|
47
55
|
h.setStretch(1, 0)
|
|
48
56
|
form.addRow("Database path", path_row)
|
|
49
57
|
|
|
58
|
+
# Encryption settings
|
|
59
|
+
enc_group = QGroupBox("Encryption")
|
|
60
|
+
enc = QVBoxLayout(enc_group)
|
|
61
|
+
enc.setContentsMargins(12, 8, 12, 12)
|
|
62
|
+
enc.setSpacing(6)
|
|
63
|
+
|
|
64
|
+
# Checkbox to remember key
|
|
65
|
+
self.save_key_btn = QCheckBox("Remember key")
|
|
66
|
+
current_settings = load_db_config()
|
|
67
|
+
if current_settings.key:
|
|
68
|
+
self.save_key_btn.setChecked(True)
|
|
69
|
+
else:
|
|
70
|
+
self.save_key_btn.setChecked(False)
|
|
71
|
+
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
|
72
|
+
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
|
73
|
+
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
|
74
|
+
|
|
75
|
+
# Explanation for remembering key
|
|
76
|
+
self.save_key_label = QLabel(
|
|
77
|
+
"If you don't want to be prompted for your encryption key, check this to remember it. "
|
|
78
|
+
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
|
|
79
|
+
)
|
|
80
|
+
self.save_key_label.setWordWrap(True)
|
|
81
|
+
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
|
82
|
+
# make it look secondary
|
|
83
|
+
pal = self.save_key_label.palette()
|
|
84
|
+
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
|
|
85
|
+
self.save_key_label.setPalette(pal)
|
|
86
|
+
|
|
87
|
+
exp_row = QHBoxLayout()
|
|
88
|
+
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
|
|
89
|
+
exp_row.addWidget(self.save_key_label)
|
|
90
|
+
enc.addLayout(exp_row)
|
|
91
|
+
|
|
92
|
+
line = QFrame()
|
|
93
|
+
line.setFrameShape(QFrame.HLine)
|
|
94
|
+
line.setFrameShadow(QFrame.Sunken)
|
|
95
|
+
enc.addWidget(line)
|
|
96
|
+
|
|
50
97
|
# Change key button
|
|
51
98
|
self.rekey_btn = QPushButton("Change key")
|
|
99
|
+
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
52
100
|
self.rekey_btn.clicked.connect(self._change_key)
|
|
101
|
+
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
|
102
|
+
|
|
103
|
+
# Put the group into the form so it spans the full width nicely
|
|
104
|
+
form.addRow(enc_group)
|
|
53
105
|
|
|
106
|
+
# Buttons
|
|
54
107
|
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
55
108
|
bb.accepted.connect(self._save)
|
|
56
109
|
bb.rejected.connect(self.reject)
|
|
57
110
|
|
|
111
|
+
# Root layout (adjust margins/spacing a bit)
|
|
58
112
|
v = QVBoxLayout(self)
|
|
113
|
+
v.setContentsMargins(12, 12, 12, 12)
|
|
114
|
+
v.setSpacing(10)
|
|
59
115
|
v.addLayout(form)
|
|
60
|
-
v.addWidget(
|
|
61
|
-
v.addWidget(bb)
|
|
116
|
+
v.addWidget(bb, 0, Qt.AlignRight)
|
|
62
117
|
|
|
63
118
|
def _browse(self):
|
|
64
119
|
p, _ = QFileDialog.getSaveFileName(
|
|
@@ -71,16 +126,16 @@ class SettingsDialog(QDialog):
|
|
|
71
126
|
self.path_edit.setText(p)
|
|
72
127
|
|
|
73
128
|
def _save(self):
|
|
74
|
-
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=
|
|
129
|
+
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
|
|
75
130
|
save_db_config(self._cfg)
|
|
76
131
|
self.accept()
|
|
77
132
|
|
|
78
133
|
def _change_key(self):
|
|
79
|
-
p1 = KeyPrompt(self, title="Change key", message="Enter new key")
|
|
134
|
+
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
|
|
80
135
|
if p1.exec() != QDialog.Accepted:
|
|
81
136
|
return
|
|
82
137
|
new_key = p1.key()
|
|
83
|
-
p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
|
|
138
|
+
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
|
|
84
139
|
if p2.exec() != QDialog.Accepted:
|
|
85
140
|
return
|
|
86
141
|
if new_key != p2.key():
|
|
@@ -92,11 +147,23 @@ class SettingsDialog(QDialog):
|
|
|
92
147
|
try:
|
|
93
148
|
self._db.rekey(new_key)
|
|
94
149
|
QMessageBox.information(
|
|
95
|
-
self, "Key changed", "The
|
|
150
|
+
self, "Key changed", "The notebook was re-encrypted with the new key!"
|
|
96
151
|
)
|
|
97
152
|
except Exception as e:
|
|
98
153
|
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
|
99
154
|
|
|
155
|
+
@Slot(bool)
|
|
156
|
+
def save_key_btn_clicked(self, checked: bool):
|
|
157
|
+
if checked:
|
|
158
|
+
p1 = KeyPrompt(
|
|
159
|
+
self, title="Enter your key", message="Enter the encryption key"
|
|
160
|
+
)
|
|
161
|
+
if p1.exec() != QDialog.Accepted:
|
|
162
|
+
return
|
|
163
|
+
self.key = p1.key()
|
|
164
|
+
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
|
|
165
|
+
save_db_config(self._cfg)
|
|
166
|
+
|
|
100
167
|
@property
|
|
101
168
|
def config(self) -> DBConfig:
|
|
102
169
|
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,
|
|
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(
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
self.actItalic = QAction("Italic", self)
|
|
32
|
+
self.actItalic.setShortcut(QKeySequence.Italic)
|
|
33
|
+
self.actItalic.triggered.connect(self.italicRequested)
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
self.actUnderline = QAction("Underline", self)
|
|
36
|
+
self.actUnderline.setShortcut(QKeySequence.Underline)
|
|
37
|
+
self.actUnderline.triggered.connect(self.underlineRequested)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
self.actStrike = QAction("Strikethrough", self)
|
|
40
|
+
self.actStrike.setShortcut("Ctrl+-")
|
|
41
|
+
self.actStrike.triggered.connect(self.strikeRequested)
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
lambda: self.alignRequested.emit(Qt.
|
|
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
|
-
|
|
78
|
-
lambda: self.alignRequested.emit(Qt.
|
|
75
|
+
self.actAlignR.triggered.connect(
|
|
76
|
+
lambda: self.alignRequested.emit(Qt.AlignRight)
|
|
79
77
|
)
|
|
80
78
|
|
|
81
79
|
self.addActions(
|
|
82
80
|
[
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
3
|
+
Version: 0.1.3
|
|
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=s3FDphbi6zxpHEFHnz44saZ9qAV4wU4WEiE6V95PkmI,7877
|
|
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=48lq5trwORpGWko6jWLGBk-_7PrtaQfZT1l-jbz67rY,15427
|
|
8
|
+
bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
|
|
9
|
+
bouquin/settings.py,sha256=aEsIIlYGwSxCVXXMpo98192QzatIIP6OvQDtcKrYWW4,742
|
|
10
|
+
bouquin/settings_dialog.py,sha256=kWR4OeeHd5uQZ6lfHtuYx3UIh_MCb-nhjHcDyhQhpKM,5747
|
|
11
|
+
bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
|
|
12
|
+
bouquin-0.1.3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
13
|
+
bouquin-0.1.3.dist-info/METADATA,sha256=y2FvqLWDTEYj1E2LCYAezvRsbycKVV71pVy9AaZv9EY,2468
|
|
14
|
+
bouquin-0.1.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
15
|
+
bouquin-0.1.3.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
16
|
+
bouquin-0.1.3.dist-info/RECORD,,
|
bouquin-0.1.2.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|