adding added temporary saves and saving mechanics, UI general clean up
This commit is contained in:
1 parent
931e3f0e36
commit
2427c3525e
5 files changed
+444
-66
No files matched your search
@@ -8,6 +8,19 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
- **Navigateur intégré** (moteur Chromium via QtWebEngine) : barre d'adresse,
|
||||
précédent / suivant / recharger, session persistante (cookies/connexions
|
||||
conservées entre deux lancements).
|
||||
- **Sauvegarde de projet** (*"📂 Ouvrir…"* / *"💾 Enregistrer"* / *"💾
|
||||
Enregistrer sous…"*, dans la barre d'outils) : un projet regroupe
|
||||
les extraits capturés, le style PDF et la page en cours, dans un
|
||||
fichier `.pdfscrap` (JSON). *Enregistrer* réutilise le fichier déjà
|
||||
nommé (ou propose *Enregistrer sous* la première fois) ; *Ouvrir*
|
||||
recharge un projet précédent (avec confirmation si des extraits non
|
||||
enregistrés seraient remplacés). Une **sauvegarde automatique** tourne
|
||||
aussi en tâche de fond, toutes les 60 secondes et à la fermeture de
|
||||
l'appli : tant qu'aucun fichier n'a été nommé explicitement, elle
|
||||
écrit dans un fichier de récupération par défaut, rechargé
|
||||
automatiquement au prochain lancement (comme la restauration de
|
||||
session d'un navigateur) — dès qu'un *Enregistrer sous* est fait, elle
|
||||
bascule sur ce fichier.
|
||||
- **Capture de texte (sélection manuelle)** : sélectionnez du texte dans la
|
||||
page puis cliquez sur *"➕ Ajouter la sélection au PDF"* (ou
|
||||
`Ctrl+Shift+A`). L'extrait est stocké avec le titre de la page, son URL et
|
||||
@@ -27,8 +40,7 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
Tant que le mode est actif (par l'une ou l'autre méthode), le clic normal
|
||||
(suivre les liens) est suspendu sur la page.
|
||||
- **Panneau latéral** : liste des extraits capturés (réorganisables,
|
||||
supprimables, **renommables**), export du PDF final quand vous le
|
||||
souhaitez. Si un extrait est sélectionné dans la liste, chaque nouvelle
|
||||
supprimables, **renommables**). Si un extrait est sélectionné dans la liste, chaque nouvelle
|
||||
capture s'insère juste à côté de lui — **au-dessus** ou **en dessous**
|
||||
selon la case à cocher *"Insérer les nouvelles captures au-dessus de la
|
||||
sélection"* (cochée par défaut = au-dessus) — et les captures suivantes
|
||||
@@ -53,7 +65,8 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
et onglet "Corriger le texte"). Deux extraits directement reliés ne
|
||||
peuvent pas être réordonnés (▲/▼) sans être déliés au préalable, pour
|
||||
éviter d'inverser silencieusement le sens du lien.
|
||||
- **Aperçu et style du PDF** (*"🖌 Aperçu et style du PDF…"*) : fenêtre
|
||||
- **Aperçu et style du PDF** (*"🖌 Aperçu et style du PDF…"*, barre
|
||||
d'outils) : fenêtre
|
||||
dédiée qui affiche un rendu en direct du PDF final (via QtPdf) pendant
|
||||
que vous ajustez les réglages, répartis en deux onglets :
|
||||
- **🎨 Style** : titre du document, format de page (A4/Letter), police
|
||||
@@ -104,14 +117,19 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
les liens de la page, celui qui mène le plus probablement à la suite —
|
||||
complète la détection heuristique quand celle-ci ne trouve rien
|
||||
(structures de page trop inhabituelles pour un motif texte).
|
||||
- **🔁 Scraping automatique (IA)** : combine les deux en boucle —
|
||||
- **🔁 Scraping auto (IA)** : combine les deux en boucle —
|
||||
extrait, capture, trouve et suit le lien suivant, répète — jusqu'à un
|
||||
nombre de chapitres maximum fixé au démarrage (garde-fou demandé à
|
||||
chaque lancement) ou jusqu'à ce qu'aucune suite ne soit trouvée ; une
|
||||
détection de boucle (retour vers une page déjà visitée) arrête aussi
|
||||
le processus automatiquement. Bouton "⏹ Arrêter" disponible à tout
|
||||
moment. L'inférence tourne sur un thread dédié pour ne jamais geler
|
||||
l'interface.
|
||||
le processus automatiquement. Le bouton devient "⏹ Arrêter le
|
||||
scraping" tant qu'il tourne. L'inférence tourne sur un thread dédié
|
||||
pour ne jamais geler l'interface.
|
||||
|
||||
Tous les boutons ci-dessus (projet, capture, aperçu, IA, export) sont
|
||||
regroupés dans la **barre d'outils** en haut de la fenêtre ; le panneau
|
||||
latéral ne garde que le tableau des extraits, ses actions de
|
||||
réorganisation, et les liens suggérés sur la page courante.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -180,11 +198,11 @@ pdfscrapper/
|
||||
ai_worker.py # exécution des appels IA sur un thread dédié (non bloquant)
|
||||
local_ai.py # inférence llama-cpp-python : extraction + choix du lien suivant
|
||||
models.py # dataclass Clip (un extrait capturé) + regroupement en chapitres
|
||||
project_io.py # sauvegarde/chargement de projet (.pdfscrap) + pointeur de session
|
||||
run.py # lance l'application
|
||||
```
|
||||
|
||||
## Pistes d'évolution
|
||||
|
||||
- Sauvegarde/chargement d'une session de capture en cours (JSON).
|
||||
- Réglage des motifs de détection de liens depuis l'interface.
|
||||
- Export additionnel en `.txt` / `.epub`.
|
||||
@@ -15,8 +15,10 @@ from dataclasses import dataclass
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
_ORG = "PdfScrapper"
|
||||
_APP = "PdfScrapper"
|
||||
# Publiques : réutilisées par project_io.py pour le pointeur de dernière
|
||||
# session (même mécanisme QSettings, un seul groupe d'organisation/appli).
|
||||
ORG_NAME = "PdfScrapper"
|
||||
APP_NAME = "PdfScrapper"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,7 +34,7 @@ class AiConfig:
|
||||
|
||||
|
||||
def load_ai_config() -> AiConfig:
|
||||
settings = QSettings(_ORG, _APP)
|
||||
settings = QSettings(ORG_NAME, APP_NAME)
|
||||
settings.beginGroup("local_ai")
|
||||
try:
|
||||
config = AiConfig(
|
||||
@@ -47,7 +49,7 @@ def load_ai_config() -> AiConfig:
|
||||
|
||||
|
||||
def save_ai_config(config: AiConfig) -> None:
|
||||
settings = QSettings(_ORG, _APP)
|
||||
settings = QSettings(ORG_NAME, APP_NAME)
|
||||
settings.beginGroup("local_ai")
|
||||
try:
|
||||
settings.setValue("model_path", config.model_path)
|
||||
|
||||
+275
-54
@@ -41,6 +41,15 @@ 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
|
||||
@@ -75,6 +84,7 @@ ALL_LINKS_FOR_AI_JS = """
|
||||
|
||||
AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20
|
||||
AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger
|
||||
AUTOSAVE_INTERVAL_MS = 60_000
|
||||
|
||||
HOME_URL = "https://www.google.com/"
|
||||
|
||||
@@ -107,19 +117,41 @@ class MainWindow(QMainWindow):
|
||||
self.auto_scrape_max_chapters = AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS
|
||||
self.auto_scrape_count = 0
|
||||
|
||||
# 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()
|
||||
|
||||
self.webview.setUrl(QUrl(HOME_URL))
|
||||
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 = Path(
|
||||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||||
) / "pdfscrapper"
|
||||
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
|
||||
@@ -193,6 +225,15 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
@@ -225,8 +266,43 @@ class MainWindow(QMainWindow):
|
||||
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)")
|
||||
@@ -246,6 +322,54 @@ class MainWindow(QMainWindow):
|
||||
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(
|
||||
@@ -256,6 +380,14 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
|
||||
@@ -294,44 +426,6 @@ class MainWindow(QMainWindow):
|
||||
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("Scraping assisté par IA locale"))
|
||||
|
||||
ai_settings_btn = QPushButton("⚙️ Configurer l'IA locale")
|
||||
ai_settings_btn.clicked.connect(self.open_ai_settings)
|
||||
layout.addWidget(ai_settings_btn)
|
||||
|
||||
ai_buttons = QHBoxLayout()
|
||||
self.ai_extract_btn = QPushButton("🤖 Extraire (IA)")
|
||||
self.ai_extract_btn.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_btn.clicked.connect(self.run_ai_extract)
|
||||
self.ai_next_link_btn = QPushButton("🤖 Lien suivant (IA)")
|
||||
self.ai_next_link_btn.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_btn.clicked.connect(self.run_ai_pick_next_link)
|
||||
ai_buttons.addWidget(self.ai_extract_btn)
|
||||
ai_buttons.addWidget(self.ai_next_link_btn)
|
||||
layout.addLayout(ai_buttons)
|
||||
|
||||
self.auto_scrape_btn = QPushButton("🔁 Démarrer le scraping automatique (IA)")
|
||||
self.auto_scrape_btn.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_btn.clicked.connect(self._toggle_auto_scrape)
|
||||
layout.addWidget(self.auto_scrape_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)
|
||||
@@ -342,10 +436,6 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
|
||||
@@ -581,6 +671,137 @@ class MainWindow(QMainWindow):
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -693,12 +914,12 @@ class MainWindow(QMainWindow):
|
||||
# ------------------------------------------------------------------ #
|
||||
def _run_ai_task(self, fn, args: tuple, on_success, busy_message: str) -> None:
|
||||
self.statusBar().showMessage(busy_message)
|
||||
self._set_ai_buttons_enabled(False)
|
||||
self._set_ai_actions_enabled(False)
|
||||
|
||||
self._ai_thread = AiTaskThread(fn, *args)
|
||||
self._ai_thread.succeeded.connect(lambda result: self._on_ai_task_succeeded(on_success, result))
|
||||
self._ai_thread.failed.connect(self._on_ai_task_failed)
|
||||
self._ai_thread.finished.connect(lambda: self._set_ai_buttons_enabled(True))
|
||||
self._ai_thread.finished.connect(lambda: self._set_ai_actions_enabled(True))
|
||||
self._ai_thread.start()
|
||||
|
||||
def _on_ai_task_succeeded(self, on_success, result: object) -> None:
|
||||
@@ -710,11 +931,11 @@ class MainWindow(QMainWindow):
|
||||
if self.auto_scrape_active:
|
||||
self._stop_auto_scrape(f"Scraping automatique arrêté : {message}")
|
||||
|
||||
def _set_ai_buttons_enabled(self, enabled: bool) -> None:
|
||||
self.ai_extract_btn.setEnabled(enabled)
|
||||
self.ai_next_link_btn.setEnabled(enabled)
|
||||
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_btn.setEnabled(True)
|
||||
self.auto_scrape_action.setEnabled(True)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — scraping automatique (boucle)
|
||||
@@ -742,13 +963,13 @@ class MainWindow(QMainWindow):
|
||||
self.auto_scrape_active = True
|
||||
self.auto_scrape_visited_urls = set()
|
||||
self.auto_scrape_count = 0
|
||||
self.auto_scrape_btn.setText("⏹ Arrêter le scraping automatique")
|
||||
self.auto_scrape_action.setText("⏹ Arrêter le scraping")
|
||||
self.statusBar().showMessage("Scraping automatique démarré…", 3000)
|
||||
self._auto_scrape_extract_current_page()
|
||||
|
||||
def _stop_auto_scrape(self, message: str) -> None:
|
||||
self.auto_scrape_active = False
|
||||
self.auto_scrape_btn.setText("🔁 Démarrer le scraping automatique (IA)")
|
||||
self.auto_scrape_action.setText("🔁 Scraping auto (IA)")
|
||||
self.statusBar().showMessage(message, 6000)
|
||||
|
||||
def _auto_scrape_extract_current_page(self) -> None:
|
||||
|
||||
@@ -96,6 +96,17 @@ class PdfPreviewWindow(QDialog):
|
||||
self._build_ui()
|
||||
self._load_style_into_controls()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Synchronisation après un changement externe (ex. chargement d'un
|
||||
# projet — voir MainWindow._apply_project_state) : self.style est
|
||||
# muté en place, mais les contrôles affichés ici ne le savent pas
|
||||
# tant qu'on ne les recharge pas explicitement.
|
||||
# ------------------------------------------------------------------ #
|
||||
def sync_from_external_change(self) -> None:
|
||||
self._load_style_into_controls()
|
||||
self._sync_editor_list(force=True)
|
||||
self.refresh_preview()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Construction de l'UI
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Sauvegarde / chargement de l'état complet de l'application (extraits +
|
||||
réglages de style + page en cours) dans un fichier "projet" JSON.
|
||||
|
||||
Utilisé aussi bien pour la sauvegarde manuelle (Enregistrer / Enregistrer
|
||||
sous / Ouvrir) que pour la sauvegarde automatique périodique et à la
|
||||
fermeture — voir main_window.py, qui est seul responsable de décider
|
||||
*quel* fichier cible (projet explicite ou fichier de récupération par
|
||||
défaut).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
from .ai_config import APP_NAME, ORG_NAME
|
||||
from .models import Clip
|
||||
from .pdf_style import PdfStyle
|
||||
|
||||
PROJECT_FILE_SUFFIX = ".pdfscrap"
|
||||
PROJECT_FILE_FILTER = "Projet PdfScrapper (*.pdfscrap)"
|
||||
|
||||
_FORMAT_VERSION = 1
|
||||
_SETTINGS_GROUP = "session"
|
||||
_SETTINGS_KEY_LAST_PATH = "last_path"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectState:
|
||||
"""Tout ce qui définit une session de capture : les extraits, le
|
||||
style d'export PDF, le réglage d'insertion, et la page en cours (pour
|
||||
reprendre la navigation là où elle s'était arrêtée)."""
|
||||
|
||||
clips: list[Clip]
|
||||
pdf_style: PdfStyle
|
||||
insert_above: bool
|
||||
current_url: str
|
||||
|
||||
|
||||
def save_project(path: str | Path, state: ProjectState) -> None:
|
||||
"""Écrit `state` en JSON dans `path`, en créant le dossier parent si
|
||||
besoin. Écriture atomique (fichier temporaire puis remplacement) pour
|
||||
ne jamais laisser un fichier à moitié écrit si l'appli est
|
||||
interrompue en plein autosave."""
|
||||
data = {
|
||||
"format_version": _FORMAT_VERSION,
|
||||
"insert_above": state.insert_above,
|
||||
"current_url": state.current_url,
|
||||
"pdf_style": asdict(state.pdf_style),
|
||||
"clips": [
|
||||
{
|
||||
"text": clip.text,
|
||||
"title": clip.title,
|
||||
"url": clip.url,
|
||||
"captured_at": clip.captured_at.isoformat(),
|
||||
"linked_to_next": clip.linked_to_next,
|
||||
}
|
||||
for clip in state.clips
|
||||
],
|
||||
}
|
||||
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(path.name + ".tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def load_project(path: str | Path) -> ProjectState:
|
||||
"""Reconstruit un `ProjectState` depuis un fichier écrit par
|
||||
save_project(). Tolérant aux champs manquants/inconnus (filtre les
|
||||
clés de pdf_style sur les champs actuels de PdfStyle, valeurs par
|
||||
défaut pour les champs de Clip absents) — robustesse si le format
|
||||
évolue dans une version future de l'appli."""
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
known_style_fields = {f.name for f in fields(PdfStyle)}
|
||||
style_data = {
|
||||
key: value
|
||||
for key, value in (data.get("pdf_style") or {}).items()
|
||||
if key in known_style_fields
|
||||
}
|
||||
pdf_style = PdfStyle(**style_data)
|
||||
|
||||
clips = [
|
||||
Clip(
|
||||
text=raw["text"],
|
||||
title=raw["title"],
|
||||
url=raw["url"],
|
||||
captured_at=datetime.fromisoformat(raw["captured_at"]),
|
||||
linked_to_next=raw.get("linked_to_next", False),
|
||||
)
|
||||
for raw in data.get("clips", [])
|
||||
]
|
||||
|
||||
return ProjectState(
|
||||
clips=clips,
|
||||
pdf_style=pdf_style,
|
||||
insert_above=data.get("insert_above", True),
|
||||
current_url=data.get("current_url", ""),
|
||||
)
|
||||
|
||||
|
||||
def load_last_session_path() -> Path | None:
|
||||
"""Chemin du dernier fichier actif (projet explicite ou fichier de
|
||||
récupération par défaut), pour la reprise automatique au démarrage."""
|
||||
settings = QSettings(ORG_NAME, APP_NAME)
|
||||
settings.beginGroup(_SETTINGS_GROUP)
|
||||
try:
|
||||
raw = settings.value(_SETTINGS_KEY_LAST_PATH, "", type=str)
|
||||
finally:
|
||||
settings.endGroup()
|
||||
return Path(raw) if raw else None
|
||||
|
||||
|
||||
def save_last_session_path(path: str | Path) -> None:
|
||||
settings = QSettings(ORG_NAME, APP_NAME)
|
||||
settings.beginGroup(_SETTINGS_GROUP)
|
||||
try:
|
||||
settings.setValue(_SETTINGS_KEY_LAST_PATH, str(path))
|
||||
finally:
|
||||
settings.endGroup()
|
||||
Reference in new issue
Block a user