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.
@@ -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()
@@ -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())