Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc15c7328 | ||
|
|
c435e277f2 |
@@ -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 `<meta>` 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/<slug>` (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
|
||||
|
||||
+25
-14
@@ -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/<slug>` : 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*
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+146
-62
@@ -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/<id> (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:
|
||||
|
||||
@@ -665,7 +665,7 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
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:
|
||||
|
||||
+74
-56
@@ -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'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
|
||||
re.I,
|
||||
)
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
||||
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", 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 <head> 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 ``<meta>`` 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"<title[^>]*>(.*?)</title>", 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"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
|
||||
if not fm:
|
||||
fm = re.search(r"<link[^>]+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)
|
||||
|
||||
@@ -439,8 +439,7 @@
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
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 `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
|
||||
}
|
||||
@@ -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='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
|
||||
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='<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+'<img class="fd-lightbox-img" style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+'<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+'<div class="fd-lightbox-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
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();
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,15 +174,48 @@
|
||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!window.katex) return;
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
if (window.katex) {
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// v5.5.0 — Image lightbox (fullscreen + keyboard navigation)
|
||||
var imgs = Array.prototype.slice.call(document.querySelectorAll('.pub-content img[data-full]'));
|
||||
imgs.forEach(function (im) { im.addEventListener('click', function () { openLb(im); }); });
|
||||
|
||||
function openLb(target) {
|
||||
var idx = imgs.indexOf(target); if (idx < 0) idx = 0;
|
||||
var 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 = '<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+ '<img style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+ '<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+ '<div class="fd-lb-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(ov);
|
||||
var img = ov.querySelector('img');
|
||||
var hint = ov.querySelector('.fd-lb-hint');
|
||||
function show() {
|
||||
var cur = imgs[idx];
|
||||
img.src = cur.getAttribute('data-full') || cur.src;
|
||||
hint.textContent = (imgs.length > 1 ? (idx + 1) + ' / ' + imgs.length + ' — ' : '') + '← → to navigate · Esc to close';
|
||||
}
|
||||
});
|
||||
function step(d) { if (imgs.length < 2) return; idx = (idx + d + imgs.length) % imgs.length; show(); }
|
||||
function close() { ov.remove(); document.removeEventListener('keydown', kd); }
|
||||
function 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.querySelectorAll('[data-nav]').forEach(function (b) {
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); step(b.getAttribute('data-nav') === 'next' ? 1 : -1); });
|
||||
});
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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:[email protected]") 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 "<iframe" in html
|
||||
assert "height:360px" in html
|
||||
|
||||
|
||||
class TestEmbedResolveEndpoint:
|
||||
def test_resolve_endpoint(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
r = client.post(
|
||||
"/board/api/embed/resolve",
|
||||
json={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
|
||||
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["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
assert data["provider"] == "youtube"
|
||||
|
||||
def test_resolve_requires_url(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
r = client.post(
|
||||
"/board/api/embed/resolve",
|
||||
json={},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 2. Bookmark cards / Open Graph
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
_OG_HTML = """<!doctype html><html><head>
|
||||
<title>Fallback title</title>
|
||||
<meta content="FlowDeck — Notion clone" property="og:title">
|
||||
<meta property="og:description" content="Plan, write and ship.">
|
||||
<meta name="twitter:image" content="/static/preview.png">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
</head><body>hi</body></html>"""
|
||||
|
||||
|
||||
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("<html><head><title>Plain title</title></head></html>", "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 "<video controls" in tpl
|
||||
assert "<audio controls" in tpl
|
||||
assert "embed_type==='pdf'" in tpl
|
||||
|
||||
def test_public_renders_video_audio_pdf(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
blocks = [
|
||||
{"type": "video", "src": "https://ex.com/v.mp4"},
|
||||
{"type": "audio", "src": "https://ex.com/a.mp3"},
|
||||
{"type": "embed", "src": "https://ex.com/doc.pdf", "embed_type": "pdf"},
|
||||
]
|
||||
html = _render_blocks_public(blocks)
|
||||
assert "<video" in html
|
||||
assert "<audio" in html
|
||||
assert "doc.pdf" in html and "<iframe" in html
|
||||
|
||||
def test_public_embed_uses_resolved_src(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
html = _render_blocks_public([{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
}])
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
||||
|
||||
def test_public_embed_resolves_when_no_cached_src(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
html = _render_blocks_public([{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
}])
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
||||
|
||||
def test_embed_src_field_persists(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Embed persist")
|
||||
blocks = [{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"embed_provider": "youtube",
|
||||
"height": 480,
|
||||
}]
|
||||
_save_blocks(client, cookie, csrf, page, blocks, "Embed persist")
|
||||
saved = json.loads(
|
||||
client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"]
|
||||
)
|
||||
assert saved[0]["embed_src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
assert saved[0]["embed_provider"] == "youtube"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 5. Cover & page icon
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCoverIcon:
|
||||
def test_set_and_remove_cover_url(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Cover")
|
||||
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
json={"cover_url": "https://example.com/cover.png"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["cover_url"] == "https://example.com/cover.png"
|
||||
|
||||
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r2.json()["cover_url"] == "https://example.com/cover.png"
|
||||
|
||||
r3 = client.delete(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r3.status_code == 200
|
||||
r4 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r4.json()["cover_url"] in ("", None)
|
||||
|
||||
def test_upload_cover_file(self, client, monkeypatch, tmp_path):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Cover upload")
|
||||
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
files={"file": ("banner.png", png, "image/png")},
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
url = r.json()["cover_url"]
|
||||
assert url.startswith("/api/files/")
|
||||
assert url.endswith(".png")
|
||||
assert (tmp_path / "uploads").exists()
|
||||
|
||||
def test_upload_rejects_non_image(self, client, monkeypatch, tmp_path):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Bad upload")
|
||||
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
files={"file": ("evil.sh", b"#!/bin/sh", "text/plain")},
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_set_page_icon(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Icon")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
json={"icon": "🚀"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["icon"] == "🚀"
|
||||
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r2.json()["page_icon"] == "🚀"
|
||||
|
||||
def test_icon_too_long_rejected(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Icon long")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
json={"icon": "x" * 32},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_public_page_renders_cover_and_icon(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Public")
|
||||
_save_blocks(client, cookie, csrf, page, [{"type": "paragraph", "content": "Hi"}], "Public")
|
||||
client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
json={"cover_url": "https://example.com/c.jpg"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
json={"icon": "🌟"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/pages/{page}/publish",
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
slug = r.json()["publish_slug"]
|
||||
html = client.get(f"/p/{slug}").text
|
||||
assert "c.jpg" in html
|
||||
assert "🌟" in html
|
||||
assert "fd-lightbox" in html
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Regression: the editor must call the board-owned endpoints (v5.5.0 bug)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEditorEndpointPaths:
|
||||
def test_editor_uses_board_prefixed_endpoints(self):
|
||||
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
for endpoint in (
|
||||
"/board/api/pages/'+this.pid+'/cover",
|
||||
"/board/api/pages/'+this.pid+'/icon",
|
||||
"/board/api/pages/'+this.pid+'/versions",
|
||||
"/board/api/pages/'+this.pid+'/backlinks",
|
||||
"/board/api/og/metadata",
|
||||
"/board/api/embed/resolve",
|
||||
):
|
||||
assert endpoint in tpl, f"missing editor endpoint: {endpoint}"
|
||||
# the old, broken paths must be gone
|
||||
assert "fetch('/api/pages/'+this.pid+'/cover" not in tpl
|
||||
assert "fetch('/api/pages/'+this.pid+'/icon" not in tpl
|
||||
assert "fetch('/api/pages/'+this.pid+'/versions" not in tpl
|
||||
assert "fetch('/api/pages/'+this.pid+'/backlinks" not in tpl
|
||||
assert "fetch('/api/og/metadata" not in tpl
|
||||
Reference in New Issue
Block a user