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) ===
|
# === Données runtime (sessions, learning, buffer, config local) ===
|
||||||
data/
|
data/
|
||||||
|
**/capture_library.json
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.deps_installed
|
.deps_installed
|
||||||
# Buffers SQLite locaux (streamer, cache)
|
# 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/screen → Capture écran
|
||||||
POST /api/v3/capture/select → Crée ancre depuis sélection
|
POST /api/v3/capture/select → Crée ancre depuis sélection
|
||||||
GET /api/v3/anchor/{id}/image → Image de l'ancre
|
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 flask import jsonify, request, send_file
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
import base64
|
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')
|
ANCHORS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'anchors')
|
||||||
os.makedirs(ANCHORS_DIR, exist_ok=True)
|
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:
|
def generate_id(prefix: str) -> str:
|
||||||
"""Génère un ID unique"""
|
"""Génère un ID unique"""
|
||||||
@@ -316,3 +323,75 @@ def get_anchor_base64(anchor_id: str):
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}), 500
|
}), 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 { useState, useEffect } from 'react';
|
||||||
import type { Capture } from '../types';
|
import type { Capture } from '../types';
|
||||||
import {
|
import {
|
||||||
loadLibrary,
|
loadLibraryAsync,
|
||||||
saveLibrary,
|
saveLibrary,
|
||||||
compressThumbnail,
|
compressThumbnail,
|
||||||
} from '../services/captureLibraryStorage';
|
} from '../services/captureLibraryStorage';
|
||||||
@@ -33,10 +33,10 @@ export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapt
|
|||||||
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
|
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Charger la bibliothèque depuis localStorage (persiste entre onglets/sessions).
|
// Charger la bibliothèque depuis le backend (prioritaire), fallback localStorage.
|
||||||
// Le helper loadLibrary() gère la migration des anciennes clés et de sessionStorage.
|
// Le helper loadLibraryAsync() gère la migration des anciennes clés et de sessionStorage.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadLibrary(currentSessionId) as LibraryItem[];
|
loadLibraryAsync(currentSessionId).then((loaded) => {
|
||||||
setLibrary(
|
setLibrary(
|
||||||
loaded.map((item) => ({
|
loaded.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -46,6 +46,7 @@ export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapt
|
|||||||
: item.timestamp,
|
: item.timestamp,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}, [currentSessionId]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
// 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 { Capture, ExecutionMode } from '../types';
|
||||||
import type { UIElement } from '../services/uiDetection';
|
import type { UIElement } from '../services/uiDetection';
|
||||||
import {
|
import {
|
||||||
loadLibrary,
|
loadLibraryAsync,
|
||||||
saveLibrary,
|
saveLibrary,
|
||||||
compressThumbnail,
|
compressThumbnail,
|
||||||
} from '../services/captureLibraryStorage';
|
} from '../services/captureLibraryStorage';
|
||||||
@@ -61,10 +61,10 @@ export default function CapturePanel({
|
|||||||
|
|
||||||
const isDebugMode = executionMode === 'debug';
|
const isDebugMode = executionMode === 'debug';
|
||||||
|
|
||||||
// Charger la bibliothèque depuis localStorage (clé unifiée 'captureLibrary_v2').
|
// Charger la bibliothèque depuis le backend (prioritaire), fallback localStorage.
|
||||||
// Le helper loadLibrary() migre aussi les données de l'ancienne clé 'captureLibrary'.
|
// Le helper loadLibraryAsync() migre aussi les données de l'ancienne clé 'captureLibrary'.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadLibrary() as LibraryItem[];
|
loadLibraryAsync().then((loaded) => {
|
||||||
setLibrary(
|
setLibrary(
|
||||||
loaded.map((item) => ({
|
loaded.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -74,6 +74,7 @@ export default function CapturePanel({
|
|||||||
: item.timestamp,
|
: item.timestamp,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
|
||||||
|
|||||||
@@ -358,3 +358,24 @@ export async function exportForLea(
|
|||||||
machine_id: machineId,
|
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).
|
* 2. Deux listes désynchronisées (clés différentes).
|
||||||
*
|
*
|
||||||
* Ce module centralise :
|
* 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'
|
* - clé unique 'captureLibrary_v2'
|
||||||
* - compression JPEG 80% / max 320×240 des thumbnails avant stockage
|
* - compression JPEG 80% / max 320×240 des thumbnails avant stockage
|
||||||
* pour rester sous le quota navigateur (typiquement 5–10 MB).
|
* 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 type { Capture } from '../types';
|
||||||
|
import { getCaptureLibrary, saveCaptureLibrary } from './api';
|
||||||
|
|
||||||
export interface LibraryItem {
|
export interface LibraryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,8 +40,51 @@ const THUMB_MAX_HEIGHT = 240;
|
|||||||
const THUMB_QUALITY = 0.8;
|
const THUMB_QUALITY = 0.8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge la bibliothèque depuis localStorage. Migre depuis sessionStorage
|
* Charge la bibliothèque depuis le backend (prioritaire), avec fallback
|
||||||
* et l'ancienne clé 'captureLibrary' si présentes.
|
* 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[] {
|
export function loadLibrary(defaultSessionId?: string): LibraryItem[] {
|
||||||
// 1) Clé principale en localStorage
|
// 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
|
* Sauvegarde la bibliothèque dans localStorage ET sur le backend.
|
||||||
* en élaguant les items les plus anciens jusqu'à ce que ça passe.
|
* Gère les erreurs de quota localStorage en élaguant les items les plus anciens.
|
||||||
*/
|
*/
|
||||||
export function saveLibrary(library: LibraryItem[]): void {
|
export function saveLibrary(library: LibraryItem[]): void {
|
||||||
let toStore = library;
|
let toStore = library;
|
||||||
// Jusqu'à 5 tentatives : si QuotaExceededError, on tronque de moitié.
|
|
||||||
|
// Sauvegarder dans localStorage (cache local)
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||||
return;
|
break;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const isQuota =
|
const isQuota =
|
||||||
e?.name === 'QuotaExceededError' ||
|
e?.name === 'QuotaExceededError' ||
|
||||||
e?.code === 22 ||
|
e?.code === 22 ||
|
||||||
e?.code === 1014; // Firefox
|
e?.code === 1014; // Firefox
|
||||||
if (!isQuota) {
|
if (!isQuota) {
|
||||||
console.error('[CaptureLibrary] Erreur save', e);
|
console.error('[CaptureLibrary] Erreur save localStorage', e);
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
// Garder la moitié la plus récente
|
// Garder la moitié la plus récente
|
||||||
const half = Math.max(1, Math.floor(toStore.length / 2));
|
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);
|
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 {
|
.workflow-list li.active .wf-name {
|
||||||
color: white;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-list .wf-main-row {
|
.workflow-list .wf-main-row {
|
||||||
@@ -197,7 +197,7 @@ body {
|
|||||||
.workflow-list .wf-name {
|
.workflow-list .wf-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: #212121 !important;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -2382,6 +2382,7 @@ body {
|
|||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
background: var(--bg-paper);
|
background: var(--bg-paper);
|
||||||
|
color: var(--text-primary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -2474,6 +2475,7 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: #212121 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item .item-meta {
|
.dropdown-item .item-meta {
|
||||||
|
|||||||
Reference in New Issue
Block a user