bouquin/bouquin/history_dialog.py
2025-11-02 19:15:55 +11:00

179 lines
6.1 KiB
Python

from __future__ import annotations
import difflib, re, html as _html
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QListWidget,
QListWidgetItem,
QPushButton,
QMessageBox,
QTextBrowser,
QTabWidget,
)
def _html_to_text(s: str) -> str:
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
BR_RE = re.compile(r"(?i)<br\s*/?>")
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
TAG_RE = re.compile(r"<[^>]+>")
MULTINL_RE = re.compile(r"\n{3,}")
s = STYLE_SCRIPT_RE.sub("", s)
s = COMMENT_RE.sub("", s)
s = BR_RE.sub("\n", s)
s = BLOCK_END_RE.sub("\n", s)
s = TAG_RE.sub("", s)
s = _html.unescape(s)
s = MULTINL_RE.sub("\n\n", s)
return s.strip()
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
a = _html_to_text(old_html).splitlines()
b = _html_to_text(new_html).splitlines()
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
lines = []
for line in ud:
if line.startswith("+") and not line.startswith("+++"):
lines.append(
f"<span style='color:#116329'>+ {_html.escape(line[1:])}</span>"
)
elif line.startswith("-") and not line.startswith("---"):
lines.append(
f"<span style='color:#b31d28'>- {_html.escape(line[1:])}</span>"
)
elif line.startswith("@@"):
lines.append(f"<span style='color:#6f42c1'>{_html.escape(line)}</span>")
else:
lines.append(f"<span style='color:#586069'>{_html.escape(line)}</span>")
css = "pre { font-family: Consolas,Menlo,Monaco,monospace; font-size: 13px; }"
return f"<style>{css}</style><pre>{'<br>'.join(lines)}</pre>"
class HistoryDialog(QDialog):
"""Show versions for a date, preview, diff, and allow revert."""
def __init__(self, db, date_iso: str, parent=None):
super().__init__(parent)
self.setWindowTitle(f"History — {date_iso}")
self._db = db
self._date = date_iso
self._versions = [] # list[dict] from DB
self._current_id = None # id of current
root = QVBoxLayout(self)
# Top: list of versions
top = QHBoxLayout()
self.list = QListWidget()
self.list.setMinimumSize(500, 650)
self.list.currentItemChanged.connect(self._on_select)
top.addWidget(self.list, 1)
# Right: tabs (Preview / Diff)
self.tabs = QTabWidget()
self.preview = QTextBrowser()
self.preview.setOpenExternalLinks(True)
self.diff = QTextBrowser()
self.diff.setOpenExternalLinks(False)
self.tabs.addTab(self.preview, "Preview")
self.tabs.addTab(self.diff, "Diff")
self.tabs.setMinimumSize(500, 650)
top.addWidget(self.tabs, 2)
root.addLayout(top)
# Buttons
row = QHBoxLayout()
row.addStretch(1)
self.btn_revert = QPushButton("Revert to Selected")
self.btn_revert.clicked.connect(self._revert)
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.reject)
row.addWidget(self.btn_revert)
row.addWidget(self.btn_close)
root.addLayout(row)
self._load_versions()
# --- Data/UX helpers ---
def _load_versions(self):
self._versions = self._db.list_versions(
self._date
) # [{id,version_no,created_at,note,is_current}]
self._current_id = next(
(v["id"] for v in self._versions if v["is_current"]), None
)
self.list.clear()
for v in self._versions:
label = f"v{v['version_no']}{v['created_at']}"
if v.get("note"):
label += f" · {v['note']}"
if v["is_current"]:
label += " **(current)**"
it = QListWidgetItem(label)
it.setData(Qt.UserRole, v["id"])
self.list.addItem(it)
# select the first non-current if available, else current
idx = 0
for i, v in enumerate(self._versions):
if not v["is_current"]:
idx = i
break
if self.list.count():
self.list.setCurrentRow(idx)
@Slot()
def _on_select(self):
item = self.list.currentItem()
if not item:
self.preview.clear()
self.diff.clear()
self.btn_revert.setEnabled(False)
return
sel_id = item.data(Qt.UserRole)
# Preview selected as HTML
sel = self._db.get_version(version_id=sel_id)
self.preview.setHtml(sel["content"])
# Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
# Enable revert only if selecting a non-current
self.btn_revert.setEnabled(sel_id != self._current_id)
@Slot()
def _revert(self):
item = self.list.currentItem()
if not item:
return
sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id:
return
sel = self._db.get_version(version_id=sel_id)
vno = sel["version_no"]
# Confirm
if (
QMessageBox.question(
self,
"Revert",
f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
QMessageBox.Yes | QMessageBox.No,
)
!= QMessageBox.Yes
):
return
# Flip head pointer
try:
self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e:
QMessageBox.critical(self, "Revert failed", str(e))
return
QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
self.accept() # let the caller refresh the editor