feat: v6.2.0 Web Clipper — extension navigateur (capture article/selection/bookmark/screenshot)
FlowDeck CI / lint (push) Successful in 1m11s
FlowDeck CI / test (push) Failing after 8m32s
FlowDeck CI / docker (push) Skipped

- Service app/services/web_clipper.py: sanitize HTML, html->blocks, extraction article, creation page workspace-aware, rate limit 50/h, device registration
- Router app/routers/web_clipper.py: POST /api/v2/web-clipper/clip, GET /status, POST /auth/verify, GET/DELETE /devices, GET /extensions (download page), auth via session ou Bearer (api_tokens / extension_devices)
- Migration 19: extension_devices + extension_clips (+ indexes)
- Extension Manifest V3: content.js (floating button, selection), background.js (clip + contextMenus), popup.html/js, clipper.css, icons
- Settings UI: onglet Extensions (liste devices, revoke, test clip, liens download), page /extensions
- Tests: 16 tests web_clipper (sanitize, blocks, article/bookmark/selection/screenshot, bearer, rate-limit, devices, extensions page)
- Bump version 6.1.0 -> 6.2.0
This commit is contained in:
2026-09-19 23:26:03 -04:00
parent 13d5f8625a
commit 0b251649e5
31 changed files with 3072 additions and 3 deletions
+82
View File
@@ -138,6 +138,7 @@
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
@@ -420,6 +421,52 @@
</div>
</div>
<!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'">
<h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group">
<h3>Web Clipper</h3>
<p style="font-size:12px;color:var(--text-dim);line-height:1.5;margin-bottom:12px;">
Install the <a href="/extensions" style="color:var(--accent);">browser extension</a> (Chrome/Firefox/Edge). After install, use <b>Verify &amp; Connect</b> once or paste an <b>API token</b> in the extension popup.
</p>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
<a href="/extensions" class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Get extension</a>
<a href="/static/extension/flowdeck-clipper.zip" download class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Download .zip</a>
</div>
<div x-show="!clipperDevices.length" class="setting-desc" style="padding:8px 0;">No extension connected yet — clip your first page to appear here.</div>
<template x-for="d in clipperDevices" :key="d.id">
<div class="setting-row">
<div style="flex:1;min-width:0;">
<div class="setting-label">
<span x-text="(d.device_name || d.extension_name || 'Clipper') + ' — ' + d.device_id.slice(0,24)"></span>
<span class="llm-badge" :class="d.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="d.revoked ? 'Révoqué' : 'Actif'"></span>
<span class="llm-badge warn" style="margin-left:6px;" x-text="d.extension_name"></span>
</div>
<div class="setting-desc" style="font-size:11px;">
<span x-text="'Connecté: ' + (d.created_at||'').slice(0,16)"></span>
<span x-show="d.last_used_at" x-text="' · Dernier: ' + d.last_used_at.slice(0,16)"></span>
<span x-text="' · ' + d.clips_count + ' clipp(s)'"></span>
<span x-show="d.last_clip_at" x-text="' · Dernier clip: ' + d.last_clip_at.slice(0,16)"></span>
</div>
</div>
<div class="setting-control">
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeClipperDevice(d)" x-show="!d.revoked">Révoquer</button>
</div>
</div>
</template>
</div>
<div class="setting-group">
<h3>Tester le clipper (sans extension)</h3>
<p style="font-size:12px;color:var(--text-dim);">Envoyez un clip тест direct via l'API :</p>
<div style="display:flex;gap:8px;margin-top:8px;">
<input class="settings-input" x-model="clipTestUrl" placeholder="https://example.com/article" style="flex:1;">
<button class="btn btn-primary" @click="testClip()" :disabled="clipTesting" x-text="clipTesting ? 'Envoi…' : 'Clip URL'"></button>
</div>
<div x-show="clipTestMsg" style="margin-top:8px;font-size:12px;" :style="{color: clipTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}" x-text="clipTestMsg"></div>
</div>
</div>
<!-- Workspace -->
<div x-show="activeSection==='workspace'">
<h2>Workspace</h2>
@@ -1056,6 +1103,12 @@ function settingsInit() {
newToken: null,
tokenName: '',
sessions: [],
// v6.2.0 Web Clipper
clipperDevices: [],
clipTestUrl: 'https://example.com',
clipTesting: false,
clipTestMsg: '',
clipTestOk: false,
// v5.2.0 Backups (admin)
backups: [],
backupRunning: false,
@@ -1853,6 +1906,35 @@ function settingsInit() {
}
} catch(e) {}
},
// ── v6.2.0 Web Clipper ──
async loadClipperDevices() {
try {
var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
var d = await r.json();
this.clipperDevices = d.devices || [];
} catch(e) { this.clipperDevices = []; }
},
async revokeClipperDevice(d) {
if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
try {
var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
if (r.ok) await this.loadClipperDevices();
} catch(e) {}
},
async testClip() {
this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
var url = (this.clipTestUrl || '').trim();
if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
try {
var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '<html><body><h1>Test clip</h1><p>Contenu de test depuis Settings.</p></body></html>', content_type: 'article', device_id: 'settings-test'})});
var d = await r.json();
if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
else this.clipTestMsg = d.detail || 'Erreur';
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
finally { this.clipTesting = false; }
},
// ── v5.2.0 Backups (admin) ──
async loadBackups() {
try {