Files
flowdeck/app/routers/dashboard/_common.py
T
bruno c0925e511b
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 9m39s
refactor: A28 lot 2 — dashboard.py (2 735 L) → package 10 fichiers (v7.30.0)
Découpe par concern de l'ancien app/routers/dashboard.py (2 735 lignes,
63 routes) en package `app/routers/dashboard/` :

- 8 modules de routes : local_workspace 559 L (15 r.), pages_html 485 (6),
  account_settings 439 (16), workspace 321 (9), pages_api 240 (6),
  workspaces 131 (6), public 78 (1), account_api 77 (4)
- _common.py (774 L) : les 15 helpers top-level INTERCALÉS dans l'ancien
  fichier + état (logger, _VERSION, WORKSPACE_COOKIE)
- __init__.py : ordre d'enregistrement identique à l'origine, re-export
  complet (7 importateurs inchangés : main, board ×3, my_tasks,
  web_clipper, wiki, sites `_dash._render_blocks_public`, tests) + __all__

Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE
byte-à-byte (509 chemins, ordre préservé).

Pièges rattrapés :
- segment décorateur sans sa fonction → assert `def in seg` + récupération
  git (corps perdus en silence à la 1ʳᵉ exécution)
- collision `settings` (section vs from app.config import settings →
  hasattr du fromlist) → renommée account_settings
- WORKSPACE_COOKIE utilisé sans import dans workspaces.py (F821)
- script __all__ mangeant la fin du fichier → __init__ réécrit

Reste A28 : collections.py 2 622 L, board.py 2 101 L (lot 3).

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-02 07:47:41 -04:00

775 lines
30 KiB
Python

