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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user