bouquin 0.1.2__py3-none-any.whl → 0.1.4__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 +114 -4
- bouquin/editor.py +133 -5
- bouquin/key_prompt.py +2 -2
- bouquin/main_window.py +326 -36
- bouquin/search.py +2 -2
- bouquin/settings.py +5 -1
- bouquin/settings_dialog.py +108 -8
- bouquin/toolbar.py +110 -60
- {bouquin-0.1.2.dist-info → bouquin-0.1.4.dist-info}/METADATA +7 -8
- bouquin-0.1.4.dist-info/RECORD +16 -0
- bouquin-0.1.2.dist-info/RECORD +0 -16
- {bouquin-0.1.2.dist-info → bouquin-0.1.4.dist-info}/LICENSE +0 -0
- {bouquin-0.1.2.dist-info → bouquin-0.1.4.dist-info}/WHEEL +0 -0
- {bouquin-0.1.2.dist-info → bouquin-0.1.4.dist-info}/entry_points.txt +0 -0
bouquin/db.py
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
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
|
|
10
17
|
class DBConfig:
|
|
11
18
|
path: Path
|
|
12
19
|
key: str
|
|
20
|
+
idle_minutes: int = 15 # 0 = never lock
|
|
13
21
|
|
|
14
22
|
|
|
15
23
|
class DBManager:
|
|
@@ -21,9 +29,9 @@ class DBManager:
|
|
|
21
29
|
# Ensure parent dir exists
|
|
22
30
|
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
31
|
self.conn = sqlite.connect(str(self.cfg.path))
|
|
32
|
+
self.conn.row_factory = sqlite.Row
|
|
24
33
|
cur = self.conn.cursor()
|
|
25
34
|
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
|
26
|
-
cur.execute("PRAGMA cipher_compatibility = 4;")
|
|
27
35
|
cur.execute("PRAGMA journal_mode = WAL;")
|
|
28
36
|
self.conn.commit()
|
|
29
37
|
try:
|
|
@@ -103,14 +111,116 @@ class DBManager:
|
|
|
103
111
|
def search_entries(self, text: str) -> list[str]:
|
|
104
112
|
cur = self.conn.cursor()
|
|
105
113
|
pattern = f"%{text}%"
|
|
106
|
-
cur.execute(
|
|
107
|
-
|
|
114
|
+
return cur.execute(
|
|
115
|
+
"SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
|
|
116
|
+
).fetchall()
|
|
108
117
|
|
|
109
118
|
def dates_with_content(self) -> list[str]:
|
|
110
119
|
cur = self.conn.cursor()
|
|
111
120
|
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
|
112
121
|
return [r[0] for r in cur.fetchall()]
|
|
113
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 ( 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
|
+
|
|
114
224
|
def close(self) -> None:
|
|
115
225
|
if self.conn is not None:
|
|
116
226
|
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,18 +1,26 @@
|
|
|
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, QEvent
|
|
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,
|
|
20
|
+
QLabel,
|
|
14
21
|
QMainWindow,
|
|
15
22
|
QMessageBox,
|
|
23
|
+
QPushButton,
|
|
16
24
|
QSizePolicy,
|
|
17
25
|
QSplitter,
|
|
18
26
|
QVBoxLayout,
|
|
@@ -23,11 +31,66 @@ from .db import DBManager
|
|
|
23
31
|
from .editor import Editor
|
|
24
32
|
from .key_prompt import KeyPrompt
|
|
25
33
|
from .search import Search
|
|
26
|
-
from .settings import APP_NAME, load_db_config, save_db_config
|
|
34
|
+
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
|
27
35
|
from .settings_dialog import SettingsDialog
|
|
28
36
|
from .toolbar import ToolBar
|
|
29
37
|
|
|
30
38
|
|
|
39
|
+
class _LockOverlay(QWidget):
|
|
40
|
+
def __init__(self, parent: QWidget, on_unlock: callable):
|
|
41
|
+
super().__init__(parent)
|
|
42
|
+
self.setObjectName("LockOverlay")
|
|
43
|
+
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
44
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
45
|
+
self.setGeometry(parent.rect())
|
|
46
|
+
|
|
47
|
+
self.setStyleSheet(
|
|
48
|
+
"""
|
|
49
|
+
#LockOverlay { background-color: #ccc; }
|
|
50
|
+
#LockOverlay QLabel { color: #fff; font-size: 18px; }
|
|
51
|
+
#LockOverlay QPushButton {
|
|
52
|
+
background-color: #f2f2f2;
|
|
53
|
+
color: #000;
|
|
54
|
+
padding: 6px 14px;
|
|
55
|
+
border: 1px solid #808080;
|
|
56
|
+
border-radius: 6px;
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
}
|
|
59
|
+
#LockOverlay QPushButton:hover { background-color: #ffffff; }
|
|
60
|
+
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
|
|
61
|
+
"""
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
lay = QVBoxLayout(self)
|
|
65
|
+
lay.addStretch(1)
|
|
66
|
+
|
|
67
|
+
msg = QLabel("Locked due to inactivity")
|
|
68
|
+
msg.setAlignment(Qt.AlignCenter)
|
|
69
|
+
|
|
70
|
+
self._btn = QPushButton("Unlock")
|
|
71
|
+
self._btn.setFixedWidth(200)
|
|
72
|
+
self._btn.setCursor(Qt.PointingHandCursor)
|
|
73
|
+
self._btn.setAutoDefault(True)
|
|
74
|
+
self._btn.setDefault(True)
|
|
75
|
+
self._btn.clicked.connect(on_unlock)
|
|
76
|
+
|
|
77
|
+
lay.addWidget(msg, 0, Qt.AlignCenter)
|
|
78
|
+
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
|
79
|
+
lay.addStretch(1)
|
|
80
|
+
|
|
81
|
+
self.hide() # start hidden
|
|
82
|
+
|
|
83
|
+
# keep overlay sized with its parent
|
|
84
|
+
def eventFilter(self, obj, event):
|
|
85
|
+
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
|
86
|
+
self.setGeometry(obj.rect())
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def showEvent(self, e):
|
|
90
|
+
super().showEvent(e)
|
|
91
|
+
self._btn.setFocus()
|
|
92
|
+
|
|
93
|
+
|
|
31
94
|
class MainWindow(QMainWindow):
|
|
32
95
|
def __init__(self):
|
|
33
96
|
super().__init__()
|
|
@@ -35,9 +98,18 @@ class MainWindow(QMainWindow):
|
|
|
35
98
|
self.setMinimumSize(1000, 650)
|
|
36
99
|
|
|
37
100
|
self.cfg = load_db_config()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
if not os.path.exists(self.cfg.path):
|
|
102
|
+
# Fresh database/first time use, so guide the user re: setting a key
|
|
103
|
+
first_time = True
|
|
104
|
+
else:
|
|
105
|
+
first_time = False
|
|
106
|
+
|
|
107
|
+
# Prompt for the key unless it is found in config
|
|
108
|
+
if not self.cfg.key:
|
|
109
|
+
if not self._prompt_for_key_until_valid(first_time):
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
else:
|
|
112
|
+
self._try_connect()
|
|
41
113
|
|
|
42
114
|
# ---- UI: Left fixed panel (calendar) + right editor -----------------
|
|
43
115
|
self.calendar = QCalendarWidget()
|
|
@@ -62,18 +134,18 @@ class MainWindow(QMainWindow):
|
|
|
62
134
|
self.editor = Editor()
|
|
63
135
|
|
|
64
136
|
# Toolbar for controlling styling
|
|
65
|
-
|
|
66
|
-
self.addToolBar(
|
|
137
|
+
self.toolBar = ToolBar()
|
|
138
|
+
self.addToolBar(self.toolBar)
|
|
67
139
|
# Wire toolbar intents to editor methods
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
140
|
+
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
|
141
|
+
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
|
142
|
+
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
|
143
|
+
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
|
144
|
+
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
|
145
|
+
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
|
146
|
+
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
|
147
|
+
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
|
148
|
+
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
|
77
149
|
|
|
78
150
|
split = QSplitter()
|
|
79
151
|
split.addWidget(left_panel)
|
|
@@ -85,20 +157,42 @@ class MainWindow(QMainWindow):
|
|
|
85
157
|
lay.addWidget(split)
|
|
86
158
|
self.setCentralWidget(container)
|
|
87
159
|
|
|
160
|
+
# Idle lock setup
|
|
161
|
+
self._idle_timer = QTimer(self)
|
|
162
|
+
self._idle_timer.setSingleShot(True)
|
|
163
|
+
self._idle_timer.timeout.connect(self._enter_lock)
|
|
164
|
+
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
|
|
165
|
+
self._idle_timer.start()
|
|
166
|
+
|
|
167
|
+
# full-window overlay that sits on top of the central widget
|
|
168
|
+
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
|
169
|
+
self.centralWidget().installEventFilter(self._lock_overlay)
|
|
170
|
+
|
|
171
|
+
self._locked = False
|
|
172
|
+
|
|
173
|
+
# reset idle timer on any key press anywhere in the app
|
|
174
|
+
from PySide6.QtWidgets import QApplication
|
|
175
|
+
|
|
176
|
+
QApplication.instance().installEventFilter(self)
|
|
177
|
+
|
|
88
178
|
# Status bar for feedback
|
|
89
179
|
self.statusBar().showMessage("Ready", 800)
|
|
90
180
|
|
|
91
181
|
# Menu bar (File)
|
|
92
182
|
mb = self.menuBar()
|
|
93
|
-
file_menu = mb.addMenu("&
|
|
183
|
+
file_menu = mb.addMenu("&File")
|
|
94
184
|
act_save = QAction("&Save", self)
|
|
95
185
|
act_save.setShortcut("Ctrl+S")
|
|
96
186
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
97
187
|
file_menu.addAction(act_save)
|
|
98
|
-
act_settings = QAction("
|
|
99
|
-
act_settings.setShortcut("Ctrl+
|
|
188
|
+
act_settings = QAction("Settin&gs", self)
|
|
189
|
+
act_settings.setShortcut("Ctrl+G")
|
|
100
190
|
act_settings.triggered.connect(self._open_settings)
|
|
101
191
|
file_menu.addAction(act_settings)
|
|
192
|
+
act_export = QAction("&Export", self)
|
|
193
|
+
act_export.setShortcut("Ctrl+E")
|
|
194
|
+
act_export.triggered.connect(self._export)
|
|
195
|
+
file_menu.addAction(act_export)
|
|
102
196
|
file_menu.addSeparator()
|
|
103
197
|
act_quit = QAction("&Quit", self)
|
|
104
198
|
act_quit.setShortcut("Ctrl+Q")
|
|
@@ -128,6 +222,21 @@ class MainWindow(QMainWindow):
|
|
|
128
222
|
nav_menu.addAction(act_today)
|
|
129
223
|
self.addAction(act_today)
|
|
130
224
|
|
|
225
|
+
# Help menu with drop-down
|
|
226
|
+
help_menu = mb.addMenu("&Help")
|
|
227
|
+
act_docs = QAction("Documentation", self)
|
|
228
|
+
act_docs.setShortcut("Ctrl+D")
|
|
229
|
+
act_docs.setShortcutContext(Qt.ApplicationShortcut)
|
|
230
|
+
act_docs.triggered.connect(self._open_docs)
|
|
231
|
+
help_menu.addAction(act_docs)
|
|
232
|
+
self.addAction(act_docs)
|
|
233
|
+
act_bugs = QAction("Report a bug", self)
|
|
234
|
+
act_bugs.setShortcut("Ctrl+R")
|
|
235
|
+
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
|
|
236
|
+
act_bugs.triggered.connect(self._open_bugs)
|
|
237
|
+
help_menu.addAction(act_bugs)
|
|
238
|
+
self.addAction(act_bugs)
|
|
239
|
+
|
|
131
240
|
# Autosave
|
|
132
241
|
self._dirty = False
|
|
133
242
|
self._save_timer = QTimer(self)
|
|
@@ -139,6 +248,10 @@ class MainWindow(QMainWindow):
|
|
|
139
248
|
self._load_selected_date()
|
|
140
249
|
self._refresh_calendar_marks()
|
|
141
250
|
|
|
251
|
+
# Restore window position from settings
|
|
252
|
+
self.settings = QSettings(APP_ORG, APP_NAME)
|
|
253
|
+
self._restore_window_position()
|
|
254
|
+
|
|
142
255
|
def _try_connect(self) -> bool:
|
|
143
256
|
"""
|
|
144
257
|
Try to connect to the database.
|
|
@@ -155,12 +268,18 @@ class MainWindow(QMainWindow):
|
|
|
155
268
|
return False
|
|
156
269
|
return ok
|
|
157
270
|
|
|
158
|
-
def _prompt_for_key_until_valid(self) -> bool:
|
|
271
|
+
def _prompt_for_key_until_valid(self, first_time: bool) -> bool:
|
|
159
272
|
"""
|
|
160
273
|
Prompt for the SQLCipher key.
|
|
161
274
|
"""
|
|
275
|
+
if first_time:
|
|
276
|
+
title = "Set an encryption key"
|
|
277
|
+
message = "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!"
|
|
278
|
+
else:
|
|
279
|
+
title = "Unlock encrypted notebook"
|
|
280
|
+
message = "Enter your key to unlock the notebook"
|
|
162
281
|
while True:
|
|
163
|
-
dlg = KeyPrompt(self, message
|
|
282
|
+
dlg = KeyPrompt(self, title, message)
|
|
164
283
|
if dlg.exec() != QDialog.Accepted:
|
|
165
284
|
return False
|
|
166
285
|
self.cfg.key = dlg.key()
|
|
@@ -206,6 +325,8 @@ class MainWindow(QMainWindow):
|
|
|
206
325
|
self._dirty = False
|
|
207
326
|
# track which date the editor currently represents
|
|
208
327
|
self._active_date_iso = date_iso
|
|
328
|
+
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
|
329
|
+
self.calendar.setSelectedDate(qd)
|
|
209
330
|
|
|
210
331
|
def _on_text_changed(self):
|
|
211
332
|
self._dirty = True
|
|
@@ -265,24 +386,193 @@ class MainWindow(QMainWindow):
|
|
|
265
386
|
|
|
266
387
|
def _open_settings(self):
|
|
267
388
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
|
268
|
-
if dlg.exec()
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
389
|
+
if dlg.exec() != QDialog.Accepted:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
new_cfg = dlg.config
|
|
393
|
+
old_path = self.cfg.path
|
|
394
|
+
|
|
395
|
+
# Update in-memory config from the dialog
|
|
396
|
+
self.cfg.path = new_cfg.path
|
|
397
|
+
self.cfg.key = new_cfg.key
|
|
398
|
+
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
|
399
|
+
|
|
400
|
+
# Persist once
|
|
401
|
+
save_db_config(self.cfg)
|
|
402
|
+
|
|
403
|
+
# Apply idle setting immediately (restart the timer with new interval if it changed)
|
|
404
|
+
self._apply_idle_minutes(self.cfg.idle_minutes)
|
|
283
405
|
|
|
406
|
+
# If the DB path changed, reconnect
|
|
407
|
+
if self.cfg.path != old_path:
|
|
408
|
+
self.db.close()
|
|
409
|
+
if not self._prompt_for_key_until_valid(first_time=False):
|
|
410
|
+
QMessageBox.warning(
|
|
411
|
+
self, "Reopen failed", "Could not unlock database at new path."
|
|
412
|
+
)
|
|
413
|
+
return
|
|
414
|
+
self._load_selected_date()
|
|
415
|
+
self._refresh_calendar_marks()
|
|
416
|
+
|
|
417
|
+
def _restore_window_position(self):
|
|
418
|
+
geom = self.settings.value("main/geometry", None)
|
|
419
|
+
state = self.settings.value("main/windowState", None)
|
|
420
|
+
was_max = self.settings.value("main/maximized", False, type=bool)
|
|
421
|
+
|
|
422
|
+
if geom is not None:
|
|
423
|
+
self.restoreGeometry(geom)
|
|
424
|
+
if state is not None:
|
|
425
|
+
self.restoreState(state)
|
|
426
|
+
if not self._rect_on_any_screen(self.frameGeometry()):
|
|
427
|
+
self._move_to_cursor_screen_center()
|
|
428
|
+
else:
|
|
429
|
+
# First run: place window on the screen where the mouse cursor is.
|
|
430
|
+
self._move_to_cursor_screen_center()
|
|
431
|
+
|
|
432
|
+
# If it was maximized, do that AFTER the window exists in the event loop.
|
|
433
|
+
if was_max:
|
|
434
|
+
QTimer.singleShot(0, self.showMaximized)
|
|
435
|
+
|
|
436
|
+
def _rect_on_any_screen(self, rect):
|
|
437
|
+
for sc in QGuiApplication.screens():
|
|
438
|
+
if sc.availableGeometry().intersects(rect):
|
|
439
|
+
return True
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
def _move_to_cursor_screen_center(self):
|
|
443
|
+
screen = (
|
|
444
|
+
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
|
|
445
|
+
)
|
|
446
|
+
r = screen.availableGeometry()
|
|
447
|
+
# Center the window in that screen’s available area
|
|
448
|
+
self.move(r.center() - self.rect().center())
|
|
449
|
+
|
|
450
|
+
@Slot()
|
|
451
|
+
def _export(self):
|
|
452
|
+
try:
|
|
453
|
+
self.export_dialog()
|
|
454
|
+
except Exception as e:
|
|
455
|
+
QMessageBox.critical(self, "Export failed", str(e))
|
|
456
|
+
|
|
457
|
+
def export_dialog(self) -> None:
|
|
458
|
+
filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
|
|
459
|
+
|
|
460
|
+
start_dir = os.path.join(os.path.expanduser("~"), "Documents")
|
|
461
|
+
filename, selected_filter = QFileDialog.getSaveFileName(
|
|
462
|
+
self, "Export entries", start_dir, filters
|
|
463
|
+
)
|
|
464
|
+
if not filename:
|
|
465
|
+
return # user cancelled
|
|
466
|
+
|
|
467
|
+
default_ext = {
|
|
468
|
+
"Text (*.txt)": ".txt",
|
|
469
|
+
"JSON (*.json)": ".json",
|
|
470
|
+
"CSV (*.csv)": ".csv",
|
|
471
|
+
"HTML (*.html)": ".html",
|
|
472
|
+
}.get(selected_filter, ".txt")
|
|
473
|
+
|
|
474
|
+
if not Path(filename).suffix:
|
|
475
|
+
filename += default_ext
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
entries = self.db.get_all_entries()
|
|
479
|
+
if selected_filter.startswith("Text"):
|
|
480
|
+
self.db.export_txt(entries, filename)
|
|
481
|
+
elif selected_filter.startswith("JSON"):
|
|
482
|
+
self.db.export_json(entries, filename)
|
|
483
|
+
elif selected_filter.startswith("CSV"):
|
|
484
|
+
self.db.export_csv(entries, filename)
|
|
485
|
+
elif selected_filter.startswith("HTML"):
|
|
486
|
+
self.bd.export_html(entries, filename)
|
|
487
|
+
else:
|
|
488
|
+
self.bd.export_by_extension(entries, filename)
|
|
489
|
+
|
|
490
|
+
QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
|
|
491
|
+
except Exception as e:
|
|
492
|
+
QMessageBox.critical(self, "Export failed", str(e))
|
|
493
|
+
|
|
494
|
+
def _open_docs(self):
|
|
495
|
+
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
|
496
|
+
url = QUrl.fromUserInput(url_str)
|
|
497
|
+
if not QDesktopServices.openUrl(url):
|
|
498
|
+
QMessageBox.warning(
|
|
499
|
+
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def _open_bugs(self):
|
|
503
|
+
url_str = "https://nr.mig5.net/forms/mig5/contact"
|
|
504
|
+
url = QUrl.fromUserInput(url_str)
|
|
505
|
+
if not QDesktopServices.openUrl(url):
|
|
506
|
+
QMessageBox.warning(
|
|
507
|
+
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Idle handlers
|
|
511
|
+
def _apply_idle_minutes(self, minutes: int):
|
|
512
|
+
minutes = max(0, int(minutes))
|
|
513
|
+
if not hasattr(self, "_idle_timer"):
|
|
514
|
+
return
|
|
515
|
+
if minutes == 0:
|
|
516
|
+
self._idle_timer.stop()
|
|
517
|
+
# If you’re currently locked, unlock when user disables the timer:
|
|
518
|
+
if getattr(self, "_locked", False):
|
|
519
|
+
try:
|
|
520
|
+
self._locked = False
|
|
521
|
+
if hasattr(self, "_lock_overlay"):
|
|
522
|
+
self._lock_overlay.hide()
|
|
523
|
+
except Exception:
|
|
524
|
+
pass
|
|
525
|
+
else:
|
|
526
|
+
self._idle_timer.setInterval(minutes * 60 * 1000)
|
|
527
|
+
if not getattr(self, "_locked", False):
|
|
528
|
+
self._idle_timer.start()
|
|
529
|
+
|
|
530
|
+
def eventFilter(self, obj, event):
|
|
531
|
+
if event.type() == QEvent.KeyPress and not self._locked:
|
|
532
|
+
self._idle_timer.start()
|
|
533
|
+
return super().eventFilter(obj, event)
|
|
534
|
+
|
|
535
|
+
def _enter_lock(self):
|
|
536
|
+
if self._locked:
|
|
537
|
+
return
|
|
538
|
+
self._locked = True
|
|
539
|
+
if self.menuBar():
|
|
540
|
+
self.menuBar().setEnabled(False)
|
|
541
|
+
if self.statusBar():
|
|
542
|
+
self.statusBar().setEnabled(False)
|
|
543
|
+
tb = getattr(self, "toolBar", None)
|
|
544
|
+
if tb:
|
|
545
|
+
tb.setEnabled(False)
|
|
546
|
+
self._lock_overlay.show()
|
|
547
|
+
self._lock_overlay.raise_()
|
|
548
|
+
|
|
549
|
+
@Slot()
|
|
550
|
+
def _on_unlock_clicked(self):
|
|
551
|
+
try:
|
|
552
|
+
ok = self._prompt_for_key_until_valid(first_time=False)
|
|
553
|
+
except Exception as e:
|
|
554
|
+
QMessageBox.critical(self, "Unlock failed", str(e))
|
|
555
|
+
return
|
|
556
|
+
if ok:
|
|
557
|
+
self._locked = False
|
|
558
|
+
self._lock_overlay.hide()
|
|
559
|
+
if self.menuBar():
|
|
560
|
+
self.menuBar().setEnabled(True)
|
|
561
|
+
if self.statusBar():
|
|
562
|
+
self.statusBar().setEnabled(True)
|
|
563
|
+
tb = getattr(self, "toolBar", None)
|
|
564
|
+
if tb:
|
|
565
|
+
tb.setEnabled(True)
|
|
566
|
+
self._idle_timer.start()
|
|
567
|
+
|
|
568
|
+
# Close app handler - save window position and database
|
|
284
569
|
def closeEvent(self, event):
|
|
285
570
|
try:
|
|
571
|
+
# Save window position
|
|
572
|
+
self.settings.setValue("main/geometry", self.saveGeometry())
|
|
573
|
+
self.settings.setValue("main/windowState", self.saveState())
|
|
574
|
+
self.settings.setValue("main/maximized", self.isMaximized())
|
|
575
|
+
# Ensure we save any last pending edits to the db
|
|
286
576
|
self._save_current()
|
|
287
577
|
self.db.close()
|
|
288
578
|
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,13 @@ 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
|
+
idle = s.value("db/idle_minutes", 15, type=int)
|
|
26
|
+
return DBConfig(path=path, key=key, idle_minutes=idle)
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def save_db_config(cfg: DBConfig) -> None:
|
|
28
30
|
s = get_settings()
|
|
29
31
|
s.setValue("db/path", str(cfg.path))
|
|
32
|
+
s.setValue("db/key", str(cfg.key))
|
|
33
|
+
s.setValue("db/idle_minutes", str(cfg.idle_minutes))
|
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,
|
|
@@ -13,11 +17,15 @@ from PySide6.QtWidgets import (
|
|
|
13
17
|
QFileDialog,
|
|
14
18
|
QDialogButtonBox,
|
|
15
19
|
QSizePolicy,
|
|
20
|
+
QSpinBox,
|
|
16
21
|
QMessageBox,
|
|
17
22
|
)
|
|
23
|
+
from PySide6.QtCore import Qt, Slot
|
|
24
|
+
from PySide6.QtGui import QPalette
|
|
25
|
+
|
|
18
26
|
|
|
19
27
|
from .db import DBConfig, DBManager
|
|
20
|
-
from .settings import save_db_config
|
|
28
|
+
from .settings import load_db_config, save_db_config
|
|
21
29
|
from .key_prompt import KeyPrompt
|
|
22
30
|
|
|
23
31
|
|
|
@@ -27,10 +35,11 @@ class SettingsDialog(QDialog):
|
|
|
27
35
|
self.setWindowTitle("Settings")
|
|
28
36
|
self._cfg = DBConfig(path=cfg.path, key="")
|
|
29
37
|
self._db = db
|
|
38
|
+
self.key = ""
|
|
30
39
|
|
|
31
40
|
form = QFormLayout()
|
|
32
41
|
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
|
33
|
-
self.setMinimumWidth(
|
|
42
|
+
self.setMinimumWidth(560)
|
|
34
43
|
self.setSizeGripEnabled(True)
|
|
35
44
|
|
|
36
45
|
self.path_edit = QLineEdit(str(self._cfg.path))
|
|
@@ -47,18 +56,88 @@ class SettingsDialog(QDialog):
|
|
|
47
56
|
h.setStretch(1, 0)
|
|
48
57
|
form.addRow("Database path", path_row)
|
|
49
58
|
|
|
59
|
+
# Encryption settings
|
|
60
|
+
enc_group = QGroupBox("Encryption and Privacy")
|
|
61
|
+
enc = QVBoxLayout(enc_group)
|
|
62
|
+
enc.setContentsMargins(12, 8, 12, 12)
|
|
63
|
+
enc.setSpacing(6)
|
|
64
|
+
|
|
65
|
+
# Checkbox to remember key
|
|
66
|
+
self.save_key_btn = QCheckBox("Remember key")
|
|
67
|
+
current_settings = load_db_config()
|
|
68
|
+
self.key = current_settings.key or ""
|
|
69
|
+
self.save_key_btn.setChecked(bool(self.key))
|
|
70
|
+
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
|
71
|
+
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
|
72
|
+
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
|
73
|
+
|
|
74
|
+
# Explanation for remembering key
|
|
75
|
+
self.save_key_label = QLabel(
|
|
76
|
+
"If you don't want to be prompted for your encryption key, check this to remember it. "
|
|
77
|
+
"WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
|
|
78
|
+
)
|
|
79
|
+
self.save_key_label.setWordWrap(True)
|
|
80
|
+
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
|
81
|
+
# make it look secondary
|
|
82
|
+
pal = self.save_key_label.palette()
|
|
83
|
+
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
|
|
84
|
+
self.save_key_label.setPalette(pal)
|
|
85
|
+
|
|
86
|
+
exp_row = QHBoxLayout()
|
|
87
|
+
exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
|
|
88
|
+
exp_row.addWidget(self.save_key_label)
|
|
89
|
+
enc.addLayout(exp_row)
|
|
90
|
+
|
|
91
|
+
line = QFrame()
|
|
92
|
+
line.setFrameShape(QFrame.HLine)
|
|
93
|
+
line.setFrameShadow(QFrame.Sunken)
|
|
94
|
+
enc.addWidget(line)
|
|
95
|
+
|
|
50
96
|
# Change key button
|
|
51
97
|
self.rekey_btn = QPushButton("Change key")
|
|
98
|
+
self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
52
99
|
self.rekey_btn.clicked.connect(self._change_key)
|
|
100
|
+
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
|
53
101
|
|
|
102
|
+
self.idle_spin = QSpinBox()
|
|
103
|
+
self.idle_spin.setRange(0, 240)
|
|
104
|
+
self.idle_spin.setSingleStep(1)
|
|
105
|
+
self.idle_spin.setAccelerated(True)
|
|
106
|
+
self.idle_spin.setSuffix(" min")
|
|
107
|
+
self.idle_spin.setSpecialValueText("Never")
|
|
108
|
+
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
|
109
|
+
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
|
110
|
+
# Explanation for idle option (autolock)
|
|
111
|
+
self.idle_spin_label = QLabel(
|
|
112
|
+
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
|
113
|
+
"Set to 0 (never) to never lock."
|
|
114
|
+
)
|
|
115
|
+
self.idle_spin_label.setWordWrap(True)
|
|
116
|
+
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
|
117
|
+
# make it look secondary
|
|
118
|
+
spal = self.idle_spin_label.palette()
|
|
119
|
+
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
|
|
120
|
+
self.idle_spin_label.setPalette(spal)
|
|
121
|
+
|
|
122
|
+
spin_row = QHBoxLayout()
|
|
123
|
+
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
|
124
|
+
spin_row.addWidget(self.idle_spin_label)
|
|
125
|
+
enc.addLayout(spin_row)
|
|
126
|
+
|
|
127
|
+
# Put the group into the form so it spans the full width nicely
|
|
128
|
+
form.addRow(enc_group)
|
|
129
|
+
|
|
130
|
+
# Buttons
|
|
54
131
|
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
55
132
|
bb.accepted.connect(self._save)
|
|
56
133
|
bb.rejected.connect(self.reject)
|
|
57
134
|
|
|
135
|
+
# Root layout (adjust margins/spacing a bit)
|
|
58
136
|
v = QVBoxLayout(self)
|
|
137
|
+
v.setContentsMargins(12, 12, 12, 12)
|
|
138
|
+
v.setSpacing(10)
|
|
59
139
|
v.addLayout(form)
|
|
60
|
-
v.addWidget(
|
|
61
|
-
v.addWidget(bb)
|
|
140
|
+
v.addWidget(bb, 0, Qt.AlignRight)
|
|
62
141
|
|
|
63
142
|
def _browse(self):
|
|
64
143
|
p, _ = QFileDialog.getSaveFileName(
|
|
@@ -71,16 +150,21 @@ class SettingsDialog(QDialog):
|
|
|
71
150
|
self.path_edit.setText(p)
|
|
72
151
|
|
|
73
152
|
def _save(self):
|
|
74
|
-
self.
|
|
153
|
+
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
|
154
|
+
self._cfg = DBConfig(
|
|
155
|
+
path=Path(self.path_edit.text()),
|
|
156
|
+
key=key_to_save,
|
|
157
|
+
idle_minutes=self.idle_spin.value(),
|
|
158
|
+
)
|
|
75
159
|
save_db_config(self._cfg)
|
|
76
160
|
self.accept()
|
|
77
161
|
|
|
78
162
|
def _change_key(self):
|
|
79
|
-
p1 = KeyPrompt(self, title="Change key", message="Enter new key")
|
|
163
|
+
p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
|
|
80
164
|
if p1.exec() != QDialog.Accepted:
|
|
81
165
|
return
|
|
82
166
|
new_key = p1.key()
|
|
83
|
-
p2 = KeyPrompt(self, title="Change key", message="Re-enter new key")
|
|
167
|
+
p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
|
|
84
168
|
if p2.exec() != QDialog.Accepted:
|
|
85
169
|
return
|
|
86
170
|
if new_key != p2.key():
|
|
@@ -92,11 +176,27 @@ class SettingsDialog(QDialog):
|
|
|
92
176
|
try:
|
|
93
177
|
self._db.rekey(new_key)
|
|
94
178
|
QMessageBox.information(
|
|
95
|
-
self, "Key changed", "The
|
|
179
|
+
self, "Key changed", "The notebook was re-encrypted with the new key!"
|
|
96
180
|
)
|
|
97
181
|
except Exception as e:
|
|
98
182
|
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
|
99
183
|
|
|
184
|
+
@Slot(bool)
|
|
185
|
+
def save_key_btn_clicked(self, checked: bool):
|
|
186
|
+
if checked:
|
|
187
|
+
if not self.key:
|
|
188
|
+
p1 = KeyPrompt(
|
|
189
|
+
self, title="Enter your key", message="Enter the encryption key"
|
|
190
|
+
)
|
|
191
|
+
if p1.exec() != QDialog.Accepted:
|
|
192
|
+
self.save_key_btn.blockSignals(True)
|
|
193
|
+
self.save_key_btn.setChecked(False)
|
|
194
|
+
self.save_key_btn.blockSignals(False)
|
|
195
|
+
return
|
|
196
|
+
self.key = p1.key() or ""
|
|
197
|
+
else:
|
|
198
|
+
self.key = ""
|
|
199
|
+
|
|
100
200
|
@property
|
|
101
201
|
def config(self) -> DBConfig:
|
|
102
202
|
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.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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
|
|
2
|
+
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
+
bouquin/db.py,sha256=kvwZRP9fcG8V8paAU0iVR7qTYO8gGCq1qb2Wuog0dKE,7922
|
|
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=Q6HJjUU4kY05GeaxNEEoqDY79MN4tu2rlBH7qgEYjuY,20835
|
|
8
|
+
bouquin/search.py,sha256=NAgH_FLjFB2i9bJXEfH3ClO8dWg7geYyoHtmLFNkrwA,6478
|
|
9
|
+
bouquin/settings.py,sha256=GpMeJcTjdL1PFumeqdlSOi7nlgGdPTOeRbFadWYFcA0,870
|
|
10
|
+
bouquin/settings_dialog.py,sha256=pgIg2G5O092mPn5EmkKrEgtl-Tyc8dwwCyNSNEAOidA,7256
|
|
11
|
+
bouquin/toolbar.py,sha256=i8uNhcAyYczVKPgSgk6tNJ63XxqlhPjLNpjzfM9NDC0,5401
|
|
12
|
+
bouquin-0.1.4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
13
|
+
bouquin-0.1.4.dist-info/METADATA,sha256=mJ_ZtOmlBZjkwq4ecGlCmzU3JVOBp_CwXoPscm37Tag,2468
|
|
14
|
+
bouquin-0.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
15
|
+
bouquin-0.1.4.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
16
|
+
bouquin-0.1.4.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
|