fix(vwb): noms workflows lisibles + bibliothèque captures persistante
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 11s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
CSS : le dropdown héritait color:white du header → forcé #212121 sur .workflow-dropdown et .dropdown-item .item-name Bibliothèque : migration localStorage → backend (capture_library.json) - GET/POST /api/v3/capture/library (max 50 captures) - loadLibraryAsync() charge depuis backend, fallback localStorage - saveLibrary() écrit dans les deux (localStorage + backend) - capture_library.json gitignored Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -95,6 +95,7 @@ archives/
|
||||
|
||||
# === Données runtime (sessions, learning, buffer, config local) ===
|
||||
data/
|
||||
**/capture_library.json
|
||||
.hypothesis/
|
||||
.deps_installed
|
||||
# Buffers SQLite locaux (streamer, cache)
|
||||
|
||||
@@ -5,10 +5,13 @@ Gestion des captures d'écran et création d'ancres visuelles
|
||||
POST /api/v3/capture/screen → Capture écran
|
||||
POST /api/v3/capture/select → Crée ancre depuis sélection
|
||||
GET /api/v3/anchor/{id}/image → Image de l'ancre
|
||||
GET /api/v3/capture/library → Charge la bibliothèque de captures
|
||||
POST /api/v3/capture/library → Sauvegarde la bibliothèque de captures
|
||||
"""
|
||||
|
||||
from flask import jsonify, request, send_file
|
||||
from datetime import datetime
|
||||
import json
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
@@ -22,6 +25,10 @@ from db.models import db, Step, VisualAnchor, get_session_state
|
||||
ANCHORS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'anchors')
|
||||
os.makedirs(ANCHORS_DIR, exist_ok=True)
|
||||
|
||||
# Fichier pour la bibliothèque de captures (persistance disque)
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
|
||||
CAPTURE_LIBRARY_PATH = os.path.join(DATA_DIR, 'capture_library.json')
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
@@ -316,3 +323,75 @@ def get_anchor_base64(anchor_id: str):
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ── Bibliothèque de captures (persistance disque) ────────────────────────
|
||||
|
||||
@api_v3_bp.route('/capture/library', methods=['GET'])
|
||||
def get_capture_library():
|
||||
"""
|
||||
Charge la bibliothèque de captures depuis le disque.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite }, ... ]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(CAPTURE_LIBRARY_PATH):
|
||||
with open(CAPTURE_LIBRARY_PATH, 'r', encoding='utf-8') as f:
|
||||
library = json.load(f)
|
||||
else:
|
||||
library = []
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'library': library
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [CaptureLibrary] Erreur lecture: {e}")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'library': []
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/library', methods=['POST'])
|
||||
def save_capture_library():
|
||||
"""
|
||||
Sauvegarde la bibliothèque de captures sur disque.
|
||||
|
||||
Request:
|
||||
{
|
||||
"library": [ { id, capture, timestamp, sessionId, favorite }, ... ]
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"count": 5
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
library = data.get('library', [])
|
||||
|
||||
# Limiter à 50 captures pour éviter un fichier trop gros
|
||||
library = library[:50]
|
||||
|
||||
with open(CAPTURE_LIBRARY_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(library, f, ensure_ascii=False)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': len(library)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [CaptureLibrary] Erreur écriture: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Capture } from '../types';
|
||||
import {
|
||||
loadLibrary,
|
||||
loadLibraryAsync,
|
||||
saveLibrary,
|
||||
compressThumbnail,
|
||||
} from '../services/captureLibraryStorage';
|
||||
@@ -33,19 +33,20 @@ export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapt
|
||||
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Charger la bibliothèque depuis localStorage (persiste entre onglets/sessions).
|
||||
// Le helper loadLibrary() gère la migration des anciennes clés et de sessionStorage.
|
||||
// Charger la bibliothèque depuis le backend (prioritaire), fallback localStorage.
|
||||
// Le helper loadLibraryAsync() gère la migration des anciennes clés et de sessionStorage.
|
||||
useEffect(() => {
|
||||
const loaded = loadLibrary(currentSessionId) as LibraryItem[];
|
||||
setLibrary(
|
||||
loaded.map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
typeof item.timestamp === 'string'
|
||||
? new Date(item.timestamp)
|
||||
: item.timestamp,
|
||||
}))
|
||||
);
|
||||
loadLibraryAsync(currentSessionId).then((loaded) => {
|
||||
setLibrary(
|
||||
loaded.map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
typeof item.timestamp === 'string'
|
||||
? new Date(item.timestamp)
|
||||
: item.timestamp,
|
||||
}))
|
||||
);
|
||||
});
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import type { Capture, ExecutionMode } from '../types';
|
||||
import type { UIElement } from '../services/uiDetection';
|
||||
import {
|
||||
loadLibrary,
|
||||
loadLibraryAsync,
|
||||
saveLibrary,
|
||||
compressThumbnail,
|
||||
} from '../services/captureLibraryStorage';
|
||||
@@ -61,19 +61,20 @@ export default function CapturePanel({
|
||||
|
||||
const isDebugMode = executionMode === 'debug';
|
||||
|
||||
// Charger la bibliothèque depuis localStorage (clé unifiée 'captureLibrary_v2').
|
||||
// Le helper loadLibrary() migre aussi les données de l'ancienne clé 'captureLibrary'.
|
||||
// Charger la bibliothèque depuis le backend (prioritaire), fallback localStorage.
|
||||
// Le helper loadLibraryAsync() migre aussi les données de l'ancienne clé 'captureLibrary'.
|
||||
useEffect(() => {
|
||||
const loaded = loadLibrary() as LibraryItem[];
|
||||
setLibrary(
|
||||
loaded.map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
typeof item.timestamp === 'string'
|
||||
? new Date(item.timestamp)
|
||||
: item.timestamp,
|
||||
}))
|
||||
);
|
||||
loadLibraryAsync().then((loaded) => {
|
||||
setLibrary(
|
||||
loaded.map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
typeof item.timestamp === 'string'
|
||||
? new Date(item.timestamp)
|
||||
: item.timestamp,
|
||||
}))
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
||||
|
||||
@@ -358,3 +358,24 @@ export async function exportForLea(
|
||||
machine_id: machineId,
|
||||
});
|
||||
}
|
||||
|
||||
// Bibliothèque de captures — persistance serveur
|
||||
export interface CaptureLibraryItem {
|
||||
id: string;
|
||||
capture: Capture;
|
||||
timestamp: string;
|
||||
sessionId?: string;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export async function getCaptureLibrary(): Promise<{
|
||||
library: CaptureLibraryItem[];
|
||||
}> {
|
||||
return request('GET', '/capture/library');
|
||||
}
|
||||
|
||||
export async function saveCaptureLibrary(library: CaptureLibraryItem[]): Promise<{
|
||||
count: number;
|
||||
}> {
|
||||
return request('POST', '/capture/library', { library });
|
||||
}
|
||||
|
||||
@@ -11,13 +11,19 @@
|
||||
* 2. Deux listes désynchronisées (clés différentes).
|
||||
*
|
||||
* Ce module centralise :
|
||||
* - localStorage (persiste entre sessions)
|
||||
* - Stockage principal : backend API (fichier JSON sur disque)
|
||||
* - Cache local : localStorage (fallback si le backend est indisponible)
|
||||
* - clé unique 'captureLibrary_v2'
|
||||
* - compression JPEG 80% / max 320×240 des thumbnails avant stockage
|
||||
* pour rester sous le quota navigateur (typiquement 5–10 MB).
|
||||
*
|
||||
* MISE À JOUR (19 avril 2026) :
|
||||
* Migration du stockage vers le backend pour survivre aux changements
|
||||
* de port, vidages de cache navigateur et Ctrl+Shift+R.
|
||||
*/
|
||||
|
||||
import type { Capture } from '../types';
|
||||
import { getCaptureLibrary, saveCaptureLibrary } from './api';
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string;
|
||||
@@ -34,8 +40,51 @@ const THUMB_MAX_HEIGHT = 240;
|
||||
const THUMB_QUALITY = 0.8;
|
||||
|
||||
/**
|
||||
* Charge la bibliothèque depuis localStorage. Migre depuis sessionStorage
|
||||
* et l'ancienne clé 'captureLibrary' si présentes.
|
||||
* Charge la bibliothèque depuis le backend (prioritaire), avec fallback
|
||||
* sur localStorage si le backend est indisponible.
|
||||
* Migre les anciennes clés sessionStorage/localStorage au premier appel.
|
||||
*/
|
||||
export async function loadLibraryAsync(defaultSessionId?: string): Promise<LibraryItem[]> {
|
||||
// 1) Essayer de charger depuis le backend
|
||||
try {
|
||||
const data = await getCaptureLibrary();
|
||||
if (data.library && data.library.length > 0) {
|
||||
const items = data.library.map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
typeof item.timestamp === 'string'
|
||||
? new Date(item.timestamp)
|
||||
: item.timestamp,
|
||||
})) as LibraryItem[];
|
||||
|
||||
// Mettre à jour le cache localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data.library));
|
||||
} catch {
|
||||
// Quota dépassé, pas grave — le backend est la source de vérité
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[CaptureLibrary] Backend indisponible, fallback localStorage', e);
|
||||
}
|
||||
|
||||
// 2) Fallback : charger depuis localStorage (fonction synchrone existante)
|
||||
const local = loadLibrary(defaultSessionId);
|
||||
|
||||
// 3) Si on a trouvé des données locales mais pas sur le backend,
|
||||
// pousser vers le backend pour la prochaine fois
|
||||
if (local.length > 0) {
|
||||
saveLibrary(local); // Sauvegarde async vers le backend
|
||||
}
|
||||
|
||||
return local;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la bibliothèque depuis localStorage (synchrone).
|
||||
* Migre depuis sessionStorage et l'ancienne clé 'captureLibrary' si présentes.
|
||||
*/
|
||||
export function loadLibrary(defaultSessionId?: string): LibraryItem[] {
|
||||
// 1) Clé principale en localStorage
|
||||
@@ -99,24 +148,25 @@ export function loadLibrary(defaultSessionId?: string): LibraryItem[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde la bibliothèque dans localStorage. Gère les erreurs de quota
|
||||
* en élaguant les items les plus anciens jusqu'à ce que ça passe.
|
||||
* Sauvegarde la bibliothèque dans localStorage ET sur le backend.
|
||||
* Gère les erreurs de quota localStorage en élaguant les items les plus anciens.
|
||||
*/
|
||||
export function saveLibrary(library: LibraryItem[]): void {
|
||||
let toStore = library;
|
||||
// Jusqu'à 5 tentatives : si QuotaExceededError, on tronque de moitié.
|
||||
|
||||
// Sauvegarder dans localStorage (cache local)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||
return;
|
||||
break;
|
||||
} catch (e: any) {
|
||||
const isQuota =
|
||||
e?.name === 'QuotaExceededError' ||
|
||||
e?.code === 22 ||
|
||||
e?.code === 1014; // Firefox
|
||||
if (!isQuota) {
|
||||
console.error('[CaptureLibrary] Erreur save', e);
|
||||
return;
|
||||
console.error('[CaptureLibrary] Erreur save localStorage', e);
|
||||
break;
|
||||
}
|
||||
// Garder la moitié la plus récente
|
||||
const half = Math.max(1, Math.floor(toStore.length / 2));
|
||||
@@ -126,7 +176,19 @@ export function saveLibrary(library: LibraryItem[]): void {
|
||||
toStore = toStore.slice(0, half);
|
||||
}
|
||||
}
|
||||
console.error('[CaptureLibrary] Impossible de sauvegarder même après élagage');
|
||||
|
||||
// Sauvegarder sur le backend (fire-and-forget, pas de blocage)
|
||||
saveCaptureLibrary(library.map((item) => ({
|
||||
id: item.id,
|
||||
capture: item.capture,
|
||||
timestamp: typeof item.timestamp === 'object' && item.timestamp instanceof Date
|
||||
? item.timestamp.toISOString()
|
||||
: String(item.timestamp),
|
||||
sessionId: item.sessionId,
|
||||
favorite: item.favorite,
|
||||
}))).catch((e) => {
|
||||
console.warn('[CaptureLibrary] Échec sauvegarde backend', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -184,7 +184,7 @@ body {
|
||||
}
|
||||
|
||||
.workflow-list li.active .wf-name {
|
||||
color: white;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.workflow-list .wf-main-row {
|
||||
@@ -197,7 +197,7 @@ body {
|
||||
.workflow-list .wf-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
color: #212121 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2382,6 +2382,7 @@ body {
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
background: var(--bg-paper);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
@@ -2474,6 +2475,7 @@ body {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #212121 !important;
|
||||
}
|
||||
|
||||
.dropdown-item .item-meta {
|
||||
|
||||
Reference in New Issue
Block a user