bouquin 0.1.1__py3-none-any.whl → 0.1.2__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 +6 -0
- bouquin/editor.py +120 -0
- bouquin/main.py +2 -1
- bouquin/main_window.py +53 -21
- bouquin/search.py +195 -0
- bouquin/settings_dialog.py +3 -1
- bouquin/toolbar.py +98 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.2.dist-info}/METADATA +7 -6
- bouquin-0.1.2.dist-info/RECORD +16 -0
- bouquin/highlighter.py +0 -112
- bouquin-0.1.1.dist-info/RECORD +0 -14
- {bouquin-0.1.1.dist-info → bouquin-0.1.2.dist-info}/LICENSE +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.2.dist-info}/WHEEL +0 -0
- {bouquin-0.1.1.dist-info → bouquin-0.1.2.dist-info}/entry_points.txt +0 -0
bouquin/db.py
CHANGED
|
@@ -100,6 +100,12 @@ class DBManager:
|
|
|
100
100
|
)
|
|
101
101
|
self.conn.commit()
|
|
102
102
|
|
|
103
|
+
def search_entries(self, text: str) -> list[str]:
|
|
104
|
+
cur = self.conn.cursor()
|
|
105
|
+
pattern = f"%{text}%"
|
|
106
|
+
cur.execute("SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,))
|
|
107
|
+
return [r for r in cur.fetchall()]
|
|
108
|
+
|
|
103
109
|
def dates_with_content(self) -> list[str]:
|
|
104
110
|
cur = self.conn.cursor()
|
|
105
111
|
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
bouquin/editor.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtGui import (
|
|
4
|
+
QColor,
|
|
5
|
+
QFont,
|
|
6
|
+
QFontDatabase,
|
|
7
|
+
QTextCharFormat,
|
|
8
|
+
QTextListFormat,
|
|
9
|
+
QTextBlockFormat,
|
|
10
|
+
)
|
|
11
|
+
from PySide6.QtCore import Slot
|
|
12
|
+
from PySide6.QtWidgets import QTextEdit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Editor(QTextEdit):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__()
|
|
18
|
+
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
|
19
|
+
self.setTabStopDistance(tab_w)
|
|
20
|
+
|
|
21
|
+
def merge_on_sel(self, fmt):
|
|
22
|
+
"""
|
|
23
|
+
Sets the styling on the selected characters.
|
|
24
|
+
"""
|
|
25
|
+
cursor = self.textCursor()
|
|
26
|
+
if not cursor.hasSelection():
|
|
27
|
+
cursor.select(cursor.SelectionType.WordUnderCursor)
|
|
28
|
+
cursor.mergeCharFormat(fmt)
|
|
29
|
+
self.mergeCurrentCharFormat(fmt)
|
|
30
|
+
|
|
31
|
+
@Slot(QFont.Weight)
|
|
32
|
+
def apply_weight(self, weight):
|
|
33
|
+
fmt = QTextCharFormat()
|
|
34
|
+
fmt.setFontWeight(weight)
|
|
35
|
+
self.merge_on_sel(fmt)
|
|
36
|
+
|
|
37
|
+
@Slot()
|
|
38
|
+
def apply_italic(self):
|
|
39
|
+
cur = self.currentCharFormat()
|
|
40
|
+
fmt = QTextCharFormat()
|
|
41
|
+
fmt.setFontItalic(not cur.fontItalic())
|
|
42
|
+
self.merge_on_sel(fmt)
|
|
43
|
+
|
|
44
|
+
@Slot()
|
|
45
|
+
def apply_underline(self):
|
|
46
|
+
cur = self.currentCharFormat()
|
|
47
|
+
fmt = QTextCharFormat()
|
|
48
|
+
fmt.setFontUnderline(not cur.fontUnderline())
|
|
49
|
+
self.merge_on_sel(fmt)
|
|
50
|
+
|
|
51
|
+
@Slot()
|
|
52
|
+
def apply_strikethrough(self):
|
|
53
|
+
cur = self.currentCharFormat()
|
|
54
|
+
fmt = QTextCharFormat()
|
|
55
|
+
fmt.setFontStrikeOut(not cur.fontStrikeOut())
|
|
56
|
+
self.merge_on_sel(fmt)
|
|
57
|
+
|
|
58
|
+
@Slot()
|
|
59
|
+
def apply_code(self):
|
|
60
|
+
c = self.textCursor()
|
|
61
|
+
if not c.hasSelection():
|
|
62
|
+
c.select(c.SelectionType.BlockUnderCursor)
|
|
63
|
+
|
|
64
|
+
bf = QTextBlockFormat()
|
|
65
|
+
bf.setLeftMargin(12)
|
|
66
|
+
bf.setRightMargin(12)
|
|
67
|
+
bf.setTopMargin(6)
|
|
68
|
+
bf.setBottomMargin(6)
|
|
69
|
+
bf.setBackground(QColor(245, 245, 245))
|
|
70
|
+
bf.setNonBreakableLines(True)
|
|
71
|
+
|
|
72
|
+
cf = QTextCharFormat()
|
|
73
|
+
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
74
|
+
cf.setFont(mono)
|
|
75
|
+
cf.setFontFixedPitch(True)
|
|
76
|
+
|
|
77
|
+
# If the current block already looks like a code block, remove styling
|
|
78
|
+
cur_bf = c.blockFormat()
|
|
79
|
+
is_code = (
|
|
80
|
+
cur_bf.nonBreakableLines()
|
|
81
|
+
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
|
|
82
|
+
)
|
|
83
|
+
if is_code:
|
|
84
|
+
# clear: margins/background/wrapping
|
|
85
|
+
bf = QTextBlockFormat()
|
|
86
|
+
cf = QTextCharFormat()
|
|
87
|
+
|
|
88
|
+
c.mergeBlockFormat(bf)
|
|
89
|
+
c.mergeBlockCharFormat(cf)
|
|
90
|
+
|
|
91
|
+
@Slot(int)
|
|
92
|
+
def apply_heading(self, size):
|
|
93
|
+
fmt = QTextCharFormat()
|
|
94
|
+
if size:
|
|
95
|
+
fmt.setFontWeight(QFont.Weight.Bold)
|
|
96
|
+
fmt.setFontPointSize(size)
|
|
97
|
+
else:
|
|
98
|
+
fmt.setFontWeight(QFont.Weight.Normal)
|
|
99
|
+
fmt.setFontPointSize(self.font().pointSizeF())
|
|
100
|
+
self.merge_on_sel(fmt)
|
|
101
|
+
|
|
102
|
+
def toggle_bullets(self):
|
|
103
|
+
c = self.textCursor()
|
|
104
|
+
lst = c.currentList()
|
|
105
|
+
if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
|
|
106
|
+
lst.remove(c.block())
|
|
107
|
+
return
|
|
108
|
+
fmt = QTextListFormat()
|
|
109
|
+
fmt.setStyle(QTextListFormat.Style.ListDisc)
|
|
110
|
+
c.createList(fmt)
|
|
111
|
+
|
|
112
|
+
def toggle_numbers(self):
|
|
113
|
+
c = self.textCursor()
|
|
114
|
+
lst = c.currentList()
|
|
115
|
+
if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
|
|
116
|
+
lst.remove(c.block())
|
|
117
|
+
return
|
|
118
|
+
fmt = QTextListFormat()
|
|
119
|
+
fmt.setStyle(QTextListFormat.Style.ListDecimal)
|
|
120
|
+
c.createList(fmt)
|
bouquin/main.py
CHANGED
bouquin/main_window.py
CHANGED
|
@@ -3,24 +3,29 @@ from __future__ import annotations
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
5
|
from PySide6.QtCore import QDate, QTimer, Qt
|
|
6
|
-
from PySide6.QtGui import
|
|
6
|
+
from PySide6.QtGui import (
|
|
7
|
+
QAction,
|
|
8
|
+
QFont,
|
|
9
|
+
QTextCharFormat,
|
|
10
|
+
)
|
|
7
11
|
from PySide6.QtWidgets import (
|
|
8
|
-
QDialog,
|
|
9
12
|
QCalendarWidget,
|
|
13
|
+
QDialog,
|
|
10
14
|
QMainWindow,
|
|
11
15
|
QMessageBox,
|
|
12
|
-
|
|
16
|
+
QSizePolicy,
|
|
13
17
|
QSplitter,
|
|
14
18
|
QVBoxLayout,
|
|
15
19
|
QWidget,
|
|
16
|
-
QSizePolicy,
|
|
17
20
|
)
|
|
18
21
|
|
|
19
22
|
from .db import DBManager
|
|
20
|
-
from .
|
|
23
|
+
from .editor import Editor
|
|
21
24
|
from .key_prompt import KeyPrompt
|
|
22
|
-
from .
|
|
25
|
+
from .search import Search
|
|
26
|
+
from .settings import APP_NAME, load_db_config, save_db_config
|
|
23
27
|
from .settings_dialog import SettingsDialog
|
|
28
|
+
from .toolbar import ToolBar
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class MainWindow(QMainWindow):
|
|
@@ -40,17 +45,35 @@ class MainWindow(QMainWindow):
|
|
|
40
45
|
self.calendar.setGridVisible(True)
|
|
41
46
|
self.calendar.selectionChanged.connect(self._on_date_changed)
|
|
42
47
|
|
|
48
|
+
self.search = Search(self.db)
|
|
49
|
+
self.search.openDateRequested.connect(self._load_selected_date)
|
|
50
|
+
|
|
51
|
+
# Lock the calendar to the left panel at the top to stop it stretching
|
|
52
|
+
# when the main window is resized.
|
|
43
53
|
left_panel = QWidget()
|
|
44
54
|
left_layout = QVBoxLayout(left_panel)
|
|
45
55
|
left_layout.setContentsMargins(8, 8, 8, 8)
|
|
46
56
|
left_layout.addWidget(self.calendar, alignment=Qt.AlignTop)
|
|
57
|
+
left_layout.addWidget(self.search, alignment=Qt.AlignBottom)
|
|
47
58
|
left_layout.addStretch(1)
|
|
48
59
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
# This is the note-taking editor
|
|
62
|
+
self.editor = Editor()
|
|
63
|
+
|
|
64
|
+
# Toolbar for controlling styling
|
|
65
|
+
tb = ToolBar()
|
|
66
|
+
self.addToolBar(tb)
|
|
67
|
+
# Wire toolbar intents to editor methods
|
|
68
|
+
tb.boldRequested.connect(self.editor.apply_weight)
|
|
69
|
+
tb.italicRequested.connect(self.editor.apply_italic)
|
|
70
|
+
tb.underlineRequested.connect(self.editor.apply_underline)
|
|
71
|
+
tb.strikeRequested.connect(self.editor.apply_strikethrough)
|
|
72
|
+
tb.codeRequested.connect(self.editor.apply_code)
|
|
73
|
+
tb.headingRequested.connect(self.editor.apply_heading)
|
|
74
|
+
tb.bulletsRequested.connect(self.editor.toggle_bullets)
|
|
75
|
+
tb.numbersRequested.connect(self.editor.toggle_numbers)
|
|
76
|
+
tb.alignRequested.connect(self.editor.setAlignment)
|
|
54
77
|
|
|
55
78
|
split = QSplitter()
|
|
56
79
|
split.addWidget(left_panel)
|
|
@@ -67,13 +90,13 @@ class MainWindow(QMainWindow):
|
|
|
67
90
|
|
|
68
91
|
# Menu bar (File)
|
|
69
92
|
mb = self.menuBar()
|
|
70
|
-
file_menu = mb.addMenu("&
|
|
93
|
+
file_menu = mb.addMenu("&Application")
|
|
71
94
|
act_save = QAction("&Save", self)
|
|
72
95
|
act_save.setShortcut("Ctrl+S")
|
|
73
96
|
act_save.triggered.connect(lambda: self._save_current(explicit=True))
|
|
74
97
|
file_menu.addAction(act_save)
|
|
75
98
|
act_settings = QAction("S&ettings", self)
|
|
76
|
-
|
|
99
|
+
act_settings.setShortcut("Ctrl+E")
|
|
77
100
|
act_settings.triggered.connect(self._open_settings)
|
|
78
101
|
file_menu.addAction(act_settings)
|
|
79
102
|
file_menu.addSeparator()
|
|
@@ -82,7 +105,7 @@ class MainWindow(QMainWindow):
|
|
|
82
105
|
act_quit.triggered.connect(self.close)
|
|
83
106
|
file_menu.addAction(act_quit)
|
|
84
107
|
|
|
85
|
-
# Navigate menu with next/previous
|
|
108
|
+
# Navigate menu with next/previous/today
|
|
86
109
|
nav_menu = mb.addMenu("&Navigate")
|
|
87
110
|
act_prev = QAction("Previous Day", self)
|
|
88
111
|
act_prev.setShortcut("Ctrl+P")
|
|
@@ -112,12 +135,14 @@ class MainWindow(QMainWindow):
|
|
|
112
135
|
self._save_timer.timeout.connect(self._save_current)
|
|
113
136
|
self.editor.textChanged.connect(self._on_text_changed)
|
|
114
137
|
|
|
115
|
-
# First load + mark dates with content
|
|
138
|
+
# First load + mark dates in calendar with content
|
|
116
139
|
self._load_selected_date()
|
|
117
140
|
self._refresh_calendar_marks()
|
|
118
141
|
|
|
119
|
-
# --- DB lifecycle
|
|
120
142
|
def _try_connect(self) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Try to connect to the database.
|
|
145
|
+
"""
|
|
121
146
|
try:
|
|
122
147
|
self.db = DBManager(self.cfg)
|
|
123
148
|
ok = self.db.connect()
|
|
@@ -131,6 +156,9 @@ class MainWindow(QMainWindow):
|
|
|
131
156
|
return ok
|
|
132
157
|
|
|
133
158
|
def _prompt_for_key_until_valid(self) -> bool:
|
|
159
|
+
"""
|
|
160
|
+
Prompt for the SQLCipher key.
|
|
161
|
+
"""
|
|
134
162
|
while True:
|
|
135
163
|
dlg = KeyPrompt(self, message="Enter a key to unlock the notebook")
|
|
136
164
|
if dlg.exec() != QDialog.Accepted:
|
|
@@ -139,8 +167,11 @@ class MainWindow(QMainWindow):
|
|
|
139
167
|
if self._try_connect():
|
|
140
168
|
return True
|
|
141
169
|
|
|
142
|
-
# --- Calendar marks to indicate text exists for htat day -----------------
|
|
143
170
|
def _refresh_calendar_marks(self):
|
|
171
|
+
"""
|
|
172
|
+
Sets a bold marker on the day to indicate that text exists
|
|
173
|
+
for that day.
|
|
174
|
+
"""
|
|
144
175
|
fmt_bold = QTextCharFormat()
|
|
145
176
|
fmt_bold.setFontWeight(QFont.Weight.Bold)
|
|
146
177
|
# Clear previous marks
|
|
@@ -161,15 +192,16 @@ class MainWindow(QMainWindow):
|
|
|
161
192
|
d = self.calendar.selectedDate()
|
|
162
193
|
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
|
163
194
|
|
|
164
|
-
def _load_selected_date(self):
|
|
165
|
-
|
|
195
|
+
def _load_selected_date(self, date_iso=False):
|
|
196
|
+
if not date_iso:
|
|
197
|
+
date_iso = self._current_date_iso()
|
|
166
198
|
try:
|
|
167
199
|
text = self.db.get_entry(date_iso)
|
|
168
200
|
except Exception as e:
|
|
169
201
|
QMessageBox.critical(self, "Read Error", str(e))
|
|
170
202
|
return
|
|
171
203
|
self.editor.blockSignals(True)
|
|
172
|
-
self.editor.
|
|
204
|
+
self.editor.setHtml(text)
|
|
173
205
|
self.editor.blockSignals(False)
|
|
174
206
|
self._dirty = False
|
|
175
207
|
# track which date the editor currently represents
|
|
@@ -212,7 +244,7 @@ class MainWindow(QMainWindow):
|
|
|
212
244
|
"""
|
|
213
245
|
if not self._dirty and not explicit:
|
|
214
246
|
return
|
|
215
|
-
text = self.editor.
|
|
247
|
+
text = self.editor.toHtml()
|
|
216
248
|
try:
|
|
217
249
|
self.db.upsert_entry(date_iso, text)
|
|
218
250
|
except Exception as e:
|
|
@@ -249,7 +281,7 @@ class MainWindow(QMainWindow):
|
|
|
249
281
|
self._load_selected_date()
|
|
250
282
|
self._refresh_calendar_marks()
|
|
251
283
|
|
|
252
|
-
def closeEvent(self, event):
|
|
284
|
+
def closeEvent(self, event):
|
|
253
285
|
try:
|
|
254
286
|
self._save_current()
|
|
255
287
|
self.db.close()
|
bouquin/search.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Iterable, Tuple
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import Qt, Signal
|
|
7
|
+
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
|
|
8
|
+
from PySide6.QtWidgets import (
|
|
9
|
+
QLabel,
|
|
10
|
+
QLineEdit,
|
|
11
|
+
QListWidget,
|
|
12
|
+
QListWidgetItem,
|
|
13
|
+
QHBoxLayout,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# type: rows are (date_iso, content)
|
|
19
|
+
Row = Tuple[str, str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Search(QWidget):
|
|
23
|
+
"""Encapsulates the search UI + logic and emits a signal when a result is chosen."""
|
|
24
|
+
|
|
25
|
+
openDateRequested = Signal(str)
|
|
26
|
+
|
|
27
|
+
def __init__(self, db, parent: QWidget | None = None):
|
|
28
|
+
super().__init__(parent)
|
|
29
|
+
self._db = db
|
|
30
|
+
|
|
31
|
+
self.search = QLineEdit()
|
|
32
|
+
self.search.setPlaceholderText("Search for notes here")
|
|
33
|
+
self.search.textChanged.connect(self._search)
|
|
34
|
+
|
|
35
|
+
self.results = QListWidget()
|
|
36
|
+
self.results.setUniformItemSizes(False)
|
|
37
|
+
self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
|
|
38
|
+
self.results.itemClicked.connect(self._open_selected)
|
|
39
|
+
self.results.hide()
|
|
40
|
+
|
|
41
|
+
lay = QVBoxLayout(self)
|
|
42
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
43
|
+
lay.setSpacing(6)
|
|
44
|
+
lay.addWidget(self.search)
|
|
45
|
+
lay.addWidget(self.results)
|
|
46
|
+
|
|
47
|
+
def _open_selected(self, item: QListWidgetItem):
|
|
48
|
+
date_str = item.data(Qt.ItemDataRole.UserRole)
|
|
49
|
+
if date_str:
|
|
50
|
+
self.openDateRequested.emit(date_str)
|
|
51
|
+
|
|
52
|
+
def _search(self, text: str):
|
|
53
|
+
"""
|
|
54
|
+
Search for the supplied text in the database.
|
|
55
|
+
For all rows found, populate the results widget with a clickable preview.
|
|
56
|
+
"""
|
|
57
|
+
q = text.strip()
|
|
58
|
+
if not q:
|
|
59
|
+
self.results.clear()
|
|
60
|
+
self.results.hide()
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
rows: Iterable[Row] = self._db.search_entries(q)
|
|
65
|
+
except Exception:
|
|
66
|
+
# be quiet on DB errors here; caller can surface if desired
|
|
67
|
+
rows = []
|
|
68
|
+
|
|
69
|
+
self._populate_results(q, rows)
|
|
70
|
+
|
|
71
|
+
def _populate_results(self, query: str, rows: Iterable[Row]):
|
|
72
|
+
self.results.clear()
|
|
73
|
+
rows = list(rows)
|
|
74
|
+
if not rows:
|
|
75
|
+
self.results.hide()
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self.results.show()
|
|
79
|
+
|
|
80
|
+
for date_str, content in rows:
|
|
81
|
+
# Build an HTML fragment around the match and whether to show ellipses
|
|
82
|
+
frag_html, left_ell, right_ell = self._make_html_snippet(
|
|
83
|
+
content, query, radius=60, maxlen=180
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
|
87
|
+
container = QWidget()
|
|
88
|
+
outer = QVBoxLayout(container)
|
|
89
|
+
outer.setContentsMargins(8, 6, 8, 6)
|
|
90
|
+
outer.setSpacing(2)
|
|
91
|
+
|
|
92
|
+
# Date label (plain text)
|
|
93
|
+
date_lbl = QLabel(date_str)
|
|
94
|
+
date_lbl.setTextFormat(Qt.TextFormat.PlainText)
|
|
95
|
+
date_f = date_lbl.font()
|
|
96
|
+
date_f.setPointSizeF(date_f.pointSizeF() - 1)
|
|
97
|
+
date_lbl.setFont(date_f)
|
|
98
|
+
date_lbl.setStyleSheet("color:#666;")
|
|
99
|
+
outer.addWidget(date_lbl)
|
|
100
|
+
|
|
101
|
+
# Preview row with optional ellipses
|
|
102
|
+
row = QWidget()
|
|
103
|
+
h = QHBoxLayout(row)
|
|
104
|
+
h.setContentsMargins(0, 0, 0, 0)
|
|
105
|
+
h.setSpacing(4)
|
|
106
|
+
|
|
107
|
+
if left_ell:
|
|
108
|
+
left = QLabel("…")
|
|
109
|
+
left.setStyleSheet("color:#888;")
|
|
110
|
+
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
|
|
111
|
+
|
|
112
|
+
preview = QLabel()
|
|
113
|
+
preview.setTextFormat(Qt.TextFormat.RichText)
|
|
114
|
+
preview.setWordWrap(True)
|
|
115
|
+
preview.setOpenExternalLinks(True) # keep links in your HTML clickable
|
|
116
|
+
preview.setText(
|
|
117
|
+
frag_html
|
|
118
|
+
if frag_html
|
|
119
|
+
else "<span style='color:#888'>(no preview)</span>"
|
|
120
|
+
)
|
|
121
|
+
h.addWidget(preview, 1)
|
|
122
|
+
|
|
123
|
+
if right_ell:
|
|
124
|
+
right = QLabel("…")
|
|
125
|
+
right.setStyleSheet("color:#888;")
|
|
126
|
+
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
|
|
127
|
+
|
|
128
|
+
outer.addWidget(row)
|
|
129
|
+
|
|
130
|
+
# ---- Add to list ----
|
|
131
|
+
item = QListWidgetItem()
|
|
132
|
+
item.setData(Qt.ItemDataRole.UserRole, date_str)
|
|
133
|
+
item.setSizeHint(container.sizeHint())
|
|
134
|
+
|
|
135
|
+
self.results.addItem(item)
|
|
136
|
+
self.results.setItemWidget(item, container)
|
|
137
|
+
|
|
138
|
+
# --- Snippet/highlight helpers -----------------------------------------
|
|
139
|
+
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
|
|
140
|
+
doc = QTextDocument()
|
|
141
|
+
doc.setHtml(html_src)
|
|
142
|
+
plain = doc.toPlainText()
|
|
143
|
+
if not plain:
|
|
144
|
+
return "", False, False
|
|
145
|
+
|
|
146
|
+
tokens = [t for t in re.split(r"\s+", query.strip()) if t]
|
|
147
|
+
L = len(plain)
|
|
148
|
+
|
|
149
|
+
# Find first occurrence (phrase first, then earliest token)
|
|
150
|
+
idx, mlen = -1, 0
|
|
151
|
+
if tokens:
|
|
152
|
+
lower = plain.lower()
|
|
153
|
+
phrase = " ".join(tokens).lower()
|
|
154
|
+
j = lower.find(phrase)
|
|
155
|
+
if j >= 0:
|
|
156
|
+
idx, mlen = j, len(phrase)
|
|
157
|
+
else:
|
|
158
|
+
for t in tokens:
|
|
159
|
+
tj = lower.find(t.lower())
|
|
160
|
+
if tj >= 0 and (idx < 0 or tj < idx):
|
|
161
|
+
idx, mlen = tj, len(t)
|
|
162
|
+
# Compute window
|
|
163
|
+
if idx < 0:
|
|
164
|
+
start, end = 0, min(L, maxlen)
|
|
165
|
+
else:
|
|
166
|
+
start = max(0, min(idx - radius, max(0, L - maxlen)))
|
|
167
|
+
end = min(L, max(idx + mlen + radius, start + maxlen))
|
|
168
|
+
|
|
169
|
+
# Bold all token matches that fall inside [start, end)
|
|
170
|
+
if tokens:
|
|
171
|
+
lower = plain.lower()
|
|
172
|
+
fmt = QTextCharFormat()
|
|
173
|
+
fmt.setFontWeight(QFont.Weight.Bold)
|
|
174
|
+
for t in tokens:
|
|
175
|
+
t_low = t.lower()
|
|
176
|
+
pos = start
|
|
177
|
+
while True:
|
|
178
|
+
k = lower.find(t_low, pos)
|
|
179
|
+
if k == -1 or k >= end:
|
|
180
|
+
break
|
|
181
|
+
c = QTextCursor(doc)
|
|
182
|
+
c.setPosition(k)
|
|
183
|
+
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
|
|
184
|
+
c.mergeCharFormat(fmt)
|
|
185
|
+
pos = k + len(t)
|
|
186
|
+
|
|
187
|
+
# Select the window and export as HTML fragment
|
|
188
|
+
c = QTextCursor(doc)
|
|
189
|
+
c.setPosition(start)
|
|
190
|
+
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
|
191
|
+
fragment_html = (
|
|
192
|
+
c.selection().toHtml()
|
|
193
|
+
) # preserves original styles + our bolding
|
|
194
|
+
|
|
195
|
+
return fragment_html, start > 0, end < L
|
bouquin/settings_dialog.py
CHANGED
|
@@ -91,7 +91,9 @@ class SettingsDialog(QDialog):
|
|
|
91
91
|
return
|
|
92
92
|
try:
|
|
93
93
|
self._db.rekey(new_key)
|
|
94
|
-
QMessageBox.information(
|
|
94
|
+
QMessageBox.information(
|
|
95
|
+
self, "Key changed", "The database key was updated."
|
|
96
|
+
)
|
|
95
97
|
except Exception as e:
|
|
96
98
|
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
|
|
97
99
|
|
bouquin/toolbar.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import Signal, Qt
|
|
4
|
+
from PySide6.QtGui import QFont, QAction
|
|
5
|
+
from PySide6.QtWidgets import QToolBar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolBar(QToolBar):
|
|
9
|
+
boldRequested = Signal(QFont.Weight)
|
|
10
|
+
italicRequested = Signal()
|
|
11
|
+
underlineRequested = Signal()
|
|
12
|
+
strikeRequested = Signal()
|
|
13
|
+
codeRequested = Signal()
|
|
14
|
+
headingRequested = Signal(int)
|
|
15
|
+
bulletsRequested = Signal()
|
|
16
|
+
numbersRequested = Signal()
|
|
17
|
+
alignRequested = Signal(Qt.AlignmentFlag)
|
|
18
|
+
|
|
19
|
+
def __init__(self, parent=None):
|
|
20
|
+
super().__init__("Format", parent)
|
|
21
|
+
self._build_actions()
|
|
22
|
+
|
|
23
|
+
def _build_actions(self):
|
|
24
|
+
# Bold
|
|
25
|
+
bold = QAction("Bold", self)
|
|
26
|
+
bold.setShortcut("Ctrl+B")
|
|
27
|
+
bold.triggered.connect(lambda: self.boldRequested.emit(QFont.Weight.Bold))
|
|
28
|
+
|
|
29
|
+
italic = QAction("Italic", self)
|
|
30
|
+
italic.setShortcut("Ctrl+I")
|
|
31
|
+
italic.triggered.connect(self.italicRequested)
|
|
32
|
+
|
|
33
|
+
underline = QAction("Underline", self)
|
|
34
|
+
underline.setShortcut("Ctrl+U")
|
|
35
|
+
underline.triggered.connect(self.underlineRequested)
|
|
36
|
+
|
|
37
|
+
strike = QAction("Strikethrough", self)
|
|
38
|
+
strike.setShortcut("Ctrl+-")
|
|
39
|
+
strike.triggered.connect(self.strikeRequested)
|
|
40
|
+
|
|
41
|
+
code = QAction("<code>", self)
|
|
42
|
+
code.setShortcut("Ctrl+`")
|
|
43
|
+
code.triggered.connect(self.codeRequested)
|
|
44
|
+
|
|
45
|
+
# Headings
|
|
46
|
+
h1 = QAction("H1", self)
|
|
47
|
+
h1.setShortcut("Ctrl+1")
|
|
48
|
+
h2 = QAction("H2", self)
|
|
49
|
+
h2.setShortcut("Ctrl+2")
|
|
50
|
+
h3 = QAction("H3", self)
|
|
51
|
+
h3.setShortcut("Ctrl+3")
|
|
52
|
+
normal = QAction("Normal", self)
|
|
53
|
+
normal.setShortcut("Ctrl+P")
|
|
54
|
+
|
|
55
|
+
h1.triggered.connect(lambda: self.headingRequested.emit(24))
|
|
56
|
+
h2.triggered.connect(lambda: self.headingRequested.emit(18))
|
|
57
|
+
h3.triggered.connect(lambda: self.headingRequested.emit(14))
|
|
58
|
+
normal.triggered.connect(lambda: self.headingRequested.emit(0))
|
|
59
|
+
|
|
60
|
+
# Lists
|
|
61
|
+
bullets = QAction("• Bullets", self)
|
|
62
|
+
bullets.triggered.connect(self.bulletsRequested)
|
|
63
|
+
numbers = QAction("1. Numbered", self)
|
|
64
|
+
numbers.triggered.connect(self.numbersRequested)
|
|
65
|
+
|
|
66
|
+
# Alignment
|
|
67
|
+
left = QAction("Align Left", self)
|
|
68
|
+
center = QAction("Align Center", self)
|
|
69
|
+
right = QAction("Align Right", self)
|
|
70
|
+
|
|
71
|
+
left.triggered.connect(
|
|
72
|
+
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignLeft)
|
|
73
|
+
)
|
|
74
|
+
center.triggered.connect(
|
|
75
|
+
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignHCenter)
|
|
76
|
+
)
|
|
77
|
+
right.triggered.connect(
|
|
78
|
+
lambda: self.alignRequested.emit(Qt.AlignmentFlag.AlignRight)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self.addActions(
|
|
82
|
+
[
|
|
83
|
+
bold,
|
|
84
|
+
italic,
|
|
85
|
+
underline,
|
|
86
|
+
strike,
|
|
87
|
+
code,
|
|
88
|
+
h1,
|
|
89
|
+
h2,
|
|
90
|
+
h3,
|
|
91
|
+
normal,
|
|
92
|
+
bullets,
|
|
93
|
+
numbers,
|
|
94
|
+
left,
|
|
95
|
+
center,
|
|
96
|
+
right,
|
|
97
|
+
]
|
|
98
|
+
)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
|
5
|
+
Home-page: https://git.mig5.net/mig5/bouquin
|
|
5
6
|
License: GPL-3.0-or-later
|
|
6
7
|
Author: Miguel Jacq
|
|
7
8
|
Author-email: mig@mig5.net
|
|
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
16
|
Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
|
|
16
17
|
Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
|
|
18
|
+
Project-URL: Repository, https://git.mig5.net/mig5/bouquin
|
|
17
19
|
Description-Content-Type: text/markdown
|
|
18
20
|
|
|
19
21
|
# Bouquin
|
|
@@ -38,16 +40,15 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
38
40
|
## Features
|
|
39
41
|
|
|
40
42
|
* Every 'page' is linked to the calendar day
|
|
41
|
-
*
|
|
43
|
+
* Text is HTML with basic styling
|
|
44
|
+
* Search
|
|
42
45
|
* Automatic periodic saving (or explicitly save)
|
|
43
|
-
* Navigating from one day to the next automatically saves
|
|
44
|
-
* Basic keyboard shortcuts
|
|
45
46
|
* Transparent integrity checking of the database when it opens
|
|
47
|
+
* Rekey the database (change the password)
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
## Yet to do
|
|
49
51
|
|
|
50
|
-
* Search
|
|
51
52
|
* Taxonomy/tagging
|
|
52
53
|
* Export to other formats (plaintext, json, sql etc)
|
|
53
54
|
|
|
@@ -65,7 +66,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
65
66
|
|
|
66
67
|
* Download the whl and run it
|
|
67
68
|
|
|
68
|
-
### From PyPi
|
|
69
|
+
### From PyPi/pip
|
|
69
70
|
|
|
70
71
|
* `pip install bouquin`
|
|
71
72
|
|
|
@@ -0,0 +1,16 @@
|
|
|
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,,
|
bouquin/highlighter.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from PySide6.QtGui import QFont, QTextCharFormat, QSyntaxHighlighter, QColor
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class MarkdownHighlighter(QSyntaxHighlighter):
|
|
8
|
-
ST_NORMAL = 0
|
|
9
|
-
ST_CODE = 1
|
|
10
|
-
|
|
11
|
-
FENCE = re.compile(r"^```")
|
|
12
|
-
|
|
13
|
-
def __init__(self, document):
|
|
14
|
-
super().__init__(document)
|
|
15
|
-
|
|
16
|
-
base_size = document.defaultFont().pointSizeF() or 12.0
|
|
17
|
-
|
|
18
|
-
# Monospace for code
|
|
19
|
-
self.mono = QFont("Monospace")
|
|
20
|
-
self.mono.setStyleHint(QFont.TypeWriter)
|
|
21
|
-
|
|
22
|
-
# Light, high-contrast scheme for code
|
|
23
|
-
self.col_bg = QColor("#eef2f6") # light code bg
|
|
24
|
-
self.col_fg = QColor("#1f2328") # dark text
|
|
25
|
-
|
|
26
|
-
# Formats
|
|
27
|
-
self.fmt_h = [QTextCharFormat() for _ in range(6)]
|
|
28
|
-
for i, f in enumerate(self.fmt_h, start=1):
|
|
29
|
-
f.setFontWeight(QFont.Weight.Bold)
|
|
30
|
-
f.setFontPointSize(base_size + (7 - i))
|
|
31
|
-
self.fmt_bold = QTextCharFormat()
|
|
32
|
-
self.fmt_bold.setFontWeight(QFont.Weight.Bold)
|
|
33
|
-
self.fmt_italic = QTextCharFormat()
|
|
34
|
-
self.fmt_italic.setFontItalic(True)
|
|
35
|
-
self.fmt_quote = QTextCharFormat()
|
|
36
|
-
self.fmt_quote.setForeground(QColor("#6a737d"))
|
|
37
|
-
self.fmt_link = QTextCharFormat()
|
|
38
|
-
self.fmt_link.setFontUnderline(True)
|
|
39
|
-
self.fmt_list = QTextCharFormat()
|
|
40
|
-
self.fmt_list.setFontWeight(QFont.Weight.DemiBold)
|
|
41
|
-
self.fmt_strike = QTextCharFormat()
|
|
42
|
-
self.fmt_strike.setFontStrikeOut(True)
|
|
43
|
-
|
|
44
|
-
# Uniform code style
|
|
45
|
-
self.fmt_code = QTextCharFormat()
|
|
46
|
-
self.fmt_code.setFont(self.mono)
|
|
47
|
-
self.fmt_code.setFontPointSize(max(6.0, base_size - 1))
|
|
48
|
-
self.fmt_code.setBackground(self.col_bg)
|
|
49
|
-
self.fmt_code.setForeground(self.col_fg)
|
|
50
|
-
|
|
51
|
-
# Simple patterns
|
|
52
|
-
self.re_heading = re.compile(r"^(#{1,6}) +.*$")
|
|
53
|
-
self.re_bold = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
|
|
54
|
-
self.re_italic = re.compile(r"\*(?!\*)(.+?)\*|_(?!_)(.+?)_")
|
|
55
|
-
self.re_strike = re.compile(r"~~(.+?)~~")
|
|
56
|
-
self.re_inline_code = re.compile(r"`([^`]+)`")
|
|
57
|
-
self.re_link = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
58
|
-
self.re_list = re.compile(r"^ *(?:[-*+] +|[0-9]+[.)] +)")
|
|
59
|
-
self.re_quote = re.compile(r"^> ?.*$")
|
|
60
|
-
|
|
61
|
-
def highlightBlock(self, text: str) -> None:
|
|
62
|
-
prev = self.previousBlockState()
|
|
63
|
-
in_code = prev == self.ST_CODE
|
|
64
|
-
|
|
65
|
-
if in_code:
|
|
66
|
-
# Entire line is code
|
|
67
|
-
self.setFormat(0, len(text), self.fmt_code)
|
|
68
|
-
if self.FENCE.match(text):
|
|
69
|
-
self.setCurrentBlockState(self.ST_NORMAL)
|
|
70
|
-
else:
|
|
71
|
-
self.setCurrentBlockState(self.ST_CODE)
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
# Starting/ending a fenced block?
|
|
75
|
-
if self.FENCE.match(text):
|
|
76
|
-
self.setFormat(0, len(text), self.fmt_code)
|
|
77
|
-
self.setCurrentBlockState(self.ST_CODE)
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
# --- Normal markdown styling ---
|
|
81
|
-
m = self.re_heading.match(text)
|
|
82
|
-
if m:
|
|
83
|
-
level = min(len(m.group(1)), 6)
|
|
84
|
-
self.setFormat(0, len(text), self.fmt_h[level - 1])
|
|
85
|
-
self.setCurrentBlockState(self.ST_NORMAL)
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
m = self.re_list.match(text)
|
|
89
|
-
if m:
|
|
90
|
-
self.setFormat(m.start(), m.end() - m.start(), self.fmt_list)
|
|
91
|
-
|
|
92
|
-
if self.re_quote.match(text):
|
|
93
|
-
self.setFormat(0, len(text), self.fmt_quote)
|
|
94
|
-
|
|
95
|
-
for m in self.re_inline_code.finditer(text):
|
|
96
|
-
self.setFormat(m.start(), m.end() - m.start(), self.fmt_code)
|
|
97
|
-
|
|
98
|
-
for m in self.re_bold.finditer(text):
|
|
99
|
-
self.setFormat(m.start(), m.end() - m.start(), self.fmt_bold)
|
|
100
|
-
|
|
101
|
-
for m in self.re_italic.finditer(text):
|
|
102
|
-
self.setFormat(m.start(), m.end() - m.start(), self.fmt_italic)
|
|
103
|
-
|
|
104
|
-
for m in self.re_strike.finditer(text):
|
|
105
|
-
self.setFormat(m.start(), m.end() - m.start(), self.fmt_strike)
|
|
106
|
-
|
|
107
|
-
for m in self.re_link.finditer(text):
|
|
108
|
-
start = m.start(1) - 1
|
|
109
|
-
length = len(m.group(1)) + 2
|
|
110
|
-
self.setFormat(start, length, self.fmt_link)
|
|
111
|
-
|
|
112
|
-
self.setCurrentBlockState(self.ST_NORMAL)
|
bouquin-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
bouquin/__init__.py,sha256=-bBNFYOq80A2Egtpo5V5zWJtYOxQfRZFQ_feve5lkFU,23
|
|
2
|
-
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
-
bouquin/db.py,sha256=-RCWeStZD-eZ2TEUaUTj67cBvq30q2aiSmLoP-K67Jk,3396
|
|
4
|
-
bouquin/highlighter.py,sha256=UPP4G4jdN7U8y1c3nk9zTswkHHJw0Tpl3PX6obZrSG0,4077
|
|
5
|
-
bouquin/key_prompt.py,sha256=RNrW0bN4xnwDGeBlgbmFaBSs_2iQyYrBYpKOQhe4E0c,1092
|
|
6
|
-
bouquin/main.py,sha256=tx59cJZnGgHQ1UHQbdlYgaC36_L0ulyKaOoy6oURXVA,348
|
|
7
|
-
bouquin/main_window.py,sha256=OQQ1BhPp3F3ULVrtjeTsKTraZZDzRECjYCORrRpWmis,9291
|
|
8
|
-
bouquin/settings.py,sha256=bJYQXbTqX_r_DfOKuGnah6IVZLiNwZAuBuz2OgdhA_E,670
|
|
9
|
-
bouquin/settings_dialog.py,sha256=NA40-RvXvM8SND-o5K6b-yKMrShY6NQnQuDAxUoCzyI,3120
|
|
10
|
-
bouquin-0.1.1.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
11
|
-
bouquin-0.1.1.dist-info/METADATA,sha256=j1CnzeNbB-bzfHaXN9rS66RDaG3-q4Ic6erwzKjqxBw,2148
|
|
12
|
-
bouquin-0.1.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
13
|
-
bouquin-0.1.1.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
14
|
-
bouquin-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|