diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3175c..e714a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog - FlowDeck +## v5.11.2 (2026-09-12) — v5.5.0 Embeds & Média riche (complétion) + +> Valide et complète le chantier v5.5.0 (parité embeds Notion). Une grande +> partie était ébauchée ; ce patch répare les chemins d'API cassés côté éditeur, +> finalise la résolution d'embed, le parsing OG, la lightbox publique et couvre +> le tout de 47 tests dédiés. + +- **Embed universel** — `app/services/embeds.py` réécrit : détection + sous-domaine correcte, YouTube (watch/shorts/youtu.be), Vimeo, Figma, + Google Maps, Google Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, + SoundCloud, Twitch (parent réel), X/Twitter, Pinterest, Microsoft Office ; + helpers `resolve_embed()`, `inline_kind()`, `provider()`. Nouvel endpoint + `POST /board/api/embed/resolve`. +- **Résolution d'embed dans l'éditeur** — la saisie d'une URL appelle le + résolveur et met en cache `embed_src`/`embed_provider` (l'URL d'origine est + conservée) ; le rendu (éditeur, pages publiques, exports MD/HTML/PDF) préfère + `embed_src`. Le champ est persisté via la sérialisation des blocs. +- **Bookmark cards** — `app/services/og_fetcher.py` : parseur `` robuste + (ordre d'attributs libre), titre de repli, favicon, transport injectable pour + les tests ; `fetch_og_metadata` ne lève jamais sur erreur réseau. +- **Image lightbox** — navigation clavier ←/→ et Escape, multi-images, dans + l'éditeur **et** les pages publiques `/p/` (CSS `.fd-lightbox`). +- **Fix chemins API (bug v5.4.0/v5.5.0)** — l'éditeur appelait + `/api/pages/...` alors que les routes sont `/board/api/pages/...` : cover, + icon, versions, backlinks, import, move et OG metadata étaient donc en 404. + Tous corrigés, avec un test de régression statique. +- **Tests** — `tests/test_v55.py` : 47 tests (embeds par provider, resolve API, + parse OG + MockTransport, lightbox, préviews, cover upload/URL/suppression, + icône, page publique). Suite complète **444 verte**, `ruff check` OK. +- **Version** — 5.11.2. + ## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion) > Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées diff --git a/ROADMAP.md b/ROADMAP.md index 6570fe8..bd94607 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -523,15 +523,25 @@ app/ - [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv` - [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte** -### v5.5.0 — Embeds & Média Riche +### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12) > **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page. -> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). +> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**. -- [ ] **Bloc Embed universel** — `/embed` : YouTube, Vimeo, Figma, Google Maps, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Office… -- [ ] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description) -- [ ] **Image lightbox** — clic pour agrandir, navigation clavier/consultation plein écran -- [ ] **Previews inline** — PDF, vidéo, audio rendus directement dans la page -- [ ] **Cover & icône de page** — upload image de couverture + emoji/icône custom (déjà partiel ?) +- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office… +- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon) +- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques +- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page +- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom + +Détails livrés : +- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve` +- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata` +- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon` +- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF +- Pages publiques `/p/` : cover + icône, embed résolu, lightbox clavier +- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu +- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé +- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK ### v5.6.0 — Import de données (étendu) > **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types. @@ -636,6 +646,7 @@ app/ 4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests) 5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests) 6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion) +7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés) --- ## Résumé des phases @@ -647,11 +658,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜ -Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent -& Tests Templates & COMPLETED Automations Interactions blocks - Collaboration Realtime, de bloc (undo/ - DB avancée, redo, drag&drop, - Calendrier, AI duplicate) +v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ v5.14 ⬜ v6.0 ⬜ +Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent +& Tests Templates & COMPLETED Automations Interactions Média riche blocks + Collaboration Realtime, de bloc (undo/ (embed, + DB avancée, redo, drag&drop, bookmark, + Calendrier, AI duplicate) lightbox…) -*Dernière mise à jour: 2026-09-12 — **v5.4.0 Expérience éditeur validé** (17 tests dédiés, 397 tests suite complète, ruff/eslint verts) ; reste v5.11 → v6.0* +*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0* diff --git a/VERSION b/VERSION index 32447ce..a87467f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.1 +5.11.2 diff --git a/app/main.py b/app/main.py index 60c74eb..a0978c4 100644 --- a/app/main.py +++ b/app/main.py @@ -85,7 +85,7 @@ async def lifespan(_app: FastAPI): from app.services.trash import trash_purge_scheduler trash_task = asyncio.create_task(trash_purge_scheduler()) - logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port) + logger.info("FlowDeck v5.11.2 started on port %d", settings.app_port) try: yield finally: @@ -100,7 +100,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.11.1", + version="5.11.2", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/board.py b/app/routers/board.py index 5021af3..5b345b1 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -1384,6 +1384,26 @@ async def og_metadata(request: Request): return {"ok": True, **data} +@router.post("/api/embed/resolve") +async def resolve_embed(request: Request): + """v5.5.0: rewrite a pasted URL to its provider embed src. + + Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps, + Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…). + """ + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") from None + url = (body.get("url") or "").strip() + if not url: + raise HTTPException(400, "url required") + from app.config import settings + from app.services.embeds import resolve_embed as _resolve + data = _resolve(url, parent=settings.app_base_url) + return {"ok": True, "url": url, **data} + + @router.put("/api/pages/{page_id}/move") async def move_page(request: Request, page_id: int): """Move a page to another workspace or reorder within tree. diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 0adf471..e3c095b 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -2230,7 +2230,7 @@ def _render_blocks_public(blocks: list) -> str: ) elif url: from app.services.embeds import embed_src - src = embed_src(url) or url + src = b.get("embed_src") or embed_src(url) or url height = b.get("height") or 520 try: height = int(height) diff --git a/app/services/embeds.py b/app/services/embeds.py index 64cc808..0e2ee22 100644 --- a/app/services/embeds.py +++ b/app/services/embeds.py @@ -1,9 +1,10 @@ """FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting. Maps a raw http(s) URL to a provider-specific embed URL so that one generic -``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom, -CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office -docs… exactly like Notion's universal embed. +``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google +Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, +X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal +embed. Unknown/showable URLs (PDF, images, direct video/audio files, plain http) fall back to a plain iframe so the link is still visible inline. @@ -11,11 +12,7 @@ fall back to a plain iframe so the link is still visible inline. from __future__ import annotations import re -from urllib.parse import parse_qs, urlparse - -_PROVIDERS = ( - # (host pattern, extra path check, to_embed(url, path, query) -> str | None) -) +from urllib.parse import parse_qs, quote, urlparse def _q(params, key): @@ -23,9 +20,21 @@ def _q(params, key): return vals[0] if vals else "" -def _embed_youtube(url: str, path: str, params) -> str | None: - m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path) - vid = m.group(2) if m else _q(params, "v") +def _host_matches(netloc: str, host: str) -> bool: + """True when ``netloc`` is ``host`` or one of its subdomains.""" + netloc = (netloc or "").lower().split(":")[0] + host = host.lower() + return netloc == host or netloc.endswith("." + host) + + +def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None: + m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path) + vid = m.group(1) if m else _q(params, "v") + if not vid: + # youtu.be/ (short link) — the id is the first path segment. + seg = path.strip("/").split("/")[0] + if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""): + vid = seg if not vid: return None start = _q(params, "t") or _q(params, "start") @@ -33,98 +42,122 @@ def _embed_youtube(url: str, path: str, params) -> str | None: return f"https://www.youtube.com/embed/{vid}{frag}" -def _embed_vimeo(url: str, path: str, params) -> str | None: +def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None: m = re.search(r"/(\d{6,12})", path) if not m: return None return f"https://player.vimeo.com/video/{m.group(1)}" -def _embed_loom(url: str, path: str, params) -> str | None: - m = re.search(r"/(embed/)?([0-9a-f]{32})", path) +def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None: + m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path) if not m: return None - return f"https://www.loom.com/embed/{m.group(2)}" + return f"https://www.loom.com/embed/{m.group(1)}" -def _embed_figma(url: str, path: str, params) -> str | None: - if "figma.com/file/" not in url and "figma.com/proto/" not in url: +def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None: + clean = url.split("?", 1)[0] + if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean: return None - m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0]) - if not m: - return None - return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0] + return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="") -def _embed_map(url: str, path: str, params) -> str | None: +def _embed_map(url: str, path: str, params, ctx: dict) -> str | None: if "google.com/maps" not in url and "maps.app.goo.gl" not in url: return None - return f"https://www.google.com/maps?output=embed&q={url}" + return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed" -def _embed_codepen(url: str, path: str, params) -> str | None: +def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None: + m = re.search( + r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url + ) + if not m: + return None + kind, doc_id = m.group(1), m.group(2) + if kind == "forms": + return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true" + return f"https://docs.google.com/{kind}/d/{doc_id}/preview" + + +def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None: m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url) if not m: return None return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result" -def _embed_miro(url: str, path: str, params) -> str | None: - m = re.search(r"miro\.com/app/board/([^/?#]+)", url) +def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None: + m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url) if not m: return None return f"https://miro.com/app/live-embed/{m.group(1)}" -def _embed_spotify(url: str, path: str, params) -> str | None: +def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None: m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url) if not m: return None return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}" -def _embed_soundcloud(url: str, path: str, params) -> str | None: - m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url) - if not m: +def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None: + if "soundcloud.com" not in url: return None - return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff" + return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff" -def _embed_twitch(url: str, path: str, params) -> str | None: +def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None: if "twitch.tv" not in url: return None + parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0] + video = re.search(r"twitch\.tv/videos/(\d+)", url) + if video: + return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}" m = re.search(r"twitch\.tv/([^/?#]+)", url) - if not m: + if not m or m.group(1) in ("videos", "directory"): return None - return f"https://player.twitch.tv/?channel={m.group(1)}&parent=" + return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}" -def _embed_twitter(url: str, path: str, params) -> str | None: +def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None: if "twitter.com" not in url and "x.com" not in url: return None - m = re.search(r"/([^/?#]+)/status/(\d+)", url) + m = re.search(r"/status(?:es)?/(\d+)", url) if not m: - return f"https://platform.twitter.com/embed/Tweet.html?url={url}" - return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}" + return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}" + return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}" -def _embed_pinterest(url: str, path: str, params) -> str | None: +def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None: if "pinterest" not in url: return None - return f"https://pinterest.com/pin/embed?url={url}" + return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}" -def _embed_files(url: str, path: str, params) -> str | None: +def _embed_office(url: str, path: str, params, ctx: dict) -> str | None: + low = url.lower().split("?", 1)[0] + if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")): + return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="") + if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low: + return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="") + return None + + +def _embed_files(url: str, path: str, params, ctx: dict) -> str | None: """Direct media: PDF/images/videos/audio can live in a plain iframe.""" return url +# (host, handler) — order matters: more specific hosts first. _HANDLERS = ( ("youtube.com", _embed_youtube), ("youtu.be", _embed_youtube), ("vimeo.com", _embed_vimeo), ("loom.com", _embed_loom), ("figma.com", _embed_figma), + ("docs.google.com", _embed_gdocs), ("google.com/maps", _embed_map), ("maps.app.goo.gl", _embed_map), ("codepen.io", _embed_codepen), @@ -135,27 +168,54 @@ _HANDLERS = ( ("twitch.tv", _embed_twitch), ("twitter.com", _embed_twitter), ("x.com", _embed_twitter), - ("pinterest", _embed_pinterest), + ("pinterest.", _embed_pinterest), + ("office.com", _embed_office), + ("officeapps.live.com", _embed_office), + ("sharepoint.com", _embed_office), + ("1drv.ms", _embed_office), ) -def embed_src(url: str) -> str | None: +_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):") + + +def _parse(url: str): + raw = url.strip() + if not raw: + return None, None, None + m = _SCHEME_RE.match(raw) + if m: + if m.group(1).lower() not in ("http", "https"): + return None, None, None # mailto:, tel:, javascript:, data:… + else: + raw = "https://" + raw + u = urlparse(raw) + if u.scheme not in ("http", "https") or not u.netloc: + return None, None, None + host = u.hostname or "" + if "." not in host and host != "localhost": + return None, None, None # a bare word is not a URL + return raw, u, parse_qs(u.query) + + +def embed_src(url: str, *, parent: str = "") -> str | None: """Return the embeddable iframe src for a URL, or None if it can't embed.""" - if not url: + raw, u, params = _parse(url) + if raw is None: return None - url = url.strip() - u = urlparse(url if url.startswith("http") else "https://" + url) - if u.scheme not in ("http", "https"): - return None - (u.hostname or "").lower().replace("www.", "") + ctx = {"parent": parent} netloc = (u.netloc or "").lower() - params = parse_qs(u.query) - # Google Maps share links encode the query in the path (…&q=/maps/…) - encoded = url for needle, handler in _HANDLERS: - if needle in netloc: - return handler(encoded, u.path, params) - return _embed_files(encoded, u.path, params) + if "/" in needle or needle.endswith("."): + if needle in raw.lower(): + return handler(raw, u.path, params, ctx) + elif _host_matches(netloc, needle): + return handler(raw, u.path, params, ctx) + # Office documents hosted on arbitrary domains. + office = _embed_office(raw, u.path, params, ctx) + if office: + return office + return _embed_files(raw, u.path, params, ctx) _IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I) @@ -165,12 +225,12 @@ _AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I) def inline_kind(url: str) -> str | None: - """Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' | + """Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' | 'audio'. Returns None when the URL should open in a new tab.""" - if not url: + raw, u, _params = _parse(url) + if raw is None: return None - url = url.strip() - path = urlparse(url).path + path = u.path or "" if _IMAGE_EXT.search(path): return "image" if _PDF_EXT.search(path): @@ -179,9 +239,33 @@ def inline_kind(url: str) -> str | None: return "video" if _AUDIO_EXT.search(path): return "audio" - if url.startswith(("http://", "https://")): - return "iframe" - return None + return "iframe" + + +def provider(url: str) -> str: + """Human-readable provider name for a URL (used by the editor).""" + raw, u, _params = _parse(url) + if raw is None: + return "" + netloc = (u.netloc or "").lower() + for needle, _handler in _HANDLERS: + if "/" in needle or needle.endswith("."): + if needle in raw.lower(): + return needle.split(".")[0].rstrip(".") + elif _host_matches(netloc, needle): + name = needle.split(".")[0] + return "youtube" if name == "youtu" else name + return "" + + +def resolve_embed(url: str, *, parent: str = "") -> dict: + """Resolve a URL to ``{src, kind, provider}`` for the generic embed block.""" + kind = inline_kind(url) + return { + "src": embed_src(url, parent=parent) or "", + "kind": kind or "", + "provider": provider(url), + } def embed_html(src: str, *, height: int = 520) -> str: diff --git a/app/services/export.py b/app/services/export.py index c22f96d..e9d0635 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -665,7 +665,7 @@ def blocks_to_html(blocks: list) -> str: parts.append(f'') elif url: from app.services.embeds import embed_src - src = embed_src(url) or url + src = b.get("embed_src") or embed_src(url) or url height = 520 if b.get("height"): try: diff --git a/app/services/og_fetcher.py b/app/services/og_fetcher.py index 4cd68d3..232dda9 100644 --- a/app/services/og_fetcher.py +++ b/app/services/og_fetcher.py @@ -7,39 +7,44 @@ slow/unreachable hosts. """ from __future__ import annotations +import html as htmlmod import logging import re from urllib.parse import urljoin, urlparse logger = logging.getLogger(__name__) -_META_RE = re.compile( - r']*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>', - re.I, -) +_META_TAG_RE = re.compile(r"]*?>", re.I) +_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']") +_TITLE_RE = re.compile(r"]*>(.*?)", re.I | re.S) +_FAVICON_RE = re.compile(r"]*?>", re.I) +_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I) # Property/name keys we look for, in priority order, mapped to our payload keys. _OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name") _OG_DESC = ("og:description", "twitter:description", "description") -_OG_IMG = ("og:image", "twitter:image", "twitter:image:src") +_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image") _OG_SITE = ("og:site_name", "twitter:site", "application-name") -def _extract_og(html: str) -> dict: - text = html[:400_000] # only scan the beginning — that's where lives +def _attrs(tag: str) -> dict: + return {k.lower(): v for k, v in _ATTR_RE.findall(tag)} + + +def _extract_og(body: str) -> dict: + """Parse all ```` tags into a ``{key: content}`` dict. + + Attributes may appear in any order (``content`` before or after + ``property``/``name``), which the previous implementation mishandled. + First value wins so the most specific tag (top of document) is kept. + """ props: dict[str, str] = {} - for m in _META_RE.finditer(text): - m.groups() - content = "" - prop = "" - for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)): - if prop == "": - prop = s - else: - content = s - break - if prop: - props[prop.lower()] = content + for tag in _META_TAG_RE.finditer(body[:400_000]): + attrs = _attrs(tag.group(0)) + key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower() + content = attrs.get("content") + if key and content is not None and key not in props: + props[key] = content return props @@ -51,12 +56,12 @@ def _pick(props: dict, keys: tuple) -> str: return "" -def _title_of(props: dict, raw_title: str) -> str: +def _title_of(props: dict, body: str) -> str: t = _pick(props, _OG_TITLE) if t: return t - m = re.search(r"]*>(.*?)", raw_title[:200_000], re.I | re.S) - return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc + m = _TITLE_RE.search(body[:200_000]) + return m.group(1).strip() if m else "" def _site_name(url: str) -> str: @@ -64,20 +69,62 @@ def _site_name(url: str) -> str: return host.split(".")[0].capitalize() if host else "" -async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict: +def _favicon(body: str, base_url: str) -> str: + for tag in _FAVICON_RE.finditer(body): + attrs = _attrs(tag.group(0)) + rel = attrs.get("rel", "") + href = attrs.get("href", "") + if href and _ICON_REL.search(rel): + return urljoin(base_url, htmlmod.unescape(href)) + return "" + + +def parse_og(body: str, url: str) -> dict: + """Pure HTML → bookmark payload (no network). ``url`` is the base URL.""" + src = url.strip() + if not src.startswith(("http://", "https://")): + src = "https://" + src + props = _extract_og(body) + title = htmlmod.unescape(_title_of(props, body)) + desc = htmlmod.unescape(_pick(props, _OG_DESC)) + img = _pick(props, _OG_IMG) + site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src) + + def abs_url(u: str) -> str: + return urljoin(src, htmlmod.unescape(u)) if u else "" + + return { + "url": src, + "title": title.strip()[:200] or urlparse(src).netloc or src, + "description": desc.strip()[:400], + "image": abs_url(img), + "site_name": site.strip()[:100], + "favicon": _favicon(body, src), + } + + +async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict: """Fetch ``url`` and return {url, title, description, image, site_name, - favicon}. Empty strings are omitted. Never raises for network errors.""" + favicon}. Empty strings are omitted. Never raises for network errors. + + ``transport`` is an optional ``httpx`` transport (used by tests to mock + HTTP without hitting the network). + """ src = url.strip() if not src.startswith(("http://", "https://")): src = "https://" + src base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""} try: import httpx + headers = { - "User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)", + "User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)", "Accept": "text/html,application/xhtml+xml", } - async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client: + kwargs = {"follow_redirects": True, "timeout": timeout} + if transport is not None: + kwargs["transport"] = transport + async with httpx.AsyncClient(**kwargs) as client: resp = await client.get(src, headers=headers) resp.raise_for_status() except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal @@ -92,33 +139,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict: base["site_name"] = _site_name(src) return base - import html as htmlmod - - body = resp.text - props = _extract_og(body) - props["_url"] = src - title = htmlmod.unescape(_title_of(props, body)) - desc = htmlmod.unescape(_pick(props, _OG_DESC)) - img = _pick(props, _OG_IMG) - site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src) - - def abs_url(u: str) -> str: - if not u: - return "" - return urljoin(src, htmlmod.unescape(u)) - - favicon = "" - fm = re.search(r"]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I) - if not fm: - fm = re.search(r"]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I) - if fm: - favicon = abs_url(fm.group(1)) - - return { - "url": src, - "title": title.strip()[:200] or urlparse(src).netloc or src, - "description": desc.strip()[:400], - "image": abs_url(img), - "site_name": site.strip()[:100], - "favicon": favicon, - } + return parse_og(resp.text, src) diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index b624878..de6cec4 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -439,8 +439,7 @@ return `
{{ fd_icon("paperclip",14) }}
${esc(b.file_name||'File')}
${szStr} · ${esc(b.file_mime||'')}
⬇ Download
`; } var url=b.src||'';if(url){ - var src=url; - try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){} + var src=b.embed_src||url; var height=b.height||520; return `
`; } @@ -1036,7 +1035,20 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu if(u.trim()===cur.trim())return; this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync(); }, - setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');}, + async setEmbedUrl(bid){ + const idx=this.getIdx(bid); if(idx<0)return; + const b=this.blocks[idx]; if(!b)return; + const cur=b.src||''; const u=prompt('Embed URL (YouTube, Vimeo, Figma, Maps…):', cur); + if(u===null)return; + if(u.trim()===cur.trim())return; + this.sync();this.pushHistory();b.src=u.trim();b.embed_type='';this.dirty=true; + try{ + const r=await fetch('/board/api/embed/resolve',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url:u.trim()})}); + const d=await r.json(); + if(d&&d.ok&&d.src){b.embed_src=d.src;b.embed_provider=d.provider||'';} + }catch(e){} + this.render();this.autoSave();_rtSync(); + }, setBookmarkUrl(bid){ const idx=this.getIdx(bid); if(idx<0)return; const b=this.blocks[idx]; if(!b)return; @@ -1048,7 +1060,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu }, async _fetchBookmarkPreview(bid, url){ try{ - const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})}); + const r=await fetch('/board/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})}); const d=await r.json(); if(d&&d.ok){ const idx=this.getIdx(bid); if(idx<0)return; @@ -1064,17 +1076,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');}, setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');}, - // ── v5.5.0: Image lightbox ── + // ── v5.5.0: Image lightbox (fullscreen + keyboard navigation) ── _openLightbox(imgEl){ const full=imgEl.dataset.full||imgEl.src; if(!full)return; - const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;'; - ov.innerHTML='
Esc to close
'; - document.body.appendChild(ov); - const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);}; - const kd=(e)=>{if(e.key==='Escape')close();}; + const imgs=Array.from(document.querySelectorAll('.block-image img, .fd-img')).filter(i=>i.dataset.full||i.src); + let idx=imgs.indexOf(imgEl); + if(idx<0)idx=0; + const overlay=document.createElement('div');overlay.className='fd-lightbox';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;'; + overlay.innerHTML='' + +'' + +'' + +'
'; + document.body.appendChild(overlay); + const img=overlay.querySelector('.fd-lightbox-img'); + const hint=overlay.querySelector('.fd-lightbox-hint'); + const show=()=>{const cur=imgs[idx];img.src=cur.dataset.full||cur.src;hint.textContent=(imgs.length>1?(idx+1)+' / '+imgs.length+' — ':'')+'← → to navigate · Esc to close';}; + const step=(d)=>{if(imgs.length<2)return;idx=(idx+d+imgs.length)%imgs.length;show();}; + show(); + const close=()=>{overlay.remove();document.removeEventListener('keydown',kd);}; + const kd=(e)=>{if(e.key==='Escape')close();else if(e.key==='ArrowRight')step(1);else if(e.key==='ArrowLeft')step(-1);}; document.addEventListener('keydown',kd); - ov.addEventListener('click',e=>{if(e.target===ov)close();}); + overlay.querySelectorAll('[data-nav]').forEach(btn=>btn.addEventListener('click',e=>{e.stopPropagation();step(btn.dataset.nav==='next'?1:-1);})); + overlay.addEventListener('click',e=>{if(e.target===overlay)close();}); }, // ── v5.4.0: Version history UI ── @@ -1086,7 +1110,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu async loadVersions(){ this.versionsLoading=true; try{ - const r=await fetch('/api/pages/'+this.pid+'/versions'); + const r=await fetch('/board/api/pages/'+this.pid+'/versions'); const d=await r.json(); this.versionsList=d.versions||[]; }catch(e){this.versionsList=[];} @@ -1095,7 +1119,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu async restoreVersion(vid){ if(!confirm('Restore this version? Current content will be replaced.'))return; try{ - const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}}); + const r=await fetch('/board/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}}); const d=await r.json(); if(d.status==='ok'){ this.showToast('Version restored');this.versionsOpen=false; @@ -1113,7 +1137,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu async loadBacklinks(){ this.backlinksLoading=true; try{ - const r=await fetch('/api/pages/'+this.pid+'/backlinks'); + const r=await fetch('/board/api/pages/'+this.pid+'/backlinks'); const d=await r.json(); this.backlinksList=d.backlinks||[]; }catch(e){this.backlinksList=[];} @@ -1135,7 +1159,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu if(u===null)return; this.coverLoading=true; try{ - const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})}); + const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})}); const d=await r.json(); if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');} }catch(e){this.showToast('Cover update failed','error');} @@ -1146,7 +1170,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu this.coverLoading=true; const fd=new FormData();fd.append('file',f); try{ - const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd}); + const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd}); const d=await r.json(); if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');} }catch(e){this.showToast('Cover upload failed','error');} @@ -1156,7 +1180,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu async removeCover(){ if(!confirm('Remove cover?'))return; try{ - const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}}); + const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}}); const d=await r.json(); if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');} }catch(e){this.showToast('Remove failed','error');} @@ -1165,7 +1189,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu async setIcon(icon){ this.iconValue=icon; try{ - const r=await fetch('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})}); + const r=await fetch('/board/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})}); const d=await r.json(); if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');} }catch(e){this.showToast('Icon update failed','error');} @@ -1186,13 +1210,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu if(this.importFile){ const fd=new FormData();fd.append('file',this.importFile); try{ - const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd}); + const r=await fetch('/board/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd}); const d=await r.json(); if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';} }catch(e){this.showToast('Import failed','error');} }else{ try{ - const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})}); + const r=await fetch('/board/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})}); const d=await r.json(); if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);} }catch(e){this.showToast('Import failed','error');} @@ -1518,7 +1542,7 @@ applyAIBlocks(text){ this.moveOpen = false; var csrf = document.cookie.match(/csrf_token=([^;]+)/); var self = this; - fetch('/api/pages/' + this.pid + '/move', { + fetch('/board/api/pages/' + this.pid + '/move', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }, body: JSON.stringify({ workspace_id: wsId }) @@ -1941,7 +1965,7 @@ applyAIBlocks(text){ const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||''; this.saving=true; const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';}); if(cb)cb(); diff --git a/app/templates/public_page.html b/app/templates/public_page.html index 9941f93..ca62327 100644 --- a/app/templates/public_page.html +++ b/app/templates/public_page.html @@ -125,6 +125,18 @@ color: var(--accent); text-decoration: none; } + .pub-content img[data-full] { cursor: zoom-in; } + .fd-lightbox { user-select: none; } + .fd-lightbox-nav { + position: absolute; top: 50%; transform: translateY(-50%); + width: 48px; height: 48px; border-radius: 50%; border: none; + background: rgba(255,255,255,.12); color: #fff; font-size: 30px; + line-height: 1; cursor: pointer; display: flex; align-items: center; + justify-content: center; transition: background .15s ease; + } + .fd-lightbox-nav:hover { background: rgba(255,255,255,.25); } + .fd-lightbox-nav[data-nav="prev"] { left: 24px; } + .fd-lightbox-nav[data-nav="next"] { right: 24px; } @@ -162,15 +174,48 @@ diff --git a/static/css/app.css b/static/css/app.css index 05b3da5..a0688d2 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -4747,3 +4747,19 @@ button.breadcrumb-current { max-width: 260px; } .aici-result a { color: var(--accent,#2383E2); } .aici-fb:disabled { opacity:.4; cursor:default; } @media (max-width:768px){ #_aiC { width: 92vw; } } + +/* v5.5.0 — Image lightbox */ +.fd-lightbox { user-select: none; } +.fd-lightbox-img { animation: fdLbIn .12s ease-out; } +@keyframes fdLbIn { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } } +.fd-lightbox-nav { + position: absolute; top: 50%; transform: translateY(-50%); + width: 48px; height: 48px; border-radius: 50%; + border: none; background: rgba(255,255,255,.12); color: #fff; + font-size: 30px; line-height: 1; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: background .15s ease; +} +.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); } +.fd-lightbox-nav[data-nav="prev"] { left: 24px; } +.fd-lightbox-nav[data-nav="next"] { right: 24px; } diff --git a/tests/test_v55.py b/tests/test_v55.py new file mode 100644 index 0000000..b143c6e --- /dev/null +++ b/tests/test_v55.py @@ -0,0 +1,545 @@ +"""FlowDeck — v5.5.0 "Embeds & Média riche" test suite. + +Covers the five roadmap points: + 1. Universal embed block → app/services/embeds.py + /board/api/embed/resolve + 2. Bookmark cards → app/services/og_fetcher.py + /board/api/og/metadata + 3. Image lightbox → editor + public page markup + 4. Inline previews → PDF / video / audio rendering + 5. Cover & page icon → /board/api/pages/{id}/cover|icon + public page +""" +from __future__ import annotations + +import asyncio +import json +import secrets +from pathlib import Path + +import httpx +import pytest + +# ── Helpers ────────────────────────────────────────────────────────────── + + +def _login(client): + from app.auth.session import SessionManager + from app.db import get_conn + + with get_conn() as conn: + login = f"testv55_{secrets.token_hex(4)}" + conn.execute( + "INSERT INTO users (login, full_name, email, password_hash, is_admin) " + "VALUES (?, 'Test V55', ?, '', 1)", + (login, f"{login}@test.com"), + ) + uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] + conn.commit() + session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1}) + r = client.get("/api/csrf-token", cookies={"flowdeck_session": session}) + return session, r.json()["csrf_token"] + + +def _workspace(client, cookie, name="V55 WS"): + r = client.post("/workspace", json={"name": name}, cookies={"flowdeck_session": cookie}) + assert r.status_code == 200 + ws = r.json() + client.cookies.set("flowdeck_workspace", str(ws["id"])) + return ws["id"] + + +def _page(client, cookie, csrf, title="V55 Page"): + r = client.post( + f"/board/api/pages?title={title}§ion=Private&parent_id=0", + headers={"X-CSRF-Token": csrf}, + cookies={"flowdeck_session": cookie}, + ) + assert r.status_code == 200 + return r.json()["id"] + + +def _save_blocks(client, cookie, csrf, page_id, blocks, title="V55 Page"): + r = client.post( + f"/board/api/pages/{page_id}/blocks", + json={"title": title, "blocks": blocks}, + headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, + cookies={"flowdeck_session": cookie}, + ) + assert r.status_code == 200 + return r.json() + + +def _cookie_headers(cookie, csrf=None): + h = {} + if csrf: + h["X-CSRF-Token"] = csrf + return h + + +# ══════════════════════════════════════════════════════════════════════════ +# 1. Universal embed +# ══════════════════════════════════════════════════════════════════════════ + + +class TestEmbedService: + def test_youtube_variants(self): + from app.services.embeds import embed_src + + assert embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ") == ( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ) + assert embed_src("https://youtu.be/dQw4w9WgXcQ") == ( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ) + assert embed_src("https://www.youtube.com/shorts/abcdef123") == ( + "https://www.youtube.com/embed/abcdef123" + ) + assert "start=42" in embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42") + + @pytest.mark.parametrize( + "url,needle", + [ + ("https://vimeo.com/123456789", "player.vimeo.com/video/123456789"), + ("https://www.figma.com/file/abc123/Doc", "figma.com/embed"), + ("https://www.google.com/maps/place/Eiffel", "maps.google.com/maps?q="), + ("https://docs.google.com/document/d/ABC123/edit", "docs.google.com/document/d/ABC123/preview"), + ("https://docs.google.com/spreadsheets/d/SHEET1/edit", "docs.google.com/spreadsheets/d/SHEET1/preview"), + ("https://www.loom.com/share/" + "a" * 32, "loom.com/embed/" + "a" * 32), + ("https://codepen.io/user/pen/abc123", "codepen.io/user/embed/abc123"), + ("https://miro.com/app/board/uXjV=", "miro.com/app/live-embed/uXjV="), + ("https://open.spotify.com/track/abc123", "open.spotify.com/embed/track/abc123"), + ("https://soundcloud.com/artist/track", "w.soundcloud.com/player/"), + ("https://www.twitch.tv/somestreamer", "player.twitch.tv/?channel=somestreamer"), + ("https://twitter.com/user/status/123456", "Tweet.html?id=123456"), + ("https://x.com/user/status/123456", "Tweet.html?id=123456"), + ("https://www.pinterest.com/pin/12345/", "pinterest.com/pin/embed"), + ("https://example.com/report.docx", "view.officeapps.live.com/op/embed.aspx"), + ], + ) + def test_providers(self, url, needle): + from app.services.embeds import embed_src + + out = embed_src(url) + assert out and needle in out + + def test_twitch_parent_is_used(self): + from app.services.embeds import embed_src + + out = embed_src("https://www.twitch.tv/chan", parent="flowdeck.example.com") + assert out.endswith("parent=flowdeck.example.com") + + def test_unknown_http_falls_back_to_iframe(self): + from app.services.embeds import embed_src + + assert embed_src("https://example.com/page") == "https://example.com/page" + + def test_bare_domain_is_upgraded_to_https(self): + from app.services.embeds import embed_src + + assert embed_src("example.com/page") == "https://example.com/page" + + def test_non_http_is_rejected(self): + from app.services.embeds import embed_src + + assert embed_src("mailto:hi@example.com") is None + assert embed_src("") is None + + def test_inline_kind(self): + from app.services.embeds import inline_kind + + assert inline_kind("https://ex.com/a.png") == "image" + assert inline_kind("https://ex.com/a.pdf") == "pdf" + assert inline_kind("https://ex.com/a.mp4") == "video" + assert inline_kind("https://ex.com/a.mp3") == "audio" + assert inline_kind("https://ex.com/page") == "iframe" + assert inline_kind("nonsense") is None + + def test_resolve_embed(self): + from app.services.embeds import resolve_embed + + out = resolve_embed("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + assert out["provider"] == "youtube" + assert out["kind"] == "iframe" + assert out["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ" + + def test_embed_html_is_responsive(self): + from app.services.embeds import embed_html + + html = embed_html("https://ex.com/embed", height=360) + assert " +Fallback title + + + + + +hi""" + + +class TestOGParser: + def test_parse_og_handles_attribute_order(self): + from app.services.og_fetcher import parse_og + + data = parse_og(_OG_HTML, "https://flowdeck.example.com/page") + assert data["title"] == "FlowDeck — Notion clone" + assert data["description"] == "Plan, write and ship." + assert data["site_name"] == "FlowDeck" + assert data["image"] == "https://flowdeck.example.com/static/preview.png" + assert data["favicon"] == "https://flowdeck.example.com/favicon.ico" + + def test_parse_og_title_fallback(self): + from app.services.og_fetcher import parse_og + + data = parse_og("Plain title", "https://ex.com") + assert data["title"] == "Plain title" + + def test_fetch_og_metadata_success(self): + from app.services.og_fetcher import fetch_og_metadata + + def handler(request): + return httpx.Response(200, headers={"content-type": "text/html"}, text=_OG_HTML) + + transport = httpx.MockTransport(handler) + data = asyncio.run(fetch_og_metadata("https://flowdeck.example.com/page", transport=transport)) + assert data["title"] == "FlowDeck — Notion clone" + assert data["site_name"] == "FlowDeck" + + def test_fetch_og_metadata_non_html(self): + from app.services.og_fetcher import fetch_og_metadata + + def handler(request): + return httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF") + + data = asyncio.run( + fetch_og_metadata("https://example.com/file.pdf", transport=httpx.MockTransport(handler)) + ) + assert data["title"] == "example.com" + + def test_fetch_og_metadata_network_error_is_safe(self): + from app.services.og_fetcher import fetch_og_metadata + + def handler(request): + raise httpx.ConnectError("boom") + + data = asyncio.run( + fetch_og_metadata("https://unreachable.example.com", transport=httpx.MockTransport(handler)) + ) + assert data["url"].startswith("https://unreachable.example.com") + assert data["title"] == "unreachable.example.com" + + +class TestOGMetadataEndpoint: + def test_endpoint_returns_payload(self, client, monkeypatch): + cookie, csrf = _login(client) + + async def fake_fetch(url, timeout=6.0, transport=None): + return { + "url": url, + "title": "Mocked title", + "description": "Mocked description", + "image": "https://ex.com/img.png", + "site_name": "Ex", + "favicon": "", + } + + monkeypatch.setattr("app.services.og_fetcher.fetch_og_metadata", fake_fetch) + r = client.post( + "/board/api/og/metadata", + json={"url": "https://ex.com"}, + headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)}, + cookies={"flowdeck_session": cookie}, + ) + assert r.status_code == 200 + data = r.json() + assert data["ok"] is True + assert data["title"] == "Mocked title" + + +class TestBookmarkPersistence: + def test_bookmark_roundtrip(self, client): + cookie, csrf = _login(client) + _workspace(client, cookie) + page = _page(client, cookie, csrf, "Bookmark") + blocks = [{ + "type": "bookmark", + "url": "https://example.com", + "title": "Example", + "description": "A test site", + "image": "https://example.com/og.png", + "site_name": "Example", + }] + _save_blocks(client, cookie, csrf, page, blocks, "Bookmark") + saved = json.loads( + client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"] + ) + assert saved[0]["type"] == "bookmark" + assert saved[0]["image"] == "https://example.com/og.png" + + +# ══════════════════════════════════════════════════════════════════════════ +# 3. Image lightbox +# ══════════════════════════════════════════════════════════════════════════ + + +class TestLightbox: + def test_editor_lightbox_supports_keyboard_navigation(self): + tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8") + assert "_openLightbox" in tpl + assert "fd-lightbox-nav" in tpl + assert "ArrowRight" in tpl and "ArrowLeft" in tpl + assert "Escape" in tpl + + def test_public_page_lightbox_markup(self): + tpl = Path("app/templates/public_page.html").read_text(encoding="utf-8") + assert "fd-lightbox" in tpl + assert "data-full" in tpl + assert "ArrowRight" in tpl and "ArrowLeft" in tpl + + def test_public_image_has_zoom_cursor(self): + from app.routers.dashboard import _render_blocks_public + + html = _render_blocks_public([{"type": "image", "src": "https://ex.com/a.png", "alt": "A"}]) + assert 'data-full="https://ex.com/a.png"' in html + assert "cursor:zoom-in" in html + + +# ══════════════════════════════════════════════════════════════════════════ +# 4. Inline previews (PDF / video / audio) +# ══════════════════════════════════════════════════════════════════════════ + + +class TestInlinePreviews: + def test_editor_renders_video_audio_pdf(self): + tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8") + assert "