bouquin 0.1.10__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.
- bouquin/__init__.py +0 -0
- bouquin/__main__.py +4 -0
- bouquin/db.py +499 -0
- bouquin/editor.py +897 -0
- bouquin/history_dialog.py +176 -0
- bouquin/key_prompt.py +41 -0
- bouquin/lock_overlay.py +127 -0
- bouquin/main.py +24 -0
- bouquin/main_window.py +904 -0
- bouquin/save_dialog.py +35 -0
- bouquin/search.py +209 -0
- bouquin/settings.py +39 -0
- bouquin/settings_dialog.py +302 -0
- bouquin/theme.py +105 -0
- bouquin/toolbar.py +221 -0
- bouquin-0.1.10.dist-info/LICENSE +674 -0
- bouquin-0.1.10.dist-info/METADATA +83 -0
- bouquin-0.1.10.dist-info/RECORD +20 -0
- bouquin-0.1.10.dist-info/WHEEL +4 -0
- bouquin-0.1.10.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib, re, html as _html
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from PySide6.QtCore import Qt, Slot
|
|
6
|
+
from PySide6.QtWidgets import (
|
|
7
|
+
QDialog,
|
|
8
|
+
QVBoxLayout,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QListWidget,
|
|
11
|
+
QListWidgetItem,
|
|
12
|
+
QPushButton,
|
|
13
|
+
QMessageBox,
|
|
14
|
+
QTextBrowser,
|
|
15
|
+
QTabWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _html_to_text(s: str) -> str:
|
|
20
|
+
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
|
|
21
|
+
IMG_RE = re.compile(r"(?is)<img\b[^>]*>")
|
|
22
|
+
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
|
23
|
+
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
24
|
+
BR_RE = re.compile(r"(?i)<br\s*/?>")
|
|
25
|
+
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
|
|
26
|
+
TAG_RE = re.compile(r"<[^>]+>")
|
|
27
|
+
MULTINL_RE = re.compile(r"\n{3,}")
|
|
28
|
+
|
|
29
|
+
s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
|
|
30
|
+
s = STYLE_SCRIPT_RE.sub("", s)
|
|
31
|
+
s = COMMENT_RE.sub("", s)
|
|
32
|
+
s = BR_RE.sub("\n", s)
|
|
33
|
+
s = BLOCK_END_RE.sub("\n", s)
|
|
34
|
+
s = TAG_RE.sub("", s)
|
|
35
|
+
s = _html.unescape(s)
|
|
36
|
+
s = MULTINL_RE.sub("\n\n", s)
|
|
37
|
+
return s.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
|
|
41
|
+
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
|
42
|
+
a = _html_to_text(old_html).splitlines()
|
|
43
|
+
b = _html_to_text(new_html).splitlines()
|
|
44
|
+
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
|
45
|
+
lines = []
|
|
46
|
+
for line in ud:
|
|
47
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
48
|
+
lines.append(
|
|
49
|
+
f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
|
|
50
|
+
)
|
|
51
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
52
|
+
lines.append(
|
|
53
|
+
f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
|
|
54
|
+
)
|
|
55
|
+
elif line.startswith("@@"):
|
|
56
|
+
lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
|
|
57
|
+
else:
|
|
58
|
+
lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
|
|
59
|
+
css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
|
|
60
|
+
return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HistoryDialog(QDialog):
|
|
64
|
+
"""Show versions for a date, preview, diff, and allow revert."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, db, date_iso: str, parent=None):
|
|
67
|
+
super().__init__(parent)
|
|
68
|
+
self.setWindowTitle(f"History — {date_iso}")
|
|
69
|
+
self._db = db
|
|
70
|
+
self._date = date_iso
|
|
71
|
+
self._versions = [] # list[dict] from DB
|
|
72
|
+
self._current_id = None # id of current
|
|
73
|
+
|
|
74
|
+
root = QVBoxLayout(self)
|
|
75
|
+
|
|
76
|
+
# Top: list of versions
|
|
77
|
+
top = QHBoxLayout()
|
|
78
|
+
self.list = QListWidget()
|
|
79
|
+
self.list.setMinimumSize(500, 650)
|
|
80
|
+
self.list.currentItemChanged.connect(self._on_select)
|
|
81
|
+
top.addWidget(self.list, 1)
|
|
82
|
+
|
|
83
|
+
# Right: tabs (Preview / Diff)
|
|
84
|
+
self.tabs = QTabWidget()
|
|
85
|
+
self.preview = QTextBrowser()
|
|
86
|
+
self.preview.setOpenExternalLinks(True)
|
|
87
|
+
self.diff = QTextBrowser()
|
|
88
|
+
self.diff.setOpenExternalLinks(False)
|
|
89
|
+
self.tabs.addTab(self.preview, "Preview")
|
|
90
|
+
self.tabs.addTab(self.diff, "Diff")
|
|
91
|
+
self.tabs.setMinimumSize(500, 650)
|
|
92
|
+
top.addWidget(self.tabs, 2)
|
|
93
|
+
|
|
94
|
+
root.addLayout(top)
|
|
95
|
+
|
|
96
|
+
# Buttons
|
|
97
|
+
row = QHBoxLayout()
|
|
98
|
+
row.addStretch(1)
|
|
99
|
+
self.btn_revert = QPushButton("Revert to Selected")
|
|
100
|
+
self.btn_revert.clicked.connect(self._revert)
|
|
101
|
+
self.btn_close = QPushButton("Close")
|
|
102
|
+
self.btn_close.clicked.connect(self.reject)
|
|
103
|
+
row.addWidget(self.btn_revert)
|
|
104
|
+
row.addWidget(self.btn_close)
|
|
105
|
+
root.addLayout(row)
|
|
106
|
+
|
|
107
|
+
self._load_versions()
|
|
108
|
+
|
|
109
|
+
# --- Data/UX helpers ---
|
|
110
|
+
def _fmt_local(self, iso_utc: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Convert UTC in the database to user's local tz
|
|
113
|
+
"""
|
|
114
|
+
dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
|
|
115
|
+
local = dt.astimezone()
|
|
116
|
+
return local.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
117
|
+
|
|
118
|
+
def _load_versions(self):
|
|
119
|
+
self._versions = self._db.list_versions(
|
|
120
|
+
self._date
|
|
121
|
+
) # [{id,version_no,created_at,note,is_current}]
|
|
122
|
+
self._current_id = next(
|
|
123
|
+
(v["id"] for v in self._versions if v["is_current"]), None
|
|
124
|
+
)
|
|
125
|
+
self.list.clear()
|
|
126
|
+
for v in self._versions:
|
|
127
|
+
label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}"
|
|
128
|
+
if v.get("note"):
|
|
129
|
+
label += f" · {v['note']}"
|
|
130
|
+
if v["is_current"]:
|
|
131
|
+
label += " **(current)**"
|
|
132
|
+
it = QListWidgetItem(label)
|
|
133
|
+
it.setData(Qt.UserRole, v["id"])
|
|
134
|
+
self.list.addItem(it)
|
|
135
|
+
# select the first non-current if available, else current
|
|
136
|
+
idx = 0
|
|
137
|
+
for i, v in enumerate(self._versions):
|
|
138
|
+
if not v["is_current"]:
|
|
139
|
+
idx = i
|
|
140
|
+
break
|
|
141
|
+
if self.list.count():
|
|
142
|
+
self.list.setCurrentRow(idx)
|
|
143
|
+
|
|
144
|
+
@Slot()
|
|
145
|
+
def _on_select(self):
|
|
146
|
+
item = self.list.currentItem()
|
|
147
|
+
if not item:
|
|
148
|
+
self.preview.clear()
|
|
149
|
+
self.diff.clear()
|
|
150
|
+
self.btn_revert.setEnabled(False)
|
|
151
|
+
return
|
|
152
|
+
sel_id = item.data(Qt.UserRole)
|
|
153
|
+
# Preview selected as HTML
|
|
154
|
+
sel = self._db.get_version(version_id=sel_id)
|
|
155
|
+
self.preview.setHtml(sel["content"])
|
|
156
|
+
# Diff vs current (textual diff)
|
|
157
|
+
cur = self._db.get_version(version_id=self._current_id)
|
|
158
|
+
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
|
159
|
+
# Enable revert only if selecting a non-current
|
|
160
|
+
self.btn_revert.setEnabled(sel_id != self._current_id)
|
|
161
|
+
|
|
162
|
+
@Slot()
|
|
163
|
+
def _revert(self):
|
|
164
|
+
item = self.list.currentItem()
|
|
165
|
+
if not item:
|
|
166
|
+
return
|
|
167
|
+
sel_id = item.data(Qt.UserRole)
|
|
168
|
+
if sel_id == self._current_id:
|
|
169
|
+
return
|
|
170
|
+
# Flip head pointer
|
|
171
|
+
try:
|
|
172
|
+
self._db.revert_to_version(self._date, version_id=sel_id)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
QMessageBox.critical(self, "Revert failed", str(e))
|
|
175
|
+
return
|
|
176
|
+
self.accept()
|
bouquin/key_prompt.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtWidgets import (
|
|
4
|
+
QDialog,
|
|
5
|
+
QVBoxLayout,
|
|
6
|
+
QLabel,
|
|
7
|
+
QLineEdit,
|
|
8
|
+
QPushButton,
|
|
9
|
+
QDialogButtonBox,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KeyPrompt(QDialog):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
parent=None,
|
|
17
|
+
title: str = "Enter key",
|
|
18
|
+
message: str = "Enter key",
|
|
19
|
+
):
|
|
20
|
+
super().__init__(parent)
|
|
21
|
+
self.setWindowTitle(title)
|
|
22
|
+
v = QVBoxLayout(self)
|
|
23
|
+
v.addWidget(QLabel(message))
|
|
24
|
+
self.edit = QLineEdit()
|
|
25
|
+
self.edit.setEchoMode(QLineEdit.Password)
|
|
26
|
+
v.addWidget(self.edit)
|
|
27
|
+
toggle = QPushButton("Show")
|
|
28
|
+
toggle.setCheckable(True)
|
|
29
|
+
toggle.toggled.connect(
|
|
30
|
+
lambda c: self.edit.setEchoMode(
|
|
31
|
+
QLineEdit.Normal if c else QLineEdit.Password
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
v.addWidget(toggle)
|
|
35
|
+
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
36
|
+
bb.accepted.connect(self.accept)
|
|
37
|
+
bb.rejected.connect(self.reject)
|
|
38
|
+
v.addWidget(bb)
|
|
39
|
+
|
|
40
|
+
def key(self) -> str:
|
|
41
|
+
return self.edit.text()
|
bouquin/lock_overlay.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import Qt, QEvent
|
|
4
|
+
from PySide6.QtGui import QPalette
|
|
5
|
+
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LockOverlay(QWidget):
|
|
9
|
+
def __init__(self, parent: QWidget, on_unlock: callable):
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
self.setObjectName("LockOverlay")
|
|
12
|
+
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
13
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
14
|
+
self.setGeometry(parent.rect())
|
|
15
|
+
|
|
16
|
+
self._styling = False # <-- reentrancy guard
|
|
17
|
+
self._last_dark: bool | None = None
|
|
18
|
+
|
|
19
|
+
lay = QVBoxLayout(self)
|
|
20
|
+
lay.addStretch(1)
|
|
21
|
+
|
|
22
|
+
msg = QLabel("Locked due to inactivity", self)
|
|
23
|
+
msg.setObjectName("lockLabel")
|
|
24
|
+
msg.setAlignment(Qt.AlignCenter)
|
|
25
|
+
|
|
26
|
+
self._btn = QPushButton("Unlock", self)
|
|
27
|
+
self._btn.setObjectName("unlockButton")
|
|
28
|
+
self._btn.setFixedWidth(200)
|
|
29
|
+
self._btn.setCursor(Qt.PointingHandCursor)
|
|
30
|
+
self._btn.setAutoDefault(True)
|
|
31
|
+
self._btn.setDefault(True)
|
|
32
|
+
self._btn.clicked.connect(on_unlock)
|
|
33
|
+
|
|
34
|
+
lay.addWidget(msg, 0, Qt.AlignCenter)
|
|
35
|
+
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
|
36
|
+
lay.addStretch(1)
|
|
37
|
+
|
|
38
|
+
self._apply_overlay_style()
|
|
39
|
+
self.hide()
|
|
40
|
+
|
|
41
|
+
def _is_dark(self, pal: QPalette) -> bool:
|
|
42
|
+
c = pal.color(QPalette.Window)
|
|
43
|
+
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
|
44
|
+
return luma < 0.5
|
|
45
|
+
|
|
46
|
+
def _apply_overlay_style(self):
|
|
47
|
+
if self._styling:
|
|
48
|
+
return
|
|
49
|
+
dark = self._is_dark(self.palette())
|
|
50
|
+
if dark == self._last_dark:
|
|
51
|
+
return
|
|
52
|
+
self._styling = True
|
|
53
|
+
try:
|
|
54
|
+
if dark:
|
|
55
|
+
link = self.palette().color(QPalette.Link)
|
|
56
|
+
accent_hex = link.name() # e.g. "#FFA500"
|
|
57
|
+
r, g, b = link.red(), link.green(), link.blue()
|
|
58
|
+
|
|
59
|
+
self.setStyleSheet(
|
|
60
|
+
f"""
|
|
61
|
+
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
|
|
62
|
+
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
|
63
|
+
|
|
64
|
+
#LockOverlay QPushButton#unlockButton {{
|
|
65
|
+
color: {accent_hex};
|
|
66
|
+
background-color: rgba({r},{g},{b},0.10);
|
|
67
|
+
border: 1px solid {accent_hex};
|
|
68
|
+
border-radius: 8px;
|
|
69
|
+
padding: 8px 16px;
|
|
70
|
+
}}
|
|
71
|
+
#LockOverlay QPushButton#unlockButton:hover {{
|
|
72
|
+
background-color: rgba({r},{g},{b},0.16);
|
|
73
|
+
border-color: {accent_hex};
|
|
74
|
+
}}
|
|
75
|
+
#LockOverlay QPushButton#unlockButton:pressed {{
|
|
76
|
+
background-color: rgba({r},{g},{b},0.24);
|
|
77
|
+
}}
|
|
78
|
+
#LockOverlay QPushButton#unlockButton:focus {{
|
|
79
|
+
outline: none;
|
|
80
|
+
border-color: {accent_hex};
|
|
81
|
+
}}
|
|
82
|
+
"""
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
# (light mode unchanged)
|
|
86
|
+
self.setStyleSheet(
|
|
87
|
+
"""
|
|
88
|
+
#LockOverlay { background-color: rgba(0,0,0,120); }
|
|
89
|
+
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
|
|
90
|
+
#LockOverlay QPushButton#unlockButton {
|
|
91
|
+
color: palette(button-text);
|
|
92
|
+
background-color: rgba(255,255,255,0.92);
|
|
93
|
+
border: 1px solid rgba(0,0,0,0.25);
|
|
94
|
+
border-radius: 8px;
|
|
95
|
+
padding: 8px 16px;
|
|
96
|
+
}
|
|
97
|
+
#LockOverlay QPushButton#unlockButton:hover {
|
|
98
|
+
background-color: rgba(255,255,255,1.0);
|
|
99
|
+
border-color: rgba(0,0,0,0.35);
|
|
100
|
+
}
|
|
101
|
+
#LockOverlay QPushButton#unlockButton:pressed {
|
|
102
|
+
background-color: rgba(245,245,245,1.0);
|
|
103
|
+
}
|
|
104
|
+
#LockOverlay QPushButton#unlockButton:focus {
|
|
105
|
+
outline: none;
|
|
106
|
+
border-color: palette(highlight);
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
self._last_dark = dark
|
|
111
|
+
finally:
|
|
112
|
+
self._styling = False
|
|
113
|
+
|
|
114
|
+
def changeEvent(self, ev):
|
|
115
|
+
super().changeEvent(ev)
|
|
116
|
+
# Only re-style on palette flips
|
|
117
|
+
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
|
118
|
+
self._apply_overlay_style()
|
|
119
|
+
|
|
120
|
+
def eventFilter(self, obj, event):
|
|
121
|
+
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
|
122
|
+
self.setGeometry(obj.rect())
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def showEvent(self, e):
|
|
126
|
+
super().showEvent(e)
|
|
127
|
+
self._btn.setFocus()
|
bouquin/main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from PySide6.QtWidgets import QApplication
|
|
5
|
+
|
|
6
|
+
from .settings import APP_NAME, APP_ORG, get_settings
|
|
7
|
+
from .main_window import MainWindow
|
|
8
|
+
from .theme import Theme, ThemeConfig, ThemeManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
app = QApplication(sys.argv)
|
|
13
|
+
app.setApplicationName(APP_NAME)
|
|
14
|
+
app.setOrganizationName(APP_ORG)
|
|
15
|
+
|
|
16
|
+
s = get_settings()
|
|
17
|
+
theme_str = s.value("ui/theme", "system")
|
|
18
|
+
cfg = ThemeConfig(theme=Theme(theme_str))
|
|
19
|
+
themes = ThemeManager(app, cfg)
|
|
20
|
+
themes.apply(cfg.theme)
|
|
21
|
+
|
|
22
|
+
win = MainWindow(themes=themes)
|
|
23
|
+
win.show()
|
|
24
|
+
sys.exit(app.exec())
|