From 2427c3525e2982257dcf30ca88b33fcf124126c1 Mon Sep 17 00:00:00 2001 From: Ange-Marie MAURIN Date: Wed, 12 Aug 2026 14:04:25 +0200 Subject: [PATCH] adding added temporary saves and saving mechanics, UI general clean up --- README.md | 34 +++- pdfscrapper/ai_config.py | 10 +- pdfscrapper/main_window.py | 329 ++++++++++++++++++++++++++++------ pdfscrapper/preview_window.py | 11 ++ pdfscrapper/project_io.py | 126 +++++++++++++ 5 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 pdfscrapper/project_io.py diff --git a/README.md b/README.md index 7b58124..12eaa15 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/pdfscrapper/ai_config.py b/pdfscrapper/ai_config.py index f6d7cb5..6f31b72 100644 --- a/pdfscrapper/ai_config.py +++ b/pdfscrapper/ai_config.py @@ -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) diff --git a/pdfscrapper/main_window.py b/pdfscrapper/main_window.py index 40a3cdc..f36265f 100644 --- a/pdfscrapper/main_window.py +++ b/pdfscrapper/main_window.py @@ -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: diff --git a/pdfscrapper/preview_window.py b/pdfscrapper/preview_window.py index c05cf08..98e47e3 100644 --- a/pdfscrapper/preview_window.py +++ b/pdfscrapper/preview_window.py @@ -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 # ------------------------------------------------------------------ # diff --git a/pdfscrapper/project_io.py b/pdfscrapper/project_io.py new file mode 100644 index 0000000..7420ab5 --- /dev/null +++ b/pdfscrapper/project_io.py @@ -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()