feat(v5.10.0): v5.4.0 + v5.5.0 features — backlinks, page/collection duplication, trash purge, version history, markdown import, embed/bookmark/video/audio blocks, cover & icon, OG metadata

- Migration v7: page_versions table + pages.cover_url/page_icon columns
- Trash service (purge_expired 30-day) + daily scheduler
- Board API: duplicate page, backlinks, versions (snapshot/list/restore), cover/icon upload, markdown/file import
- Collections API: duplicate collection (deep copy properties/views/pages/data sources)
- Export service: render embed/bookmark/video/audio in markdown/HTML/PDF/public
- Public page renderer: cover image + icon
- Editor: slash commands for media blocks, lightbox, version history UI, backlinks panel, cover/icon picker, import modal
- OG metadata endpoint for bookmark cards
- 386 tests passing
This commit is contained in:
2026-09-11 08:56:48 -04:00
parent 5c350ff8f6
commit 6fe5d2f723
15 changed files with 2085 additions and 39 deletions
+194
View File
@@ -0,0 +1,194 @@
"""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.
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
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)
)
def _q(params, key):
vals = params.get(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")
if not vid:
return None
start = _q(params, "t") or _q(params, "start")
frag = f"?start={start}" if start else ""
return f"https://www.youtube.com/embed/{vid}{frag}"
def _embed_vimeo(url: str, path: str, params) -> 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)
if not m:
return None
return f"https://www.loom.com/embed/{m.group(2)}"
def _embed_figma(url: str, path: str, params) -> str | None:
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
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]
def _embed_map(url: str, path: str, params) -> 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}"
def _embed_codepen(url: str, path: str, params) -> 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)
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:
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:
return None
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
def _embed_twitch(url: str, path: str, params) -> str | None:
if "twitch.tv" not in url:
return None
m = re.search(r"twitch\.tv/([^/?#]+)", url)
if not m:
return None
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
def _embed_twitter(url: str, path: str, params) -> str | None:
if "twitter.com" not in url and "x.com" not in url:
return None
m = re.search(r"/([^/?#]+)/status/(\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)}"
def _embed_pinterest(url: str, path: str, params) -> str | None:
if "pinterest" not in url:
return None
return f"https://pinterest.com/pin/embed?url={url}"
def _embed_files(url: str, path: str, params) -> str | None:
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
return url
_HANDLERS = (
("youtube.com", _embed_youtube),
("youtu.be", _embed_youtube),
("vimeo.com", _embed_vimeo),
("loom.com", _embed_loom),
("figma.com", _embed_figma),
("google.com/maps", _embed_map),
("maps.app.goo.gl", _embed_map),
("codepen.io", _embed_codepen),
("miro.com", _embed_miro),
("open.spotify.com", _embed_spotify),
("spotify.com", _embed_spotify),
("soundcloud.com", _embed_soundcloud),
("twitch.tv", _embed_twitch),
("twitter.com", _embed_twitter),
("x.com", _embed_twitter),
("pinterest", _embed_pinterest),
)
def embed_src(url: str) -> str | None:
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
if not url:
return None
url = url.strip()
u = urlparse(url if url.startswith("http") else "https://" + url)
if u.scheme not in ("http", "https"):
return None
host = (u.hostname or "").lower().replace("www.", "")
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)
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
_PDF_EXT = re.compile(r"\.pdf$", re.I)
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
_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' |
| 'audio'. Returns None when the URL should open in a new tab."""
if not url:
return None
url = url.strip()
path = urlparse(url).path
if _IMAGE_EXT.search(path):
return "image"
if _PDF_EXT.search(path):
return "pdf"
if _VIDEO_EXT.search(path):
return "video"
if _AUDIO_EXT.search(path):
return "audio"
if url.startswith(("http://", "https://")):
return "iframe"
return None
def embed_html(src: str, *, height: int = 520) -> str:
"""A responsive, borderless iframe for a provider embed URL."""
return (
f'<iframe src="{src}" loading="lazy" '
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
f'picture-in-picture" allowfullscreen></iframe>'
)
+67 -1
View File
@@ -464,6 +464,11 @@ def _page_markdown_source(page: dict) -> str:
return ""
def markdown_to_blocks(md: str) -> list:
"""Public wrapper around the GFM→blocks parser (used by page import)."""
return _md_to_blocks(md)
# ═══════════════ Markdown ═══════════════
def blocks_to_markdown(blocks: list) -> str:
@@ -508,6 +513,20 @@ def blocks_to_markdown(blocks: list) -> str:
src = b.get("src") or ""
alt = (b.get("alt") or "").strip() or "image"
out.append(f"![{alt}]({src})")
elif t == "video":
out.append(f"[Video]({b.get('src') or ''})")
elif t == "audio":
out.append(f"[Audio]({b.get('src') or ''})")
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = (b.get("title") or "").strip()
out.append(f"[{title or url}]({url})" if title else url)
elif t == "embed":
url = b.get("src") or ""
if b.get("embed_type") in ("pdf", "download", None, ""):
out.append(f"[{url}]({url})" if url else "[embed]")
else:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table":
out.append(_table_to_markdown(b))
else:
@@ -609,7 +628,54 @@ def blocks_to_html(blocks: list) -> str:
elif t == "image":
src = b.get("src") or ""
alt = _text(b.get("alt"))
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
elif t == "video":
src = b.get("src") or ""
if src:
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
elif t == "audio":
src = b.get("src") or ""
if src:
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = _text(b.get("title")) or url
desc = _text(b.get("description"))
img = b.get("image") or ""
site = _text(b.get("site_name")) or ""
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
parts.append(
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src") or ""
emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"):
parts.append(f'<div class="embed-note">[Embedded content]</div>')
elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url:
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
height = 520
if b.get("height"):
try:
height = int(b["height"])
except (ValueError, TypeError):
pass
parts.append(
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "table":
parts.append(_table_to_html(b))
else:
+124
View File
@@ -0,0 +1,124 @@
"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx.
Fetches a URL server-side, extracts OG/Twitter meta tags (title, description,
image, site name, favicon) and returns a safe, compact payload used to render
Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and
slow/unreachable hosts.
"""
from __future__ import annotations
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,
)
# 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_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
props: dict[str, str] = {}
for m in _META_RE.finditer(text):
groups = 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
return props
def _pick(props: dict, keys: tuple) -> str:
for k in keys:
v = props.get(k)
if v:
return v
return ""
def _title_of(props: dict, raw_title: 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
def _site_name(url: str) -> str:
host = urlparse(url).netloc.replace("www.", "")
return host.split(".")[0].capitalize() if host else ""
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors."""
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)",
"Accept": "text/html,application/xhtml+xml",
}
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) 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
logger.debug("og fetch failed for %s: %s", src, exc)
base["title"] = urlparse(src).netloc or src
base["site_name"] = _site_name(src)
return base
ctype = (resp.headers.get("content-type") or "").lower()
if "text/html" not in ctype and "xhtml" not in ctype:
base["title"] = urlparse(src).netloc or src
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,
}
+87
View File
@@ -0,0 +1,87 @@
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
window (default 30 days, matching the UI copy in ``trash.html``), then are
permanently deleted together with their child pages and orphaned versions.
The purge is run from a background scheduler in ``app.main`` so it is fully
automatic (no per-request cost).
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from app.db import get_conn
logger = logging.getLogger(__name__)
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
def _parse_dt(value: str) -> datetime | None:
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
if not value:
return None
try:
s = value[:19].replace("T", " ")
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
dt = datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
if not ISO_RE.match(value):
return None
return dt
def purge_expired(days: int = 30) -> dict:
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
Children are re-parented to their grandparent before deletion in order
to avoid silently dropping whole sub-trees; deleted pages are removed
together with their ``page_versions`` (FK cascade).
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
for row in rows:
dt = _parse_dt(row["deleted_at"])
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
"WHERE parent_id=?",
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
purged.append(page_id)
conn.commit()
if purged:
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
return {"purged": purged, "count": len(purged)}
async def trash_purge_scheduler(interval_hours: int = 24):
"""Background loop purging the trash once a day (idempotent, cheap when
there is nothing expired)."""
import asyncio
# Run once shortly after startup, then on the configured interval.
await asyncio.sleep(5)
while True:
try:
purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600)