Compare commits
10 Commits
ea214db170
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 012445755a | |||
| 4b825976bd | |||
| ab5a24fa68 | |||
| 6586b89b8f | |||
| 234137ec50 | |||
| 003be68ca8 | |||
| 8e43d8d1ae | |||
| f17438c2ec | |||
| 0a377bc001 | |||
| e2e2a7c8e3 |
7
.gitignore
vendored
@@ -40,6 +40,13 @@ models/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
# Exception : assets embarqués dans l'exe (splash, icônes…) doivent être versionnés
|
||||
!assets/**
|
||||
!assets
|
||||
|
||||
# build_info.py : régénéré automatiquement par scripts/rebuild_anon.ps1
|
||||
# avec date/commit/branch. Ne pas versionner.
|
||||
build_info.py
|
||||
*.mp3
|
||||
*.wav
|
||||
*.mp4
|
||||
|
||||
@@ -83,11 +83,45 @@ try:
|
||||
except ImportError:
|
||||
sv_ttk = None
|
||||
|
||||
# PIL pour charger le logo / icônes (optionnel — dégradation si absent).
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
_PIL_AVAILABLE = True
|
||||
except Exception:
|
||||
_PIL_AVAILABLE = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constantes
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_TITLE = "Pseudonymisation de vos documents"
|
||||
APP_VERSION = "v5.4"
|
||||
APP_VERSION = "v5.5"
|
||||
|
||||
# Métadonnées de build — chargées depuis build_info.py (régénéré par rebuild_anon.ps1)
|
||||
try:
|
||||
from build_info import BUILD_DATE, BUILD_COMMIT, BUILD_BRANCH
|
||||
except Exception:
|
||||
BUILD_DATE = "dev"
|
||||
BUILD_COMMIT = "dev"
|
||||
BUILD_BRANCH = "dev"
|
||||
|
||||
|
||||
def _version_long() -> str:
|
||||
"""Version étendue : v5.4 · 2026-04-15 18:15 · 234137e"""
|
||||
parts = [APP_VERSION]
|
||||
if BUILD_DATE != "dev":
|
||||
parts.append(BUILD_DATE)
|
||||
if BUILD_COMMIT != "dev":
|
||||
parts.append(f"#{BUILD_COMMIT}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _asset(name: str) -> Path:
|
||||
"""Résout le chemin d'un asset dans assets/ (compatible frozen PyInstaller)."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
base = Path(sys._MEIPASS)
|
||||
else:
|
||||
base = Path(__file__).resolve().parent
|
||||
return base / 'assets' / name
|
||||
|
||||
def _app_dir() -> Path:
|
||||
"""Répertoire racine de l'application (compatible PyInstaller/Nuitka)."""
|
||||
@@ -150,19 +184,27 @@ flags:
|
||||
regex_engine: "python"
|
||||
"""
|
||||
|
||||
# Couleurs
|
||||
CLR_PRIMARY = "#2563eb"
|
||||
CLR_PRIMARY_LIGHT = "#dbeafe"
|
||||
CLR_GREEN = "#16a34a"
|
||||
CLR_GREEN_LIGHT = "#dcfce7"
|
||||
CLR_RED = "#dc2626"
|
||||
CLR_RED_LIGHT = "#fee2e2"
|
||||
CLR_BLUE_LIGHT = "#eff6ff"
|
||||
CLR_CARD_BG = "#ffffff"
|
||||
CLR_CARD_BORDER = "#d1d5db"
|
||||
CLR_BG = "#f9fafb"
|
||||
CLR_TEXT = "#111827"
|
||||
CLR_TEXT_SECONDARY = "#6b7280"
|
||||
# Palette dérivée du logo aivanonym (gradient magenta → rose → pêche → noir)
|
||||
# Magenta du logo : primaire (boutons, accents)
|
||||
# Pêche : secondaire (tags, highlights)
|
||||
# Noir/gris : texte et neutres
|
||||
# Blanc/gris clair : fonds
|
||||
CLR_PRIMARY = "#E91E63" # magenta logo (CTA, liens)
|
||||
CLR_PRIMARY_DARK = "#C2185B" # hover / pressed
|
||||
CLR_PRIMARY_LIGHT = "#FCE4EC" # fond léger (cartes sélectionnées)
|
||||
CLR_ACCENT = "#FFB74D" # pêche logo (tags secondaires)
|
||||
CLR_ACCENT_LIGHT = "#FFF3E0" # fond accent léger
|
||||
CLR_GREEN = "#2E7D32" # succès
|
||||
CLR_GREEN_LIGHT = "#E8F5E9"
|
||||
CLR_RED = "#C62828" # erreur / danger
|
||||
CLR_RED_LIGHT = "#FFEBEE"
|
||||
CLR_BLUE_LIGHT = "#FCE4EC" # conservé pour compat (remappé vers primary_light)
|
||||
CLR_CARD_BG = "#FFFFFF"
|
||||
CLR_CARD_BORDER = "#E0E0E0"
|
||||
CLR_BG = "#FAFAFA" # fond principal (gris très clair)
|
||||
CLR_TEXT = "#212121" # quasi-noir (du logo)
|
||||
CLR_TEXT_SECONDARY = "#757575" # gris moyen
|
||||
CLR_DIVIDER = "#EEEEEE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages worker → UI
|
||||
@@ -283,10 +325,22 @@ class ToolTip:
|
||||
class App:
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.root.title(APP_TITLE)
|
||||
# Titre avec version longue pour identifier la build au premier coup d'œil
|
||||
# (évite les confusions entre exe ancien/nouveau lors des tests).
|
||||
self.root.title(f"{APP_TITLE} — {_version_long()}")
|
||||
self.root.geometry("780x820")
|
||||
self.root.minsize(600, 650)
|
||||
|
||||
# Icône de la fenêtre (coin haut-gauche + taskbar Windows).
|
||||
# En mode dev (Linux) tkinter lit iconphoto PNG ; sur Windows, iconbitmap
|
||||
# accepte .ico. On tente les deux pour couvrir.
|
||||
self._icon_refs: list = [] # refs pour éviter garbage collection
|
||||
self._apply_window_icon()
|
||||
|
||||
# Préchargement logo pour l'en-tête (besoin de ref persistante sinon
|
||||
# tkinter nettoie l'image → label blanc).
|
||||
self._logo_img = self._load_image_safe(_asset('logo_header.png'))
|
||||
|
||||
# --- Thème ---
|
||||
self._apply_theme()
|
||||
|
||||
@@ -340,6 +394,8 @@ class App:
|
||||
|
||||
# --- Construction UI ---
|
||||
self._build_ui()
|
||||
# Afficher l'onglet Anonymisation par défaut
|
||||
self._switch_tab("anonym")
|
||||
self._pump_logs()
|
||||
self._ensure_cfg_exists()
|
||||
self._load_cfg()
|
||||
@@ -347,6 +403,63 @@ class App:
|
||||
# --- Chargement automatique du modèle NER ---
|
||||
self._auto_load_ner()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Onglets custom
|
||||
# ---------------------------------------------------------------
|
||||
def _switch_tab(self, name: str):
|
||||
"""Affiche l'onglet nommé, met à jour les styles des boutons."""
|
||||
if name not in self._tab_frames:
|
||||
return
|
||||
# Cacher tous les contenus
|
||||
for frame in self._tab_frames.values():
|
||||
frame.pack_forget()
|
||||
# Afficher l'onglet demandé
|
||||
self._tab_frames[name].pack(fill=tk.BOTH, expand=True)
|
||||
# Mettre à jour les styles des boutons d'onglets
|
||||
for tab_name, widgets in self._tab_buttons.items():
|
||||
if tab_name == name:
|
||||
widgets["label"].configure(fg=CLR_PRIMARY, bg=CLR_BG)
|
||||
widgets["underline"].configure(bg=CLR_PRIMARY)
|
||||
else:
|
||||
widgets["label"].configure(fg=CLR_TEXT_SECONDARY, bg=CLR_BG)
|
||||
widgets["underline"].configure(bg=CLR_BG)
|
||||
self._active_tab = name
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Icônes & assets
|
||||
# ---------------------------------------------------------------
|
||||
def _apply_window_icon(self):
|
||||
"""Définit l'icône de la fenêtre. Windows : .ico préférable ; Linux : PNG."""
|
||||
try:
|
||||
ico = _asset('icons/app.ico')
|
||||
if sys.platform == 'win32' and ico.exists():
|
||||
try:
|
||||
self.root.iconbitmap(str(ico))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback : iconphoto PNG (toutes plateformes)
|
||||
png = _asset('icons/icon_128.png')
|
||||
if png.exists() and _PIL_AVAILABLE:
|
||||
img = Image.open(png)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self._icon_refs.append(photo)
|
||||
self.root.iconphoto(True, photo)
|
||||
except Exception:
|
||||
pass # dégradation silencieuse — l'icône n'est pas bloquante
|
||||
|
||||
def _load_image_safe(self, path: Path):
|
||||
"""Charge une image et garde la ref pour éviter le GC. None si PIL absent."""
|
||||
if not _PIL_AVAILABLE or not path.exists():
|
||||
return None
|
||||
try:
|
||||
img = Image.open(path).convert('RGBA')
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self._icon_refs.append(photo)
|
||||
return photo
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Thème
|
||||
# ---------------------------------------------------------------
|
||||
@@ -366,15 +479,89 @@ class App:
|
||||
# ---------------------------------------------------------------
|
||||
def _build_ui(self):
|
||||
self.root.configure(bg=CLR_BG)
|
||||
pad_x = 32
|
||||
|
||||
# Conteneur scrollable
|
||||
outer = tk.Frame(self.root, bg=CLR_BG)
|
||||
outer.pack(fill=tk.BOTH, expand=True)
|
||||
# =============================================================
|
||||
# HEADER fixe (logo + titre + baseline), hors onglets
|
||||
# =============================================================
|
||||
header = tk.Frame(self.root, bg=CLR_BG)
|
||||
header.pack(fill=tk.X, padx=pad_x, pady=(16, 8))
|
||||
|
||||
canvas = tk.Canvas(outer, bg=CLR_BG, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
|
||||
if self._logo_img is not None:
|
||||
tk.Label(header, image=self._logo_img, bg=CLR_BG).pack(anchor="w")
|
||||
else:
|
||||
tk.Label(header, text="aivanonym", font=(self._font_family, 22, "bold"),
|
||||
bg=CLR_BG, fg=CLR_PRIMARY).pack(anchor="w")
|
||||
|
||||
tk.Label(
|
||||
header,
|
||||
text="Pseudonymisation de documents médicaux — 100% local",
|
||||
font=(self._font_family, 10),
|
||||
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
||||
).pack(fill=tk.X, pady=(4, 0))
|
||||
|
||||
# Ligne colorée inspirée du gradient du logo
|
||||
accent_bar = tk.Frame(self.root, bg=CLR_PRIMARY, height=3)
|
||||
accent_bar.pack(fill=tk.X)
|
||||
|
||||
# =============================================================
|
||||
# ONGLETS CUSTOM (boutons uniformes — rendu pro)
|
||||
# Remplace ttk.Notebook dont les onglets ont des tailles/styles
|
||||
# variables selon l'état actif. Ici : tous les onglets identiques,
|
||||
# seule une bordure basse magenta signale l'onglet actif.
|
||||
# =============================================================
|
||||
tabs_bar = tk.Frame(self.root, bg=CLR_BG)
|
||||
tabs_bar.pack(fill=tk.X, padx=0, pady=(4, 0))
|
||||
|
||||
self._tab_frames: dict = {} # nom → frame outer
|
||||
self._tab_buttons: dict = {} # nom → dict(container, label, underline)
|
||||
self._active_tab: Optional[str] = None
|
||||
|
||||
def _make_tab_button(parent, name: str, label: str):
|
||||
"""Crée un onglet cliquable uniforme (fond, texte, underline)."""
|
||||
container = tk.Frame(parent, bg=CLR_BG, cursor="hand2")
|
||||
container.pack(side=tk.LEFT)
|
||||
|
||||
txt = tk.Label(
|
||||
container, text=label,
|
||||
font=(self._font_family, 11, "bold"),
|
||||
bg=CLR_BG, fg=CLR_TEXT_SECONDARY,
|
||||
padx=26, pady=10, cursor="hand2",
|
||||
)
|
||||
txt.pack(fill=tk.X)
|
||||
|
||||
# Bordure basse qui devient magenta quand actif
|
||||
underline = tk.Frame(container, bg=CLR_BG, height=3)
|
||||
underline.pack(fill=tk.X)
|
||||
|
||||
def _on_click(_e=None):
|
||||
self._switch_tab(name)
|
||||
for w in (container, txt, underline):
|
||||
w.bind("<Button-1>", _on_click)
|
||||
|
||||
self._tab_buttons[name] = {
|
||||
"container": container, "label": txt, "underline": underline,
|
||||
}
|
||||
|
||||
_make_tab_button(tabs_bar, "anonym", "Anonymisation")
|
||||
_make_tab_button(tabs_bar, "params", "Paramètres")
|
||||
|
||||
# Séparateur gris clair sous les onglets
|
||||
tk.Frame(self.root, bg=CLR_DIVIDER, height=1).pack(fill=tk.X)
|
||||
|
||||
# Conteneur des contenus (un seul visible à la fois)
|
||||
tabs_content = tk.Frame(self.root, bg=CLR_BG)
|
||||
tabs_content.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
tab_anonym_outer = tk.Frame(tabs_content, bg=CLR_BG)
|
||||
tab_params_outer = tk.Frame(tabs_content, bg=CLR_BG)
|
||||
self._tab_frames["anonym"] = tab_anonym_outer
|
||||
self._tab_frames["params"] = tab_params_outer
|
||||
|
||||
# --- Scroll pour l'onglet Anonymisation ---
|
||||
canvas = tk.Canvas(tab_anonym_outer, bg=CLR_BG, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(tab_anonym_outer, orient=tk.VERTICAL, command=canvas.yview)
|
||||
self._scroll_frame = tk.Frame(canvas, bg=CLR_BG)
|
||||
|
||||
self._scroll_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
||||
@@ -382,12 +569,10 @@ class App:
|
||||
canvas_window = canvas.create_window((0, 0), window=self._scroll_frame, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Ajuster la largeur du frame interne à celle du canvas
|
||||
def _on_canvas_configure(event):
|
||||
canvas.itemconfig(canvas_window, width=event.width)
|
||||
canvas.bind("<Configure>", _on_canvas_configure)
|
||||
|
||||
# Scroll molette
|
||||
def _on_mousewheel(event):
|
||||
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
def _on_mousewheel_linux(event):
|
||||
@@ -395,30 +580,32 @@ class App:
|
||||
canvas.yview_scroll(-3, "units")
|
||||
elif event.num == 5:
|
||||
canvas.yview_scroll(3, "units")
|
||||
|
||||
canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
||||
canvas.bind_all("<Button-4>", _on_mousewheel_linux)
|
||||
canvas.bind_all("<Button-5>", _on_mousewheel_linux)
|
||||
|
||||
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# --- Scroll pour l'onglet Paramètres ---
|
||||
canvas2 = tk.Canvas(tab_params_outer, bg=CLR_BG, highlightthickness=0)
|
||||
scrollbar2 = ttk.Scrollbar(tab_params_outer, orient=tk.VERTICAL, command=canvas2.yview)
|
||||
self._params_scroll = tk.Frame(canvas2, bg=CLR_BG)
|
||||
self._params_scroll.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas2.configure(scrollregion=canvas2.bbox("all")),
|
||||
)
|
||||
canvas2_window = canvas2.create_window((0, 0), window=self._params_scroll, anchor="nw")
|
||||
canvas2.configure(yscrollcommand=scrollbar2.set)
|
||||
def _on_canvas2_configure(event):
|
||||
canvas2.itemconfig(canvas2_window, width=event.width)
|
||||
canvas2.bind("<Configure>", _on_canvas2_configure)
|
||||
canvas2.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar2.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# "main" pointe désormais sur le scroll de l'onglet Anonymisation.
|
||||
# Tout le contenu existant (étape 1, formats, boutons, progress, résultats)
|
||||
# reste inchangé — seul le parent implicite a changé.
|
||||
main = self._scroll_frame
|
||||
pad_x = 32
|
||||
|
||||
# --- Titre ---
|
||||
tk.Label(
|
||||
main, text=APP_TITLE, font=self._f_title,
|
||||
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(24, 2))
|
||||
|
||||
tk.Label(
|
||||
main,
|
||||
text="Masquez automatiquement les données personnelles de vos documents.",
|
||||
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
|
||||
|
||||
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
|
||||
|
||||
# =============================================================
|
||||
# ÉTAPE 1 — Choix du dossier
|
||||
@@ -538,70 +725,71 @@ class App:
|
||||
help_lbl.bind("<Button-1>", lambda e: self._show_help())
|
||||
|
||||
# =============================================================
|
||||
# SECTION PARAMÈTRES (repliable)
|
||||
# ONGLET "PARAMÈTRES" — contenu monté dans self._params_scroll
|
||||
# =============================================================
|
||||
self._params_visible = False
|
||||
params_toggle = tk.Label(
|
||||
main, text="\u2699 Paramètres avancés \u25B6", font=self._f_small,
|
||||
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
|
||||
)
|
||||
params_toggle.pack(pady=(0, 4), padx=pad_x, anchor="w")
|
||||
self._params_frame = self._params_scroll
|
||||
|
||||
self._params_frame = tk.Frame(main, bg=CLR_BG)
|
||||
# NE PAS pack — déplié à la demande
|
||||
tk.Label(
|
||||
self._params_frame,
|
||||
text="Personnaliser le masquage",
|
||||
font=(self._font_family, 14, "bold"),
|
||||
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(20, 4))
|
||||
|
||||
def _toggle_params(event=None):
|
||||
if self._params_visible:
|
||||
self._params_frame.pack_forget()
|
||||
params_toggle.configure(text="\u2699 Paramètres avancés \u25B6")
|
||||
else:
|
||||
self._params_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
|
||||
params_toggle.configure(text="\u2699 Paramètres avancés \u25BC")
|
||||
self._params_visible = not self._params_visible
|
||||
params_toggle.bind("<Button-1>", _toggle_params)
|
||||
tk.Label(
|
||||
self._params_frame,
|
||||
text=("Ces listes complètent les détections automatiques du programme. "
|
||||
"Utile pour gérer les spécificités de votre établissement."),
|
||||
font=self._f_small,
|
||||
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w", justify=tk.LEFT, wraplength=700,
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 16))
|
||||
|
||||
# Conteneur interne avec padding latéral pour les listboxes
|
||||
params_inner = tk.Frame(self._params_frame, bg=CLR_BG)
|
||||
params_inner.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
|
||||
|
||||
# --- Whitelist (phrases à ne pas anonymiser) ---
|
||||
self._wl_listbox, self._wl_entry = self._build_phrase_list(
|
||||
self._params_frame,
|
||||
params_inner,
|
||||
title="\u2705 Phrases à ne PAS anonymiser :",
|
||||
placeholder="Ajouter une phrase à protéger...",
|
||||
color_tag="#e8f5e9",
|
||||
color_tag=CLR_GREEN_LIGHT,
|
||||
)
|
||||
|
||||
# --- Blacklist (phrases à toujours masquer) ---
|
||||
self._bl_listbox, self._bl_entry = self._build_phrase_list(
|
||||
self._params_frame,
|
||||
params_inner,
|
||||
title="\u26d4 Mots/phrases à TOUJOURS masquer :",
|
||||
placeholder="Ajouter un mot ou phrase à masquer...",
|
||||
color_tag="#fce4ec",
|
||||
color_tag=CLR_PRIMARY_LIGHT,
|
||||
)
|
||||
|
||||
# --- Stop-words additionnels (mots à ne jamais identifier comme noms) ---
|
||||
# Différent de la whitelist : agit en amont, pour les sigles, acronymes,
|
||||
# termes métier locaux qui ressemblent à des noms mais n'en sont pas.
|
||||
self._sw_listbox, self._sw_entry = self._build_phrase_list(
|
||||
self._params_frame,
|
||||
params_inner,
|
||||
title="\u26a0 Mots à ne jamais identifier comme noms (sigles, acronymes...) :",
|
||||
placeholder="Ajouter un mot (ex: sigle local, acronyme métier)...",
|
||||
color_tag="#fff8e1",
|
||||
color_tag=CLR_ACCENT_LIGHT,
|
||||
)
|
||||
|
||||
# Boutons sauvegarder + exporter
|
||||
btn_row = tk.Frame(self._params_frame, bg=CLR_BG)
|
||||
btn_row.pack(fill=tk.X, pady=(4, 4))
|
||||
btn_row = tk.Frame(params_inner, bg=CLR_BG)
|
||||
btn_row.pack(fill=tk.X, pady=(12, 12))
|
||||
|
||||
export_btn = tk.Button(
|
||||
btn_row, text="\u2709 Exporter pour envoi",
|
||||
font=self._f_small, bg="#e3f2fd", fg="#1565c0",
|
||||
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
|
||||
font=self._f_small, bg=CLR_ACCENT_LIGHT, fg=CLR_TEXT,
|
||||
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
|
||||
command=self._export_params,
|
||||
)
|
||||
export_btn.pack(side=tk.LEFT)
|
||||
|
||||
import_btn = tk.Button(
|
||||
btn_row, text="\u2B07 Importer",
|
||||
font=self._f_small, bg="#fff3e0", fg="#e65100",
|
||||
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
|
||||
font=self._f_small, bg=CLR_PRIMARY_LIGHT, fg=CLR_TEXT,
|
||||
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
|
||||
command=self._import_params,
|
||||
)
|
||||
import_btn.pack(side=tk.LEFT, padx=(4, 0))
|
||||
@@ -609,8 +797,8 @@ class App:
|
||||
save_btn = tk.Button(
|
||||
btn_row, text="Sauvegarder",
|
||||
font=self._f_small, bg=CLR_PRIMARY, fg="white",
|
||||
activebackground="#1d4ed8", activeforeground="white",
|
||||
relief=tk.FLAT, cursor="hand2", padx=12, pady=4,
|
||||
activebackground=CLR_PRIMARY_DARK, activeforeground="white",
|
||||
relief=tk.FLAT, cursor="hand2", padx=14, pady=6,
|
||||
command=self._save_params,
|
||||
)
|
||||
save_btn.pack(side=tk.RIGHT)
|
||||
@@ -618,6 +806,7 @@ class App:
|
||||
# Charger les valeurs initiales depuis la config
|
||||
self._load_params()
|
||||
|
||||
# Retour dans l'onglet Anonymisation
|
||||
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 8))
|
||||
|
||||
# =============================================================
|
||||
@@ -726,7 +915,7 @@ class App:
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
tk.Label(
|
||||
status_bar, text=APP_VERSION, font=self._f_small,
|
||||
status_bar, text=_version_long(), font=self._f_small,
|
||||
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="e",
|
||||
).pack(side=tk.RIGHT)
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ datas = [
|
||||
(os.path.join(app_dir, 'models', 'camembert-bio-deid', 'onnx'), os.path.join('models', 'camembert-bio-deid', 'onnx')),
|
||||
(os.path.join(app_dir, 'detectors'), 'detectors'),
|
||||
(os.path.join(app_dir, 'scripts'), 'scripts'),
|
||||
# Assets UI : logo (header + splash), icônes fenêtre, splash image.
|
||||
# Le launcher et la GUI y accèdent via _asset(name) qui résout sous
|
||||
# sys._MEIPASS/assets en mode frozen.
|
||||
(os.path.join(app_dir, 'assets'), 'assets'),
|
||||
]
|
||||
# Fichiers directs dans data/ — IMPÉRATIF pour fonctionnement correct du core.
|
||||
# Sans eux : stop-words/villes/DPI labels/companion blacklist sont des sets vides,
|
||||
@@ -25,7 +29,7 @@ for data_file in [
|
||||
datas.append((src, 'data'))
|
||||
for pyfile in ['anonymizer_core_refactored_onnx.py', 'eds_pseudo_manager.py',
|
||||
'gliner_manager.py', 'camembert_ner_manager.py',
|
||||
'Pseudonymisation_Gui_V5.py']:
|
||||
'Pseudonymisation_Gui_V5.py', 'build_info.py']:
|
||||
datas.append((os.path.join(app_dir, pyfile), '.'))
|
||||
|
||||
a = Analysis(
|
||||
@@ -40,17 +44,47 @@ a = Analysis(
|
||||
'transformers', 'tokenizers', 'torch', 'pdfplumber',
|
||||
'ahocorasick', 'sklearn', 'scipy', 'pydantic', 'yaml', 'PIL',
|
||||
'loguru', 'regex',
|
||||
# optimum : utilisé par ner_manager_onnx.py (fallback NER legacy).
|
||||
# Sans ça, la GUI affiche "NER indisponible : optimum.onnxruntime introuvable"
|
||||
# si EDS-Pseudo échoue. Le pipeline principal (CamemBERT-bio ONNX +
|
||||
# EDS-Pseudo + GLiNER) n'en dépend pas — mais l'absence du hiddenimport
|
||||
# crée un message d'erreur cosmétique gênant.
|
||||
'optimum', 'optimum.onnxruntime', 'optimum.pipelines',
|
||||
'optimum.modeling_base', 'optimum.exporters.onnx',
|
||||
],
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
# Splash natif PyInstaller : image affichée AU LANCEMENT DE L'EXE,
|
||||
# avant même que Python démarre. Couvre les ~15-30 s de décompression
|
||||
# du bundle --onefile dans %TEMP% qui laissaient l'écran vide auparavant.
|
||||
# Le launcher ferme le splash via pyi_splash.close() une fois la GUI prête.
|
||||
splash = Splash(
|
||||
os.path.join(app_dir, 'assets', 'splash.png'),
|
||||
binaries=a.binaries,
|
||||
datas=a.datas,
|
||||
# Texte dynamique PyInstaller positionné dans la zone libre du PNG
|
||||
# (y=170-235). text_pos correspond au coin haut-gauche du texte.
|
||||
text_pos=(60, 195),
|
||||
text_size=10,
|
||||
text_color='white',
|
||||
minify_script=True,
|
||||
always_on_top=False,
|
||||
)
|
||||
|
||||
exe = EXE(
|
||||
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
|
||||
pyz, a.scripts,
|
||||
splash, # image affichée immédiatement
|
||||
splash.binaries, # bootloader splash
|
||||
a.binaries, a.zipfiles, a.datas, [],
|
||||
name='Anonymisation',
|
||||
debug=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=False,
|
||||
icon=None,
|
||||
# Icône du fichier .exe visible dans l'Explorateur Windows et la taskbar
|
||||
# (dérivée du logo aivanonym, multi-résolution 16→256 dans le .ico).
|
||||
icon=os.path.join(app_dir, 'assets', 'icons', 'app.ico'),
|
||||
)
|
||||
|
||||
@@ -1903,9 +1903,13 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit], set,
|
||||
force_names: set = set() # noms issus de contextes structurés (DR., Signé, etc.) → bypass stop words
|
||||
|
||||
def _add_candidate(token: str, source: str, strength: str, bypass: bool):
|
||||
"""Ajoute un NameCandidate à la liste."""
|
||||
"""Ajoute un NameCandidate à la liste.
|
||||
Accepte les prénoms courts 3 chars (Ute, Eva, Léo…) si bypass=True
|
||||
(contexte Dr/Mme fort) ET majuscule initiale + alpha pur."""
|
||||
token = token.strip(" .-'(),")
|
||||
if len(token) < 4:
|
||||
if len(token) < 3:
|
||||
return
|
||||
if len(token) == 3 and not (bypass and token[0].isupper() and token.isalpha()):
|
||||
return
|
||||
candidates.append(NameCandidate(
|
||||
token=token, source=source,
|
||||
@@ -2220,9 +2224,13 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
|
||||
candidates: List[NameCandidate] = []
|
||||
|
||||
def _add_candidate(token: str, source: str, strength: str, bypass: bool):
|
||||
"""Ajoute un NameCandidate à la liste (dédupliqué par token+source)."""
|
||||
"""Ajoute un NameCandidate à la liste (dédupliqué par token+source).
|
||||
Accepte les prénoms courts 3 chars (Ute, Eva, Léo…) si bypass=True
|
||||
(contexte Dr/Mme fort) ET majuscule initiale + alpha pur."""
|
||||
token = token.strip(" .-'")
|
||||
if len(token) < 4:
|
||||
if len(token) < 3:
|
||||
return
|
||||
if len(token) == 3 and not (bypass and token[0].isupper() and token.isalpha()):
|
||||
return
|
||||
candidates.append(NameCandidate(
|
||||
token=token, source=source,
|
||||
@@ -2270,12 +2278,21 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
|
||||
|
||||
Après Dr/Mme, tous les tokens sont des noms — même s'ils sont
|
||||
homonymes de termes médicaux (ex: Dr Laurence MASSE).
|
||||
|
||||
Accepte les prénoms courts 3 chars (Dr Ute, Dr Eva, Dr Léo) : le
|
||||
contexte Dr/Mme est suffisamment fort pour lever le filtre de
|
||||
longueur, à condition que le token soit alpha et commence par
|
||||
une majuscule. 2 chars reste filtré (trop ambigu : initiales).
|
||||
"""
|
||||
_add_compound(match_str)
|
||||
tokens = match_str.split()
|
||||
for token in tokens:
|
||||
token = token.strip(" .-'")
|
||||
if len(token) < 4:
|
||||
if len(token) < 3:
|
||||
continue
|
||||
# 3 chars : accepter uniquement si majuscule initiale + alpha
|
||||
# (évite "Les", "Des" mais accepte "Ute", "Eva").
|
||||
if len(token) == 3 and not (token[0].isupper() and token.isalpha()):
|
||||
continue
|
||||
if token.upper() in wl_sections or token in wl_phrases:
|
||||
continue
|
||||
@@ -3929,15 +3946,42 @@ def _search_ocr_words(ocr_words: List[Tuple[str, float, float, float, float]], t
|
||||
def _search_whole_word(page, token: str) -> list:
|
||||
"""Cherche un token comme mot entier (pas substring) via get_text('words').
|
||||
Évite les faux positifs de page.search_for() qui fait du substring matching.
|
||||
Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF."""
|
||||
Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF.
|
||||
Gère aussi les tokens collés à de la ponctuation sans espace : "Douar,nécessitant"
|
||||
(passe 2 avec regex word-boundary + bbox proportionnelle)."""
|
||||
rects = []
|
||||
token_lower = token.lower().strip()
|
||||
words = page.get_text("words")
|
||||
# Passe 1 : comparaison stricte après strip des ponctuations frontières.
|
||||
# Couvre la majorité des cas normaux (mots bien séparés).
|
||||
for w in words:
|
||||
# w = (x0, y0, x1, y1, word, block_no, line_no, word_no)
|
||||
word_text = w[4].strip(".,;:!?()[]{}\"'«»-–—/\\")
|
||||
if word_text.lower() == token_lower:
|
||||
rects.append(fitz.Rect(w[0], w[1], w[2], w[3]))
|
||||
# Passe 2 : token collé à d'autres mots via ponctuation interne.
|
||||
# Ex: "Douar,nécessitant" où "Douar" doit être masqué. Le strip ci-dessus
|
||||
# ne marche pas (la virgule est au milieu). Utiliser regex word-boundary
|
||||
# sur le texte complet du word, calculer bbox proportionnelle.
|
||||
if not rects and len(token_lower) >= 3:
|
||||
pattern = re.compile(
|
||||
r"(?<![A-Za-zÀ-ÿ])" + re.escape(token_lower) + r"(?![A-Za-zÀ-ÿ])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for w in words:
|
||||
word_text = w[4]
|
||||
if len(word_text) < len(token_lower):
|
||||
continue
|
||||
for m in pattern.finditer(word_text):
|
||||
# Bbox proportionnelle : approximation pour polices proportionnelles,
|
||||
# exacte pour chasses fixes. Marge de quelques pixels couverte par
|
||||
# le rectangle de redaction.
|
||||
wlen = len(word_text)
|
||||
start_ratio = m.start() / wlen
|
||||
end_ratio = m.end() / wlen
|
||||
x0 = w[0] + (w[2] - w[0]) * start_ratio
|
||||
x1 = w[0] + (w[2] - w[0]) * end_ratio
|
||||
rects.append(fitz.Rect(x0, w[1], x1, w[3]))
|
||||
# Fallback pour noms composés avec tiret (JEAN-PIERRE) splittés par le PDF
|
||||
if not rects and "-" in token:
|
||||
parts = [p for p in token.split("-") if p]
|
||||
|
||||
BIN
assets/icons/app.ico
Normal file
|
After Width: | Height: | Size: 270 B |
BIN
assets/icons/icon_128.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/icons/icon_16.png
Normal file
|
After Width: | Height: | Size: 248 B |
BIN
assets/icons/icon_256.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/icons/icon_32.png
Normal file
|
After Width: | Height: | Size: 559 B |
BIN
assets/icons/icon_48.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/icon_512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/icons/icon_64.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/logo.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/logo_header.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/logo_splash.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/splash.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
212
launcher.py
@@ -9,6 +9,35 @@ from pathlib import Path
|
||||
import threading
|
||||
import logging
|
||||
|
||||
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
|
||||
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
|
||||
# pendant la décompression --onefile (~15-30 s sur Windows). En mode dev
|
||||
# (pas frozen), le module n'existe pas → fallback silencieux.
|
||||
try:
|
||||
import pyi_splash # type: ignore
|
||||
_HAS_PYI_SPLASH = True
|
||||
except Exception:
|
||||
pyi_splash = None
|
||||
_HAS_PYI_SPLASH = False
|
||||
|
||||
|
||||
def _splash_update(text: str) -> None:
|
||||
"""Met à jour le texte affiché sous le splash natif PyInstaller (si actif)."""
|
||||
if _HAS_PYI_SPLASH:
|
||||
try:
|
||||
pyi_splash.update_text(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _splash_close() -> None:
|
||||
"""Ferme le splash natif PyInstaller (si actif)."""
|
||||
if _HAS_PYI_SPLASH:
|
||||
try:
|
||||
pyi_splash.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-instance guard (lock file in user's temp directory)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,93 +105,119 @@ def check_models_ready():
|
||||
|
||||
|
||||
def launch_gui():
|
||||
"""Launch the main GUI with a splash screen during the slow core import.
|
||||
"""Launch the main GUI — étapes de chargement affichées DANS le splash natif.
|
||||
|
||||
Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements,
|
||||
BDPM 7k+ médicaments) prend 10–30 s. Sans feedback visuel, l'utilisateur
|
||||
croit que l'application est plantée. Le splash permet d'indiquer l'avancée.
|
||||
Le splash natif PyInstaller (image avec logo + texte dynamique) reste
|
||||
visible pendant TOUTE la phase de chargement. On intercepte les log.info()
|
||||
du core via un logging.Handler et on pousse chaque étape traduite dans
|
||||
le splash natif via pyi_splash.update_text(). L'utilisateur voit défiler
|
||||
sous le logo :
|
||||
"Chargement des prénoms français (INSEE)…"
|
||||
"Chargement des noms de famille (INSEE)…"
|
||||
"Chargement des numéros FINESS…"
|
||||
…
|
||||
Puis le splash se ferme et la GUI s'ouvre — pas de fenêtre intermédiaire.
|
||||
|
||||
En mode dev (pas frozen), pyi_splash n'existe pas ; on ajoute un
|
||||
mini-splash tkinter temporaire pour voir le même rendu pendant le test.
|
||||
"""
|
||||
log.info("Launching GUI...")
|
||||
|
||||
splash = tk.Tk()
|
||||
splash.title("Anonymisation")
|
||||
splash.geometry("440x200")
|
||||
splash.resizable(False, False)
|
||||
# Traductions log.info() → libellés "prod" lisibles pour l'utilisateur.
|
||||
_LOG_TRANSLATIONS = [
|
||||
("Gazetteers INSEE prénoms", "Chargement des prénoms français (INSEE)…"),
|
||||
("Gazetteers INSEE communes", "Chargement des communes françaises (INSEE)…"),
|
||||
("Gazetteers INSEE noms de famille", "Chargement des noms de famille (INSEE)…"),
|
||||
("Villes blacklist", "Chargement de la blacklist des villes…"),
|
||||
("Gazetteer FINESS numéros", "Chargement des numéros FINESS…"),
|
||||
("Gazetteer FINESS villes", "Chargement des villes FINESS…"),
|
||||
("Gazetteer FINESS téléphones", "Chargement des téléphones FINESS…"),
|
||||
("Gazetteer FINESS Aho-Corasick", "Indexation des établissements de santé…"),
|
||||
("Gazetteer FINESS adresses", "Chargement des adresses FINESS…"),
|
||||
("Gazetteer VILLE Aho-Corasick", "Indexation des villes…"),
|
||||
("Whitelist termes médicaux", "Chargement du lexique médical…"),
|
||||
("Whitelist médicaments", "Chargement de la base médicamenteuse (BDPM)…"),
|
||||
("Stop-words manuels", "Chargement des stop-words…"),
|
||||
("BDPM stop-words", "Chargement des médicaments BDPM…"),
|
||||
("DPI labels blacklist", "Chargement des libellés DPI…"),
|
||||
("Companion blacklist", "Chargement du vocabulaire clinique…"),
|
||||
("Whitelist phrases", "Chargement des phrases protégées…"),
|
||||
("FINESS mono-mots", "Chargement des sigles d'établissement…"),
|
||||
("Core imported OK", "Moteur d'anonymisation prêt…"),
|
||||
("GUI module imported OK", "Interface prête — finalisation…"),
|
||||
]
|
||||
|
||||
frame = ttk.Frame(splash, padding=20)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(frame, text="Anonymisation", font=("", 14, "bold")).pack(pady=(0, 4))
|
||||
ttk.Label(frame, text="Pseudonymisation de documents médicaux",
|
||||
foreground="#666666").pack(pady=(0, 12))
|
||||
def _translate(msg: str) -> str:
|
||||
for key, human in _LOG_TRANSLATIONS:
|
||||
if key in msg:
|
||||
return human
|
||||
return msg
|
||||
|
||||
status_var = tk.StringVar(value="Chargement des dictionnaires médicaux…")
|
||||
ttk.Label(frame, textvariable=status_var, foreground="#1a568e").pack(pady=(0, 8))
|
||||
|
||||
pb = ttk.Progressbar(frame, mode="indeterminate", length=380)
|
||||
pb.pack(pady=4)
|
||||
pb.start(10)
|
||||
|
||||
ttk.Label(frame, text="Première phase : ~15–30 s",
|
||||
foreground="#999999", font=("", 8)).pack(pady=(6, 0))
|
||||
|
||||
result = {"done": False, "error": None}
|
||||
|
||||
def _do_import():
|
||||
try:
|
||||
import anonymizer_core_refactored_onnx # noqa
|
||||
log.info("Core imported OK")
|
||||
result["step"] = "gui"
|
||||
import Pseudonymisation_Gui_V5 # noqa
|
||||
log.info("GUI module imported OK")
|
||||
except Exception as e:
|
||||
result["error"] = f"{e}\n{traceback.format_exc()}"
|
||||
log.error(f"Import error: {result['error']}")
|
||||
finally:
|
||||
result["done"] = True
|
||||
|
||||
threading.Thread(target=_do_import, daemon=True).start()
|
||||
|
||||
def _poll():
|
||||
# Met à jour le message selon l'étape atteinte par le thread d'import
|
||||
if result.get("step") == "gui" and not result["done"]:
|
||||
status_var.set("Chargement de l'interface…")
|
||||
if result["done"]:
|
||||
pb.stop()
|
||||
# Handler logs → splash natif. Installé sur le root logger pour capturer
|
||||
# tous les log.info() des modules chargés pendant l'import.
|
||||
class _SplashHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
try:
|
||||
splash.destroy()
|
||||
_splash_update(_translate(record.getMessage()))
|
||||
except Exception:
|
||||
pass
|
||||
if result["error"]:
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"Erreur",
|
||||
f"Erreur au lancement :\n\n{result['error'].splitlines()[0]}\n\n"
|
||||
f"Voir {LOG_FILE} pour les détails.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
# Lancement de la GUI principale
|
||||
try:
|
||||
import Pseudonymisation_Gui_V5
|
||||
log.info("Starting mainloop…")
|
||||
root = tk.Tk()
|
||||
Pseudonymisation_Gui_V5.App(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"Erreur",
|
||||
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
splash.after(200, _poll)
|
||||
|
||||
splash.after(200, _poll)
|
||||
splash.mainloop()
|
||||
_handler = _SplashHandler()
|
||||
_handler.setLevel(logging.INFO)
|
||||
logging.getLogger().addHandler(_handler)
|
||||
|
||||
# Afficher tout de suite un message initial sous le logo
|
||||
_splash_update("Démarrage…")
|
||||
|
||||
# Import du core et de la GUI (synchrone : pas besoin de thread puisque
|
||||
# le splash natif tourne dans son propre processus bootloader).
|
||||
result = {"error": None}
|
||||
try:
|
||||
_splash_update("Chargement des dictionnaires médicaux…")
|
||||
import anonymizer_core_refactored_onnx # noqa
|
||||
log.info("Core imported OK")
|
||||
import Pseudonymisation_Gui_V5 # noqa
|
||||
log.info("GUI module imported OK")
|
||||
except Exception as e:
|
||||
result["error"] = f"{e}\n{traceback.format_exc()}"
|
||||
log.error(f"Import error: {result['error']}")
|
||||
|
||||
# Retirer le handler — la GUI principale utilise ses propres logs
|
||||
try:
|
||||
logging.getLogger().removeHandler(_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fermer le splash natif maintenant que tout est prêt
|
||||
_splash_close()
|
||||
|
||||
if result["error"]:
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"Erreur",
|
||||
f"Erreur au lancement :\n\n{result['error'].splitlines()[0]}\n\n"
|
||||
f"Voir {LOG_FILE} pour les détails.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Lancer la fenêtre principale
|
||||
try:
|
||||
import Pseudonymisation_Gui_V5
|
||||
log.info("Starting mainloop…")
|
||||
root = tk.Tk()
|
||||
Pseudonymisation_Gui_V5.App(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"Erreur",
|
||||
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SetupWindow:
|
||||
@@ -358,8 +413,11 @@ def main():
|
||||
|
||||
try:
|
||||
if check_models_ready():
|
||||
_splash_update("Modèles déjà installés — chargement…")
|
||||
launch_gui()
|
||||
else:
|
||||
_splash_update("Premier lancement — configuration initiale")
|
||||
_splash_close() # laisse place à la SetupWindow qui a sa propre UI
|
||||
setup = SetupWindow()
|
||||
setup.run()
|
||||
except Exception as e:
|
||||
|
||||
128
scripts/rebuild_anon.ps1
Normal file
@@ -0,0 +1,128 @@
|
||||
# Rebuild script - anonymisation Windows
|
||||
$ErrorActionPreference = "Continue"
|
||||
$project = "C:\Users\dom\ai\anonymisation"
|
||||
$gitea = "http://192.168.1.40:3100/Dom/anonymisation.git"
|
||||
|
||||
Write-Host "=== STEP 0 : Désactiver le .git parasite dans C:\Users\dom\ ==="
|
||||
if (Test-Path "C:\Users\dom\.git") {
|
||||
if (-not (Test-Path "C:\Users\dom\.git_parasite_disabled")) {
|
||||
Rename-Item "C:\Users\dom\.git" "C:\Users\dom\.git_parasite_disabled" -ErrorAction SilentlyContinue
|
||||
Write-Host " .git parasite renommé en .git_parasite_disabled"
|
||||
} else {
|
||||
Write-Host " .git_parasite_disabled existe déjà"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "=== STEP 1 : Init/sync repo dans $project ==="
|
||||
Set-Location $project
|
||||
if (-not (Test-Path "$project\.git")) {
|
||||
Write-Host " git init"
|
||||
git init 2>&1 | Out-Host
|
||||
Write-Host " git remote add gitea $gitea"
|
||||
git remote add gitea $gitea 2>&1 | Out-Host
|
||||
} else {
|
||||
Write-Host " .git existe déjà"
|
||||
$existing = git remote 2>&1 | Out-String
|
||||
if ($existing -notmatch "gitea") {
|
||||
git remote add gitea $gitea 2>&1 | Out-Host
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "=== STEP 2 : Fetch + reset main ==="
|
||||
git fetch gitea 2>&1 | Out-Host
|
||||
git checkout -B main 2>&1 | Out-Host
|
||||
git reset --hard gitea/main 2>&1 | Out-Host
|
||||
|
||||
Write-Host "=== STEP 3 : Vérification du dernier commit ==="
|
||||
git log --oneline -3 2>&1 | Out-Host
|
||||
|
||||
# Choix du venv
|
||||
$venvPython = if (Test-Path "$project\venv\Scripts\python.exe") {
|
||||
"$project\venv\Scripts\python.exe"
|
||||
} elseif (Test-Path "$project\.venv\Scripts\python.exe") {
|
||||
"$project\.venv\Scripts\python.exe"
|
||||
} else {
|
||||
Write-Host "ERREUR : aucun venv trouvé (ni venv\, ni .venv\)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Venv détecté : $venvPython"
|
||||
|
||||
Write-Host "=== STEP 4 : Régénérer finess_numbers.txt (entjur fix) ==="
|
||||
& $venvPython "$project\scripts\build_finess_gazetteers.py" 2>&1 | Out-Host
|
||||
|
||||
Write-Host "=== STEP 4a : Générer build_info.py (identifiant de build) ==="
|
||||
$commit = (git rev-parse --short HEAD 2>&1).Trim()
|
||||
$branch = (git rev-parse --abbrev-ref HEAD 2>&1).Trim()
|
||||
$buildDate = Get-Date -Format "yyyy-MM-dd HH:mm"
|
||||
$buildInfo = @"
|
||||
"""Métadonnées de build - GENERE AUTOMATIQUEMENT par rebuild_anon.ps1.
|
||||
Ne PAS editer a la main - ecrase a chaque rebuild."""
|
||||
BUILD_DATE = "$buildDate"
|
||||
BUILD_COMMIT = "$commit"
|
||||
BUILD_BRANCH = "$branch"
|
||||
"@
|
||||
$buildInfo | Set-Content "$project\build_info.py" -Encoding UTF8
|
||||
Write-Host " build_info.py: $buildDate / $commit / $branch"
|
||||
|
||||
Write-Host "=== STEP 4bis : Désactiver scan Defender sur dist/ (évite locks PyInstaller) ==="
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath "$project\dist" -ErrorAction SilentlyContinue
|
||||
Add-MpPreference -ExclusionPath "$project\build" -ErrorAction SilentlyContinue
|
||||
Write-Host " Exclusions Defender ajoutées (dist, build)"
|
||||
} catch {
|
||||
Write-Host " Impossible d'ajouter exclusion Defender : $_"
|
||||
}
|
||||
|
||||
Write-Host "=== STEP 5 : Cleanup build/ et dist/ (contournement lock) ==="
|
||||
# Stratégie anti-lock : RENOMMER l'ancien EXE au lieu de le supprimer.
|
||||
# PyInstaller essaie de `os.remove` l'EXE existant avant d'en créer un nouveau.
|
||||
# Si Defender/antivirus tient un handle sur le fichier, le remove échoue avec
|
||||
# PermissionError WinError 5. En renommant AVANT (Move-Item bypass le lock de
|
||||
# la plupart des scanners), PyInstaller voit dist\Anonymisation.exe absent et
|
||||
# peut créer le nouveau fichier sans conflit.
|
||||
$exePath = "$project\dist\Anonymisation.exe"
|
||||
$timestampAvant = if (Test-Path $exePath) { (Get-Item $exePath).LastWriteTime } else { [DateTime]::MinValue }
|
||||
|
||||
# Renommer l'ancien EXE avec un suffixe timestamp (évite conflit si multiples retries)
|
||||
if (Test-Path $exePath) {
|
||||
$backupName = "Anonymisation.old-$(Get-Date -Format 'HHmmss').exe"
|
||||
try {
|
||||
Move-Item $exePath "$project\dist\$backupName" -Force -ErrorAction Stop
|
||||
Write-Host " Ancien EXE renomme -> $backupName"
|
||||
} catch {
|
||||
Write-Host " AVERTISSEMENT : impossible de renommer l'ancien EXE : $_"
|
||||
# Fallback : essayer de le supprimer quand meme (peut marcher si le lock se libere)
|
||||
Remove-Item $exePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Nettoyer le dossier build/ (peut toujours etre verrouille mais moins critique)
|
||||
if (Test-Path "$project\build") {
|
||||
Remove-Item -Recurse -Force "$project\build" -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "=== STEP 6 : PyInstaller (5-15 min) ==="
|
||||
$pyinstaller = if (Test-Path "$project\venv\Scripts\pyinstaller.exe") {
|
||||
"$project\venv\Scripts\pyinstaller.exe"
|
||||
} else {
|
||||
"$project\.venv\Scripts\pyinstaller.exe"
|
||||
}
|
||||
& $pyinstaller --clean anonymisation_onefile.spec 2>&1 | Out-Host
|
||||
|
||||
Write-Host "=== STEP 7 : Vérification (timestamp AVANT/APRÈS) ==="
|
||||
$exe = "$project\dist\Anonymisation.exe"
|
||||
if (-not (Test-Path $exe)) {
|
||||
Write-Host "ÉCHEC CRITIQUE : aucun exe produit"
|
||||
exit 1
|
||||
}
|
||||
$timestampApres = (Get-Item $exe).LastWriteTime
|
||||
if ($timestampApres -le $timestampAvant) {
|
||||
$size = [math]::Round((Get-Item $exe).Length / 1MB, 1)
|
||||
Write-Host "ÉCHEC CRITIQUE : l'exe n'a pas été mis à jour (timestamp inchangé)"
|
||||
Write-Host " Avant : $timestampAvant"
|
||||
Write-Host " Après : $timestampApres"
|
||||
Write-Host " Taille : $size MB (mais c'est probablement l'ancien exe)"
|
||||
exit 2
|
||||
}
|
||||
$size = [math]::Round((Get-Item $exe).Length / 1MB, 1)
|
||||
Write-Host "OK : $exe ($size MB) - LastWriteTime : $timestampApres"
|
||||