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:
+5
-1
@@ -47,6 +47,8 @@ from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -109,7 +111,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.1.0",
|
||||
version="6.2.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -153,6 +155,8 @@ app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -796,6 +796,53 @@ def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
|
||||
@register(19, "v6.0.0: web clipper — extension devices & clips")
|
||||
def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Web Clipper: extension browser + capture."""
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL DEFAULT 'clipper',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
token_hash TEXT NOT NULL DEFAULT '',
|
||||
scopes TEXT NOT NULL DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_user ON extension_devices(user_id, revoked)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_device ON extension_devices(device_id)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
clip_type TEXT NOT NULL DEFAULT 'article',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
target_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
target_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
title TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_user ON extension_clips(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_device ON extension_clips(device_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
apply_auto_properties,
|
||||
@@ -25,7 +26,6 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""FlowDeck — Web Clipper router (v6.0.0).
|
||||
|
||||
Endpoints:
|
||||
GET /api/v2/web-clipper/status
|
||||
POST /api/v2/web-clipper/auth/verify
|
||||
POST /api/v2/web-clipper/clip
|
||||
GET /api/v2/web-clipper/devices
|
||||
DELETE /api/v2/web-clipper/devices/{id}
|
||||
GET /extensions (HTML download page)
|
||||
|
||||
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.web_clipper import (
|
||||
MAX_CLIP_BYTES,
|
||||
_check_rate_limit,
|
||||
create_page_from_clip,
|
||||
list_devices,
|
||||
log_clip,
|
||||
register_device,
|
||||
revoke_device,
|
||||
sanitize_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web-clipper"])
|
||||
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _user_from_request(request: Request) -> dict | None:
|
||||
# 1) session cookie
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user:
|
||||
return user
|
||||
# 2) Authorization Bearer
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash(token)
|
||||
with get_conn() as conn:
|
||||
# api_tokens (Settings → API tokens)
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# extension_devices
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# legacy user_tokens
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
with get_conn() as conn:
|
||||
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
|
||||
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
|
||||
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
|
||||
|
||||
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
if not device_id:
|
||||
raise HTTPException(status_code=400, detail="device_id required")
|
||||
if len(device_id) > 128:
|
||||
raise HTTPException(status_code=400, detail="device_id too long")
|
||||
try:
|
||||
res = register_device(user["id"], device_id, device_name, extension_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
if res["existing"]:
|
||||
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
|
||||
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
if clen:
|
||||
try:
|
||||
if int(clen) > MAX_CLIP_BYTES + 1024:
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
# Rate limit 50/hour per device
|
||||
if not _check_rate_limit(f"{user['id']}:{device_id}"):
|
||||
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
|
||||
|
||||
url = (body.get("url") or body.get("source_url") or "").strip()
|
||||
title = (body.get("title") or "").strip()
|
||||
content = body.get("content") or body.get("html") or ""
|
||||
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
|
||||
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
|
||||
clip_type = "article"
|
||||
|
||||
if not url and not title and not content:
|
||||
raise HTTPException(status_code=400, detail="url, title or content required")
|
||||
|
||||
# Validate url if present
|
||||
if url:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
# allow bare domain? reject javascript:
|
||||
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
# Cap content bytes
|
||||
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
|
||||
|
||||
# Sanitize HTML content if present
|
||||
if content and "<" in content:
|
||||
# sanitize but keep structure for blocks converter
|
||||
content = sanitize_html(content)[: MAX_CLIP_BYTES]
|
||||
|
||||
# Prepare payload for service
|
||||
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
|
||||
if not _img_b64 and body.get("images"):
|
||||
try:
|
||||
_imgs = body.get("images")
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
"content": content,
|
||||
"content_type": clip_type,
|
||||
"selection_html": body.get("selection_html") or body.get("selection") or "",
|
||||
"image_base64": _img_b64,
|
||||
"tags": body.get("tags") or [],
|
||||
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
|
||||
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
|
||||
"metadata": body.get("metadata") or {},
|
||||
}
|
||||
|
||||
try:
|
||||
result = create_page_from_clip(clip_data, user["id"])
|
||||
except Exception as e:
|
||||
logger.exception("clip creation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create page") from None
|
||||
|
||||
# Log clip
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
sidebar = {}
|
||||
# Simple standalone page reusing base.html
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
user = _user_from_request(request)
|
||||
# Count for auth user
|
||||
devices = []
|
||||
clips = 0
|
||||
if user:
|
||||
try:
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
.ext-hero{{text-align:center;padding:28px 0 8px;}}
|
||||
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
|
||||
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
|
||||
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
|
||||
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
|
||||
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
|
||||
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
|
||||
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
|
||||
.ext-card a:hover{{text-decoration:underline;}}
|
||||
.ext-section{{margin:28px 0;}}
|
||||
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
|
||||
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
|
||||
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
|
||||
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
|
||||
.ext-dev-list{{margin-top:12px;}}
|
||||
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
|
||||
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
|
||||
</style>
|
||||
<div class="ext-page">
|
||||
<div class="ext-hero">
|
||||
<h1>🧩 FlowDeck Web Clipper</h1>
|
||||
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
|
||||
</div>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-card">
|
||||
<h3>🟢 Chrome / Edge</h3>
|
||||
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>🟠 Firefox</h3>
|
||||
<p>Firefox 109+ (Manifest V2 compat).</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>⌨️ Sans extension</h3>
|
||||
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
|
||||
<a href="/help">Docs /help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>How it works</h2>
|
||||
<ol class="ext-steps">
|
||||
<li>Install the extension (.zip) → enable in your browser.</li>
|
||||
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
|
||||
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
|
||||
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>Captures on this account</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
|
||||
<div class="ext-dev-list">
|
||||
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
|
||||
</div>
|
||||
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))
|
||||
@@ -0,0 +1,435 @@
|
||||
"""FlowDeck — Web Clipper service (v6.0.0).
|
||||
|
||||
Handles web content capture → FlowDeck page creation.
|
||||
|
||||
- Sanitizes incoming HTML (removes scripts, styles, event handlers).
|
||||
- Extracts readable content via BeautifulSoup heuristics (article/main/body).
|
||||
- Converts HTML → FlowDeck block list (headings, paragraphs, lists, quotes,
|
||||
code, images, bookmarks).
|
||||
- Creates a pages row (workspace-aware) and logs the clip.
|
||||
|
||||
No external network calls: images stay as remote URLs (no download in MVP);
|
||||
a future iteration can download + re-host inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CLIP_BYTES = 10 * 1024 * 1024 # 10 MB per clip
|
||||
MAX_CLIPS_PER_HOUR = 50
|
||||
|
||||
# In-memory rate limiter per device: {device_id: [timestamps]}
|
||||
_rate_store: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(device_id: str) -> bool:
|
||||
"""Return True if allowed, False if rate-limited (50/hour)."""
|
||||
now = time.time()
|
||||
window = 3600
|
||||
bucket = _rate_store.get(device_id, [])
|
||||
bucket = [t for t in bucket if now - t < window]
|
||||
if len(bucket) >= MAX_CLIPS_PER_HOUR:
|
||||
_rate_store[device_id] = bucket
|
||||
return False
|
||||
bucket.append(now)
|
||||
_rate_store[device_id] = bucket
|
||||
return True
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sanitize_html(html_str: str) -> str:
|
||||
"""Strip dangerous tags/attributes, return sanitized HTML string."""
|
||||
if not html_str:
|
||||
return ""
|
||||
# Cap size
|
||||
if len(html_str.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
html_str = html_str[: MAX_CLIP_BYTES // 2]
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "iframe"]):
|
||||
tag.decompose()
|
||||
# Strip event handlers and javascript: URLs
|
||||
for el in soup.find_all(True):
|
||||
for attr in list(el.attrs):
|
||||
if attr.lower().startswith("on"):
|
||||
del el.attrs[attr]
|
||||
elif attr in ("href", "src", "action"):
|
||||
val = str(el.attrs[attr]).strip()
|
||||
if val.lower().startswith("javascript:") or val.lower().startswith("data:text/html"):
|
||||
del el.attrs[attr]
|
||||
return str(soup)
|
||||
|
||||
|
||||
def _extract_main(soup: BeautifulSoup) -> BeautifulSoup:
|
||||
"""Pick the most content-rich container: article > main > body."""
|
||||
for sel in ["article", "main", "[role=article]", "#content", ".post-content", ".article-content"]:
|
||||
el = soup.select_one(sel)
|
||||
if el and len(el.get_text(strip=True)) > 120:
|
||||
return el
|
||||
return soup.body or soup
|
||||
|
||||
|
||||
def _text_node(el) -> str:
|
||||
return el.get_text(separator=" ", strip=True) if el else ""
|
||||
|
||||
|
||||
def html_to_blocks(html_str: str, source_url: str = "") -> list[dict]:
|
||||
"""Convert HTML → FlowDeck block list.
|
||||
|
||||
Covers: headings (h1-h4), paragraphs, blockquotes, code, lists,
|
||||
images, links as bookmark when standalone.
|
||||
"""
|
||||
if not html_str or not html_str.strip():
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
main = _extract_main(soup)
|
||||
blocks: list[dict] = []
|
||||
|
||||
def _add(b):
|
||||
if b.get("content") or b.get("src") or b.get("url"):
|
||||
blocks.append(b)
|
||||
|
||||
# Walk direct children and deeper elements
|
||||
for el in main.find_all(["h1", "h2", "h3", "h4", "p", "blockquote", "pre", "ul", "ol", "img", "figure", "a"], recursive=True):
|
||||
tag = el.name.lower()
|
||||
if tag in ("h1", "h2", "h3", "h4"):
|
||||
level = int(tag[1])
|
||||
level = min(level, 4)
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": f"heading_{level}", "content": txt})
|
||||
elif tag == "p":
|
||||
txt = _text_node(el)
|
||||
# Skip if parent is blockquote or li already handled
|
||||
if el.find_parent(["blockquote", "li"]):
|
||||
continue
|
||||
# If p contains an image, emit image even when text empty
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
# If there is also text alongside image, emit paragraph too
|
||||
if txt:
|
||||
# Remove image alt from paragraph? Keep text
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
continue
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
elif tag == "blockquote":
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "quote", "content": txt})
|
||||
elif tag == "pre":
|
||||
code_el = el.find("code")
|
||||
txt = (code_el.get_text() if code_el else el.get_text())
|
||||
if txt.strip():
|
||||
lang = ""
|
||||
if code_el and code_el.get("class"):
|
||||
for c in code_el.get("class"):
|
||||
if c.startswith("language-"):
|
||||
lang = c.replace("language-", "")
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "code", "content": txt.strip("\n"), "language": lang})
|
||||
elif tag in ("ul", "ol"):
|
||||
# Only top-level lists - skip nested
|
||||
if el.find_parent(["ul", "ol"]):
|
||||
continue
|
||||
is_ordered = tag == "ol"
|
||||
for li in el.find_all("li", recursive=False):
|
||||
txt = _text_node(li)
|
||||
if not txt:
|
||||
continue
|
||||
# Detect todo
|
||||
if re.match(r"^\[ ?[xX] ?\]\s*", txt):
|
||||
checked = bool(re.match(r"^\[ ?[xX] ?\]", txt))
|
||||
txt = re.sub(r"^\[ ?[xX] ?\]\s*", "", txt)
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "to_do", "content": txt, "checked": checked})
|
||||
else:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bulleted_list" if not is_ordered else "numbered_list", "content": txt})
|
||||
elif tag == "img":
|
||||
# Avoid double-count when inside p/figure already emitted
|
||||
if el.find_parent("p") or el.find_parent("figure"):
|
||||
# Still emit if parent p wasn't counted
|
||||
parent_p = el.find_parent("p")
|
||||
if parent_p and parent_p.find("img") == el:
|
||||
continue
|
||||
src = el.get("src", "").strip()
|
||||
if not src:
|
||||
continue
|
||||
# Skip data URIs for size
|
||||
if src.startswith("data:"):
|
||||
continue
|
||||
alt = el.get("alt", "") or ""
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
elif tag == "a":
|
||||
# Standalone links as bookmark when they are the only content in p
|
||||
parent = el.find_parent("p")
|
||||
txt = el.get_text(strip=True)
|
||||
href = el.get("href", "").strip()
|
||||
if href and parent and _text_node(parent) == txt and href.startswith("http"):
|
||||
# Will be handled as paragraph already; add bookmark variant if distinct
|
||||
pass
|
||||
elif tag == "figure":
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
|
||||
if not blocks:
|
||||
# Fallback: whole text as paragraphs
|
||||
texts = [t.strip() for t in main.get_text(separator="\n").split("\n") if t.strip()]
|
||||
for t in texts[:30]:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": t})
|
||||
|
||||
# Always ensure at least one block when source_url present
|
||||
if not blocks and source_url:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": source_url, "title": source_url})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_article(html_str: str, url: str = "") -> dict:
|
||||
"""High-level extraction returning title, text, images, metadata."""
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
title = ""
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
og_title = soup.find("meta", property="og:title")
|
||||
if og_title and og_title.get("content"):
|
||||
title = og_title["content"].strip() or title
|
||||
# Sanitize and convert
|
||||
clean = sanitize_html(html_str)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
# Images
|
||||
images = []
|
||||
for b in blocks:
|
||||
if b.get("type") == "image" and b.get("src"):
|
||||
images.append(b["src"])
|
||||
# Text
|
||||
text_parts = []
|
||||
for b in blocks:
|
||||
if b.get("content"):
|
||||
text_parts.append(b["content"])
|
||||
return {"title": title or "Clipped page", "blocks": blocks, "images": images, "text": "\n\n".join(text_parts)}
|
||||
|
||||
|
||||
def _ensure_workspace(conn, user_id: int, workspace_id: int | None) -> tuple[int, str]:
|
||||
"""Return (workspace_id, workspace_key) for clip insertion."""
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
# Check membership or owner
|
||||
mem = conn.execute(
|
||||
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user_id)
|
||||
).fetchone()
|
||||
if mem or conn.execute("SELECT 1 FROM workspaces WHERE id=? AND owner_id=?", (workspace_id, user_id)).fetchone():
|
||||
return workspace_id, row["name"]
|
||||
# Fallback: first workspace owned or member, else create one
|
||||
row = conn.execute(
|
||||
"SELECT w.id, w.name FROM workspaces w LEFT JOIN workspace_members wm ON w.id=wm.workspace_id "
|
||||
"WHERE w.owner_id=? OR wm.user_id=? ORDER BY w.id LIMIT 1",
|
||||
(user_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
# Create default workspace
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("My Workspace", user_id))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'admin')", (ws_id, user_id))
|
||||
return ws_id, "My Workspace"
|
||||
|
||||
|
||||
def create_page_from_clip(clip_data: dict, user_id: int) -> dict:
|
||||
"""Create a FlowDeck page from a clip payload. Returns {page_id, title}."""
|
||||
url = (clip_data.get("url") or clip_data.get("source_url") or "").strip()
|
||||
title = (clip_data.get("title") or "").strip()
|
||||
content = clip_data.get("content") or clip_data.get("html") or ""
|
||||
content_type = clip_data.get("content_type") or clip_data.get("clip_type") or "article"
|
||||
selection_html = clip_data.get("selection_html") or ""
|
||||
tags = clip_data.get("tags") or []
|
||||
target_ws = clip_data.get("target_workspace_id") or clip_data.get("workspace_id")
|
||||
target_page_id = clip_data.get("target_page_id") or clip_data.get("parent_page_id")
|
||||
# Normalize workspace id
|
||||
try:
|
||||
target_ws = int(target_ws) if target_ws is not None else None
|
||||
except (ValueError, TypeError):
|
||||
target_ws = None
|
||||
|
||||
# Determine title and blocks
|
||||
if content_type == "screenshot":
|
||||
# Screenshot: base64 image block + source bookmark (even with empty html)
|
||||
img_b64 = clip_data.get("image_base64") or clip_data.get("screenshot") or ""
|
||||
blocks = []
|
||||
if img_b64:
|
||||
src = img_b64 if img_b64.startswith("data:") else f"data:image/png;base64,{img_b64}"
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": title or "Screenshot"})
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
if not blocks:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": title or "Screenshot"})
|
||||
title = title or "Screenshot"
|
||||
elif content_type == "selection" and selection_html and selection_html.strip():
|
||||
clean = sanitize_html(selection_html)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
title = title or "Clipped selection"
|
||||
elif content_type == "bookmark" or not content.strip():
|
||||
# Bookmark mode: no HTML body, just link card
|
||||
bookmark_title = title or (url or "Bookmark")
|
||||
blocks = [
|
||||
{"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": bookmark_title, "description": clip_data.get("metadata", {}).get("og_description", "") or ""}
|
||||
]
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": f"Source: {url}"})
|
||||
title = bookmark_title
|
||||
else:
|
||||
# Article full
|
||||
result = extract_article(content, url=url)
|
||||
blocks = result["blocks"]
|
||||
if not title:
|
||||
title = result["title"]
|
||||
# Append source bookmark if not already dominant
|
||||
if url:
|
||||
# avoid duplicate bookmark if last block already is bookmark to same url
|
||||
if not blocks or blocks[-1].get("url") != url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": "Source"})
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": url})
|
||||
|
||||
# Cap blocks
|
||||
if len(blocks) > 200:
|
||||
blocks = blocks[:200]
|
||||
title = (title or "Clipped page").strip()[:200] or "Clipped page"
|
||||
|
||||
# Persist
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_key = _ensure_workspace(conn, user_id, target_ws)
|
||||
# Validate target_page_id belongs to same workspace
|
||||
parent_id = None
|
||||
if target_page_id:
|
||||
try:
|
||||
pid = int(target_page_id)
|
||||
pr = conn.execute("SELECT id, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if pr and (pr["workspace_id"] == ws_id or pr["workspace_id"] is None):
|
||||
parent_id = pid
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Determine sort order
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id=?", (parent_id,)).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", (ws_id,)).fetchone()[0]
|
||||
blocks_json = json.dumps(blocks, ensure_ascii=False)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, 'blocks', 'Private', ?, ?)""",
|
||||
(ws_key or "", ws_id, title, blocks_json, parent_id, next_order),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
# Tags: create per-user tags if needed and attach via page_tags
|
||||
for tname in tags[:10]:
|
||||
tn = tname.strip()[:50]
|
||||
if not tn:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name, color, user_id) VALUES (?, '#787774', ?)", (tn, user_id))
|
||||
tr = conn.execute("SELECT id FROM tags WHERE name=? AND user_id=?", (tn, user_id)).fetchone()
|
||||
if tr:
|
||||
conn.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tr["id"]))
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "title": title, "blocks": blocks, "workspace_id": ws_id}
|
||||
|
||||
|
||||
def register_device(user_id: int, device_id: str, device_name: str = "", extension_name: str = "chrome") -> dict:
|
||||
"""Register or update an extension device, returns {device, token} (token shown once if new)."""
|
||||
if not device_id or len(device_id) > 128:
|
||||
raise ValueError("Invalid device_id")
|
||||
token = f"fd_clip_{uuid.uuid4().hex}{uuid.uuid4().hex[:8]}"
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_hash FROM extension_devices WHERE user_id=? AND device_id=? AND extension_name=?",
|
||||
(user_id, device_id, extension_name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE extension_devices SET device_name=?, last_used_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(device_name[:200], existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": existing["id"], "device_id": device_id, "token": None, "existing": True}
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO extension_devices (user_id, extension_name, device_id, device_name, token_hash)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(user_id, extension_name, device_id, device_name[:200], thash),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
||||
|
||||
|
||||
def verify_device_token(device_id: str, token: str) -> dict | None:
|
||||
"""Verify a device token, returns device row or None."""
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
|
||||
(device_id, thash),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO extension_clips (user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, device_id, clip_type[:20], source_url[:2000], target_page_id, workspace_id, title[:200]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_devices(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, extension_name, device_id, device_name, scopes, last_used_at, created_at, revoked FROM extension_devices WHERE user_id=? ORDER BY last_used_at DESC, created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
# Enrich with clip counts
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
cnt = conn.execute("SELECT COUNT(*) AS n FROM extension_clips WHERE user_id=? AND device_id=?", (user_id, r["device_id"])).fetchone()["n"]
|
||||
d["clips_count"] = cnt
|
||||
# Last clip
|
||||
last = conn.execute("SELECT created_at FROM extension_clips WHERE user_id=? AND device_id=? ORDER BY created_at DESC LIMIT 1", (user_id, r["device_id"])).fetchone()
|
||||
d["last_clip_at"] = last["created_at"] if last else None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def revoke_device(user_id: int, device_row_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE extension_devices SET revoked=1 WHERE id=? AND user_id=?", (device_row_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -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 & 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 {
|
||||
|
||||
Reference in New Issue
Block a user