feat: v6.2.0 Web Clipper — extension navigateur (capture article/selection/bookmark/screenshot)
- 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:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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.
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user