Files
PdfScrapper/pdfscrapper/main_window.py
T
2026-08-11 15:41:55 +02:00

519 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fenêtre principale : navigateur + panneau latéral de capture PDF."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QStandardPaths, QUrl, Qt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import (
QCheckBox,
QDockWidget,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPushButton,
QToolBar,
QVBoxLayout,
QWidget,
)
from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE
from .bridge import Bridge, build_bridge_init_script
from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS
from .models import Clip
from .pdf_builder import build_pdf
from .pdf_style import PdfStyle
from .preview_window import PdfPreviewWindow
HOME_URL = "https://www.google.com/"
class BrowserPage(QWebEnginePage):
"""Page web qui ouvre les liens target="_blank" dans la même vue
plutôt que de les laisser disparaître silencieusement."""
def createWindow(self, _window_type): # noqa: N802 (nom imposé par Qt)
return self
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("PdfScrapper")
self.clips: list[Clip] = []
self.last_suggested_relnext: str | None = None
self.block_picker_enabled = False
self.insert_above = True
self.pdf_style = PdfStyle()
self.preview_window: PdfPreviewWindow | None = None
self._build_webview()
self._build_toolbar()
self._build_sidebar()
self.webview.setUrl(QUrl(HOME_URL))
# ------------------------------------------------------------------ #
# Construction de l'UI
# ------------------------------------------------------------------ #
def _build_webview(self) -> None:
storage_dir = Path(
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
) / "pdfscrapper"
storage_dir.mkdir(parents=True, exist_ok=True)
profile = QWebEngineProfile("pdfscrapper_profile", self)
profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
)
profile.setPersistentStoragePath(str(storage_dir))
profile.setCachePath(str(storage_dir / "cache"))
# Injecté au tout début de chaque page pour établir le pont
# QWebChannel (window.pdfscrapperBridge) utilisé par le mode
# "capture au survol" — voir bridge.py / block_picker.py.
bridge_script = QWebEngineScript()
bridge_script.setName("pdfscrapper_bridge_init")
bridge_script.setSourceCode(build_bridge_init_script())
bridge_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
bridge_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
bridge_script.setRunsOnSubFrames(False)
profile.scripts().insert(bridge_script)
# Script persistant du mode "capture au survol" (surlignage de
# bloc + activation par Ctrl maintenu ou par bouton) — voir
# block_picker.py pour le détail du fonctionnement.
block_picker_script = QWebEngineScript()
block_picker_script.setName("pdfscrapper_block_picker_init")
block_picker_script.setSourceCode(BLOCK_PICKER_INIT_JS)
block_picker_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
block_picker_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
block_picker_script.setRunsOnSubFrames(False)
profile.scripts().insert(block_picker_script)
# Script persistant de détection des liens de "suite" — scanne en
# continu via un MutationObserver (plutôt qu'une seule fois au
# chargement) pour détecter aussi les liens ajoutés dynamiquement
# par la page elle-même (SPA, contenu chargé après coup). Voir
# link_detector.py.
link_detector_script = QWebEngineScript()
link_detector_script.setName("pdfscrapper_link_detector_init")
link_detector_script.setSourceCode(LINK_DETECTOR_INIT_JS)
link_detector_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
link_detector_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
link_detector_script.setRunsOnSubFrames(False)
profile.scripts().insert(link_detector_script)
self.webview = QWebEngineView(self)
self.webview.setPage(BrowserPage(profile, self.webview))
self.setCentralWidget(self.webview)
self.bridge = Bridge(self)
self.bridge.block_captured.connect(self._handle_block_capture)
self.bridge.links_detected.connect(self._on_links_detected)
self.web_channel = QWebChannel(self)
self.web_channel.registerObject("pdfscrapperBridge", self.bridge)
self.webview.page().setWebChannel(self.web_channel)
self.webview.urlChanged.connect(self._on_url_changed)
self.webview.loadFinished.connect(self._on_load_finished)
self.webview.titleChanged.connect(self._on_title_changed)
def _build_toolbar(self) -> None:
toolbar = QToolBar("Navigation", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)
back_action = QAction("◀", self)
back_action.setToolTip("Page précédente")
back_action.triggered.connect(self.webview.back)
toolbar.addAction(back_action)
forward_action = QAction("▶", self)
forward_action.setToolTip("Page suivante")
forward_action.triggered.connect(self.webview.forward)
toolbar.addAction(forward_action)
reload_action = QAction("⟳", self)
reload_action.setToolTip("Recharger")
reload_action.triggered.connect(self.webview.reload)
toolbar.addAction(reload_action)
home_action = QAction("🏠", self)
home_action.setToolTip("Page d'accueil")
home_action.triggered.connect(lambda: self.webview.setUrl(QUrl(HOME_URL)))
toolbar.addAction(home_action)
self.address_bar = QLineEdit(self)
self.address_bar.returnPressed.connect(self._navigate_to_address_bar)
toolbar.addWidget(self.address_bar)
go_action = QAction("Go", self)
go_action.triggered.connect(self._navigate_to_address_bar)
toolbar.addAction(go_action)
toolbar.addSeparator()
capture_action = QAction("➕ Ajouter la sélection au PDF", self)
capture_action.setShortcut(QKeySequence("Ctrl+Shift+A"))
capture_action.setToolTip("Ajoute le texte actuellement sélectionné dans la page (Ctrl+Shift+A)")
capture_action.triggered.connect(self.capture_selection)
toolbar.addAction(capture_action)
self.block_picker_action = QAction("🎯 Mode capture au survol", self)
self.block_picker_action.setCheckable(True)
self.block_picker_action.setShortcut(QKeySequence("Ctrl+Shift+B"))
self.block_picker_action.setToolTip(
"Survolez un paragraphe/bloc pour le surligner, cliquez pour l'ajouter au PDF.\n"
"• Ctrl+Shift+B (ou ce bouton) : reste activé jusqu'à nouvelle bascule.\n"
"• Maintenir Ctrl enfoncé dans la page : activation temporaire, désactivée au "
"relâchement.\n"
"Désactive temporairement le clic normal sur la page tant qu'actif."
)
self.block_picker_action.toggled.connect(self._toggle_block_picker)
toolbar.addAction(self.block_picker_action)
def _build_sidebar(self) -> None:
dock = QDockWidget("Capture PDF", self)
dock.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
)
panel = QWidget()
layout = QVBoxLayout(panel)
self.clip_count_label = QLabel("Extraits capturés : 0")
layout.addWidget(self.clip_count_label)
self.insert_above_checkbox = QCheckBox("Insérer les nouvelles captures au-dessus de la sélection")
self.insert_above_checkbox.setChecked(self.insert_above)
self.insert_above_checkbox.setToolTip(
"Coché : chaque nouvelle capture s'insère juste au-dessus de l'extrait "
"sélectionné dans la liste.\n"
"Décoché : elle s'insère juste en dessous.\n"
"Sans sélection, elle s'ajoute toujours à la fin."
)
self.insert_above_checkbox.toggled.connect(self._toggle_insert_direction)
layout.addWidget(self.insert_above_checkbox)
self.clip_list = QListWidget()
self.clip_list.setToolTip("Double-cliquez sur un extrait pour le renommer.")
self.clip_list.itemDoubleClicked.connect(lambda _item: self._rename_selected_clip())
layout.addWidget(self.clip_list, stretch=2)
clip_buttons = QHBoxLayout()
rename_btn = QPushButton("✏️ Renommer")
up_btn = QPushButton("▲ Monter")
down_btn = QPushButton("▼ Descendre")
delete_btn = QPushButton("🗑 Supprimer")
rename_btn.clicked.connect(self._rename_selected_clip)
up_btn.clicked.connect(lambda: self._move_clip(-1))
down_btn.clicked.connect(lambda: self._move_clip(1))
delete_btn.clicked.connect(self._delete_selected_clip)
clip_buttons.addWidget(rename_btn)
clip_buttons.addWidget(up_btn)
clip_buttons.addWidget(down_btn)
clip_buttons.addWidget(delete_btn)
layout.addLayout(clip_buttons)
clear_btn = QPushButton("Vider tout")
clear_btn.clicked.connect(self._clear_clips)
layout.addWidget(clear_btn)
preview_btn = QPushButton("🖌 Aperçu et style du PDF…")
preview_btn.clicked.connect(self.open_pdf_preview)
layout.addWidget(preview_btn)
layout.addWidget(_separator_label("Liens suggérés sur cette page"))
self.suggested_list = QListWidget()
self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested)
layout.addWidget(self.suggested_list, stretch=1)
self.next_page_btn = QPushButton("→ Aller à la suite détectée")
self.next_page_btn.setEnabled(False)
self.next_page_btn.clicked.connect(self._go_to_relnext)
layout.addWidget(self.next_page_btn)
export_btn = QPushButton("📄 Exporter en PDF…")
export_btn.clicked.connect(self.export_pdf)
layout.addWidget(export_btn)
dock.setWidget(panel)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
# ------------------------------------------------------------------ #
# Navigation
# ------------------------------------------------------------------ #
def _navigate_to_address_bar(self) -> None:
text = self.address_bar.text().strip()
if not text:
return
if "://" not in text and " " not in text and "." in text:
text = "https://" + text
elif "://" not in text:
text = "https://www.google.com/search?q=" + text.replace(" ", "+")
self.webview.setUrl(QUrl(text))
def _on_url_changed(self, url: QUrl) -> None:
self.address_bar.setText(url.toString())
# Efface les suggestions de la page précédente : le script de
# détection (persistant, voir link_detector.py) rescanne tout seul
# et repoussera des résultats frais sous peu — inutile d'attendre
# loadFinished, qui ne se déclenche d'ailleurs pas pour les
# navigations internes de type SPA (pushState).
self.suggested_list.clear()
self.next_page_btn.setEnabled(False)
self.last_suggested_relnext = None
def _on_title_changed(self, title: str) -> None:
self.setWindowTitle(f"PdfScrapper — {title}" if title else "PdfScrapper")
def _on_load_finished(self, ok: bool) -> None:
if not ok:
return
# L'état JS "sticky" (bascule bouton/raccourci) ne survit pas à une
# navigation : on le reporte sur la page fraîchement chargée. Le
# maintien de Ctrl, lui, est purement géré côté JS en continu. La
# détection de liens, elle, est poussée en continu par le script
# persistant (MutationObserver) — rien à déclencher ici.
self._sync_sticky_state()
def _sync_sticky_state(self) -> None:
value = "true" if self.block_picker_enabled else "false"
self.webview.page().runJavaScript(SET_STICKY_JS_TEMPLATE.format(value=value))
def _on_links_detected(self, links_json: str) -> None:
try:
result = json.loads(links_json)
except (TypeError, ValueError):
return
self.suggested_list.clear()
for link in result.get("links", []):
item = QListWidgetItem(link["text"] or link["href"])
item.setData(Qt.ItemDataRole.UserRole, link["href"])
item.setToolTip(link["href"])
self.suggested_list.addItem(item)
self.last_suggested_relnext = result.get("relNext")
self.next_page_btn.setEnabled(bool(self.last_suggested_relnext))
def _navigate_to_suggested(self, item: QListWidgetItem) -> None:
href = item.data(Qt.ItemDataRole.UserRole)
if href:
self.webview.setUrl(QUrl(href))
def _go_to_relnext(self) -> None:
if self.last_suggested_relnext:
self.webview.setUrl(QUrl(self.last_suggested_relnext))
# ------------------------------------------------------------------ #
# Capture de texte
# ------------------------------------------------------------------ #
def capture_selection(self) -> None:
self.webview.page().runJavaScript(SELECTION_JS, self._on_selection_captured)
def _on_selection_captured(self, selected_text: str | None) -> None:
if not selected_text or not selected_text.strip():
self.statusBar().showMessage("Aucun texte sélectionné dans la page.", 4000)
return
self._add_clip(selected_text)
def _toggle_block_picker(self, enabled: bool) -> None:
self.block_picker_enabled = enabled
self._sync_sticky_state()
if enabled:
self.statusBar().showMessage(
"Mode capture au survol activé en continu : survolez un bloc puis cliquez "
"pour l'ajouter au PDF (le clic normal est suspendu tant que ce mode est actif).",
6000,
)
else:
self.statusBar().showMessage(
"Mode capture au survol désactivé (astuce : maintenez Ctrl enfoncé dans la "
"page pour l'activer temporairement sans bouton).",
4000,
)
def _handle_block_capture(self, text: str) -> None:
self._add_clip(text)
def _toggle_insert_direction(self, above: bool) -> None:
self.insert_above = above
self.statusBar().showMessage(
"Nouvelles captures insérées au-dessus de la sélection." if above
else "Nouvelles captures insérées en dessous de la sélection.",
3000,
)
def _add_clip(self, text: str) -> None:
clip = Clip(
text=text.strip(),
title=self.webview.title() or self.webview.url().toString(),
url=self.webview.url().toString(),
captured_at=datetime.now(),
)
# Insère juste au-dessus ou en dessous de l'extrait actuellement
# sélectionné (selon self.insert_above), plutôt que toujours à la
# fin — pour pouvoir intercaler une capture au milieu d'un ordre
# déjà établi.
selected_row = self.clip_list.currentRow()
has_selection = 0 <= selected_row < len(self.clips)
if not has_selection:
# Pas de repère : simple accumulation chronologique à la fin.
# On ne sélectionne rien après coup, pour que les captures
# suivantes continuent d'être de simples ajouts à la fin,
# quel que soit le mode — le mode au-dessus/en dessous ne
# prend effet que si l'utilisateur choisit lui-même un repère.
insert_at = len(self.clips)
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
self.clip_list.setCurrentRow(-1)
elif self.insert_above:
insert_at = selected_row
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
# On garde le repère (l'extrait qui était sélectionné, décalé
# d'un cran) comme sélection courante, pour que les captures
# suivantes continuent de s'empiler au-dessus de lui, dans
# l'ordre de capture.
self.clip_list.setCurrentRow(selected_row + 1)
else:
insert_at = selected_row + 1
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
# La nouvelle capture devient elle-même le repère, pour que
# les suivantes s'enchaînent juste en dessous d'elle.
self.clip_list.setCurrentRow(insert_at)
self.statusBar().showMessage("Extrait ajouté au PDF.", 3000)
def _refresh_clip_list(self) -> None:
# Préserve la sélection courante : cette méthode est aussi appelée
# en arrière-plan par la fenêtre d'aperçu (à chaque correction de
# texte), qui ne doit pas faire perdre le repère d'insertion
# au-dessus/en dessous choisi dans le panneau latéral.
preserved_row = self.clip_list.currentRow()
self.clip_list.clear()
for clip in self.clips:
label = f"[{clip.captured_at.strftime('%H:%M')}] {clip.title} — {clip.preview()}"
item = QListWidgetItem(label)
item.setToolTip(clip.url)
self.clip_list.addItem(item)
self.clip_count_label.setText(f"Extraits capturés : {len(self.clips)}")
if 0 <= preserved_row < len(self.clips):
self.clip_list.setCurrentRow(preserved_row)
def _move_clip(self, offset: int) -> None:
row = self.clip_list.currentRow()
new_row = row + offset
if row < 0 or not (0 <= new_row < len(self.clips)):
return
self.clips[row], self.clips[new_row] = self.clips[new_row], self.clips[row]
self._refresh_clip_list()
self.clip_list.setCurrentRow(new_row)
def _delete_selected_clip(self) -> None:
row = self.clip_list.currentRow()
if row < 0:
return
del self.clips[row]
self._refresh_clip_list()
def _rename_selected_clip(self) -> None:
row = self.clip_list.currentRow()
if not (0 <= row < len(self.clips)):
self.statusBar().showMessage("Sélectionnez d'abord un extrait à renommer.", 3000)
return
clip = self.clips[row]
new_title, ok = QInputDialog.getText(
self,
"Renommer l'extrait",
"Titre affiché (utilisé aussi comme titre de section dans le PDF) :",
QLineEdit.EchoMode.Normal,
clip.title,
)
if ok and new_title.strip():
clip.title = new_title.strip()
self._refresh_clip_list()
self.clip_list.setCurrentRow(row)
def _clear_clips(self) -> None:
if not self.clips:
return
confirm = QMessageBox.question(
self,
"Vider les extraits",
"Supprimer tous les extraits capturés ?",
)
if confirm == QMessageBox.StandardButton.Yes:
self.clips.clear()
self._refresh_clip_list()
# ------------------------------------------------------------------ #
# Aperçu & style du PDF
# ------------------------------------------------------------------ #
def open_pdf_preview(self) -> None:
if self.preview_window is None:
self.preview_window = PdfPreviewWindow(
style=self.pdf_style,
get_clips=lambda: self.clips,
parent=self,
on_clips_changed=self._refresh_clip_list,
)
self.preview_window.refresh_preview()
self.preview_window.show()
self.preview_window.raise_()
self.preview_window.activateWindow()
# ------------------------------------------------------------------ #
# Export PDF
# ------------------------------------------------------------------ #
def export_pdf(self) -> None:
if not self.clips:
QMessageBox.warning(self, "Export PDF", "Aucun extrait à exporter.")
return
default_name = f"extraits_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf"
path, _ = QFileDialog.getSaveFileName(
self, "Exporter en PDF", default_name, "Fichiers PDF (*.pdf)"
)
if not path:
return
if not path.lower().endswith(".pdf"):
path += ".pdf"
try:
build_pdf(self.clips, path, self.pdf_style)
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
QMessageBox.critical(self, "Export PDF", f"Échec de l'export :\n{exc}")
return
self.statusBar().showMessage(f"PDF exporté : {path}", 5000)
QMessageBox.information(self, "Export PDF", f"PDF généré avec succès :\n{path}")
def _separator_label(text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("font-weight: bold; margin-top: 8px;")
return label