"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28).
Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état :
_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException, Request
from fastapi.responses import RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
_VERSION = None
WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_app_version() -> str:
"""Read version from VERSION file with caching."""
global _VERSION
if _VERSION is not None:
return _VERSION
try:
import os
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
# Docker fallback
version_path = "/app/VERSION"
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
_VERSION = "0.0.0"
except Exception:
_VERSION = "0.0.0"
return _VERSION
def _get_user_or_redirect(request: Request):
"""Return decoded user or a RedirectResponse to login page.
Skips redirect when DB has no users (fresh install / test env)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
# Allow through if no users exist yet (fresh install / tests)
try:
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
# Get avatar info from DB
avatar_url = ""
avatar_color = "#3A3A3A"
if user:
try:
with get_conn() as conn:
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
if row:
avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "folder",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
# Active workspace from cookie (skip on pages like /workspaces where no
# workspace context should be shown)
from app.routers.board import _load_workspace_pages
ws_cookie = request.cookies.get("flowdeck_workspace", "")
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# AND open the local workspace mirror of the same name in the
# sidebar's top "My Workspaces" section, in parallel with the
# Gitea repository tree.
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in "My
# Workspaces" alongside the Gitea repository section.
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
with get_conn() as conn:
# Verify this workspace belongs to the current user
row = conn.execute(
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
(wsi, user["id"])
).fetchone()
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try:
with get_conn() as conn:
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if am_row and am_row["auth_method"]:
auth_method = am_row["auth_method"]
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_linked = True
elif t["provider"] == "github":
github_linked = True
except Exception:
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
except Exception:
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
if gitea_workspace and local_ws_id:
try:
with get_conn() as conn:
pp_rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
(local_ws_id,)
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
shared_received_pages = []
published_pages = []
shared_pages = []
if user and user.get("id"):
from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
sidebar = {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
"gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"workspace_pages": workspace_pages,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": private_pages,
"favorite_pages": [],
"shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"avatar_url": avatar_url,
"avatar_color": avatar_color,
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
}
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
return sidebar
# ═══════════ User API endpoints ═══════════
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
def _require_user_id(request: Request) -> int:
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user["id"]
def _file_page_disk_path(page: dict):
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
when the row is not a file page, references a non-textual/missing file, or
the path escapes the data root (path-traversal guard).
"""
if (page.get("content_format") or "") != "file":
return None
import json as _json
try:
meta = _json.loads(page.get("content", "{}"))
except (_json.JSONDecodeError, TypeError):
meta = {}
if not isinstance(meta, dict):
return None
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
if not rel or not rel.startswith("uploads/"):
return None
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
from pathlib import Path
root = Path(settings.data_dir).resolve()
full = (root / rel).resolve()
try:
full.relative_to(root)
except ValueError:
return None
if not full.exists() or not full.is_file():
return None
filename = parts[-1] or page.get("title", "file")
mime = meta.get("mime_type") or "application/octet-stream"
size = meta.get("size") or 0
return (full, filename, mime, size)
def _require_page_view(request: Request, page_id: int) -> None:
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
from app.services.permission_manager import PermissionManager
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
favorited_ids = set()
if all_ids:
placeholders = ",".join("?" for _ in all_ids)
tag_rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
all_ids,
).fetchall()
for tr in tag_rows:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
if uid is not None:
fav_rows = conn.execute(
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
[uid, *all_ids],
).fetchall()
favorited_ids = {fr["page_id"] for fr in fav_rows}
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id, uid)
# Compute size
size = 0
if r["content_format"] == "file":
import json as _json
try:
meta = _json.loads(r["content"])
size = meta.get("size", 0)
except Exception:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"type": "folder" if is_folder else "page",
"is_folder": is_folder,
"content_format": r["content_format"] if not is_folder else None,
"page_icon": r["page_icon"] or "",
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
"size": size,
"size_display": _format_size(size),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"favorited": r["id"] in favorited_ids,
})
return tree
def _format_size(size_bytes: int) -> str:
"""Human-readable file size."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _build_breadcrumb(conn, folder_id: int) -> list:
"""Build breadcrumb trail from root to folder_id."""
breadcrumb = []
current = folder_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
(current,),
).fetchone()
if row:
breadcrumb.insert(0, {
"id": row["id"],
"name": row["title"] or "Untitled",
"is_folder": row["parent_section"] == "Workspace",
})
current = row["parent_id"]
else:
break
return breadcrumb
def _nav_breadcrumb(conn, page_id: int) -> list:
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
current page (url = None). Every item has ``menu: True`` so the header can
open a sibling-navigation dropdown for it.
"""
from app.routers.board import _file_icon
chain = []
current = page_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section, content_format "
"FROM pages WHERE id=? AND deleted_at IS NULL",
(current,),
).fetchone()
if not row:
break
is_folder = row["parent_section"] == "Workspace"
title = row["title"] or "Untitled"
chain.insert(0, {
"id": row["id"],
"label": title,
"url": None,
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
"menu": True,
})
current = row["parent_id"]
# All items except the current page are navigable links.
for i, item in enumerate(chain):
if i < len(chain) - 1:
item["url"] = f"/pages/{item['id']}"
return chain
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
ws_id = request.cookies.get(WORKSPACE_COOKIE)
if ws_id:
try:
with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
(int(ws_id),)).fetchone()
if ws:
ws_dict = dict(ws)
# Verify ownership — only return if it belongs to the current user
if user_id is None or ws_dict.get("owner_id") == user_id:
return ws_dict
except (ValueError, Exception):
pass
# Fallback: first workspace owned by this user
if user_id:
with get_conn() as conn:
ws = conn.execute(
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user_id,)
).fetchone()
if ws:
return dict(ws)
return None
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id:
return ""
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
"""Render FlowDeck blocks as plain HTML for public pages.
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
"""
html_parts = []
def _wiki(c: str) -> str:
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
from app.services.wiki_links import resolve_tokens_html
return resolve_tokens_html(c, titles)
return c
for b in blocks:
t = b.get("type", "paragraph")
c = _wiki(b.get("content", "") or "")
if t == "heading_1":
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
elif t == "heading_2":
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
elif t == "heading_3":
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
elif t == "heading_4":
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
elif t == "bulleted_list":
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
elif t == "numbered_list":
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
elif t == "to_do":
checked = "checked" if b.get("checked") else ""
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
html_parts.append(
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
f'<input type="checkbox" {checked} disabled>'
f'<span style="{todo_style}">{c}</span>'
f'</div>'
)
elif t == "toggle":
children_html = ""
if b.get("children"):
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
children_html += _render_blocks_public(b["children"], titles)
children_html += "</div>"
html_parts.append(
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
)
elif t == "quote":
html_parts.append(
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
)
elif t == "table_of_contents":
toc = [
x for x in blocks
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
]
if toc:
items = []
for h in toc:
lvl = int(h["type"].split("_")[-1])
items.append(
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
)
html_parts.append(
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
+ "".join(items) + "</div>"
)
elif t == "math":
tex = c.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html_parts.append(
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
)
elif t == "columns":
cols_html = ""
for child in b.get("children") or []:
cols_html += (
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
'border-radius:8px;box-sizing:border-box;">'
+ _render_blocks_public([child], titles) + "</div>"
)
html_parts.append(
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
)
elif t == "callout":
icon = b.get("icon", "💡")
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
html_parts.append(
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
f'background:{bg};align-items:flex-start;">'
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
f'<span>{c}</span></div>'
)
elif t == "code":
lang = b.get("language", "")
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
elif t == "image":
src = b.get("src", "")
alt = b.get("alt", "")
html_parts.append(
f'<figure style="margin:16px 0;text-align:center;">'
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
f'</figure>'
)
elif t == "video":
src = b.get("src", "")
if src:
html_parts.append(
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
f'<source src="{src}"></video>'
)
elif t == "audio":
src = b.get("src", "")
if src:
html_parts.append(
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
)
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = b.get("title") or url
desc = b.get("description") or ""
img = b.get("image") or ""
site = 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;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
html_parts.append(
f'<a href="{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 rgba(255,255,255,.12);border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
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", "")
emb = b.get("embed_type") or ""
if emb in ("inline_dbs", "collection"):
html_parts.append('<div>[Embedded content]</div>')
elif emb == "download":
html_parts.append(
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
)
elif emb == "pdf" and url:
html_parts.append(
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
)
elif url:
from app.services.embeds import embed_src
src = b.get("embed_src") or embed_src(url) or url
height = b.get("height") or 520
try:
height = int(height)
except (ValueError, TypeError):
height = 520
html_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 == "synced":
# v6.5.0: render synced block instances (resolved server-side).
if b.get("_synced_deleted"):
html_parts.append(
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
'Deleted synced block</div>'
)
else:
inner = b.get("_synced_content")
if not isinstance(inner, list) or not inner:
try:
import json as _sj
parsed = _sj.loads(b.get("content") or "[]")
inner = parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
inner = []
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
for x in inner]
if inner:
html_parts.append(
'<div style="margin:8px 0;padding-left:12px;'
'border-left:3px solid var(--accent,#4c9aff);">'
+ _render_blocks_public(inner, titles) + '</div>'
)
else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
return "\n".join(html_parts)
# ═══════════ Library page actions API ═══════════