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
+120
View File
@@ -0,0 +1,120 @@
// FlowDeck Web Clipper — background service worker (Manifest V3)
const STORAGE_KEY = "flowdeck_clipper";
const DEFAULT_SERVER = "http://localhost:8080";
async function getConfig() {
const data = await chrome.storage.local.get(STORAGE_KEY);
return data[STORAGE_KEY] || { serverUrl: DEFAULT_SERVER, token: "", deviceId: "" };
}
async function setConfig(cfg) {
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
}
function genDeviceId() {
return "dev_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
}
async function ensureDeviceId() {
const cfg = await getConfig();
if (!cfg.deviceId) {
cfg.deviceId = genDeviceId();
await setConfig(cfg);
}
return cfg.deviceId;
}
chrome.runtime.onInstalled.addListener(async () => {
await ensureDeviceId();
chrome.contextMenus.create({
id: "fd-clip-selection",
title: "📌 Clip selection to FlowDeck",
contexts: ["selection"]
});
chrome.contextMenus.create({
id: "fd-clip-page",
title: "📌 Clip page to FlowDeck",
contexts: ["page"]
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (!tab || !tab.id) return;
if (info.menuItemId === "fd-clip-selection") {
chrome.tabs.sendMessage(tab.id, { action: "clipSelection" });
} else if (info.menuItemId === "fd-clip-page") {
chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => window.__fdOpenClipper && window.__fdOpenClipper("article")
});
}
});
// Unified clip handler
async function handleClip(data) {
const cfg = await getConfig();
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
const deviceId = cfg.deviceId || (await ensureDeviceId());
const token = cfg.token || "";
// Require token or let server handle session cookie fallback
if (!token) {
// Try clipboard without token; server may accept session if user logged in flowdeck tab
// but background fetch cannot send cookie cross-origin without credentials; warn
}
const payload = {
url: data.url,
title: data.title,
content: data.html || data.content,
content_type: data.content_type || "article",
selection_html: data.selection_html || "",
device_id: deviceId,
metadata: data.metadata || {}
};
const headers = { "Content-Type": "application/json", "X-Device-Id": deviceId };
if (token) headers["Authorization"] = "Bearer " + token;
const resp = await fetch(serverUrl + "/api/v2/web-clipper/clip", {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
return { error: body.detail || body.error || "clip_failed", status: resp.status, ...body };
}
return body;
}
async function handleAuth() {
// Token is set via popup; verify it
const cfg = await getConfig();
if (!cfg.token) return { error: "no_token" };
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
const resp = await fetch(serverUrl + "/api/v2/web-clipper/status", {
headers: { Authorization: "Bearer " + cfg.token }
});
const body = await resp.json().catch(() => ({}));
return body;
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "clip") {
handleClip(message.data).then((r) => sendResponse(r));
return true;
}
if (message.action === "auth") {
handleAuth().then((r) => sendResponse(r));
return true;
}
if (message.action === "getConfig") {
getConfig().then((c) => sendResponse(c));
return true;
}
if (message.action === "setConfig") {
setConfig(message.config).then(() => sendResponse({ ok: true }));
return true;
}
});
+13
View File
@@ -0,0 +1,13 @@
.fd-clipper-btn{
position:fixed;bottom:20px;right:20px;z-index:2147483647;
background:#2383E2;color:#fff;border:none;border-radius:999px;
padding:10px 16px;font-size:13px;font-weight:600;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.25);font-family:system-ui,sans-serif;
}
.fd-clipper-btn:hover{background:#1a6fc2}
.fd-clipper-toast{
position:fixed;bottom:60px;right:20px;z-index:2147483647;
color:#fff;padding:10px 14px;border-radius:8px;font-size:13px;font-family:system-ui,sans-serif;
transform:translateY(10px);opacity:0;transition:all .2s;pointer-events:none;
}
.fd-clipper-toast.show{transform:translateY(0);opacity:1}
+98
View File
@@ -0,0 +1,98 @@
// FlowDeck Web Clipper — content script
// Injected in every page: floating button + context menu + selection handling
(function () {
if (window.__fdClipperInjected) return;
window.__fdClipperInjected = true;
const BTN_ID = "fd-clipper-btn";
function createFloatButton() {
if (document.getElementById(BTN_ID)) return;
const btn = document.createElement("button");
btn.id = BTN_ID;
btn.className = "fd-clipper-btn";
btn.textContent = "📌 Clip to FlowDeck";
btn.title = "Clip this page to FlowDeck";
btn.addEventListener("click", () => openClipper("article"));
document.body.appendChild(btn);
}
function getSelectionHtml() {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
const range = sel.getRangeAt(0);
const div = document.createElement("div");
div.appendChild(range.cloneContents());
return div.innerHTML;
}
async function openClipper(type) {
const html = document.documentElement.outerHTML;
const selectionHtml = getSelectionHtml();
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
let clipType = type;
if (selectionHtml && selectionText.length > 20) clipType = "selection";
const payload = {
url: window.location.href,
title: document.title,
content: html,
content_type: clipType,
selection_html: selectionHtml,
metadata: {
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
author: document.querySelector('meta[name="author"]')?.content || ""
}
};
// Send to background
try {
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
if (resp && resp.error === "not_authenticated") {
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
} else if (resp && resp.status === "ok") {
showToast("Clipped to FlowDeck ✓ — page " + resp.page_id);
} else if (resp && resp.error) {
showToast("Clip failed: " + resp.error, true);
}
} catch (e) {
console.error("FlowDeck clipper", e);
showToast("Clip failed: " + e.message, true);
}
}
function showToast(msg, isError) {
let t = document.getElementById("fd-clipper-toast");
if (!t) {
t = document.createElement("div");
t.id = "fd-clipper-toast";
t.className = "fd-clipper-toast";
document.body.appendChild(t);
}
t.textContent = msg;
t.style.background = isError ? "#e03e3e" : "#1a7f37";
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 3000);
}
// Context menu via right-click: if selection exists, inject inline menu
document.addEventListener("mouseup", () => {
// No heavy UI here; popup/background handles contextMenus API
});
// Keyboard shortcut Ctrl+Shift+C
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
e.preventDefault();
openClipper("article");
}
});
// Expose for popup
window.__fdOpenClipper = openClipper;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", createFloatButton);
} else {
createFloatButton();
}
})();
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+40
View File
@@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "FlowDeck Web Clipper",
"description": "Capturez du contenu web directement dans FlowDeck",
"version": "1.0.0",
"permissions": ["activeTab", "storage", "scripting", "contextMenus"],
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["clipper.css"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["readability.js"],
"matches": ["<all_urls>"]
}
],
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;width:340px;padding:16px;background:#191919;color:#e0e0e0}
h2{font-size:15px;margin-bottom:6px}
.desc{font-size:12px;color:#999;margin-bottom:12px;line-height:1.4}
label{font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.5px;display:block;margin-top:10px;margin-bottom:4px}
input{width:100%;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px;color:#fff;padding:8px 10px;font-size:13px;outline:none}
input:focus{border-color:#2383E2}
.btn{width:100%;margin-top:12px;padding:9px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
.btn-primary{background:#2383E2;color:#fff}
.btn-secondary{background:#2a2a2a;color:#ccc;border:1px solid #3a3a3a}
.btn:active{opacity:.9}
.row{display:flex;gap:8px;margin-top:10px}
.row .btn{flex:1}
.status{margin-top:10px;padding:8px 10px;border-radius:6px;font-size:12px;display:none}
.status.ok{background:rgba(46,160,67,.15);color:#2ea043;display:block}
.status.err{background:rgba(224,62,62,.15);color:#e03e3e;display:block}
.status.warn{background:rgba(217,115,13,.15);color:#d9730d;display:block}
.small{font-size:11px;color:#777;margin-top:6px}
a{color:#2383E2;text-decoration:none}
</style>
</head>
<body>
<h2>📌 FlowDeck Clipper</h2>
<p class="desc">Capture any page into FlowDeck in one click.</p>
<div class="row">
<button class="btn btn-primary" id="clipArticle">Clip article</button>
<button class="btn btn-secondary" id="clipBookmark">Bookmark</button>
</div>
<button class="btn btn-secondary" id="clipSelection" style="margin-top:8px;">Clip selection</button>
<label>FlowDeck server URL</label>
<input id="serverUrl" placeholder="http://localhost:8080">
<label>Bearer token <span style="text-transform:none;color:#666">(Settings → API tokens)</span></label>
<input id="token" placeholder="fd_... or fd_clip_..." type="password">
<div style="display:flex;gap:6px;margin-top:6px">
<button class="btn btn-secondary" id="save" style="flex:1;padding:7px;font-size:12px;">Save</button>
<button class="btn btn-secondary" id="verify" style="flex:1;padding:7px;font-size:12px;">Verify</button>
</div>
<div id="status" class="status"></div>
<p class="small">Need a token? Open <a href="#" id="openFlowDeck">FlowDeck → Settings → API tokens</a> or <a href="#" id="openExtensions">Extensions</a>.</p>
<script src="popup.js"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
// Popup logic
const $ = (id) => document.getElementById(id);
async function load() {
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
$("serverUrl").value = cfg.serverUrl || "http://localhost:8080";
$("token").value = cfg.token || "";
}
async function save() {
const serverUrl = $("serverUrl").value.trim() || "http://localhost:8080";
const token = $("token").value.trim();
// deviceId preserved
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
cfg.serverUrl = serverUrl.replace(/\/$/, "");
cfg.token = token;
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
show("Saved ✓", "ok");
}
async function verify() {
await save();
const res = await chrome.runtime.sendMessage({ action: "auth" });
if (res && res.authenticated) show(`Connected as ${res.user?.login || "user"} — ${res.devices} device(s)`, "ok");
else if (res && res.authenticated === false) show("Not authenticated — check token / server URL", "err");
else show(JSON.stringify(res).slice(0, 200), "warn");
}
function show(msg, kind) {
const el = $("status");
el.textContent = msg;
el.className = "status " + kind;
}
async function doClip(type) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
// Get page data via scripting
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (clipType) => {
const sel = window.getSelection();
const html = document.documentElement.outerHTML;
const selectionHtml = (() => {
if (!sel || sel.rangeCount === 0) return "";
const d = document.createElement("div");
d.appendChild(sel.getRangeAt(0).cloneContents());
return d.innerHTML;
})();
return {
url: location.href,
title: document.title,
html: html,
content_type: sel && sel.toString().trim().length > 20 ? "selection" : clipType,
selection_html: selectionHtml,
metadata: {
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || ""
}
};
},
args: [type]
});
const resp = await chrome.runtime.sendMessage({ action: "clip", data: result });
if (resp && resp.status === "ok") show(`Clipped → page ${resp.page_id} ✓`, "ok");
else show(resp.detail || resp.error || "Clip failed", "err");
}
$("save").addEventListener("click", save);
$("verify").addEventListener("click", verify);
$("clipArticle").addEventListener("click", () => doClip("article"));
$("clipBookmark").addEventListener("click", () => doClip("bookmark"));
$("clipSelection").addEventListener("click", () => doClip("selection"));
$("openFlowDeck").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: $("serverUrl").value || "http://localhost:8080" }); });
$("openExtensions").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: ($("serverUrl").value || "http://localhost:8080") + "/extensions" }); });
load();