1380 lines
61 KiB
Python
1380 lines
61 KiB
Python
"""Fenêtre principale : navigateur + panneau latéral de capture PDF."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import QStandardPaths, QTimer, 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 .ai_config import AiConfig, load_ai_config, save_ai_config
|
||
from .ai_settings_dialog import AiSettingsDialog
|
||
from .ai_worker import AiTaskThread
|
||
from .auto_scrape_dialog import AutoScrapeDialog
|
||
from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE
|
||
from .bridge import Bridge, build_bridge_init_script
|
||
from .clip_table import ClipTable
|
||
from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS
|
||
from .local_ai import LinkCandidate, LocalAi, LocalAiError, NextLinkResult
|
||
from .models import Clip
|
||
from .pdf_builder import build_pdf
|
||
from .pdf_style import PdfStyle
|
||
from .preview_window import PdfPreviewWindow
|
||
from .project_io import (
|
||
PROJECT_FILE_FILTER,
|
||
PROJECT_FILE_SUFFIX,
|
||
ProjectState,
|
||
load_last_session_path,
|
||
load_project,
|
||
save_last_session_path,
|
||
save_project,
|
||
)
|
||
|
||
# Texte brut de la page, tronqué et débarrassé des zones techniques
|
||
# évidentes (script/style/nav) avant d'être envoyé au modèle local pour
|
||
# extraction — réduit le bruit et la taille du prompt.
|
||
PAGE_TEXT_FOR_AI_JS = """
|
||
(function () {
|
||
var clone = document.body.cloneNode(true);
|
||
clone.querySelectorAll('script, style, nav, header, footer').forEach(function (el) {
|
||
el.remove();
|
||
});
|
||
return (clone.innerText || clone.textContent || '').trim();
|
||
})();
|
||
"""
|
||
|
||
# Liste large de liens candidats (texte + href + contexte proche), pour
|
||
# la sélection IA du lien "suivant" — plus permissive que
|
||
# link_detector.py (pas de filtrage par mots-clés), puisque c'est
|
||
# justement ce que l'IA doit juger. Le contexte (texte du conteneur
|
||
# parent immédiat) reprend la même logique que link_detector.py : sur
|
||
# certains sites, un lien de pagination interne à un chapitre réparti sur
|
||
# plusieurs pages n'a lui-même qu'un texte quasi vide (icône, "›", "2")
|
||
# — sans ce contexte, l'IA n'a aucun indice pour le distinguer d'un lien
|
||
# sans rapport.
|
||
ALL_LINKS_FOR_AI_JS = """
|
||
(function () {
|
||
var seen = {};
|
||
var links = [];
|
||
document.querySelectorAll('a[href]').forEach(function (a) {
|
||
var text = (a.innerText || a.textContent || '').trim();
|
||
if (text.length > 100) { return; }
|
||
if (seen[a.href]) { return; }
|
||
|
||
var context = '';
|
||
if (a.parentElement) {
|
||
var contextText = (a.parentElement.innerText || a.parentElement.textContent || '').trim();
|
||
if (contextText && contextText.length <= 150 && contextText !== text) {
|
||
context = contextText;
|
||
}
|
||
}
|
||
// Un lien sans texte propre n'est retenu que s'il a au moins un
|
||
// contexte proche exploitable (ex. icône de pagination) — sinon
|
||
// aucun signal à donner à l'IA.
|
||
if (!text && !context) { return; }
|
||
|
||
seen[a.href] = true;
|
||
links.push({ text: text, href: a.href, context: context });
|
||
});
|
||
return JSON.stringify(links.slice(0, 150));
|
||
})();
|
||
"""
|
||
|
||
AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20
|
||
AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger
|
||
AUTO_SCRAPE_AI_TIMEOUT_MS = 120_000 # délai d'INACTIVITÉ (pas la durée totale) avant d'abandonner l'IA
|
||
AUTOSAVE_INTERVAL_MS = 60_000
|
||
|
||
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.ai_config = load_ai_config()
|
||
self.local_ai = LocalAi(self.ai_config)
|
||
self._ai_thread: AiTaskThread | None = None
|
||
self._ai_task_id = 0
|
||
self._ai_task_done = True
|
||
self._ai_task_silent_on_failure = False
|
||
self._ai_task_last_activity = 0.0
|
||
self._ai_task_watchdog: QTimer | None = None
|
||
self.auto_scrape_active = False
|
||
self.auto_scrape_visited_urls: set[str] = set()
|
||
self.auto_scrape_max_chapters = AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS
|
||
self.auto_scrape_count = 0
|
||
self.auto_scrape_dialog: AutoScrapeDialog | None = None
|
||
# Dernier extrait ajouté par le scraping automatique — sert à le
|
||
# marquer linked_to_next si l'IA juge la page suivante comme la
|
||
# suite du même chapitre (voir _auto_scrape_on_next_link_found).
|
||
self._auto_scrape_last_clip: Clip | None = None
|
||
|
||
# Dossier data de l'appli : persistance QtWebEngine (voir
|
||
# _build_webview) et fichier de récupération de session.
|
||
self.app_data_dir = Path(
|
||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||
) / "pdfscrapper"
|
||
self.autosave_path = self.app_data_dir / f"session_autosave{PROJECT_FILE_SUFFIX}"
|
||
# Fichier projet explicitement nommé par l'utilisateur (Enregistrer
|
||
# sous / Ouvrir). Tant qu'il vaut None, l'autosave écrit vers
|
||
# self.autosave_path — voir _autosave().
|
||
self.project_path: Path | None = None
|
||
|
||
self._build_webview()
|
||
self._build_toolbar()
|
||
self._build_sidebar()
|
||
|
||
if not self._restore_last_session():
|
||
self.webview.setUrl(QUrl(HOME_URL))
|
||
self._update_project_label()
|
||
|
||
self._autosave_timer = QTimer(self)
|
||
self._autosave_timer.setInterval(AUTOSAVE_INTERVAL_MS)
|
||
self._autosave_timer.timeout.connect(self._autosave)
|
||
self._autosave_timer.start()
|
||
|
||
def closeEvent(self, event) -> None: # noqa: N802 (nom imposé par Qt)
|
||
# Sauvegarde silencieuse à la fermeture — même mécanisme que
|
||
# l'autosave périodique, pas de boîte de dialogue bloquante.
|
||
self._autosave()
|
||
super().closeEvent(event)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Construction de l'UI
|
||
# ------------------------------------------------------------------ #
|
||
def _build_webview(self) -> None:
|
||
storage_dir = self.app_data_dir
|
||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Pas encore parenté à `self` ici — voir plus bas, juste après la
|
||
# création de self.webview, pour l'explication.
|
||
profile = QWebEngineProfile("pdfscrapper_profile")
|
||
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)
|
||
|
||
# Parenté à `self` seulement maintenant, APRÈS self.webview : Qt
|
||
# détruit les enfants d'un QObject dans l'ordre où ils ont été
|
||
# parentés, donc le profile doit apparaître après la vue web dans
|
||
# cette liste pour être détruit après elle (et donc après sa
|
||
# page) à la fermeture de l'appli. Dans l'autre sens, on obtient
|
||
# l'avertissement "Release of profile requested but
|
||
# WebEnginePage still not deleted" (la page tente encore d'écrire
|
||
# dans un profile déjà libéré).
|
||
profile.setParent(self)
|
||
self.web_profile = profile
|
||
|
||
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:
|
||
# Deux barres d'outils séparées (plutôt qu'une seule mêlant tout) :
|
||
# la barre d'actions (projet/capture/IA/export) au-dessus, la barre
|
||
# de navigation (adresse comprise) juste en dessous, contre la page
|
||
# web — addToolBarBreak() force la seconde sur sa propre ligne.
|
||
self._build_actions_toolbar()
|
||
self.addToolBarBreak()
|
||
self._build_navigation_toolbar()
|
||
|
||
def _build_navigation_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)
|
||
|
||
def _build_actions_toolbar(self) -> None:
|
||
toolbar = QToolBar("Actions", self)
|
||
toolbar.setMovable(False)
|
||
# Barre "principale" : nettement plus haute que la barre de
|
||
# navigation (~100px, look "gros boutons" façon OpenOffice), via
|
||
# padding plutôt que taille d'icône — les actions n'ont pas
|
||
# d'icône, seulement un emoji dans leur texte.
|
||
toolbar.setStyleSheet(
|
||
"QToolBar { padding: 15px; spacing: 8px; }"
|
||
"QToolButton { padding: 25px 14px; font-size: 11pt; }"
|
||
)
|
||
self.addToolBar(toolbar)
|
||
|
||
# --- Projets -------------------------------------------------------
|
||
open_action = QAction("📂 Ouvrir…", self)
|
||
open_action.setToolTip("Ouvre un projet .pdfscrap enregistré précédemment.")
|
||
open_action.triggered.connect(self.open_project_action)
|
||
toolbar.addAction(open_action)
|
||
|
||
save_action = QAction("💾 Enregistrer", self)
|
||
save_action.setToolTip(
|
||
"Enregistre le projet courant (extraits + style). Demande un nom la première "
|
||
"fois, réutilise ensuite le même fichier."
|
||
)
|
||
save_action.triggered.connect(self.save_project_action)
|
||
toolbar.addAction(save_action)
|
||
|
||
save_as_action = QAction("💾 Enregistrer sous…", self)
|
||
save_as_action.setToolTip("Enregistre le projet courant dans un nouveau fichier.")
|
||
save_as_action.triggered.connect(self.save_project_as_action)
|
||
toolbar.addAction(save_as_action)
|
||
|
||
toolbar.addSeparator()
|
||
|
||
# --- Éditions --------------------------------------------------------
|
||
# Capture (sélection manuelle / mode survol) + aperçu-style : tout
|
||
# ce qui sert à constituer et affiner le contenu avant génération.
|
||
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)
|
||
|
||
preview_action = QAction("🖌 Aperçu et style du PDF…", self)
|
||
preview_action.setToolTip(
|
||
"Aperçu en direct du PDF final, réglages de style et correction du texte."
|
||
)
|
||
preview_action.triggered.connect(self.open_pdf_preview)
|
||
toolbar.addAction(preview_action)
|
||
|
||
toolbar.addSeparator()
|
||
|
||
# --- IA ----------------------------------------------------------
|
||
ai_settings_action = QAction("⚙️ Configurer l'IA locale", self)
|
||
ai_settings_action.triggered.connect(self.open_ai_settings)
|
||
toolbar.addAction(ai_settings_action)
|
||
|
||
self.ai_extract_action = QAction("🤖 Extraire (IA)", self)
|
||
self.ai_extract_action.setToolTip(
|
||
"Demande à l'IA locale d'extraire le texte narratif principal de la page "
|
||
"courante et de l'ajouter au PDF (alternative à la sélection manuelle / au "
|
||
"mode survol)."
|
||
)
|
||
self.ai_extract_action.triggered.connect(self.run_ai_extract)
|
||
toolbar.addAction(self.ai_extract_action)
|
||
|
||
self.ai_next_link_action = QAction("🤖 Lien suivant (IA)", self)
|
||
self.ai_next_link_action.setToolTip(
|
||
"Demande à l'IA locale de désigner, parmi tous les liens de la page, celui "
|
||
"qui mène le plus probablement à la suite — utile quand la détection "
|
||
"automatique (mots-clés) ne trouve rien."
|
||
)
|
||
self.ai_next_link_action.triggered.connect(self.run_ai_pick_next_link)
|
||
toolbar.addAction(self.ai_next_link_action)
|
||
|
||
self.auto_scrape_action = QAction("🔁 Scraping auto (IA)", self)
|
||
self.auto_scrape_action.setToolTip(
|
||
"Boucle : extrait le contenu de la page courante (IA), capture le PDF, "
|
||
"trouve et suit le lien suivant (IA), répète — jusqu'à la limite de "
|
||
"chapitres fixée ou jusqu'à ce qu'aucune suite ne soit trouvée."
|
||
)
|
||
self.auto_scrape_action.triggered.connect(self._toggle_auto_scrape)
|
||
toolbar.addAction(self.auto_scrape_action)
|
||
|
||
toolbar.addSeparator()
|
||
|
||
# --- Génération ------------------------------------------------------
|
||
export_action = QAction("📄 Exporter en PDF…", self)
|
||
export_action.triggered.connect(self.export_pdf)
|
||
toolbar.addAction(export_action)
|
||
|
||
def _build_sidebar(self) -> None:
|
||
dock = QDockWidget("Capture PDF", self)
|
||
dock.setFeatures(
|
||
QDockWidget.DockWidgetFeature.DockWidgetMovable
|
||
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
|
||
)
|
||
|
||
panel = QWidget()
|
||
layout = QVBoxLayout(panel)
|
||
|
||
# Actions Ouvrir/Enregistrer/Enregistrer sous : dans la toolbar
|
||
# (voir _build_toolbar) — seul l'état ("Projet : xxx" / "Aucun
|
||
# projet enregistré") reste affiché ici.
|
||
self.project_path_label = QLabel()
|
||
self.project_path_label.setStyleSheet("color: grey; font-style: italic;")
|
||
self.project_path_label.setWordWrap(True)
|
||
layout.addWidget(self.project_path_label)
|
||
|
||
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 = ClipTable()
|
||
self.clip_list.renameRequested.connect(self._rename_clip_at)
|
||
self.clip_list.linkToggled.connect(self._toggle_link_after)
|
||
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)
|
||
|
||
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)
|
||
|
||
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) -> Clip:
|
||
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.clear_current()
|
||
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.select_row(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.select_row(insert_at)
|
||
|
||
self.statusBar().showMessage("Extrait ajouté au PDF.", 3000)
|
||
return clip
|
||
|
||
def _refresh_clip_list(self) -> None:
|
||
# ClipTable.set_clips préserve elle-même la sélection courante par
|
||
# index — 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.
|
||
self.clip_list.set_clips(self.clips)
|
||
self.clip_count_label.setText(f"Extraits capturés : {len(self.clips)}")
|
||
|
||
def _rename_clip_at(self, index: int) -> None:
|
||
self.clip_list.select_row(index)
|
||
self._rename_selected_clip()
|
||
|
||
def _toggle_link_after(self, index: int) -> None:
|
||
if not (0 <= index < len(self.clips)):
|
||
return
|
||
clip = self.clips[index]
|
||
clip.linked_to_next = not clip.linked_to_next
|
||
self._refresh_clip_list()
|
||
self.statusBar().showMessage(
|
||
"Extraits liés : ils formeront un seul chapitre à l'export."
|
||
if clip.linked_to_next
|
||
else "Extraits déliés.",
|
||
3000,
|
||
)
|
||
|
||
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
|
||
# Deux extraits directement liés forment un seul chapitre dans un
|
||
# ordre précis : les permuter inverserait silencieusement le sens
|
||
# du lien (et pourrait fusionner le mauvais extrait avec le
|
||
# mauvais chapitre à l'export). On demande de délier d'abord.
|
||
earlier_row = min(row, new_row)
|
||
if self.clips[earlier_row].linked_to_next:
|
||
self.statusBar().showMessage(
|
||
"Extraits liés : déliez-les d'abord (icône 🔗) pour les réordonner.", 4000
|
||
)
|
||
return
|
||
self.clips[row], self.clips[new_row] = self.clips[new_row], self.clips[row]
|
||
self._refresh_clip_list()
|
||
self.clip_list.select_row(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.select_row(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()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Projet : sauvegarde manuelle/auto, reprise au démarrage
|
||
# ------------------------------------------------------------------ #
|
||
def _current_state(self) -> ProjectState:
|
||
return ProjectState(
|
||
clips=self.clips,
|
||
pdf_style=self.pdf_style,
|
||
insert_above=self.insert_above,
|
||
current_url=self.webview.url().toString(),
|
||
)
|
||
|
||
def _save_to(self, path: Path) -> None:
|
||
save_project(path, self._current_state())
|
||
save_last_session_path(path)
|
||
|
||
def _autosave(self) -> None:
|
||
target = self.project_path or self.autosave_path
|
||
try:
|
||
self._save_to(target)
|
||
except Exception: # noqa: BLE001 — l'autosave ne doit jamais interrompre l'appli
|
||
return
|
||
self.statusBar().showMessage("💾 Sauvegarde automatique effectuée.", 2000)
|
||
|
||
def save_project_action(self) -> None:
|
||
if self.project_path is None:
|
||
self.save_project_as_action()
|
||
return
|
||
try:
|
||
self._save_to(self.project_path)
|
||
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
|
||
QMessageBox.critical(self, "Enregistrer le projet", f"Échec de l'enregistrement :\n{exc}")
|
||
return
|
||
self.statusBar().showMessage(f"Projet enregistré : {self.project_path}", 4000)
|
||
|
||
def save_project_as_action(self) -> None:
|
||
default_name = (
|
||
self.project_path.name
|
||
if self.project_path
|
||
else f"projet_{datetime.now().strftime('%Y%m%d_%H%M')}{PROJECT_FILE_SUFFIX}"
|
||
)
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "Enregistrer le projet sous…", default_name, PROJECT_FILE_FILTER
|
||
)
|
||
if not path:
|
||
return
|
||
if not path.lower().endswith(PROJECT_FILE_SUFFIX):
|
||
path += PROJECT_FILE_SUFFIX
|
||
target = Path(path)
|
||
|
||
try:
|
||
self._save_to(target)
|
||
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
|
||
QMessageBox.critical(self, "Enregistrer le projet", f"Échec de l'enregistrement :\n{exc}")
|
||
return
|
||
|
||
self.project_path = target
|
||
self._update_project_label()
|
||
self.statusBar().showMessage(f"Projet enregistré : {target}", 4000)
|
||
|
||
def open_project_action(self) -> None:
|
||
if self.clips:
|
||
confirm = QMessageBox.question(
|
||
self,
|
||
"Ouvrir un projet",
|
||
"Ouvrir un autre projet remplacera les extraits actuels (non enregistrés "
|
||
"y compris). Continuer ?",
|
||
)
|
||
if confirm != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
path, _ = QFileDialog.getOpenFileName(self, "Ouvrir un projet", "", PROJECT_FILE_FILTER)
|
||
if not path:
|
||
return
|
||
|
||
try:
|
||
state = load_project(path)
|
||
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
|
||
QMessageBox.critical(self, "Ouvrir un projet", f"Échec de l'ouverture :\n{exc}")
|
||
return
|
||
|
||
self._apply_project_state(state)
|
||
self.project_path = Path(path)
|
||
save_last_session_path(self.project_path)
|
||
self._update_project_label()
|
||
self.statusBar().showMessage(f"Projet ouvert : {path}", 4000)
|
||
|
||
def _apply_project_state(self, state: ProjectState) -> None:
|
||
self.clips = state.clips
|
||
# Mute l'objet PdfStyle existant en place plutôt que de le
|
||
# remplacer : preview_window.py le garde par référence une fois
|
||
# la fenêtre d'aperçu ouverte une première fois (voir sa
|
||
# docstring) — le remplacer désynchroniserait l'aperçu déjà
|
||
# ouvert du style du projet qu'on vient de charger.
|
||
vars(self.pdf_style).update(vars(state.pdf_style))
|
||
self.insert_above = state.insert_above
|
||
self.insert_above_checkbox.setChecked(self.insert_above)
|
||
self._refresh_clip_list()
|
||
self.webview.setUrl(QUrl(state.current_url or HOME_URL))
|
||
if self.preview_window is not None:
|
||
self.preview_window.sync_from_external_change()
|
||
|
||
def _restore_last_session(self) -> bool:
|
||
"""Recharge la dernière session (projet explicite ou fichier de
|
||
récupération automatique) au démarrage. Retourne False si rien
|
||
n'a pu être restauré (première utilisation, fichier absent ou
|
||
corrompu) — l'appelant démarre alors sur la page d'accueil."""
|
||
last_path = load_last_session_path()
|
||
if last_path is None or not last_path.is_file():
|
||
return False
|
||
try:
|
||
state = load_project(last_path)
|
||
except Exception: # noqa: BLE001 — fichier corrompu : démarrage à vide, pas d'erreur
|
||
return False
|
||
|
||
self._apply_project_state(state)
|
||
# Le fichier de récupération par défaut ne compte pas comme un
|
||
# "vrai" projet nommé : Enregistrer proposera toujours Enregistrer
|
||
# sous tant que l'utilisateur n'a pas choisi lui-même un fichier.
|
||
if last_path != self.autosave_path:
|
||
self.project_path = last_path
|
||
self.statusBar().showMessage("Session précédente restaurée.", 4000)
|
||
return True
|
||
|
||
def _update_project_label(self) -> None:
|
||
if self.project_path:
|
||
self.project_path_label.setText(f"Projet : {self.project_path.name}")
|
||
else:
|
||
self.project_path_label.setText(
|
||
"Aucun projet enregistré (sauvegarde automatique locale)."
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# IA locale — configuration
|
||
# ------------------------------------------------------------------ #
|
||
def open_ai_settings(self) -> None:
|
||
dialog = AiSettingsDialog(self.ai_config, self)
|
||
if dialog.exec() == AiSettingsDialog.DialogCode.Accepted:
|
||
self.ai_config = dialog.updated_config()
|
||
save_ai_config(self.ai_config)
|
||
self.local_ai.config = self.ai_config
|
||
self.local_ai.reset() # force le rechargement si le modèle a changé
|
||
self.statusBar().showMessage("Configuration IA enregistrée.", 3000)
|
||
|
||
def _require_ai_configured(self) -> bool:
|
||
if self.local_ai.is_configured():
|
||
return True
|
||
confirm = QMessageBox.question(
|
||
self,
|
||
"IA locale non configurée",
|
||
"Aucun modèle IA n'est configuré. Ouvrir les réglages maintenant ?",
|
||
)
|
||
if confirm == QMessageBox.StandardButton.Yes:
|
||
self.open_ai_settings()
|
||
return False
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# IA locale — extraction de contenu
|
||
# ------------------------------------------------------------------ #
|
||
def run_ai_extract(self) -> None:
|
||
if not self._require_ai_configured():
|
||
return
|
||
self.statusBar().showMessage("IA : lecture de la page…", 3000)
|
||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_extract)
|
||
|
||
def _on_page_text_for_ai_extract(self, page_text: str | None) -> None:
|
||
if not page_text or not page_text.strip():
|
||
QMessageBox.warning(self, "IA locale", "Aucun texte exploitable sur cette page.")
|
||
return
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
self._run_ai_task(
|
||
self.local_ai.extract_main_content,
|
||
args=(title, page_text),
|
||
on_success=self._on_ai_extract_succeeded,
|
||
busy_message="IA : extraction du contenu en cours (peut prendre du temps sur CPU)…",
|
||
)
|
||
|
||
def _on_ai_extract_succeeded(self, extracted_text: object) -> None:
|
||
self._add_clip(str(extracted_text))
|
||
self.statusBar().showMessage("Extrait ajouté au PDF (IA).", 3000)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# IA locale — suggestion du lien suivant
|
||
# ------------------------------------------------------------------ #
|
||
def run_ai_pick_next_link(self) -> None:
|
||
if not self._require_ai_configured():
|
||
return
|
||
self.statusBar().showMessage("IA : lecture de la page…", 3000)
|
||
# Lit d'abord le texte de la page (nécessaire pour juger si le
|
||
# contenu s'interrompt en plein chapitre, ou si la page est une
|
||
# table des matières — voir LocalAi.pick_next_link), puis les
|
||
# liens — même ordre que le scraping automatique.
|
||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_pick_next_link)
|
||
|
||
def _on_page_text_for_ai_pick_next_link(self, page_text: str | None) -> None:
|
||
page_text = page_text or ""
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
self.statusBar().showMessage("IA : analyse de la page…", 3000)
|
||
self._run_ai_task(
|
||
self.local_ai.is_table_of_contents,
|
||
args=(title, page_text),
|
||
on_success=lambda is_toc: self._on_toc_checked_for_ai_pick_next_link(page_text, bool(is_toc)),
|
||
busy_message="IA : analyse de la page…",
|
||
)
|
||
|
||
def _on_toc_checked_for_ai_pick_next_link(self, page_text: str, is_toc: bool) -> None:
|
||
self.statusBar().showMessage("IA : lecture des liens de la page…", 3000)
|
||
self.webview.page().runJavaScript(
|
||
ALL_LINKS_FOR_AI_JS,
|
||
lambda links_json: self._on_links_for_ai_pick(page_text, is_toc, links_json),
|
||
)
|
||
|
||
def _on_links_for_ai_pick(self, page_text: str, is_toc: bool, links_json: str | None) -> None:
|
||
try:
|
||
raw_links = json.loads(links_json) if links_json else []
|
||
except (TypeError, ValueError):
|
||
raw_links = []
|
||
if not raw_links:
|
||
QMessageBox.warning(self, "IA locale", "Aucun lien exploitable sur cette page.")
|
||
return
|
||
|
||
candidates = [
|
||
LinkCandidate(text=l["text"], href=l["href"], context=l.get("context", ""))
|
||
for l in raw_links
|
||
]
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
self._run_ai_task(
|
||
self.local_ai.pick_next_link,
|
||
args=(candidates, title, page_text, is_toc),
|
||
on_success=self._on_ai_pick_next_link_succeeded,
|
||
busy_message="IA : recherche du lien suivant en cours…",
|
||
)
|
||
|
||
def _on_ai_pick_next_link_succeeded(self, result: object) -> None:
|
||
result: NextLinkResult
|
||
if result.candidate is None:
|
||
QMessageBox.information(
|
||
self, "IA locale", "L'IA n'a identifié aucun lien menant à une suite sur cette page."
|
||
)
|
||
return
|
||
candidate = result.candidate
|
||
chapter_note = (
|
||
"\n\n(suite du même chapitre)" if result.same_chapter else "\n\n(nouveau chapitre)"
|
||
)
|
||
confirm = QMessageBox.question(
|
||
self,
|
||
"Lien suivant suggéré par l'IA",
|
||
f"L'IA suggère :\n\n« {candidate.text} »\n{candidate.href}{chapter_note}\n\nY aller maintenant ?",
|
||
)
|
||
if confirm == QMessageBox.StandardButton.Yes:
|
||
self.webview.setUrl(QUrl(candidate.href))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# IA locale — exécution en arrière-plan
|
||
# ------------------------------------------------------------------ #
|
||
def _run_ai_task(
|
||
self,
|
||
fn,
|
||
args: tuple,
|
||
on_success,
|
||
busy_message: str,
|
||
timeout_ms: int | None = None,
|
||
silent_on_failure: bool = False,
|
||
) -> None:
|
||
"""Lance `fn(*args)` sur un thread dédié (voir ai_worker.py).
|
||
|
||
`timeout_ms` : filet de sécurité pour le scraping automatique — un
|
||
watchdog (voir _on_ai_task_watchdog_tick) surveille non pas la durée
|
||
TOTALE de l'appel, mais l'INACTIVITÉ : `fn` reçoit un callback
|
||
`on_activity` (voir LocalAi._complete, qui l'appelle à chaque
|
||
fragment généré en streaming) qui réarme le délai à chaque signe de
|
||
vie du modèle. Un modèle lent mais qui progresse encore n'est ainsi
|
||
jamais interrompu à tort ; seul un silence prolongé (aucun nouveau
|
||
fragment pendant `timeout_ms`) déclenche l'arrêt. Le thread
|
||
orphelin, lui, continue en arrière-plan mais son résultat tardif
|
||
est ignoré (voir `task_id` : un identifiant unique par appel, pour
|
||
qu'un watchdog resté actif après la fin d'un appel précédent ne
|
||
puisse jamais interférer avec un appel suivant).
|
||
|
||
`silent_on_failure` : n'affiche pas la boîte de dialogue d'erreur
|
||
(utilisé en scraping automatique, où personne ne surveille
|
||
forcément l'écran pour la fermer).
|
||
"""
|
||
self.statusBar().showMessage(busy_message)
|
||
self._set_ai_actions_enabled(False)
|
||
self._ai_task_id += 1
|
||
task_id = self._ai_task_id
|
||
self._ai_task_done = False
|
||
self._ai_task_silent_on_failure = silent_on_failure
|
||
|
||
extra_kwargs = {}
|
||
if timeout_ms is not None:
|
||
self._ai_task_last_activity = time.monotonic()
|
||
extra_kwargs["on_activity"] = self._mark_ai_task_activity
|
||
|
||
self._ai_thread = AiTaskThread(fn, *args, **extra_kwargs)
|
||
self._ai_thread.succeeded.connect(
|
||
lambda result: self._on_ai_task_succeeded(task_id, on_success, result)
|
||
)
|
||
self._ai_thread.failed.connect(lambda message: self._on_ai_task_failed(task_id, message))
|
||
self._ai_thread.finished.connect(self._on_ai_task_finished)
|
||
self._ai_thread.start()
|
||
|
||
if timeout_ms is not None:
|
||
watchdog = QTimer(self)
|
||
watchdog.setInterval(min(timeout_ms, 5000))
|
||
watchdog.timeout.connect(lambda: self._on_ai_task_watchdog_tick(task_id, timeout_ms, watchdog))
|
||
watchdog.start()
|
||
self._ai_task_watchdog = watchdog
|
||
|
||
def _mark_ai_task_activity(self) -> None:
|
||
# Appelé depuis le thread IA en arrière-plan (voir LocalAi._complete)
|
||
# — simple écriture d'un float, suffisamment sûre sous le GIL pour
|
||
# ce cas d'usage (un rendez-vous approximatif suffit, pas besoin
|
||
# d'un verrou pour un compteur de dernière activité).
|
||
self._ai_task_last_activity = time.monotonic()
|
||
|
||
def _on_ai_task_succeeded(self, task_id: int, on_success, result: object) -> None:
|
||
if task_id == self._ai_task_id:
|
||
self._ai_task_done = True
|
||
on_success(result)
|
||
|
||
def _on_ai_task_failed(self, task_id: int, message: str) -> None:
|
||
if task_id == self._ai_task_id:
|
||
self._ai_task_done = True
|
||
self.statusBar().clearMessage()
|
||
if not self._ai_task_silent_on_failure:
|
||
QMessageBox.warning(self, "IA locale", message)
|
||
if self.auto_scrape_active:
|
||
self._stop_auto_scrape(f"Scraping automatique arrêté : {message}")
|
||
|
||
def _on_ai_task_finished(self) -> None:
|
||
self._set_ai_actions_enabled(True)
|
||
|
||
def _on_ai_task_watchdog_tick(self, task_id: int, timeout_ms: int, watchdog: QTimer) -> None:
|
||
# Un appel plus récent a démarré depuis, ou celui-ci a déjà répondu
|
||
# (succès ou échec) : ce watchdog est obsolète, il s'arrête lui-même.
|
||
if task_id != self._ai_task_id or self._ai_task_done:
|
||
watchdog.stop()
|
||
watchdog.deleteLater()
|
||
return
|
||
inactive_ms = (time.monotonic() - self._ai_task_last_activity) * 1000
|
||
if inactive_ms < timeout_ms:
|
||
return # l'IA a encore donné signe de vie récemment, on continue d'attendre
|
||
watchdog.stop()
|
||
watchdog.deleteLater()
|
||
if self.auto_scrape_active:
|
||
self._stop_auto_scrape(
|
||
f"Scraping automatique arrêté : aucune réponse de l'IA depuis "
|
||
f"{timeout_ms // 1000}s (analyse probablement bloquée)."
|
||
)
|
||
|
||
def _set_ai_actions_enabled(self, enabled: bool) -> None:
|
||
self.ai_extract_action.setEnabled(enabled)
|
||
self.ai_next_link_action.setEnabled(enabled)
|
||
if enabled and not self.auto_scrape_active:
|
||
self.auto_scrape_action.setEnabled(True)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# IA locale — scraping automatique (boucle)
|
||
# ------------------------------------------------------------------ #
|
||
def _toggle_auto_scrape(self) -> None:
|
||
if self.auto_scrape_active:
|
||
self._stop_auto_scrape("Scraping automatique arrêté.")
|
||
return
|
||
|
||
if not self._require_ai_configured():
|
||
return
|
||
|
||
max_chapters, ok = QInputDialog.getInt(
|
||
self,
|
||
"Scraping automatique",
|
||
"Nombre maximum de chapitres à capturer automatiquement\n"
|
||
"(0 = illimité : l'IA tente de détecter le nombre total de "
|
||
"chapitres sur la page actuelle avant de démarrer ; à défaut, "
|
||
"le scraping s'arrête seulement quand plus aucune suite n'est "
|
||
"trouvée) :",
|
||
self.auto_scrape_max_chapters,
|
||
0,
|
||
500,
|
||
)
|
||
if not ok:
|
||
return
|
||
|
||
self.auto_scrape_active = True
|
||
self.auto_scrape_visited_urls = set()
|
||
self.auto_scrape_count = 0
|
||
self._auto_scrape_last_clip = None
|
||
self.auto_scrape_action.setText("⏹ Arrêter le scraping")
|
||
|
||
# Fenêtre de suivi (journal + progression) : une seule instance
|
||
# réutilisée d'un lancement à l'autre plutôt que recréée à chaque
|
||
# fois — voir auto_scrape_dialog.py.
|
||
if self.auto_scrape_dialog is None:
|
||
self.auto_scrape_dialog = AutoScrapeDialog(self, on_stop=self._toggle_auto_scrape)
|
||
self.auto_scrape_dialog.show()
|
||
self.auto_scrape_dialog.raise_()
|
||
self.auto_scrape_dialog.activateWindow()
|
||
|
||
if max_chapters == 0:
|
||
# Limite laissée à 0 pour l'instant : ajustée juste après par
|
||
# _auto_scrape_on_chapter_count_estimated() si la détection
|
||
# réussit, sinon le scraping reste illimité (voir le garde-fou
|
||
# `> 0` dans _auto_scrape_on_extracted).
|
||
self.auto_scrape_max_chapters = 0
|
||
self.auto_scrape_dialog.reset(0)
|
||
self.auto_scrape_dialog.log(
|
||
"Limite illimitée — tentative de détection automatique du nombre de "
|
||
"chapitres sur la page actuelle…"
|
||
)
|
||
self.statusBar().showMessage("Scraping automatique : détection du nombre de chapitres…", 3000)
|
||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._auto_scrape_on_page_text_for_estimate)
|
||
else:
|
||
self.auto_scrape_max_chapters = max_chapters
|
||
self.auto_scrape_dialog.reset(max_chapters)
|
||
self.auto_scrape_dialog.log(f"Démarrage — limite : {max_chapters} chapitre(s).")
|
||
self.statusBar().showMessage("Scraping automatique démarré…", 3000)
|
||
self._auto_scrape_extract_current_page()
|
||
|
||
def _auto_scrape_on_page_text_for_estimate(self, page_text: str | None) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
self._run_ai_task(
|
||
self.local_ai.estimate_chapter_count,
|
||
args=(title, page_text or ""),
|
||
on_success=self._auto_scrape_on_chapter_count_estimated,
|
||
busy_message="Scraping automatique : détection du nombre de chapitres (IA)…",
|
||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||
silent_on_failure=True,
|
||
)
|
||
|
||
def _auto_scrape_on_chapter_count_estimated(self, count: object) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
if isinstance(count, int) and count > 0:
|
||
self.auto_scrape_max_chapters = count
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.set_progress(0, count)
|
||
self.auto_scrape_dialog.log(f"Nombre de chapitres détecté automatiquement : {count}.")
|
||
elif self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(
|
||
"Nombre de chapitres indéterminable sur cette page — scraping sans "
|
||
"limite fixe (s'arrêtera de lui-même quand plus aucune suite ne sera "
|
||
"trouvée)."
|
||
)
|
||
self._auto_scrape_extract_current_page()
|
||
|
||
def _stop_auto_scrape(self, message: str) -> None:
|
||
self.auto_scrape_active = False
|
||
self.auto_scrape_action.setText("🔁 Scraping auto (IA)")
|
||
self.statusBar().showMessage(message, 6000)
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(message)
|
||
|
||
def _auto_scrape_extract_current_page(self) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
self.auto_scrape_visited_urls.add(self.webview.url().toString())
|
||
page_label = self.webview.title() or self.webview.url().toString()
|
||
chapter_ref = (
|
||
f"{self.auto_scrape_count + 1}/{self.auto_scrape_max_chapters}"
|
||
if self.auto_scrape_max_chapters > 0
|
||
else f"{self.auto_scrape_count + 1}"
|
||
)
|
||
self.statusBar().showMessage(f"Scraping automatique : extraction du chapitre {chapter_ref}…")
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(f"Chapitre {chapter_ref} : lecture de la page « {page_label} »…")
|
||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._auto_scrape_on_page_text)
|
||
|
||
def _auto_scrape_on_page_text(self, page_text: str | None) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
if not page_text or not page_text.strip():
|
||
self._stop_auto_scrape("Scraping automatique arrêté : page sans texte exploitable.")
|
||
return
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log("Analyse de la page : table des matières ou chapitre ? (IA)…")
|
||
# Vérifie d'abord si la page est une table des matières (liste de
|
||
# chapitres) plutôt qu'un chapitre en soi — sinon on tenterait d'en
|
||
# "extraire le contenu narratif" (résultat vide/aberrant, capturé
|
||
# comme un faux chapitre) et on choisirait ensuite un lien "suivant"
|
||
# arbitraire parmi tous les chapitres listés plutôt que viser le
|
||
# premier. Voir LocalAi.is_table_of_contents.
|
||
self._run_ai_task(
|
||
self.local_ai.is_table_of_contents,
|
||
args=(title, page_text),
|
||
on_success=lambda is_toc: self._auto_scrape_on_toc_checked(page_text, title, bool(is_toc)),
|
||
busy_message="Scraping automatique : analyse de la page (IA)…",
|
||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||
silent_on_failure=True,
|
||
)
|
||
|
||
def _auto_scrape_on_toc_checked(self, page_text: str, title: str, is_toc: bool) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
if is_toc:
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(
|
||
"↳ Table des matières détectée : rien à capturer ici — recherche du "
|
||
"premier chapitre plutôt que d'une suite."
|
||
)
|
||
self.webview.page().runJavaScript(
|
||
ALL_LINKS_FOR_AI_JS,
|
||
lambda links_json: self._auto_scrape_on_links(links_json, from_toc=True),
|
||
)
|
||
return
|
||
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log("Extraction du contenu principal (IA)…")
|
||
self._run_ai_task(
|
||
self.local_ai.extract_main_content,
|
||
args=(title, page_text),
|
||
on_success=self._auto_scrape_on_extracted,
|
||
busy_message=f"Scraping automatique : extraction IA du chapitre {self.auto_scrape_count + 1}…",
|
||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||
silent_on_failure=True,
|
||
)
|
||
|
||
def _auto_scrape_on_extracted(self, extracted_text: object) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
self._auto_scrape_last_clip = self._add_clip(str(extracted_text))
|
||
self.auto_scrape_count += 1
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.set_progress(self.auto_scrape_count, self.auto_scrape_max_chapters)
|
||
self.auto_scrape_dialog.log(f"Chapitre {self.auto_scrape_count} capturé et ajouté au PDF.")
|
||
|
||
# 0 = pas de limite fixe (détectée indéterminable, ou volontairement
|
||
# illimité) : on ne s'arrête alors que faute de suite trouvée.
|
||
if self.auto_scrape_max_chapters > 0 and self.auto_scrape_count >= self.auto_scrape_max_chapters:
|
||
self._stop_auto_scrape(
|
||
f"Scraping automatique terminé : limite de {self.auto_scrape_max_chapters} "
|
||
"chapitres atteinte."
|
||
)
|
||
return
|
||
|
||
self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._auto_scrape_on_links)
|
||
|
||
def _auto_scrape_on_links(self, links_json: str | None, from_toc: bool = False) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
try:
|
||
raw_links = json.loads(links_json) if links_json else []
|
||
except (TypeError, ValueError):
|
||
raw_links = []
|
||
if not raw_links:
|
||
self._stop_auto_scrape("Scraping automatique terminé : plus aucun lien sur la page.")
|
||
return
|
||
|
||
candidates = [
|
||
LinkCandidate(text=l["text"], href=l["href"], context=l.get("context", ""))
|
||
for l in raw_links
|
||
]
|
||
title = self.webview.title() or self.webview.url().toString()
|
||
if self.auto_scrape_dialog is not None:
|
||
target = "premier chapitre" if from_toc else "lien suivant"
|
||
self.auto_scrape_dialog.log(f"Recherche du {target} parmi {len(candidates)} lien(s) (IA)…")
|
||
# Réutilise le texte déjà extrait du chapitre qu'on vient de
|
||
# capturer (voir _auto_scrape_on_extracted) plutôt que de le
|
||
# relire : c'est le contenu réel affiché à l'écran, dont la fin
|
||
# sert d'indice pour juger si la page suivante continue le même
|
||
# chapitre (voir LocalAi.pick_next_link). Sans objet depuis une
|
||
# table des matières (pas de contenu narratif à juger).
|
||
current_content = (
|
||
"" if from_toc or self._auto_scrape_last_clip is None else self._auto_scrape_last_clip.text
|
||
)
|
||
self._run_ai_task(
|
||
self.local_ai.pick_next_link,
|
||
args=(candidates, title, current_content, from_toc),
|
||
on_success=self._auto_scrape_on_next_link_found,
|
||
busy_message="Scraping automatique : recherche du chapitre suivant (IA)…",
|
||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||
silent_on_failure=True,
|
||
)
|
||
|
||
def _auto_scrape_on_next_link_found(self, result: object) -> None:
|
||
if not self.auto_scrape_active:
|
||
return
|
||
result: NextLinkResult
|
||
if result.candidate is None:
|
||
self._stop_auto_scrape(
|
||
f"Scraping automatique terminé : {self.auto_scrape_count} chapitre(s) capturé(s), "
|
||
"aucune suite trouvée."
|
||
)
|
||
return
|
||
|
||
candidate = result.candidate
|
||
if candidate.href in self.auto_scrape_visited_urls:
|
||
self._stop_auto_scrape(
|
||
"Scraping automatique arrêté : le lien suivant pointe vers une page déjà visitée "
|
||
"(boucle détectée)."
|
||
)
|
||
return
|
||
|
||
# Chapitre réparti sur plusieurs pages : l'IA a jugé que la page
|
||
# suivante est la suite du MÊME chapitre plutôt qu'un nouveau —
|
||
# on relie automatiquement l'extrait qu'on vient de capturer (🔗
|
||
# colonne Lien) pour qu'ils forment une seule section à l'export,
|
||
# sans intervention manuelle. Voir models.group_into_chapters.
|
||
if result.same_chapter and self._auto_scrape_last_clip is not None:
|
||
self._auto_scrape_last_clip.linked_to_next = True
|
||
self._refresh_clip_list()
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(
|
||
"↳ Page suivante détectée comme suite du même chapitre — extraits liés "
|
||
"automatiquement (🔗)."
|
||
)
|
||
|
||
if self.auto_scrape_dialog is not None:
|
||
self.auto_scrape_dialog.log(f"Lien suivant : « {candidate.text} » → {candidate.href}")
|
||
|
||
def on_next_page_loaded(ok: bool) -> None:
|
||
self.webview.loadFinished.disconnect(on_next_page_loaded)
|
||
if not self.auto_scrape_active:
|
||
return
|
||
if not ok:
|
||
self._stop_auto_scrape("Scraping automatique arrêté : échec du chargement de la page suivante.")
|
||
return
|
||
# Laisse le temps au contenu chargé en JS de s'installer avant
|
||
# de relire la page (voir link_detector.py pour le même souci).
|
||
QTimer.singleShot(AUTO_SCRAPE_SETTLE_DELAY_MS, self._auto_scrape_extract_current_page)
|
||
|
||
self.webview.loadFinished.connect(on_next_page_loaded)
|
||
self.webview.setUrl(QUrl(candidate.href))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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
|