"""Blog page data service — provides serialized dicts for .sx defpages.""" from __future__ import annotations from shared.sx.parser import SxExpr def _sx_content_expr(raw: str) -> SxExpr | None: """Wrap non-empty sx_content as SxExpr so it serializes unquoted.""" return SxExpr(raw) if raw else None class BlogPageService: """Service for blog page data, callable via (service "blog-page" ...).""" async def cache_data(self, session, **kw): from quart import url_for as qurl from shared.browser.app.csrf import generate_csrf_token return { "clear_url": qurl("settings.cache_clear"), "csrf": generate_csrf_token(), } async def snippets_data(self, session, **kw): from quart import g, url_for as qurl from sqlalchemy import select, or_ from models import Snippet from shared.browser.app.csrf import generate_csrf_token uid = g.user.id is_admin = g.rights.get("admin") csrf = generate_csrf_token() filters = [Snippet.user_id == uid, Snippet.visibility == "shared"] if is_admin: filters.append(Snippet.visibility == "admin") rows = (await session.execute( select(Snippet).where(or_(*filters)).order_by(Snippet.name) )).scalars().all() snippets = [] for s in rows: s_id = s.id s_vis = s.visibility or "private" s_uid = s.user_id owner = "You" if s_uid == uid else f"User #{s_uid}" can_delete = s_uid == uid or is_admin d = { "id": s_id, "name": s.name or "", "visibility": s_vis, "owner": owner, "can_delete": can_delete, } if is_admin: d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id) if can_delete: d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id) snippets.append(d) return { "snippets": snippets, "is_admin": bool(is_admin), "csrf": csrf, } async def menu_items_data(self, session, **kw): from quart import url_for as qurl from bp.menu_items.services.menu_items import get_all_menu_items from shared.browser.app.csrf import generate_csrf_token menu_items = await get_all_menu_items(session) csrf = generate_csrf_token() items = [] for mi in menu_items: i_id = mi.id label = mi.label or "" fi = getattr(mi, "feature_image", None) sort = mi.position or 0 items.append({ "id": i_id, "label": label, "url": mi.url or "", "sort_order": sort, "feature_image": fi, "edit_url": qurl("menu_items.edit_menu_item", item_id=i_id), "delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id), }) return { "menu_items": items, "new_url": qurl("menu_items.new_menu_item"), "csrf": csrf, } async def tag_groups_data(self, session, **kw): from quart import url_for as qurl from sqlalchemy import select from models.tag_group import TagGroup from bp.blog.admin.routes import _unassigned_tags from shared.browser.app.csrf import generate_csrf_token groups_rows = list( (await session.execute( select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) )).scalars() ) unassigned = await _unassigned_tags(session) groups = [] for g in groups_rows: groups.append({ "id": g.id, "name": g.name or "", "slug": getattr(g, "slug", "") or "", "feature_image": getattr(g, "feature_image", None), "colour": getattr(g, "colour", None), "sort_order": getattr(g, "sort_order", 0) or 0, "edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id), }) unassigned_tags = [] for t in unassigned: unassigned_tags.append({ "name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""), }) return { "groups": groups, "unassigned_tags": unassigned_tags, "create_url": qurl("blog.tag_groups_admin.create"), "csrf": generate_csrf_token(), } async def tag_group_edit_data(self, session, *, id=None, **kw): from quart import abort, url_for as qurl from sqlalchemy import select from models.tag_group import TagGroup, TagGroupTag from models.ghost_content import Tag from shared.browser.app.csrf import generate_csrf_token tg = await session.get(TagGroup, id) if not tg: abort(404) assigned_rows = list( (await session.execute( select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id) )).scalars() ) assigned_set = set(assigned_rows) all_tags_rows = list( (await session.execute( select(Tag).where( Tag.deleted_at.is_(None), (Tag.visibility == "public") | (Tag.visibility.is_(None)), ).order_by(Tag.name) )).scalars() ) all_tags = [] for t in all_tags_rows: all_tags.append({ "id": t.id, "name": getattr(t, "name", "") or "", "feature_image": getattr(t, "feature_image", None), "checked": t.id in assigned_set, }) return { "group": { "id": tg.id, "name": tg.name or "", "colour": getattr(tg, "colour", "") or "", "sort_order": getattr(tg, "sort_order", 0) or 0, "feature_image": getattr(tg, "feature_image", "") or "", }, "all_tags": all_tags, "save_url": qurl("blog.tag_groups_admin.save", id=tg.id), "delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id), "csrf": generate_csrf_token(), } async def index_data(self, session, **kw): """Blog index page data — posts or pages listing with filters.""" from quart import g, request, url_for as qurl from bp.blog.services.posts_data import posts_data from bp.blog.services.pages_data import pages_data from bp.blog.filters.qs import decode from shared.utils import host_url from shared.browser.app.csrf import generate_csrf_token q = decode() content_type = request.args.get("type", "posts") is_admin = bool((g.get("rights") or {}).get("admin")) user = getattr(g, "user", None) csrf = generate_csrf_token() blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/") if content_type == "pages": data = await pages_data(session, q.page, q.search) posts_list = data.get("pages", []) tag_groups_raw = [] authors_raw = [] draft_count = 0 selected_tags = () selected_authors = () selected_groups = () else: show_drafts = bool(q.drafts and user) drafts_user_id = None if (not show_drafts or is_admin) else user.id count_drafts_uid = None if (user and is_admin) else (user.id if user else False) data = await posts_data( session, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked, drafts=show_drafts, drafts_user_id=drafts_user_id, count_drafts_for_user_id=count_drafts_uid, selected_groups=q.selected_groups, ) posts_list = data.get("posts", []) tag_groups_raw = data.get("tag_groups", []) authors_raw = data.get("authors", []) draft_count = data.get("draft_count", 0) selected_tags = q.selected_tags selected_authors = q.selected_authors selected_groups = q.selected_groups page_num = data.get("page", q.page) total_pages = data.get("total_pages", 1) card_widgets = data.get("card_widgets_html", {}) current_local_href = f"{blog_url_base}/index" if content_type == "pages": current_local_href += "?type=pages" hx_select = "#main-panel" # Serialize posts for cards def _format_ts(dt): if not dt: return "" return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt) cards = [] for p in posts_list: slug = p.get("slug", "") href = f"{blog_url_base}/{slug}/" status = p.get("status", "published") is_draft = status == "draft" ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at")) tags = [] for t in (p.get("tags") or []): name = t.get("name") or getattr(t, "name", "") fi = t.get("feature_image") or getattr(t, "feature_image", None) tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""}) authors = [] for a in (p.get("authors") or []): name = a.get("name") or getattr(a, "name", "") img = a.get("profile_image") or getattr(a, "profile_image", None) authors.append({"name": name, "image": img or ""}) card = { "slug": slug, "href": href, "hx_select": hx_select, "title": p.get("title", ""), "feature_image": p.get("feature_image"), "excerpt": p.get("custom_excerpt") or p.get("excerpt", ""), "is_draft": is_draft, "publish_requested": p.get("publish_requested", False) if is_draft else False, "status_timestamp": ts, "tags": tags, "authors": authors, "has_like": bool(user), } if user: card["liked"] = p.get("is_liked", False) card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/" card["csrf_token"] = csrf widget = card_widgets.get(str(p.get("id", "")), "") if widget: card["widget"] = widget # Page-specific fields features = p.get("features") or {} if content_type == "pages": card["has_calendar"] = features.get("calendar", False) card["has_market"] = features.get("market", False) card["pub_timestamp"] = ts cards.append(card) # Serialize tag groups for filter tag_groups = [] for grp in tag_groups_raw: g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "") g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "") g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None) g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None) g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0) if g_count <= 0 and g_slug not in selected_groups: continue tag_groups.append({ "slug": g_slug, "name": g_name, "feature_image": g_fi, "colour": g_colour, "post_count": g_count, "is_selected": g_slug in selected_groups, }) # Serialize authors for filter authors_list = [] for a in authors_raw: a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "") a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "") a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None) a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0) authors_list.append({ "slug": a_slug, "name": a_name, "profile_image": a_img, "published_post_count": a_count, "is_selected": a_slug in selected_authors, }) # Filter summary names tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]] au_summary_names = [a["name"] for a in authors_list if a["is_selected"]] return { "content_type": content_type, "view": q.view, "cards": cards, "page": page_num, "total_pages": total_pages, "current_local_href": current_local_href, "hx_select": hx_select, "is_admin": is_admin, "has_user": bool(user), "draft_count": draft_count, "drafts": bool(q.drafts) if user else False, "new_post_href": f"{blog_url_base}/new/", "new_page_href": f"{blog_url_base}/new-page/", "tag_groups": tag_groups, "authors": authors_list, "is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0, "is_any_author": len(selected_authors) == 0, "tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "", "au_summary": ", ".join(au_summary_names) if au_summary_names else "", "blog_url_base": blog_url_base, "csrf": csrf, } async def post_admin_data(self, session, *, slug=None, **kw): """Post admin panel — just needs post loaded into context.""" from quart import g from sqlalchemy import select from shared.models.page_config import PageConfig # _ensure_post_data is called by before_request in defpage context post = (g.post_data or {}).get("post", {}) features = {} sumup_configured = False if post.get("is_page"): pc = (await session.execute( select(PageConfig).where( PageConfig.container_type == "page", PageConfig.container_id == post["id"], ) )).scalar_one_or_none() if pc: features = pc.features or {} sumup_configured = bool(pc.sumup_api_key) return { "features": features, "sumup_configured": sumup_configured, } def post_meta_data(self, post, base_title): """Compute SEO meta tag values from post dict.""" import re from quart import request as req is_public = post.get("visibility") == "public" is_published = post.get("status") == "published" email_only = post.get("email_only", False) robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow" desc = (post.get("meta_description") or post.get("og_description") or post.get("twitter_description") or post.get("custom_excerpt") or post.get("excerpt") or "") if not desc and post.get("html"): desc = re.sub(r'<[^>]+>', '', post["html"]) desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160] image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "") canonical = post.get("canonical_url") or (req.url if req else "") post_title = post.get("meta_title") or post.get("title") or "" page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title og_title = post.get("og_title") or page_title tw_title = post.get("twitter_title") or page_title is_article = not post.get("is_page") return { "robots": robots, "page_title": page_title, "desc": desc, "canonical": canonical, "og_type": "article" if is_article else "website", "og_title": og_title, "image": image, "twitter_card": "summary_large_image" if image else "summary", "twitter_title": tw_title, } def post_detail_data(self, post, user, rights, csrf, blog_url_base): """Serialize post detail view data for ~detail/post-detail-content defcomp.""" slug = post.get("slug", "") is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) user_id = getattr(user, "id", None) if user else None # Tags and authors tags = [] for t in (post.get("tags") or []): name = t.get("name") or getattr(t, "name", "") fi = t.get("feature_image") or getattr(t, "feature_image", None) tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""}) authors = [] for a in (post.get("authors") or []): name = a.get("name") or getattr(a, "name", "") img = a.get("profile_image") or getattr(a, "profile_image", None) authors.append({"name": name, "image": img or ""}) return { "slug": slug, "is_draft": post.get("status") == "draft", "publish_requested": post.get("publish_requested", False), "can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id), "edit_href": f"{blog_url_base}/{slug}/admin/edit/", "is_page": bool(post.get("is_page")), "has_user": bool(user), "liked": post.get("is_liked", False), "like_url": f"{blog_url_base}/{slug}/like/toggle/", "csrf": csrf, "custom_excerpt": post.get("custom_excerpt") or "", "tags": tags, "authors": authors, "feature_image": post.get("feature_image"), "html_content": post.get("html", ""), "sx_content": _sx_content_expr(post.get("sx_content", "")), } async def preview_data(self, session, *, slug=None, **kw): """Build preview data with prettified/rendered content.""" from quart import g from models.ghost_content import Post from sqlalchemy import select as sa_select post_id = g.post_data["post"]["id"] post = (await session.execute( sa_select(Post).where(Post.id == post_id) )).scalar_one_or_none() result = {} sx_content = getattr(post, "sx_content", None) or "" if sx_content: from shared.sx.prettify import sx_to_pretty_sx result["sx_pretty"] = sx_to_pretty_sx(sx_content) lexical_raw = getattr(post, "lexical", None) or "" if lexical_raw: from shared.sx.prettify import json_to_pretty_sx result["json_pretty"] = json_to_pretty_sx(lexical_raw) if sx_content: from shared.sx.parser import parse as sx_parse from shared.sx.html import render as sx_html_render from shared.sx.jinja_bridge import _COMPONENT_ENV try: parsed = sx_parse(sx_content) result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) except Exception: result["sx_rendered"] = "Error rendering sx" if lexical_raw: from bp.blog.ghost.lexical_renderer import render_lexical try: result["lex_rendered"] = render_lexical(lexical_raw) except Exception: result["lex_rendered"] = "Error rendering lexical" return result