adding added temporary saves and saving mechanics, UI general clean up

This commit is contained in:
ange committed 2026-08-12 14:04:25 +02:00
1 parent 931e3f0e36
commit 2427c3525e
5 files changed
+444 -66

No files matched your search

+26 -8
View File
@@ -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`.
+6 -4
View File
@@ -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
View File
@@ -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:
+11
View File
@@ -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
# ------------------------------------------------------------------ #
+126
View File
@@ -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()