commit 8f7a15186c97dead8f672f0b44db4fca32387c85 Author: giles Date: Mon Feb 9 23:15:56 2026 +0000 feat: initialize blog app with blueprints and templates Extract blog-specific code from the coop monolith into a standalone repository. Includes auth, blog, post, admin, menu_items, snippets blueprints, associated templates, Dockerfile (APP_MODULE=app:app), entrypoint, and Gitea CI workflow. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2a2208e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,63 @@ +name: Build and Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: registry.rose-ash.com:5000 + IMAGE: blog + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + apt-get update && apt-get install -y --no-install-recommends openssh-client + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Pull latest code on server + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/blog + git fetch origin main + git reset --hard origin/main + " + + - name: Build and push image + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/blog + docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} + " + + - name: Deploy stack + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/blog + source .env + docker stack deploy -c docker-compose.yml blog + echo 'Waiting for services to update...' + sleep 10 + docker stack services blog + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d616e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +node_modules/ +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5ff8d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +# Install system deps + psql client +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +# ---------- Runtime setup ---------- +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE ${APP_PORT} +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f247d86 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Blog App + +Blog and content management application for the Rose Ash cooperative platform. + +## Overview + +This is the **blog** service extracted from the Rose Ash (Suma Browser) monolith. +It handles: + +- **Blog**: Ghost CMS integration for browsing, creating, and editing posts +- **Auth**: Magic link authentication and user account management +- **Admin/Settings**: Administrative interface and settings management +- **Menu Items**: Navigation menu item management +- **Snippets**: Reusable content snippet management +- **Internal API**: Server-to-server endpoints for cross-app data sharing + +## Tech Stack + +- **Quart** (async Flask) with HTMX +- **SQLAlchemy 2.0** (async) with PostgreSQL +- **Redis** for page caching +- **Ghost CMS** for blog content + +## Running + +```bash +# Set environment variables (see .env.example) +export APP_MODULE=app:app + +# Run migrations +alembic upgrade head + +# Start the server +hypercorn app:app --bind 0.0.0.0:8000 +``` + +## Docker + +```bash +docker build -t blog . +docker run -p 8000:8000 --env-file .env blog +``` + +## Directory Structure + +``` +app.py # Application factory and entry point +bp/ # Blueprints + auth/ # Authentication (magic links, account) + blog/ # Blog listing, Ghost CMS integration + post/ # Individual post viewing and admin + admin/ # Settings admin interface + menu_items/ # Navigation menu management + snippets/ # Content snippet management + coop_api.py # Internal API endpoints +templates/ # Jinja2 templates + _types/ # Feature-specific templates +entrypoint.sh # Docker entrypoint (migrations + server start) +Dockerfile # Container build definition +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..725d681 --- /dev/null +++ b/app.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from quart import g, request +from sqlalchemy import select + +from shared.factory import create_base_app +from config import config +from models import KV + +from suma_browser.app.bp import ( + register_auth_bp, + register_blog_bp, + register_admin, + register_menu_items, + register_snippets, + register_coop_api, +) + + +async def coop_context() -> dict: + """ + Coop app context processor. + + - menu_items: direct DB query (coop owns this data) + - cart_count/cart_total: fetched from cart internal API + """ + from shared.context import base_context + from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items + from shared.internal_api import get as api_get + + ctx = await base_context() + + # Coop owns menu_items — query directly + ctx["menu_items"] = await get_all_menu_items(g.s) + + # Cart data from cart app API + cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True) + if cart_data: + ctx["cart_count"] = cart_data.get("count", 0) + ctx["cart_total"] = cart_data.get("total", 0) + else: + ctx["cart_count"] = 0 + ctx["cart_total"] = 0 + + return ctx + + +def create_app() -> "Quart": + app = create_base_app("coop", context_fn=coop_context) + + # --- blueprints --- + app.register_blueprint(register_auth_bp()) + + app.register_blueprint( + register_blog_bp( + url_prefix=config()["blog_root"], + title=config()["blog_title"], + ), + url_prefix=config()["blog_root"], + ) + + app.register_blueprint(register_admin("/settings")) + app.register_blueprint(register_menu_items()) + app.register_blueprint(register_snippets()) + + # Internal API (server-to-server, CSRF-exempt) + app.register_blueprint(register_coop_api()) + + # --- KV admin endpoints --- + @app.get("/settings/kv/") + async def kv_get(key: str): + row = ( + await g.s.execute(select(KV).where(KV.key == key)) + ).scalar_one_or_none() + return {"key": key, "value": (row.value if row else None)} + + @app.post("/settings/kv/") + async def kv_set(key: str): + data = await request.get_json() or {} + val = data.get("value", "") + obj = await g.s.get(KV, key) + if obj is None: + obj = KV(key=key, value=val) + g.s.add(obj) + else: + obj.value = val + return {"ok": True, "key": key, "value": val} + + # --- debug: url rules --- + @app.get("/__rules") + async def dump_rules(): + rules = [] + for r in app.url_map.iter_rules(): + rules.append({ + "endpoint": r.endpoint, + "rule": repr(r.rule), + "methods": sorted(r.methods - {"HEAD", "OPTIONS"}), + "strict_slashes": r.strict_slashes, + }) + return {"rules": rules} + + return app + + +app = create_app() diff --git a/bp/admin/routes.py b/bp/admin/routes.py new file mode 100644 index 0000000..ee90805 --- /dev/null +++ b/bp/admin/routes.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +#from quart import Blueprint, g + +from quart import ( + render_template, + make_response, + Blueprint, + redirect, + url_for, + request, + jsonify +) +from suma_browser.app.redis_cacher import clear_all_cache +from suma_browser.app.authz import require_admin +from suma_browser.app.utils.htmx import is_htmx_request +from config import config +from datetime import datetime + +def register(url_prefix): + bp = Blueprint("settings", __name__, url_prefix = url_prefix) + + @bp.context_processor + async def inject_root(): + return { + "base_title": f"{config()['title']} settings", + } + + @bp.get("/") + @require_admin + async def home(): + + # Determine which template to use based on request type and pagination + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template( + "_types/root/settings/index.html", + ) + + else: + html = await render_template("_types/root/settings/_oob_elements.html") + + + return await make_response(html) + + @bp.get("/cache/") + @require_admin + async def cache(): + if not is_htmx_request(): + html = await render_template("_types/root/settings/cache/index.html") + else: + html = await render_template("_types/root/settings/cache/_oob_elements.html") + return await make_response(html) + + @bp.post("/cache_clear/") + @require_admin + async def cache_clear(): + await clear_all_cache() + if is_htmx_request(): + now = datetime.now() + html = f'Cache cleared at {now.strftime("%H:%M:%S")}' + return html + + return redirect(url_for("settings.cache")) + return bp + + diff --git a/bp/auth/routes.py b/bp/auth/routes.py new file mode 100644 index 0000000..63db016 --- /dev/null +++ b/bp/auth/routes.py @@ -0,0 +1,313 @@ +from __future__ import annotations +import os +import secrets +from datetime import datetime, timedelta, timezone + +from quart import ( + Blueprint, + request, + render_template, + make_response, + redirect, + url_for, + session as qsession, + g, + current_app, +) +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from ..blog.ghost.ghost_sync import ( + sync_member_to_ghost, +) + +from db.session import get_session +from models import User, MagicLink, UserNewsletter +from models.ghost_membership_entities import GhostNewsletter +from config import config +from utils import host_url +from shared.urls import coop_url + +from sqlalchemy.orm import selectinload +from suma_browser.app.redis_cacher import clear_cache +from shared.cart_identity import current_cart_identity +from shared.internal_api import post as api_post +from .services import pop_login_redirect_target, store_login_redirect_target +from .services.auth_operations import ( + get_app_host, + get_app_root, + send_magic_email, + load_user_by_id, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +oob = { + "oob_extends": "oob_elements.html", + "extends": "_types/root/_index.html", + "parent_id": "root-header-child", + "child_id": "auth-header-child", + "header": "_types/auth/header/_header.html", + "parent_header": "_types/root/header/_header.html", + "nav": "_types/auth/_nav.html", + "main": "_types/auth/_main_panel.html" +} +def register(url_prefix="/auth"): + + auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix) + + @auth_bp.before_request + def route(): + pass + + + SESSION_USER_KEY = "uid" + @auth_bp.context_processor + def context(): + return { + "oob": oob, + } + + # NOTE: load_current_user moved to shared/user_loader.py + # and registered in shared/factory.py as an app-level before_request + + @auth_bp.get("/login/") + async def login_form(): + store_login_redirect_target() + if g.get("user"): + return redirect(coop_url("/")) + return await render_template("_types/auth/login.html") + + + + @auth_bp.get("/account/") + async def account(): + from suma_browser.app.utils.htmx import is_htmx_request + + if not g.get("user"): + return redirect(host_url(url_for("auth.login_form"))) + # TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX + # For now, render full template for both HTMX and normal requests + # Determine which template to use based on request type + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/auth/index.html") + else: + # HTMX request: main panel + OOB elements + html = await render_template( + "_types/auth/_oob_elements.html", + ) + + return await make_response(html) + + @auth_bp.get("/newsletters/") + async def newsletters(): + from suma_browser.app.utils.htmx import is_htmx_request + + if not g.get("user"): + return redirect(host_url(url_for("auth.login_form"))) + + # Fetch all newsletters, sorted alphabetically + result = await g.s.execute( + select(GhostNewsletter).order_by(GhostNewsletter.name) + ) + all_newsletters = result.scalars().all() + + # Fetch user's subscription states + sub_result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + ) + ) + user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} + + # Build list with subscription state for template + newsletter_list = [] + for nl in all_newsletters: + un = user_subs.get(nl.id) + newsletter_list.append({ + "newsletter": nl, + "un": un, + "subscribed": un.subscribed if un else False, + }) + + nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"} + + if not is_htmx_request(): + html = await render_template( + "_types/auth/index.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + else: + html = await render_template( + "_types/auth/_oob_elements.html", + oob=nl_oob, + newsletter_list=newsletter_list, + ) + + return await make_response(html) + + @auth_bp.post("/start/") + @clear_cache(tag_scope="user", clear_user=True) + async def start_login(): + # 1. Get and validate email + form = await request.form + email_input = form.get("email") or "" + + is_valid, email = validate_email(email_input) + if not is_valid: + return ( + await render_template( + "_types/auth/login.html", + error="Please enter a valid email address.", + email=email_input, + ), + 400, + ) + + # 2. Create/find user and issue magic link token + user = await find_or_create_user(g.s, email) + token, expires = await create_magic_link(g.s, user.id) + g.s.commit() + + # 3. Build the magic link URL + magic_url = host_url(url_for("auth.magic", token=token)) + + # 4. Try sending the email + email_error = None + try: + await send_magic_email(email, magic_url) + except Exception as e: + print("EMAIL SEND FAILED:", repr(e)) + email_error = ( + "We couldn't send the email automatically. " + "Please try again in a moment." + ) + + # 5. Render "check your email" page + return await render_template( + "_types/auth/check_email.html", + email=email, + email_error=email_error, + ) + + @auth_bp.get("/magic//") + async def magic(token: str): + now = datetime.now(timezone.utc) + user_id: int | None = None + + # ---- Step 1: Validate & consume magic link ---- + try: + async with get_session() as s: + async with s.begin(): + user, error = await validate_magic_link(s, token) + + if error: + return ( + await render_template( + "_types/auth/login.html", + error=error, + ), + 400, + ) + + user_id = user.id + + # Try to ensure Ghost membership inside this txn + try: + if not user.ghost_id: + await sync_member_to_ghost(s, user.id) + except Exception: + current_app.logger.exception( + "[auth] Ghost upsert failed for user_id=%s", user.id + ) + raise + + except Exception: + # Any DB/Ghost error → generic failure + return ( + await render_template( + "_types/auth/login.html", + error="Could not sign you in right now. Please try again.", + ), + 502, + ) + + # At this point: + # - magic link is consumed + # - user_id is valid + # - Ghost membership is ensured or we already returned 502 + + assert user_id is not None # for type checkers / sanity + + # Figure out any anonymous session we want to adopt + ident = current_cart_identity() + anon_session_id = ident.get("session_id") + + # ---- Step 3: best-effort local update (non-fatal) ---- + try: + async with get_session() as s: + async with s.begin(): + u2 = await s.get(User, user_id) + if u2: + u2.last_login_at = now + # s.begin() will commit on successful exit + except SQLAlchemyError: + current_app.logger.exception( + "[auth] non-fatal DB update after Ghost upsert for user_id=%s", user_id + ) + + # Adopt cart + calendar entries via cart app internal API + if anon_session_id: + await api_post( + "cart", + "/internal/cart/adopt", + json={"user_id": user_id, "session_id": anon_session_id}, + ) + + # ---- Finalize login ---- + qsession[SESSION_USER_KEY] = user_id + + # Redirect back to where they came from, if we stored it. + redirect_url = pop_login_redirect_target() + return redirect(redirect_url, 303) + + @auth_bp.post("/newsletter//toggle/") + async def toggle_newsletter(newsletter_id: int): + if not g.get("user"): + return "", 401 + + result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + UserNewsletter.newsletter_id == newsletter_id, + ) + ) + un = result.scalar_one_or_none() + + if un: + un.subscribed = not un.subscribed + else: + un = UserNewsletter( + user_id=g.user.id, + newsletter_id=newsletter_id, + subscribed=True, + ) + g.s.add(un) + + await g.s.flush() + + return await render_template( + "_types/auth/_newsletter_toggle.html", + un=un, + ) + + @auth_bp.post("/logout/") + async def logout(): + qsession.pop(SESSION_USER_KEY, None) + return redirect(coop_url("/")) + + return auth_bp diff --git a/bp/auth/services/__init__.py b/bp/auth/services/__init__.py new file mode 100644 index 0000000..648f87d --- /dev/null +++ b/bp/auth/services/__init__.py @@ -0,0 +1,24 @@ +from .login_redirect import pop_login_redirect_target, store_login_redirect_target +from .auth_operations import ( + get_app_host, + get_app_root, + send_magic_email, + load_user_by_id, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +__all__ = [ + "pop_login_redirect_target", + "store_login_redirect_target", + "get_app_host", + "get_app_root", + "send_magic_email", + "load_user_by_id", + "find_or_create_user", + "create_magic_link", + "validate_magic_link", + "validate_email", +] diff --git a/bp/auth/services/auth_operations.py b/bp/auth/services/auth_operations.py new file mode 100644 index 0000000..8dfdc3e --- /dev/null +++ b/bp/auth/services/auth_operations.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +from quart import current_app, request, g +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from models import User, MagicLink, UserNewsletter +from config import config + + +def get_app_host() -> str: + """Get the application host URL from config or environment.""" + host = ( + config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000" + ).rstrip("/") + return host + + +def get_app_root() -> str: + """Get the application root path from request context.""" + root = (g.root).rstrip("/") + return root + + +async def send_magic_email(to_email: str, link_url: str) -> None: + """ + Send magic link email via SMTP if configured, otherwise log to console. + + Args: + to_email: Recipient email address + link_url: Magic link URL to include in email + + Raises: + Exception: If SMTP sending fails + """ + host = os.getenv("SMTP_HOST") + port = int(os.getenv("SMTP_PORT") or "587") + username = os.getenv("SMTP_USER") + password = os.getenv("SMTP_PASS") + mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com" + + subject = "Your sign-in link" + body = f"""Hello, + +Click this link to sign in: +{link_url} + +This link will expire in 15 minutes. + +If you did not request this, you can ignore this email. +""" + + if not host or not username or not password: + # Fallback: log to console + current_app.logger.warning( + "SMTP not configured. Printing magic link to console for %s: %s", + to_email, + link_url, + ) + print(f"[DEV] Magic link for {to_email}: {link_url}") + return + + # Lazy import to avoid dependency unless used + import aiosmtplib + from email.message import EmailMessage + + msg = EmailMessage() + msg["From"] = mail_from + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content(body) + + is_secure = port == 465 # implicit TLS if true + if is_secure: + # implicit TLS (like nodemailer secure: true) + smtp = aiosmtplib.SMTP( + hostname=host, + port=port, + use_tls=True, + username=username, + password=password, + ) + else: + # plain connect then STARTTLS (like secure: false but with TLS upgrade) + smtp = aiosmtplib.SMTP( + hostname=host, + port=port, + start_tls=True, + username=username, + password=password, + ) + + async with smtp: + await smtp.send_message(msg) + + +async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: + """ + Load a user by ID with labels and newsletters eagerly loaded. + + Args: + session: Database session + user_id: User ID to load + + Returns: + User object or None if not found + """ + stmt = ( + select(User) + .options( + selectinload(User.labels), + selectinload(User.user_newsletters).selectinload( + UserNewsletter.newsletter + ), + ) + .where(User.id == user_id) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def find_or_create_user(session: AsyncSession, email: str) -> User: + """ + Find existing user by email or create a new one. + + Args: + session: Database session + email: User email address (should be lowercase and trimmed) + + Returns: + User object (either existing or newly created) + """ + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + user = User(email=email) + session.add(user) + await session.flush() # Ensure user.id exists + + return user + + +async def create_magic_link( + session: AsyncSession, + user_id: int, + purpose: str = "signin", + expires_minutes: int = 15, +) -> Tuple[str, datetime]: + """ + Create a new magic link token for authentication. + + Args: + session: Database session + user_id: User ID to create link for + purpose: Purpose of the link (default: "signin") + expires_minutes: Minutes until expiration (default: 15) + + Returns: + Tuple of (token, expires_at) + """ + token = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes) + + ml = MagicLink( + token=token, + user_id=user_id, + purpose=purpose, + expires_at=expires, + ip=request.headers.get("x-forwarded-for", request.remote_addr), + user_agent=request.headers.get("user-agent"), + ) + session.add(ml) + + return token, expires + + +async def validate_magic_link( + session: AsyncSession, + token: str, +) -> Tuple[Optional[User], Optional[str]]: + """ + Validate and consume a magic link token. + + Args: + session: Database session (should be in a transaction) + token: Magic link token to validate + + Returns: + Tuple of (user, error_message) + - If user is None, error_message contains the reason + - If user is returned, the link was valid and has been consumed + """ + now = datetime.now(timezone.utc) + + ml = await session.scalar( + select(MagicLink) + .where(MagicLink.token == token) + .with_for_update() + ) + + if not ml or ml.purpose != "signin": + return None, "Invalid or expired link." + + if ml.used_at or ml.expires_at < now: + return None, "This link has expired. Please request a new one." + + user = await session.get(User, ml.user_id) + if not user: + return None, "User not found." + + # Mark link as used + ml.used_at = now + + return user, None + + +def validate_email(email: str) -> Tuple[bool, str]: + """ + Validate email address format. + + Args: + email: Email address to validate + + Returns: + Tuple of (is_valid, normalized_email) + """ + email = email.strip().lower() + + if not email or "@" not in email: + return False, email + + return True, email diff --git a/bp/auth/services/login_redirect.py b/bp/auth/services/login_redirect.py new file mode 100644 index 0000000..e7267e3 --- /dev/null +++ b/bp/auth/services/login_redirect.py @@ -0,0 +1,45 @@ +from urllib.parse import urlparse +from quart import session + +from shared.urls import coop_url + + +LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to" + + +def store_login_redirect_target() -> None: + from quart import request + + target = request.args.get("next") + if not target: + ref = request.referrer or "" + try: + parsed = urlparse(ref) + target = parsed.path or "" + except Exception: + target = "" + + if not target: + return + + # Accept both relative paths and absolute URLs (cross-app redirects) + if target.startswith("http://") or target.startswith("https://"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + elif target.startswith("/") and not target.startswith("//"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + + +def pop_login_redirect_target() -> str: + path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None) + if not path or not isinstance(path, str): + return coop_url("/auth/") + + # Absolute URL: return as-is (cross-app redirect) + if path.startswith("http://") or path.startswith("https://"): + return path + + # Relative path: must start with / and not // + if path.startswith("/") and not path.startswith("//"): + return coop_url(path) + + return coop_url("/auth/") diff --git a/bp/blog/__init__.py b/bp/blog/__init__.py new file mode 100644 index 0000000..85fd1a5 --- /dev/null +++ b/bp/blog/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +# create the blueprint at package import time +from .routes import register # = Blueprint("browse_bp", __name__) + +# import routes AFTER browse_bp is defined so routes can attach to it +from . import routes # noqa: F401 diff --git a/bp/blog/admin/__init__.py b/bp/blog/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/blog/admin/routes.py b/bp/blog/admin/routes.py new file mode 100644 index 0000000..d13441b --- /dev/null +++ b/bp/blog/admin/routes.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import re +from quart import ( + render_template, + make_response, + Blueprint, + redirect, + url_for, + request, + g, +) +from sqlalchemy import select, delete + +from suma_browser.app.authz import require_admin +from suma_browser.app.utils.htmx import is_htmx_request +from suma_browser.app.redis_cacher import invalidate_tag_cache + +from models.tag_group import TagGroup, TagGroupTag +from models.ghost_content import Tag + + +def _slugify(name: str) -> str: + s = name.strip().lower() + s = re.sub(r"[^\w\s-]", "", s) + s = re.sub(r"[\s_]+", "-", s) + return s.strip("-") + + +async def _unassigned_tags(session): + """Return public, non-deleted tags not assigned to any group.""" + assigned_sq = select(TagGroupTag.tag_id).subquery() + q = ( + select(Tag) + .where( + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + Tag.id.notin_(select(assigned_sq)), + ) + .order_by(Tag.name) + ) + return list((await session.execute(q)).scalars()) + + +def register(): + bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") + + @bp.get("/") + @require_admin + async def index(): + groups = list( + (await g.s.execute( + select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) + )).scalars() + ) + unassigned = await _unassigned_tags(g.s) + + ctx = {"groups": groups, "unassigned_tags": unassigned} + + if not is_htmx_request(): + return await render_template("_types/blog/admin/tag_groups/index.html", **ctx) + else: + return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx) + + @bp.post("/") + @require_admin + async def create(): + form = await request.form + name = (form.get("name") or "").strip() + if not name: + return redirect(url_for("blog.tag_groups_admin.index")) + + slug = _slugify(name) + feature_image = (form.get("feature_image") or "").strip() or None + colour = (form.get("colour") or "").strip() or None + sort_order = int(form.get("sort_order") or 0) + + tg = TagGroup( + name=name, slug=slug, + feature_image=feature_image, colour=colour, + sort_order=sort_order, + ) + g.s.add(tg) + await g.s.flush() + + await invalidate_tag_cache("blog") + return redirect(url_for("blog.tag_groups_admin.index")) + + @bp.get("//") + @require_admin + async def edit(id: int): + tg = await g.s.get(TagGroup, id) + if not tg: + return redirect(url_for("blog.tag_groups_admin.index")) + + # Assigned tag IDs for this group + assigned_rows = list( + (await g.s.execute( + select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id) + )).scalars() + ) + assigned_tag_ids = set(assigned_rows) + + # All public, non-deleted tags + all_tags = list( + (await g.s.execute( + select(Tag).where( + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + ).order_by(Tag.name) + )).scalars() + ) + + ctx = { + "group": tg, + "all_tags": all_tags, + "assigned_tag_ids": assigned_tag_ids, + } + + if not is_htmx_request(): + return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx) + else: + return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx) + + @bp.post("//") + @require_admin + async def save(id: int): + tg = await g.s.get(TagGroup, id) + if not tg: + return redirect(url_for("blog.tag_groups_admin.index")) + + form = await request.form + name = (form.get("name") or "").strip() + if name: + tg.name = name + tg.slug = _slugify(name) + tg.feature_image = (form.get("feature_image") or "").strip() or None + tg.colour = (form.get("colour") or "").strip() or None + tg.sort_order = int(form.get("sort_order") or 0) + + # Update tag assignments + selected_tag_ids = set() + for val in form.getlist("tag_ids"): + try: + selected_tag_ids.add(int(val)) + except (ValueError, TypeError): + pass + + # Remove old assignments + await g.s.execute( + delete(TagGroupTag).where(TagGroupTag.tag_group_id == id) + ) + await g.s.flush() + + # Add new assignments + for tid in selected_tag_ids: + g.s.add(TagGroupTag(tag_group_id=id, tag_id=tid)) + await g.s.flush() + + await invalidate_tag_cache("blog") + return redirect(url_for("blog.tag_groups_admin.edit", id=id)) + + @bp.post("//delete/") + @require_admin + async def delete_group(id: int): + tg = await g.s.get(TagGroup, id) + if tg: + await g.s.delete(tg) + await g.s.flush() + await invalidate_tag_cache("blog") + return redirect(url_for("blog.tag_groups_admin.index")) + + return bp diff --git a/bp/blog/filters/qs.py b/bp/blog/filters/qs.py new file mode 100644 index 0000000..0064cc7 --- /dev/null +++ b/bp/blog/filters/qs.py @@ -0,0 +1,120 @@ +from quart import request + +from typing import Iterable, Optional, Union + +from suma_browser.app.filters.qs_base import ( + KEEP, _norm, make_filter_set, build_qs, +) +from suma_browser.app.filters.query_types import BlogQuery + + +def decode() -> BlogQuery: + page = int(request.args.get("page", 1)) + search = request.args.get("search") + sort = request.args.get("sort") + liked = request.args.get("liked") + drafts = request.args.get("drafts") + + selected_tags = tuple(s.strip() for s in request.args.getlist("tag") if s.strip())[:1] + selected_authors = tuple(s.strip().lower() for s in request.args.getlist("author") if s.strip())[:1] + selected_groups = tuple(s.strip() for s in request.args.getlist("group") if s.strip())[:1] + view = request.args.get("view") or None + + return BlogQuery(page, search, sort, selected_tags, selected_authors, liked, view, drafts, selected_groups) + + +def makeqs_factory(): + """ + Build a makeqs(...) that starts from the current filters + page. + Auto-resets page to 1 when filters change unless you pass page explicitly. + """ + q = decode() + base_tags = [s for s in q.selected_tags if (s or "").strip()] + base_authors = [s for s in q.selected_authors if (s or "").strip()] + base_groups = [s for s in q.selected_groups if (s or "").strip()] + base_search = q.search or None + base_liked = q.liked or None + base_sort = q.sort or None + base_page = int(q.page or 1) + base_view = q.view or None + base_drafts = q.drafts or None + + def makeqs( + *, + clear_filters: bool = False, + add_tag: Union[str, Iterable[str], None] = None, + remove_tag: Union[str, Iterable[str], None] = None, + add_author: Union[str, Iterable[str], None] = None, + remove_author: Union[str, Iterable[str], None] = None, + add_group: Union[str, Iterable[str], None] = None, + remove_group: Union[str, Iterable[str], None] = None, + search: Union[str, None, object] = KEEP, + sort: Union[str, None, object] = KEEP, + page: Union[int, None, object] = None, + extra: Optional[Iterable[tuple]] = None, + leading_q: bool = True, + liked: Union[bool, None, object] = KEEP, + view: Union[str, None, object] = KEEP, + drafts: Union[str, None, object] = KEEP, + ) -> str: + groups = make_filter_set(base_groups, add_group, remove_group, clear_filters, single_select=True) + tags = make_filter_set(base_tags, add_tag, remove_tag, clear_filters, single_select=True) + authors = make_filter_set(base_authors, add_author, remove_author, clear_filters, single_select=True) + + # Mutual exclusion: selecting a group clears tags, selecting a tag clears groups + if add_group is not None: + tags = [] + if add_tag is not None: + groups = [] + + final_search = None if clear_filters else base_search if search is KEEP else ((search or "").strip() or None) + final_sort = base_sort if sort is KEEP else (sort or None) + final_liked = None if clear_filters else base_liked if liked is KEEP else liked + final_view = base_view if view is KEEP else (view or None) + final_drafts = None if clear_filters else base_drafts if drafts is KEEP else (drafts or None) + + # Did filters change? + filters_changed = ( + set(map(_norm, tags)) != set(map(_norm, base_tags)) + or set(map(_norm, authors)) != set(map(_norm, base_authors)) + or set(map(_norm, groups)) != set(map(_norm, base_groups)) + or final_search != base_search + or final_sort != base_sort + or final_liked != base_liked + or final_drafts != base_drafts + ) + + # Page logic + if page is KEEP: + final_page = 1 if filters_changed else base_page + else: + final_page = page + + # Build params + params = [] + for s in groups: + params.append(("group", s)) + for s in tags: + params.append(("tag", s)) + for s in authors: + params.append(("author", s)) + if final_search: + params.append(("search", final_search)) + if final_liked is not None: + params.append(("liked", final_liked)) + if final_sort: + params.append(("sort", final_sort)) + if final_view: + params.append(("view", final_view)) + if final_drafts: + params.append(("drafts", final_drafts)) + if final_page is not None: + params.append(("page", str(final_page))) + if extra: + for k, v in extra: + if v is not None: + params.append((k, str(v))) + + return build_qs(params, leading_q=leading_q) + + return makeqs diff --git a/bp/blog/ghost/editor_api.py b/bp/blog/ghost/editor_api.py new file mode 100644 index 0000000..a7c1855 --- /dev/null +++ b/bp/blog/ghost/editor_api.py @@ -0,0 +1,256 @@ +""" +Editor API proxy – image/media/file uploads and oembed. + +Forwards requests to the Ghost Admin API with JWT auth so the browser +never needs direct Ghost access. +""" +from __future__ import annotations + +import logging +import os + +import httpx +from quart import Blueprint, request, jsonify, g +from sqlalchemy import select, or_ + +from suma_browser.app.authz import require_admin, require_login +from models import Snippet +from .ghost_admin_token import make_ghost_admin_jwt + +log = logging.getLogger(__name__) + +GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"] +MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB +MAX_MEDIA_SIZE = 100 * 1024 * 1024 # 100 MB +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB + +ALLOWED_IMAGE_MIMETYPES = frozenset({ + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", +}) +ALLOWED_MEDIA_MIMETYPES = frozenset({ + "audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/aac", + "video/mp4", "video/webm", "video/ogg", +}) + +editor_api_bp = Blueprint("editor_api", __name__, url_prefix="/editor-api") + + +def _auth_header() -> dict[str, str]: + return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"} + + +@editor_api_bp.post("/images/upload/") +@require_admin +async def upload_image(): + """Proxy image upload to Ghost Admin API.""" + files = await request.files + uploaded = files.get("file") + if not uploaded: + return jsonify({"errors": [{"message": "No file provided"}]}), 400 + + content = uploaded.read() + if len(content) > MAX_IMAGE_SIZE: + return jsonify({"errors": [{"message": "File too large (max 10 MB)"}]}), 413 + + if uploaded.content_type not in ALLOWED_IMAGE_MIMETYPES: + return jsonify({"errors": [{"message": f"Unsupported file type: {uploaded.content_type}"}]}), 415 + + url = f"{GHOST_ADMIN_API_URL}/images/upload/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + url, + headers=_auth_header(), + files={"file": (uploaded.filename, content, uploaded.content_type)}, + ) + + if not resp.is_success: + log.error("Ghost image upload failed %s: %s", resp.status_code, resp.text[:500]) + + return resp.json(), resp.status_code + + +@editor_api_bp.post("/media/upload/") +@require_admin +async def upload_media(): + """Proxy audio/video upload to Ghost Admin API.""" + files = await request.files + uploaded = files.get("file") + if not uploaded: + return jsonify({"errors": [{"message": "No file provided"}]}), 400 + + content = uploaded.read() + if len(content) > MAX_MEDIA_SIZE: + return jsonify({"errors": [{"message": "File too large (max 100 MB)"}]}), 413 + + if uploaded.content_type not in ALLOWED_MEDIA_MIMETYPES: + return jsonify({"errors": [{"message": f"Unsupported media type: {uploaded.content_type}"}]}), 415 + + ghost_files = {"file": (uploaded.filename, content, uploaded.content_type)} + + # Optional video thumbnail + thumbnail = files.get("thumbnail") + if thumbnail: + thumb_content = thumbnail.read() + ghost_files["thumbnail"] = (thumbnail.filename, thumb_content, thumbnail.content_type) + + url = f"{GHOST_ADMIN_API_URL}/media/upload/" + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, headers=_auth_header(), files=ghost_files) + + if not resp.is_success: + log.error("Ghost media upload failed %s: %s", resp.status_code, resp.text[:500]) + + return resp.json(), resp.status_code + + +@editor_api_bp.post("/files/upload/") +@require_admin +async def upload_file(): + """Proxy file upload to Ghost Admin API.""" + files = await request.files + uploaded = files.get("file") + if not uploaded: + return jsonify({"errors": [{"message": "No file provided"}]}), 400 + + content = uploaded.read() + if len(content) > MAX_FILE_SIZE: + return jsonify({"errors": [{"message": "File too large (max 50 MB)"}]}), 413 + + url = f"{GHOST_ADMIN_API_URL}/files/upload/" + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + url, + headers=_auth_header(), + files={"file": (uploaded.filename, content, uploaded.content_type)}, + ) + + if not resp.is_success: + log.error("Ghost file upload failed %s: %s", resp.status_code, resp.text[:500]) + + return resp.json(), resp.status_code + + +@editor_api_bp.get("/oembed/") +@require_admin +async def oembed_proxy(): + """Proxy oembed lookups to Ghost Admin API.""" + params = dict(request.args) + if not params.get("url"): + return jsonify({"errors": [{"message": "url parameter required"}]}), 400 + + url = f"{GHOST_ADMIN_API_URL}/oembed/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header(), params=params) + + if not resp.is_success: + log.error("Ghost oembed failed %s: %s", resp.status_code, resp.text[:500]) + + return resp.json(), resp.status_code + + +# ── Snippets ──────────────────────────────────────────────────────── + +VALID_VISIBILITY = frozenset({"private", "shared", "admin"}) + + +@editor_api_bp.get("/snippets/") +@require_login +async def list_snippets(): + """Return snippets visible to the current user.""" + uid = g.user.id + is_admin = g.rights.get("admin") + + filters = [Snippet.user_id == uid, Snippet.visibility == "shared"] + if is_admin: + filters.append(Snippet.visibility == "admin") + + rows = (await g.s.execute( + select(Snippet).where(or_(*filters)).order_by(Snippet.name) + )).scalars().all() + + return jsonify([ + {"id": s.id, "name": s.name, "value": s.value, "visibility": s.visibility} + for s in rows + ]) + + +@editor_api_bp.post("/snippets/") +@require_login +async def create_snippet(): + """Create or upsert a snippet by (user_id, name).""" + data = await request.get_json(force=True) + name = (data.get("name") or "").strip() + value = data.get("value") + visibility = data.get("visibility", "private") + + if not name or value is None: + return jsonify({"error": "name and value are required"}), 400 + if visibility not in VALID_VISIBILITY: + return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400 + if visibility != "private" and not g.rights.get("admin"): + visibility = "private" + + uid = g.user.id + + existing = (await g.s.execute( + select(Snippet).where(Snippet.user_id == uid, Snippet.name == name) + )).scalar_one_or_none() + + if existing: + existing.value = value + existing.visibility = visibility + snippet = existing + else: + snippet = Snippet(user_id=uid, name=name, value=value, visibility=visibility) + g.s.add(snippet) + + await g.s.flush() + return jsonify({ + "id": snippet.id, "name": snippet.name, + "value": snippet.value, "visibility": snippet.visibility, + }), 200 if existing else 201 + + +@editor_api_bp.patch("/snippets//") +@require_login +async def patch_snippet(snippet_id: int): + """Update snippet visibility. Only admins may set shared/admin.""" + snippet = await g.s.get(Snippet, snippet_id) + if not snippet: + return jsonify({"error": "not found"}), 404 + + is_admin = g.rights.get("admin") + + if snippet.user_id != g.user.id and not is_admin: + return jsonify({"error": "forbidden"}), 403 + + data = await request.get_json(force=True) + visibility = data.get("visibility") + if visibility is not None: + if visibility not in VALID_VISIBILITY: + return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400 + if visibility != "private" and not is_admin: + return jsonify({"error": "only admins may set shared/admin visibility"}), 403 + snippet.visibility = visibility + + await g.s.flush() + return jsonify({ + "id": snippet.id, "name": snippet.name, + "value": snippet.value, "visibility": snippet.visibility, + }) + + +@editor_api_bp.delete("/snippets//") +@require_login +async def delete_snippet(snippet_id: int): + """Delete a snippet. Owners can delete their own; admins can delete any.""" + snippet = await g.s.get(Snippet, snippet_id) + if not snippet: + return jsonify({"error": "not found"}), 404 + + if snippet.user_id != g.user.id and not g.rights.get("admin"): + return jsonify({"error": "forbidden"}), 403 + + await g.s.delete(snippet) + await g.s.flush() + return jsonify({"ok": True}) diff --git a/bp/blog/ghost/ghost_admin_token.py b/bp/blog/ghost/ghost_admin_token.py new file mode 100644 index 0000000..1974075 --- /dev/null +++ b/bp/blog/ghost/ghost_admin_token.py @@ -0,0 +1,46 @@ +import os +import time +import jwt # PyJWT +from typing import Tuple + + +def _split_key(raw_key: str) -> Tuple[str, bytes]: + """ + raw_key is the 'id:secret' from Ghost. + Returns (id, secret_bytes) + """ + key_id, key_secret_hex = raw_key.split(':', 1) + secret_bytes = bytes.fromhex(key_secret_hex) + return key_id, secret_bytes + + +def make_ghost_admin_jwt() -> str: + """ + Generate a short-lived JWT suitable for Authorization: Ghost + """ + raw_key = os.environ["GHOST_ADMIN_API_KEY"] + key_id, secret_bytes = _split_key(raw_key) + + now = int(time.time()) + + payload = { + "iat": now, + "exp": now + 5 * 60, # now + 5 minutes + "aud": "/admin/", + } + + headers = { + "alg": "HS256", + "kid": key_id, + "typ": "JWT", + } + + token = jwt.encode( + payload, + secret_bytes, + algorithm="HS256", + headers=headers, + ) + + # PyJWT returns str in recent versions; Ghost expects bare token string + return token diff --git a/bp/blog/ghost/ghost_posts.py b/bp/blog/ghost/ghost_posts.py new file mode 100644 index 0000000..255ba75 --- /dev/null +++ b/bp/blog/ghost/ghost_posts.py @@ -0,0 +1,170 @@ +""" +Ghost Admin API – post CRUD. + +Uses the same JWT auth and httpx patterns as ghost_sync.py. +""" +from __future__ import annotations + +import logging +import os + +import httpx + +from .ghost_admin_token import make_ghost_admin_jwt + +log = logging.getLogger(__name__) + +GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"] + + +def _auth_header() -> dict[str, str]: + return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"} + + +def _check(resp: httpx.Response) -> None: + """Raise with the Ghost error body so callers see what went wrong.""" + if resp.is_success: + return + body = resp.text[:2000] + log.error("Ghost API %s %s → %s: %s", resp.request.method, resp.request.url, resp.status_code, body) + resp.raise_for_status() + + +async def get_post_for_edit(ghost_id: str) -> dict | None: + """Fetch a single post by Ghost ID, including lexical source.""" + url = ( + f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/" + "?formats=lexical,html,mobiledoc&include=newsletters" + ) + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header()) + if resp.status_code == 404: + return None + _check(resp) + return resp.json()["posts"][0] + + +async def create_post( + title: str, + lexical_json: str, + status: str = "draft", + feature_image: str | None = None, + custom_excerpt: str | None = None, + feature_image_caption: str | None = None, +) -> dict: + """Create a new post in Ghost. Returns the created post dict.""" + post_body: dict = { + "title": title, + "lexical": lexical_json, + "mobiledoc": None, + "status": status, + } + if feature_image: + post_body["feature_image"] = feature_image + if custom_excerpt: + post_body["custom_excerpt"] = custom_excerpt + if feature_image_caption is not None: + post_body["feature_image_caption"] = feature_image_caption + payload = {"posts": [post_body]} + url = f"{GHOST_ADMIN_API_URL}/posts/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(url, json=payload, headers=_auth_header()) + _check(resp) + return resp.json()["posts"][0] + + +async def update_post( + ghost_id: str, + lexical_json: str, + title: str | None, + updated_at: str, + feature_image: str | None = None, + custom_excerpt: str | None = None, + feature_image_caption: str | None = None, + status: str | None = None, + newsletter_slug: str | None = None, + email_segment: str | None = None, + email_only: bool | None = None, +) -> dict: + """Update an existing Ghost post. Returns the updated post dict. + + ``updated_at`` is Ghost's optimistic-locking token – pass the value + you received from ``get_post_for_edit``. + + When ``newsletter_slug`` is set the publish request also triggers an + email send via Ghost's query-parameter API: + ``?newsletter={slug}&email_segment={segment}``. + """ + post_body: dict = { + "lexical": lexical_json, + "mobiledoc": None, + "updated_at": updated_at, + } + if title is not None: + post_body["title"] = title + if feature_image is not None: + post_body["feature_image"] = feature_image or None + if custom_excerpt is not None: + post_body["custom_excerpt"] = custom_excerpt or None + if feature_image_caption is not None: + post_body["feature_image_caption"] = feature_image_caption + if status is not None: + post_body["status"] = status + if email_only: + post_body["email_only"] = True + payload = {"posts": [post_body]} + + url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/" + if newsletter_slug: + url += f"?newsletter={newsletter_slug}" + if email_segment: + url += f"&email_segment={email_segment}" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.put(url, json=payload, headers=_auth_header()) + _check(resp) + return resp.json()["posts"][0] + + +_SETTINGS_FIELDS = ( + "slug", + "published_at", + "featured", + "visibility", + "email_only", + "custom_template", + "meta_title", + "meta_description", + "canonical_url", + "og_image", + "og_title", + "og_description", + "twitter_image", + "twitter_title", + "twitter_description", + "tags", + "feature_image_alt", +) + + +async def update_post_settings( + ghost_id: str, + updated_at: str, + **kwargs, +) -> dict: + """Update Ghost post settings (slug, tags, SEO, social, etc.). + + Only non-None keyword args are included in the PUT payload. + Accepts any key from ``_SETTINGS_FIELDS``. + """ + post_body: dict = {"updated_at": updated_at} + for key in _SETTINGS_FIELDS: + val = kwargs.get(key) + if val is not None: + post_body[key] = val + + payload = {"posts": [post_body]} + url = f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.put(url, json=payload, headers=_auth_header()) + _check(resp) + return resp.json()["posts"][0] diff --git a/bp/blog/ghost/ghost_sync.py b/bp/blog/ghost/ghost_sync.py new file mode 100644 index 0000000..bc1d010 --- /dev/null +++ b/bp/blog/ghost/ghost_sync.py @@ -0,0 +1,1069 @@ +from __future__ import annotations +import os +import asyncio +from datetime import datetime +from typing import Dict, Any, Optional + +import httpx +from sqlalchemy import select, delete, or_, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON columns + +# Content models +from models.ghost_content import ( + Post, Author, Tag, PostAuthor, PostTag +) + +# User-centric membership models +from models import User +from models.ghost_membership_entities import ( + GhostLabel, UserLabel, + GhostNewsletter, UserNewsletter, + GhostTier, GhostSubscription, +) + +from .ghost_admin_token import make_ghost_admin_jwt + +from urllib.parse import quote + +GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"] + +from suma_browser.app.utils import ( + utcnow +) + + + +def _auth_header() -> dict[str, str]: + return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"} + + +def _iso(val: str | None) -> datetime | None: + if not val: + return None + return datetime.fromisoformat(val.replace("Z", "+00:00")) + +def _to_str_or_none(v) -> Optional[str]: + """Return a trimmed string if v is safely stringifiable; else None.""" + if v is None: + return None + # Disallow complex types that would stringify to JSON-like noise + if isinstance(v, (dict, list, set, tuple, bytes, bytearray)): + return None + s = str(v).strip() + return s or None + + +def _sanitize_member_payload(payload: dict) -> dict: + """Coerce types Ghost expects and drop empties to avoid 422/500 quirks.""" + out: dict = {} + + # email -> lowercase string + email = _to_str_or_none(payload.get("email")) + if email: + out["email"] = email.lower() + + # name / note must be strings if present + name = _to_str_or_none(payload.get("name")) + if name is not None: + out["name"] = name + + note = _to_str_or_none(payload.get("note")) + if note is not None: + out["note"] = note + + # subscribed -> bool + if "subscribed" in payload: + out["subscribed"] = bool(payload.get("subscribed")) + + # labels: keep only rows that have a non-empty id OR name + labels = [] + for item in payload.get("labels") or []: + gid = _to_str_or_none(item.get("id")) + gname = _to_str_or_none(item.get("name")) + if gid: + labels.append({"id": gid}) + elif gname: # only include if non-empty + labels.append({"name": gname}) + if labels: + out["labels"] = labels + + # newsletters: keep only rows with id OR name; coerce subscribed -> bool + newsletters = [] + for item in payload.get("newsletters") or []: + gid = _to_str_or_none(item.get("id")) + gname = _to_str_or_none(item.get("name")) + row = {"subscribed": bool(item.get("subscribed", True))} + if gid: + row["id"] = gid + newsletters.append(row) + elif gname: + row["name"] = gname + newsletters.append(row) + if newsletters: + out["newsletters"] = newsletters + + # id (if we carry a known ghost_id) + gid = _to_str_or_none(payload.get("id")) + if gid: + out["id"] = gid + + return out +# ===================== +# CONTENT UPSERT HELPERS +# ===================== + +async def _upsert_author(sess: AsyncSession, ga: Dict[str, Any]) -> Author: + res = await sess.execute(select(Author).where(Author.ghost_id == ga["id"])) + obj = res.scalar_one_or_none() + if obj is None: + obj = Author(ghost_id=ga["id"]) + sess.add(obj) + + # revive if soft-deleted + obj.deleted_at = None + + obj.slug = ga.get("slug") or obj.slug + obj.name = ga.get("name") or obj.name + obj.email = ga.get("email") or obj.email + obj.profile_image = ga.get("profile_image") + obj.cover_image = ga.get("cover_image") + obj.bio = ga.get("bio") + obj.website = ga.get("website") + obj.location = ga.get("location") + obj.facebook = ga.get("facebook") + obj.twitter = ga.get("twitter") + obj.created_at = _iso(ga.get("created_at")) or obj.created_at or utcnow() + obj.updated_at = _iso(ga.get("updated_at")) or utcnow() + + await sess.flush() + return obj + + +async def _upsert_tag(sess: AsyncSession, gt: Dict[str, Any]) -> Tag: + res = await sess.execute(select(Tag).where(Tag.ghost_id == gt["id"])) + obj = res.scalar_one_or_none() + if obj is None: + obj = Tag(ghost_id=gt["id"]) + sess.add(obj) + + obj.deleted_at = None # revive if soft-deleted + + obj.slug = gt.get("slug") or obj.slug + obj.name = gt.get("name") or obj.name + obj.description = gt.get("description") + obj.visibility = gt.get("visibility") or obj.visibility + obj.feature_image = gt.get("feature_image") + obj.meta_title = gt.get("meta_title") + obj.meta_description = gt.get("meta_description") + obj.created_at = _iso(gt.get("created_at")) or obj.created_at or utcnow() + obj.updated_at = _iso(gt.get("updated_at")) or utcnow() + + await sess.flush() + return obj + + +async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[str, Author], tag_map: Dict[str, Tag]) -> Post: + res = await sess.execute(select(Post).where(Post.ghost_id == gp["id"])) + obj = res.scalar_one_or_none() + if obj is None: + obj = Post(ghost_id=gp["id"]) # type: ignore[call-arg] + sess.add(obj) + + obj.deleted_at = None # revive if soft-deleted + + obj.uuid = gp.get("uuid") or obj.uuid + obj.slug = gp.get("slug") or obj.slug + obj.title = gp.get("title") or obj.title + obj.html = gp.get("html") + obj.plaintext = gp.get("plaintext") + obj.mobiledoc = gp.get("mobiledoc") + obj.lexical = gp.get("lexical") + obj.feature_image = gp.get("feature_image") + obj.feature_image_alt = gp.get("feature_image_alt") + obj.feature_image_caption = gp.get("feature_image_caption") + obj.excerpt = gp.get("excerpt") + obj.custom_excerpt = gp.get("custom_excerpt") + obj.visibility = gp.get("visibility") or obj.visibility + obj.status = gp.get("status") or obj.status + obj.featured = bool(gp.get("featured") or False) + obj.is_page = bool(gp.get("page") or False) + obj.email_only = bool(gp.get("email_only") or False) + obj.canonical_url = gp.get("canonical_url") + obj.meta_title = gp.get("meta_title") + obj.meta_description = gp.get("meta_description") + obj.og_image = gp.get("og_image") + obj.og_title = gp.get("og_title") + obj.og_description = gp.get("og_description") + obj.twitter_image = gp.get("twitter_image") + obj.twitter_title = gp.get("twitter_title") + obj.twitter_description = gp.get("twitter_description") + obj.custom_template = gp.get("custom_template") + obj.reading_time = gp.get("reading_time") + obj.comment_id = gp.get("comment_id") + + obj.published_at = _iso(gp.get("published_at")) + obj.updated_at = _iso(gp.get("updated_at")) or obj.updated_at or utcnow() + obj.created_at = _iso(gp.get("created_at")) or obj.created_at or utcnow() + + pa = gp.get("primary_author") + obj.primary_author_id = author_map[pa["id"].strip()].id if pa else None # type: ignore[index] + + pt = gp.get("primary_tag") + obj.primary_tag_id = tag_map[pt["id"].strip()].id if (pt and pt["id"] in tag_map) else None # type: ignore[index] + + await sess.flush() + + # Backfill user_id from primary author email if not already set + if obj.user_id is None and obj.primary_author_id is not None: + pa_obj = author_map.get(gp.get("primary_author", {}).get("id", "")) + if pa_obj and pa_obj.email: + user_res = await sess.execute( + select(User).where(User.email.ilike(pa_obj.email)) + ) + matched_user = user_res.scalar_one_or_none() + if matched_user: + obj.user_id = matched_user.id + await sess.flush() + + # rebuild post_authors + await sess.execute(delete(PostAuthor).where(PostAuthor.post_id == obj.id)) + for idx, a in enumerate(gp.get("authors") or []): + aa = author_map[a["id"]] + sess.add(PostAuthor(post_id=obj.id, author_id=aa.id, sort_order=idx)) + + # rebuild post_tags + await sess.execute(delete(PostTag).where(PostTag.post_id == obj.id)) + for idx, t in enumerate(gp.get("tags") or []): + tt = tag_map[t["id"]] + sess.add(PostTag(post_id=obj.id, tag_id=tt.id, sort_order=idx)) + + return obj + +async def _ghost_find_member_by_email(email: str) -> Optional[dict]: + """Return first Ghost member with this email, or None.""" + if not email: + return None + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/members/?filter=email:{quote(email)}&limit=1", + headers=_auth_header(), + ) + resp.raise_for_status() + members = resp.json().get("members") or [] + return members[0] if members else None + + +# --- add this helper next to fetch_all_posts_from_ghost() --- + +async def _fetch_all_from_ghost(endpoint: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/{endpoint}/?include=authors,tags&limit=all&formats=html,plaintext,mobiledoc,lexical", + headers=_auth_header(), + ) + resp.raise_for_status() + # admin posts endpoint returns {"posts": [...]}, pages returns {"pages": [...]} + key = "posts" if endpoint == "posts" else "pages" + return resp.json().get(key, []) + +async def fetch_all_posts_and_pages_from_ghost() -> list[dict[str, Any]]: + posts, pages = await asyncio.gather( + _fetch_all_from_ghost("posts"), + _fetch_all_from_ghost("pages"), + ) + # Be explicit: ensure page flag exists for pages (Ghost typically includes "page": true) + for p in pages: + p["page"] = True + return posts + pages + + +async def sync_all_content_from_ghost(sess: AsyncSession) -> None: + #data = await fetch_all_posts_from_ghost() + data = await fetch_all_posts_and_pages_from_ghost() + # Use a transaction so all upserts/soft-deletes commit together + # buckets of authors/tags we saw in Ghost + author_bucket: Dict[str, dict[str, Any]] = {} + tag_bucket: Dict[str, dict[str, Any]] = {} + + for p in data: + for a in p.get("authors") or []: + author_bucket[a["id"]] = a + if p.get("primary_author"): + author_bucket[p["primary_author"]["id"]] = p["primary_author"] + + for t in p.get("tags") or []: + tag_bucket[t["id"]] = t + if p.get("primary_tag"): + tag_bucket[p["primary_tag"]["id"]] = p["primary_tag"] + + # sets of ghost_ids we've seen in Ghost RIGHT NOW + seen_post_ids = {p["id"] for p in data} + seen_author_ids = set(author_bucket.keys()) + seen_tag_ids = set(tag_bucket.keys()) + + # upsert authors + author_map: Dict[str, Author] = {} + for ga in author_bucket.values(): + a = await _upsert_author(sess, ga) + author_map[ga["id"]] = a + + # upsert tags + tag_map: Dict[str, Tag] = {} + for gt in tag_bucket.values(): + t = await _upsert_tag(sess, gt) + tag_map[gt["id"]] = t + + # upsert posts (including M2M) + for gp in data: + await _upsert_post(sess, gp, author_map, tag_map) + + # soft-delete anything that no longer exists in Ghost + now = utcnow() + + # Authors not seen -> mark deleted_at if not already + db_authors = await sess.execute(select(Author)) + for local_author in db_authors.scalars(): + if local_author.ghost_id not in seen_author_ids: + if local_author.deleted_at is None: + local_author.deleted_at = now + + # Tags not seen -> mark deleted_at + db_tags = await sess.execute(select(Tag)) + for local_tag in db_tags.scalars(): + if local_tag.ghost_id not in seen_tag_ids: + if local_tag.deleted_at is None: + local_tag.deleted_at = now + + # Posts not seen -> mark deleted_at + db_posts = await sess.execute(select(Post)) + for local_post in db_posts.scalars(): + if local_post.ghost_id not in seen_post_ids: + if local_post.deleted_at is None: + local_post.deleted_at = now + + # transaction auto-commits here + + +#===================================================== +# MEMBERSHIP SYNC (USER-CENTRIC) Ghost -> DB +#===================================================== + +def _member_email(m: dict[str, Any]) -> Optional[str]: + email = (m.get("email") or "").strip().lower() or None + return email + + +# ---- small upsert helpers for related entities ---- + +async def _upsert_label(sess: AsyncSession, data: dict) -> GhostLabel: + res = await sess.execute(select(GhostLabel).where(GhostLabel.ghost_id == data["id"])) + obj = res.scalar_one_or_none() + if not obj: + obj = GhostLabel(ghost_id=data["id"]) + sess.add(obj) + obj.name = data.get("name") or obj.name + obj.slug = data.get("slug") or obj.slug + await sess.flush() + return obj + + +async def _upsert_newsletter(sess: AsyncSession, data: dict) -> GhostNewsletter: + res = await sess.execute(select(GhostNewsletter).where(GhostNewsletter.ghost_id == data["id"])) + obj = res.scalar_one_or_none() + if not obj: + obj = GhostNewsletter(ghost_id=data["id"]) + sess.add(obj) + obj.name = data.get("name") or obj.name + obj.slug = data.get("slug") or obj.slug + obj.description = data.get("description") or obj.description + await sess.flush() + return obj + + +async def _upsert_tier(sess: AsyncSession, data: dict) -> GhostTier: + res = await sess.execute(select(GhostTier).where(GhostTier.ghost_id == data["id"])) + obj = res.scalar_one_or_none() + if not obj: + obj = GhostTier(ghost_id=data["id"]) + sess.add(obj) + obj.name = data.get("name") or obj.name + obj.slug = data.get("slug") or obj.slug + obj.type = data.get("type") or obj.type + obj.visibility = data.get("visibility") or obj.visibility + await sess.flush() + return obj + + +def _price_cents(sd: dict) -> Optional[int]: + try: + return int((sd.get("price") or {}).get("amount")) + except Exception: + return None + + +# ---- application of member payload onto User + related tables ---- + +async def _find_or_create_user_by_ghost_or_email(sess: AsyncSession, data: dict) -> User: + ghost_id = data.get("id") + email = _member_email(data) + + if ghost_id: + res = await sess.execute(select(User).where(User.ghost_id == ghost_id)) + u = res.scalar_one_or_none() + if u: + return u + + if email: + res = await sess.execute(select(User).where(User.email.ilike(email))) + u = res.scalar_one_or_none() + if u: + if ghost_id and not u.ghost_id: + u.ghost_id = ghost_id + return u + + # create a new user (Ghost is source of truth for member list) + u = User(email=email or f"_ghost_{ghost_id}@invalid.local") + if ghost_id: + u.ghost_id = ghost_id + sess.add(u) + await sess.flush() + return u + + +async def _apply_user_membership(sess: AsyncSession, user: User, m: dict) -> User: + """Apply Ghost member payload to local User WITHOUT touching relationship collections directly. + We mutate join tables explicitly to avoid lazy-loads (which cause MissingGreenlet in async). + """ + sess.add(user) + + # scalar fields + user.name = m.get("name") or user.name + user.ghost_status = m.get("status") or user.ghost_status + user.ghost_subscribed = bool(m.get("subscribed", True)) + user.ghost_note = m.get("note") or user.ghost_note + user.avatar_image = m.get("avatar_image") or user.avatar_image + user.stripe_customer_id = ( + (m.get("stripe") or {}).get("customer_id") + or (m.get("customer") or {}).get("id") + or m.get("stripe_customer_id") + or user.stripe_customer_id + ) + user.ghost_raw = dict(m) + flag_modified(user, "ghost_raw") + + await sess.flush() # ensure user.id exists + + # Labels join + label_ids: list[int] = [] + for ld in m.get("labels") or []: + lbl = await _upsert_label(sess, ld) + label_ids.append(lbl.id) + await sess.execute(delete(UserLabel).where(UserLabel.user_id == user.id)) + for lid in label_ids: + sess.add(UserLabel(user_id=user.id, label_id=lid)) + await sess.flush() + + # Newsletters join with subscribed flag + nl_rows: list[tuple[int, bool]] = [] + for nd in m.get("newsletters") or []: + nl = await _upsert_newsletter(sess, nd) + nl_rows.append((nl.id, bool(nd.get("subscribed", True)))) + await sess.execute(delete(UserNewsletter).where(UserNewsletter.user_id == user.id)) + for nl_id, subbed in nl_rows: + sess.add(UserNewsletter(user_id=user.id, newsletter_id=nl_id, subscribed=subbed)) + await sess.flush() + + # Subscriptions + for sd in m.get("subscriptions") or []: + sid = sd.get("id") + if not sid: + continue + + tier_id: Optional[int] = None + if sd.get("tier"): + tier = await _upsert_tier(sess, sd["tier"]) + await sess.flush() + tier_id = tier.id + + res = await sess.execute(select(GhostSubscription).where(GhostSubscription.ghost_id == sid)) + sub = res.scalar_one_or_none() + if not sub: + sub = GhostSubscription(ghost_id=sid, user_id=user.id) + sess.add(sub) + + sub.user_id = user.id + sub.status = sd.get("status") or sub.status + sub.cadence = (sd.get("plan") or {}).get("interval") or sd.get("cadence") or sub.cadence + sub.price_amount = _price_cents(sd) + sub.price_currency = (sd.get("price") or {}).get("currency") or sub.price_currency + sub.stripe_customer_id = ( + (sd.get("customer") or {}).get("id") + or (sd.get("stripe") or {}).get("customer_id") + or sub.stripe_customer_id + ) + sub.stripe_subscription_id = ( + sd.get("stripe_subscription_id") + or (sd.get("stripe") or {}).get("subscription_id") + or sub.stripe_subscription_id + ) + if tier_id is not None: + sub.tier_id = tier_id + sub.raw = dict(sd) + flag_modified(sub, "raw") + + await sess.flush() + return user + + +# ===================================================== +# PUSH MEMBERS FROM LOCAL DB -> GHOST (DB -> Ghost) +# ===================================================== + +def _ghost_member_payload_base(u: User) -> dict: + """Compose writable Ghost member fields from local User, validating types.""" + email = _to_str_or_none(getattr(u, "email", None)) + payload: dict = {} + if email: + payload["email"] = email.lower() + + name = _to_str_or_none(getattr(u, "name", None)) + if name: + payload["name"] = name + + note = _to_str_or_none(getattr(u, "ghost_note", None)) + if note: + payload["note"] = note + + # If ghost_subscribed is None, default True (Ghost expects boolean) + subscribed = getattr(u, "ghost_subscribed", True) + payload["subscribed"] = bool(subscribed) + + return payload + +async def _newsletters_for_user(sess: AsyncSession, user_id: int) -> list[dict]: + """Return list of {'id': ghost_id, 'subscribed': bool} rows for Ghost API, excluding blanks.""" + q = await sess.execute( + select(GhostNewsletter.ghost_id, UserNewsletter.subscribed, GhostNewsletter.name) + .join(UserNewsletter, UserNewsletter.newsletter_id == GhostNewsletter.id) + .where(UserNewsletter.user_id == user_id) + ) + seen = set() + out: list[dict] = [] + for gid, subscribed, name in q.all(): + gid = (gid or "").strip() or None + name = (name or "").strip() or None + row: dict = {"subscribed": bool(subscribed)} + if gid: + key = ("id", gid) + if key in seen: + continue + row["id"] = gid + seen.add(key) + out.append(row) + elif name: + key = ("name", name.lower()) + if key in seen: + continue + row["name"] = name + seen.add(key) + out.append(row) + # else: skip + return out + +async def _labels_for_user(sess: AsyncSession, user_id: int) -> list[dict]: + """Return list of {'id': ghost_id} or {'name': name} for Ghost API, excluding blanks.""" + q = await sess.execute( + select(GhostLabel.ghost_id, GhostLabel.name) + .join(UserLabel, UserLabel.label_id == GhostLabel.id) + .where(UserLabel.user_id == user_id) + ) + seen = set() + out: list[dict] = [] + for gid, name in q.all(): + gid = (gid or "").strip() or None + name = (name or "").strip() or None + if gid: + key = ("id", gid) + if key not in seen: + out.append({"id": gid}) + seen.add(key) + elif name: + key = ("name", name.lower()) + if key not in seen: + out.append({"name": name}) + seen.add(key) + # else: skip empty label row + return out + + +async def _ghost_find_member_by_email(email: str) -> dict | None: + """Query Ghost for a member by email to resolve conflicts / missing IDs.""" + if not email: + return None + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/members/", + headers=_auth_header(), + params={"filter": f"email:{email}", "limit": 1}, + ) + resp.raise_for_status() + members = (resp.json() or {}).get("members") or [] + return members[0] if members else None + + +from urllib.parse import quote # make sure this import exists at top + +async def _ghost_find_member_by_email(email: str) -> Optional[dict]: + if not email: + return None + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/members/?filter=email:{quote(email)}&limit=1", + headers=_auth_header(), + ) + resp.raise_for_status() + members = resp.json().get("members") or [] + return members[0] if members else None + +async def _ghost_upsert_member(payload: dict, ghost_id: str | None = None) -> dict: + """Create/update a member, with sanitization + 5xx retry/backoff. + - Prefer PUT if ghost_id given. + - On 422: retry without name/note; if 'already exists', find-by-email then PUT. + - On 404: find-by-email and PUT; if still missing, POST create. + - On 5xx: small exponential backoff retry. + """ + safe_keys = ("email", "name", "note", "subscribed", "labels", "newsletters", "id") + pl_raw = {k: v for k, v in payload.items() if k in safe_keys} + pl = _sanitize_member_payload(pl_raw) + + async def _request_with_retry(client: httpx.AsyncClient, method: str, url: str, json: dict) -> httpx.Response: + delay = 0.5 + for attempt in range(3): + r = await client.request(method, url, headers=_auth_header(), json=json) + if r.status_code >= 500: + if attempt < 2: + await asyncio.sleep(delay) + delay *= 2 + continue + return r + return r # last response + + async with httpx.AsyncClient(timeout=30) as client: + + async def _put(mid: str, p: dict) -> dict: + r = await _request_with_retry( + client, "PUT", + f"{GHOST_ADMIN_API_URL}/members/{mid}/", + {"members": [p]}, + ) + if r.status_code == 404: + # Stale id: try by email, then create if absent + existing = await _ghost_find_member_by_email(p.get("email", "")) + if existing and existing.get("id"): + r2 = await _request_with_retry( + client, "PUT", + f"{GHOST_ADMIN_API_URL}/members/{existing['id']}/", + {"members": [p]}, + ) + r2.raise_for_status() + return (r2.json().get("members") or [None])[0] or {} + r3 = await _request_with_retry( + client, "POST", + f"{GHOST_ADMIN_API_URL}/members/", + {"members": [p]}, + ) + r3.raise_for_status() + return (r3.json().get("members") or [None])[0] or {} + + if r.status_code == 422: + body = (r.text or "").lower() + retry = dict(p) + dropped = False + if '"note"' in body or "for note" in body: + retry.pop("note", None); dropped = True + if '"name"' in body or "for name" in body: + retry.pop("name", None); dropped = True + if "labels.name" in body: + retry.pop("labels", None); dropped = True + if dropped: + r2 = await _request_with_retry( + client, "PUT", + f"{GHOST_ADMIN_API_URL}/members/{mid}/", + {"members": [retry]}, + ) + if r2.status_code == 404: + existing = await _ghost_find_member_by_email(retry.get("email", "")) + if existing and existing.get("id"): + r3 = await _request_with_retry( + client, "PUT", + f"{GHOST_ADMIN_API_URL}/members/{existing['id']}/", + {"members": [retry]}, + ) + r3.raise_for_status() + return (r3.json().get("members") or [None])[0] or {} + r3 = await _request_with_retry( + client, "POST", + f"{GHOST_ADMIN_API_URL}/members/", + {"members": [retry]}, + ) + r3.raise_for_status() + return (r3.json().get("members") or [None])[0] or {} + r2.raise_for_status() + return (r2.json().get("members") or [None])[0] or {} + r.raise_for_status() + return (r.json().get("members") or [None])[0] or {} + + async def _post_upsert(p: dict) -> dict: + r = await _request_with_retry( + client, "POST", + f"{GHOST_ADMIN_API_URL}/members/?upsert=true", + {"members": [p]}, + ) + if r.status_code == 422: + lower = (r.text or "").lower() + + # sanitize further name/note/labels on schema complaints + retry = dict(p) + changed = False + if '"note"' in lower or "for note" in lower: + retry.pop("note", None); changed = True + if '"name"' in lower or "for name" in lower: + retry.pop("name", None); changed = True + if "labels.name" in lower: + retry.pop("labels", None); changed = True + + if changed: + r2 = await _request_with_retry( + client, "POST", + f"{GHOST_ADMIN_API_URL}/members/?upsert=true", + {"members": [retry]}, + ) + if r2.status_code != 422: + r2.raise_for_status() + return (r2.json().get("members") or [None])[0] or {} + lower = (r2.text or "").lower() + + # existing email => find-by-email then PUT + if "already exists" in lower and "email address" in lower: + existing = await _ghost_find_member_by_email(p.get("email", "")) + if existing and existing.get("id"): + return await _put(existing["id"], p) + + # unrecoverable + raise httpx.HTTPStatusError( + "Validation error, cannot edit member.", + request=r.request, + response=r, + ) + r.raise_for_status() + return (r.json().get("members") or [None])[0] or {} + + if ghost_id: + return await _put(ghost_id, pl) + return await _post_upsert(pl) + +async def sync_member_to_ghost(sess: AsyncSession, user_id: int) -> Optional[str]: + res = await sess.execute(select(User).where(User.id == user_id)) + user = res.scalar_one_or_none() + if not user: + return None + + payload = _ghost_member_payload_base(user) + + labels = await _labels_for_user(sess, user.id) + if labels: + payload["labels"] = labels # Ghost accepts label ids on upsert + + ghost_member = await _ghost_upsert_member(payload, ghost_id=user.ghost_id) + + if ghost_member: + gm_id = ghost_member.get("id") + if gm_id and user.ghost_id != gm_id: + user.ghost_id = gm_id + user.ghost_raw = dict(ghost_member) + flag_modified(user, "ghost_raw") + await sess.flush() + return user.ghost_id or gm_id + return user.ghost_id + + +async def sync_members_to_ghost( + sess: AsyncSession, + changed_since: Optional[datetime] = None, + limit: Optional[int] = None, +) -> int: + """Upsert a batch of users to Ghost. Returns count processed.""" + stmt = select(User.id) + if changed_since: + stmt = stmt.where( + or_( + User.created_at >= changed_since, + and_(User.last_login_at != None, User.last_login_at >= changed_since), + ) + ) + if limit: + stmt = stmt.limit(limit) + + ids = [row[0] for row in (await sess.execute(stmt)).all()] + processed = 0 + for uid in ids: + try: + await sync_member_to_ghost(sess, uid) + processed += 1 + except httpx.HTTPStatusError as e: + # Log and continue; don't kill startup + print(f"[ghost sync] failed upsert for user {uid}: {e.response.status_code} {e.response.text}") + except Exception as e: + print(f"[ghost sync] failed upsert for user {uid}: {e}") + return processed + + +# ===================================================== +# Membership fetch/sync (Ghost -> DB) bulk + single +# ===================================================== + +async def fetch_all_members_from_ghost() -> list[dict[str, Any]]: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/members/?include=labels,subscriptions,tiers,newsletters&limit=all", + headers=_auth_header(), + ) + resp.raise_for_status() + return resp.json().get("members", []) + + +async def sync_all_membership_from_ghost(sess: AsyncSession) -> None: + members = await fetch_all_members_from_ghost() + + # collect related lookups and ensure catalogs exist first (avoid FK races) + label_bucket: Dict[str, dict[str, Any]] = {} + tier_bucket: Dict[str, dict[str, Any]] = {} + newsletter_bucket: Dict[str, dict[str, Any]] = {} + + for m in members: + for l in m.get("labels") or []: + label_bucket[l["id"]] = l + for n in m.get("newsletters") or []: + newsletter_bucket[n["id"]] = n + for s in m.get("subscriptions") or []: + t = s.get("tier") + if isinstance(t, dict) and t.get("id"): + tier_bucket[t["id"]] = t + + for L in label_bucket.values(): + await _upsert_label(sess, L) + for T in tier_bucket.values(): + await _upsert_tier(sess, T) + for N in newsletter_bucket.values(): + await _upsert_newsletter(sess, N) + + # Users + for gm in members: + user = await _find_or_create_user_by_ghost_or_email(sess, gm) + await _apply_user_membership(sess, user, gm) + + # transaction auto-commits here + + +async def fetch_single_member_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{GHOST_ADMIN_API_URL}/members/{ghost_id}/?include=labels,newsletters,subscriptions,tiers", + headers=_auth_header(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + items = data.get("members") or data.get("member") or [] + if isinstance(items, dict): + return items + return (items[0] if items else None) + + +async def sync_single_member(sess: AsyncSession, ghost_id: str) -> None: + m = await fetch_single_member_from_ghost(ghost_id) + if m is None: + # If member deleted in Ghost, we won't delete local user here. + return + + # ensure catalogs for this payload + for l in m.get("labels") or []: + await _upsert_label(sess, l) + for n in m.get("newsletters") or []: + await _upsert_newsletter(sess, n) + for s in m.get("subscriptions") or []: + if isinstance(s.get("tier"), dict): + await _upsert_tier(sess, s["tier"]) + + user = await _find_or_create_user_by_ghost_or_email(sess, m) + await _apply_user_membership(sess, user, m) + # transaction auto-commits here + + +# ===================================================== +# Single-item content helpers (posts/authors/tags) +# ===================================================== + +async def fetch_single_post_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]: + url = ( + f"{GHOST_ADMIN_API_URL}/posts/{ghost_id}/" + "?include=authors,tags&formats=html,plaintext,mobiledoc,lexical" + ) + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header()) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + posts = data.get("posts") or [] + return posts[0] if posts else None + + +async def fetch_single_page_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]: + url = ( + f"{GHOST_ADMIN_API_URL}/pages/{ghost_id}/" + "?include=authors,tags&formats=html,plaintext,mobiledoc,lexical" + ) + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header()) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + pages = data.get("pages") or [] + return pages[0] if pages else None + + +async def fetch_single_author_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]: + url = f"{GHOST_ADMIN_API_URL}/users/{ghost_id}/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header()) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + users = data.get("users") or [] + return users[0] if users else None + + +async def fetch_single_tag_from_ghost(ghost_id: str) -> Optional[dict[str, Any]]: + url = f"{GHOST_ADMIN_API_URL}/tags/{ghost_id}/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_auth_header()) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + tags = data.get("tags") or [] + return tags[0] if tags else None + + +async def sync_single_post(sess: AsyncSession, ghost_id: str) -> None: + gp = await fetch_single_post_from_ghost(ghost_id) + if gp is None: + res = await sess.execute(select(Post).where(Post.ghost_id == ghost_id)) + obj = res.scalar_one_or_none() + if obj is not None and obj.deleted_at is None: + obj.deleted_at = utcnow() + return + + author_map: Dict[str, Author] = {} + tag_map: Dict[str, Tag] = {} + + for a in gp.get("authors") or []: + author_obj = await _upsert_author(sess, a) + author_map[a["id"]] = author_obj + if gp.get("primary_author"): + pa = gp["primary_author"] + author_obj = await _upsert_author(sess, pa) + author_map[pa["id"]] = author_obj + + for t in gp.get("tags") or []: + tag_obj = await _upsert_tag(sess, t) + tag_map[t["id"]] = tag_obj + if gp.get("primary_tag"): + pt = gp["primary_tag"] + tag_obj = await _upsert_tag(sess, pt) + tag_map[pt["id"]] = tag_obj + + await _upsert_post(sess, gp, author_map, tag_map) + # auto-commit + + +async def sync_single_page(sess: AsyncSession, ghost_id: str) -> None: + gp = await fetch_single_page_from_ghost(ghost_id) + if gp is None: + res = await sess.execute(select(Post).where(Post.ghost_id == ghost_id)) + obj = res.scalar_one_or_none() + if obj is not None and obj.deleted_at is None: + obj.deleted_at = utcnow() + return + + author_map: Dict[str, Author] = {} + tag_map: Dict[str, Tag] = {} + + for a in gp.get("authors") or []: + author_obj = await _upsert_author(sess, a) + author_map[a["id"]] = author_obj + if gp.get("primary_author"): + pa = gp["primary_author"] + author_obj = await _upsert_author(sess, pa) + author_map[pa["id"]] = author_obj + + for t in gp.get("tags") or []: + tag_obj = await _upsert_tag(sess, t) + tag_map[t["id"]] = tag_obj + if gp.get("primary_tag"): + pt = gp["primary_tag"] + tag_obj = await _upsert_tag(sess, pt) + tag_map[pt["id"]] = tag_obj + + await _upsert_post(sess, gp, author_map, tag_map) + + +async def sync_single_author(sess: AsyncSession, ghost_id: str) -> None: + ga = await fetch_single_author_from_ghost(ghost_id) + if ga is None: + result = await sess.execute(select(Author).where(Author.ghost_id == ghost_id)) + author_obj = result.scalar_one_or_none() + if author_obj and author_obj.deleted_at is None: + author_obj.deleted_at = utcnow() + return + + await _upsert_author(sess, ga) + + +async def sync_single_tag(sess: AsyncSession, ghost_id: str) -> None: + gt = await fetch_single_tag_from_ghost(ghost_id) + if gt is None: + result = await sess.execute(select(Tag).where(Tag.ghost_id == ghost_id)) + tag_obj = result.scalar_one_or_none() + if tag_obj and tag_obj.deleted_at is None: + tag_obj.deleted_at = utcnow() + return + + await _upsert_tag(sess, gt) + + +# ---- explicit public exports (back-compat) ---- +__all__ = [ + # bulk content + "sync_all_content_from_ghost", + # bulk membership (user-centric) + "sync_all_membership_from_ghost", + # DB -> Ghost + "sync_member_to_ghost", + "sync_members_to_ghost", + # single fetch + "fetch_single_post_from_ghost", + "fetch_single_author_from_ghost", + "fetch_single_tag_from_ghost", + "fetch_single_member_from_ghost", + # single sync + "sync_single_post", + "sync_single_author", + "sync_single_tag", + "sync_single_member", +] diff --git a/bp/blog/ghost/lexical_renderer.py b/bp/blog/ghost/lexical_renderer.py new file mode 100644 index 0000000..fafe7b5 --- /dev/null +++ b/bp/blog/ghost/lexical_renderer.py @@ -0,0 +1,668 @@ +""" +Lexical JSON → HTML renderer. + +Produces HTML matching Ghost's ``kg-*`` class conventions so the existing +``cards.css`` stylesheet works unchanged. + +Public API +---------- + render_lexical(doc) – Lexical JSON (dict or string) → HTML string +""" +from __future__ import annotations + +import html +import json +from typing import Callable + +import mistune + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_RENDERERS: dict[str, Callable[[dict], str]] = {} + + +def _renderer(node_type: str): + """Decorator — register a function as the renderer for *node_type*.""" + def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]: + _RENDERERS[node_type] = fn + return fn + return decorator + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def render_lexical(doc: dict | str) -> str: + """Render a Lexical JSON document to an HTML string.""" + if isinstance(doc, str): + doc = json.loads(doc) + root = doc.get("root", doc) + return _render_children(root.get("children", [])) + + +# --------------------------------------------------------------------------- +# Core dispatch +# --------------------------------------------------------------------------- + +def _render_node(node: dict) -> str: + node_type = node.get("type", "") + renderer = _RENDERERS.get(node_type) + if renderer: + return renderer(node) + return "" + + +def _render_children(children: list[dict]) -> str: + return "".join(_render_node(c) for c in children) + + +# --------------------------------------------------------------------------- +# Text formatting +# --------------------------------------------------------------------------- + +# Lexical format bitmask +_FORMAT_BOLD = 1 +_FORMAT_ITALIC = 2 +_FORMAT_STRIKETHROUGH = 4 +_FORMAT_UNDERLINE = 8 +_FORMAT_CODE = 16 +_FORMAT_SUBSCRIPT = 32 +_FORMAT_SUPERSCRIPT = 64 +_FORMAT_HIGHLIGHT = 128 + +_FORMAT_TAGS: list[tuple[int, str, str]] = [ + (_FORMAT_BOLD, "", ""), + (_FORMAT_ITALIC, "", ""), + (_FORMAT_STRIKETHROUGH, "", ""), + (_FORMAT_UNDERLINE, "", ""), + (_FORMAT_CODE, "", ""), + (_FORMAT_SUBSCRIPT, "", ""), + (_FORMAT_SUPERSCRIPT, "", ""), + (_FORMAT_HIGHLIGHT, "", ""), +] + +# Element-level alignment from ``format`` field +_ALIGN_MAP = { + 1: "text-align: left", + 2: "text-align: center", + 3: "text-align: right", + 4: "text-align: justify", +} + + +def _align_style(node: dict) -> str: + fmt = node.get("format") + if isinstance(fmt, int) and fmt in _ALIGN_MAP: + return f' style="{_ALIGN_MAP[fmt]}"' + if isinstance(fmt, str) and fmt: + return f' style="text-align: {fmt}"' + return "" + + +def _wrap_format(text: str, fmt: int) -> str: + for mask, open_tag, close_tag in _FORMAT_TAGS: + if fmt & mask: + text = f"{open_tag}{text}{close_tag}" + return text + + +# --------------------------------------------------------------------------- +# Tier 1 — text nodes +# --------------------------------------------------------------------------- + +@_renderer("text") +def _text(node: dict) -> str: + text = html.escape(node.get("text", "")) + fmt = node.get("format", 0) + if isinstance(fmt, int) and fmt: + text = _wrap_format(text, fmt) + return text + + +@_renderer("linebreak") +def _linebreak(_node: dict) -> str: + return "
" + + +@_renderer("tab") +def _tab(_node: dict) -> str: + return "\t" + + +@_renderer("paragraph") +def _paragraph(node: dict) -> str: + inner = _render_children(node.get("children", [])) + if not inner: + inner = "
" + style = _align_style(node) + return f"{inner}

" + + +@_renderer("extended-text") +def _extended_text(node: dict) -> str: + return _paragraph(node) + + +@_renderer("heading") +def _heading(node: dict) -> str: + tag = node.get("tag", "h2") + inner = _render_children(node.get("children", [])) + style = _align_style(node) + return f"<{tag}{style}>{inner}" + + +@_renderer("extended-heading") +def _extended_heading(node: dict) -> str: + return _heading(node) + + +@_renderer("quote") +def _quote(node: dict) -> str: + inner = _render_children(node.get("children", [])) + return f"
{inner}
" + + +@_renderer("extended-quote") +def _extended_quote(node: dict) -> str: + return _quote(node) + + +@_renderer("aside") +def _aside(node: dict) -> str: + inner = _render_children(node.get("children", [])) + return f"" + + +@_renderer("link") +def _link(node: dict) -> str: + href = html.escape(node.get("url", ""), quote=True) + target = node.get("target", "") + rel = node.get("rel", "") + inner = _render_children(node.get("children", [])) + attrs = f' href="{href}"' + if target: + attrs += f' target="{html.escape(target, quote=True)}"' + if rel: + attrs += f' rel="{html.escape(rel, quote=True)}"' + return f"{inner}" + + +@_renderer("autolink") +def _autolink(node: dict) -> str: + return _link(node) + + +@_renderer("at-link") +def _at_link(node: dict) -> str: + return _link(node) + + +@_renderer("list") +def _list(node: dict) -> str: + tag = "ol" if node.get("listType") == "number" else "ul" + start = node.get("start") + inner = _render_children(node.get("children", [])) + attrs = "" + if tag == "ol" and start and start != 1: + attrs = f' start="{start}"' + return f"<{tag}{attrs}>{inner}" + + +@_renderer("listitem") +def _listitem(node: dict) -> str: + inner = _render_children(node.get("children", [])) + return f"
  • {inner}
  • " + + +@_renderer("horizontalrule") +def _horizontalrule(_node: dict) -> str: + return "
    " + + +@_renderer("code") +def _code(node: dict) -> str: + # Inline code nodes from Lexical — just render inner text + inner = _render_children(node.get("children", [])) + return f"{inner}" + + +@_renderer("codeblock") +def _codeblock(node: dict) -> str: + lang = node.get("language", "") + code = html.escape(node.get("code", "")) + cls = f' class="language-{html.escape(lang)}"' if lang else "" + return f'
    {code}
    ' + + +@_renderer("code-highlight") +def _code_highlight(node: dict) -> str: + text = html.escape(node.get("text", "")) + highlight_type = node.get("highlightType", "") + if highlight_type: + return f'{text}' + return text + + +# --------------------------------------------------------------------------- +# Tier 2 — common cards +# --------------------------------------------------------------------------- + +@_renderer("image") +def _image(node: dict) -> str: + src = node.get("src", "") + alt = node.get("alt", "") + caption = node.get("caption", "") + width = node.get("cardWidth", "") or node.get("width", "") + href = node.get("href", "") + + width_class = "" + if width == "wide": + width_class = " kg-width-wide" + elif width == "full": + width_class = " kg-width-full" + + img_tag = f'{html.escape(alt, quote=True)}' + if href: + img_tag = f'{img_tag}' + + parts = [f'
    '] + parts.append(img_tag) + if caption: + parts.append(f"
    {caption}
    ") + parts.append("
    ") + return "".join(parts) + + +@_renderer("gallery") +def _gallery(node: dict) -> str: + images = node.get("images", []) + if not images: + return "" + + rows = [] + for i in range(0, len(images), 3): + row_imgs = images[i:i + 3] + row_cls = f"kg-gallery-row" if len(row_imgs) <= 3 else "kg-gallery-row" + imgs_html = [] + for img in row_imgs: + src = img.get("src", "") + alt = img.get("alt", "") + caption = img.get("caption", "") + img_tag = f'{html.escape(alt, quote=True)}' + fig = f'" + imgs_html.append(fig) + rows.append(f'
    {"".join(imgs_html)}
    ') + + caption = node.get("caption", "") + caption_html = f"
    {caption}
    " if caption else "" + return ( + f'" + ) + + +@_renderer("html") +def _html_card(node: dict) -> str: + raw = node.get("html", "") + return f"{raw}" + + +@_renderer("markdown") +def _markdown(node: dict) -> str: + md_text = node.get("markdown", "") + rendered = mistune.html(md_text) + return f"{rendered}" + + +@_renderer("embed") +def _embed(node: dict) -> str: + embed_html = node.get("html", "") + caption = node.get("caption", "") + url = node.get("url", "") + caption_html = f"
    {caption}
    " if caption else "" + return ( + f'
    ' + f"{embed_html}{caption_html}
    " + ) + + +@_renderer("bookmark") +def _bookmark(node: dict) -> str: + url = node.get("url", "") + title = html.escape(node.get("metadata", {}).get("title", "") or node.get("title", "")) + description = html.escape(node.get("metadata", {}).get("description", "") or node.get("description", "")) + icon = node.get("metadata", {}).get("icon", "") or node.get("icon", "") + author = html.escape(node.get("metadata", {}).get("author", "") or node.get("author", "")) + publisher = html.escape(node.get("metadata", {}).get("publisher", "") or node.get("publisher", "")) + thumbnail = node.get("metadata", {}).get("thumbnail", "") or node.get("thumbnail", "") + caption = node.get("caption", "") + + icon_html = f'' if icon else "" + thumbnail_html = ( + f'
    ' + f'
    ' + ) if thumbnail else "" + + meta_parts = [] + if icon_html: + meta_parts.append(icon_html) + if author: + meta_parts.append(f'{author}') + if publisher: + meta_parts.append(f'{publisher}') + metadata_html = f'' if meta_parts else "" + + caption_html = f"
    {caption}
    " if caption else "" + + return ( + f'
    ' + f'' + f'
    ' + f'
    {title}
    ' + f'
    {description}
    ' + f'{metadata_html}' + f'
    ' + f'{thumbnail_html}' + f'
    ' + f'{caption_html}' + f'
    ' + ) + + +@_renderer("callout") +def _callout(node: dict) -> str: + color = node.get("backgroundColor", "grey") + emoji = node.get("calloutEmoji", "") + inner = _render_children(node.get("children", [])) + + emoji_html = f'
    {emoji}
    ' if emoji else "" + return ( + f'
    ' + f'{emoji_html}' + f'
    {inner}
    ' + f'
    ' + ) + + +@_renderer("button") +def _button(node: dict) -> str: + text = html.escape(node.get("buttonText", "")) + url = html.escape(node.get("buttonUrl", ""), quote=True) + alignment = node.get("alignment", "center") + return ( + f'
    ' + f'{text}' + f'
    ' + ) + + +@_renderer("toggle") +def _toggle(node: dict) -> str: + heading = node.get("heading", "") + # Toggle content is in children + inner = _render_children(node.get("children", [])) + return ( + f'
    ' + f'
    ' + f'

    {heading}

    ' + f'' + f'
    ' + f'
    {inner}
    ' + f'
    ' + ) + + +# --------------------------------------------------------------------------- +# Tier 3 — media & remaining cards +# --------------------------------------------------------------------------- + +@_renderer("audio") +def _audio(node: dict) -> str: + src = node.get("src", "") + title = html.escape(node.get("title", "")) + duration = node.get("duration", 0) + thumbnail = node.get("thumbnailSrc", "") + + duration_min = int(duration) // 60 + duration_sec = int(duration) % 60 + duration_str = f"{duration_min}:{duration_sec:02d}" + + if thumbnail: + thumb_html = ( + f'audio-thumbnail' + ) + else: + thumb_html = ( + '
    ' + '' + '
    ' + ) + + return ( + f'
    ' + f'{thumb_html}' + f'
    ' + f'
    {title}
    ' + f'
    ' + f'' + f'
    0:00
    ' + f'
    / {duration_str}
    ' + f'' + f'' + f'' + f'' + f'
    ' + f'
    ' + f'' + f'
    ' + ) + + +@_renderer("video") +def _video(node: dict) -> str: + src = node.get("src", "") + caption = node.get("caption", "") + width = node.get("cardWidth", "") + thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "") + loop = node.get("loop", False) + + width_class = "" + if width == "wide": + width_class = " kg-width-wide" + elif width == "full": + width_class = " kg-width-full" + + loop_attr = " loop" if loop else "" + poster_attr = f' poster="{html.escape(thumbnail, quote=True)}"' if thumbnail else "" + caption_html = f"
    {caption}
    " if caption else "" + + return ( + f'
    ' + f'
    ' + f'' + f'
    ' + f'{caption_html}' + f'
    ' + ) + + +@_renderer("file") +def _file(node: dict) -> str: + src = node.get("src", "") + title = html.escape(node.get("fileName", "") or node.get("title", "")) + caption = node.get("caption", "") + file_size = node.get("fileSize", 0) + file_name = html.escape(node.get("fileName", "")) + + # Format size + if file_size: + kb = file_size / 1024 + if kb < 1024: + size_str = f"{kb:.0f} KB" + else: + size_str = f"{kb / 1024:.1f} MB" + else: + size_str = "" + + caption_html = f'
    {caption}
    ' if caption else "" + size_html = f'
    {size_str}
    ' if size_str else "" + + return ( + f'' + ) + + +@_renderer("paywall") +def _paywall(_node: dict) -> str: + return "" + + +@_renderer("header") +def _header(node: dict) -> str: + heading = node.get("heading", "") + subheading = node.get("subheading", "") + size = node.get("size", "small") + style = node.get("style", "dark") + bg_image = node.get("backgroundImageSrc", "") + button_text = node.get("buttonText", "") + button_url = node.get("buttonUrl", "") + + bg_style = f' style="background-image: url({html.escape(bg_image, quote=True)})"' if bg_image else "" + heading_html = f"

    {heading}

    " if heading else "" + subheading_html = f"

    {subheading}

    " if subheading else "" + button_html = ( + f'{html.escape(button_text)}' + if button_text and button_url else "" + ) + + return ( + f'
    ' + f'{heading_html}{subheading_html}{button_html}' + f'
    ' + ) + + +@_renderer("signup") +def _signup(node: dict) -> str: + heading = node.get("heading", "") + subheading = node.get("subheading", "") + disclaimer = node.get("disclaimer", "") + button_text = html.escape(node.get("buttonText", "Subscribe")) + button_color = node.get("buttonColor", "") + bg_color = node.get("backgroundColor", "") + bg_image = node.get("backgroundImageSrc", "") + style = node.get("style", "dark") + + bg_style_parts = [] + if bg_color: + bg_style_parts.append(f"background-color: {bg_color}") + if bg_image: + bg_style_parts.append(f"background-image: url({html.escape(bg_image, quote=True)})") + style_attr = f' style="{"; ".join(bg_style_parts)}"' if bg_style_parts else "" + + heading_html = f"

    {heading}

    " if heading else "" + subheading_html = f"

    {subheading}

    " if subheading else "" + disclaimer_html = f'' if disclaimer else "" + btn_style = f' style="background-color: {button_color}"' if button_color else "" + + return ( + f'' + ) + + +@_renderer("product") +def _product(node: dict) -> str: + title = html.escape(node.get("productTitle", "") or node.get("title", "")) + description = node.get("productDescription", "") or node.get("description", "") + img_src = node.get("productImageSrc", "") + button_text = html.escape(node.get("buttonText", "")) + button_url = node.get("buttonUrl", "") + rating = node.get("rating", 0) + + img_html = ( + f'' + if img_src else "" + ) + button_html = ( + f'{button_text}' + if button_text and button_url else "" + ) + stars = "" + if rating: + active = int(rating) + stars_html = [] + for i in range(5): + cls = "kg-product-card-rating-active" if i < active else "" + stars_html.append( + f'' + f'' + f'' + ) + stars = f'
    {"".join(stars_html)}
    ' + + return ( + f'
    ' + f'{img_html}' + f'
    ' + f'

    {title}

    ' + f'{stars}' + f'
    {description}
    ' + f'{button_html}' + f'
    ' + f'
    ' + ) + + +@_renderer("email") +def _email(node: dict) -> str: + raw_html = node.get("html", "") + return f"{raw_html}" + + +@_renderer("email-cta") +def _email_cta(node: dict) -> str: + raw_html = node.get("html", "") + return f"{raw_html}" + + +@_renderer("call-to-action") +def _call_to_action(node: dict) -> str: + raw_html = node.get("html", "") + sponsor_label = node.get("sponsorLabel", "") + label_html = ( + f'{html.escape(sponsor_label)}' + if sponsor_label else "" + ) + return ( + f'
    ' + f'{label_html}{raw_html}' + f'
    ' + ) diff --git a/bp/blog/ghost/lexical_validator.py b/bp/blog/ghost/lexical_validator.py new file mode 100644 index 0000000..3cd39a2 --- /dev/null +++ b/bp/blog/ghost/lexical_validator.py @@ -0,0 +1,86 @@ +""" +Server-side validation for Lexical editor JSON. + +Walk the document tree and reject any node whose ``type`` is not in +ALLOWED_NODE_TYPES. This is a belt-and-braces check: the Lexical +client already restricts which nodes can be created, but we validate +server-side too. +""" +from __future__ import annotations + +ALLOWED_NODE_TYPES: frozenset[str] = frozenset( + { + # Standard Lexical nodes + "root", + "paragraph", + "heading", + "quote", + "list", + "listitem", + "link", + "autolink", + "code", + "code-highlight", + "linebreak", + "text", + "horizontalrule", + "image", + "tab", + # Ghost "extended-*" variants + "extended-text", + "extended-heading", + "extended-quote", + # Ghost card types + "html", + "gallery", + "embed", + "bookmark", + "markdown", + "email", + "email-cta", + "button", + "callout", + "toggle", + "video", + "audio", + "file", + "product", + "header", + "signup", + "aside", + "codeblock", + "call-to-action", + "at-link", + "paywall", + } +) + + +def validate_lexical(doc: dict) -> tuple[bool, str | None]: + """Recursively validate a Lexical JSON document. + + Returns ``(True, None)`` when the document is valid, or + ``(False, reason)`` when an unknown node type is found. + """ + if not isinstance(doc, dict): + return False, "Document must be a JSON object" + + root = doc.get("root") + if not isinstance(root, dict): + return False, "Document must contain a 'root' object" + + return _walk(root) + + +def _walk(node: dict) -> tuple[bool, str | None]: + node_type = node.get("type") + if node_type is not None and node_type not in ALLOWED_NODE_TYPES: + return False, f"Disallowed node type: {node_type}" + + for child in node.get("children", []): + if isinstance(child, dict): + ok, reason = _walk(child) + if not ok: + return False, reason + + return True, None diff --git a/bp/blog/ghost_db.py b/bp/blog/ghost_db.py new file mode 100644 index 0000000..19ec0d5 --- /dev/null +++ b/bp/blog/ghost_db.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple +from sqlalchemy import select, func, asc, desc, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload, joinedload + +from models.ghost_content import Post, Author, Tag, PostTag +from models.tag_group import TagGroup, TagGroupTag + + +class DBAPIError(Exception): + """Raised when our local DB returns something unexpected.""" + + +def _author_to_public(a: Optional[Author]) -> Optional[Dict[str, Any]]: + if a is None: + return None + if a.deleted_at is not None: + # treat deleted authors as missing + return None + return { + "id": a.ghost_id, + "slug": a.slug, + "name": a.name, + "profile_image": a.profile_image, + "cover_image": a.cover_image, + # expose more (bio, etc.) if needed + } + + +def _tag_to_public(t: Tag) -> Dict[str, Any]: + return { + "id": t.ghost_id, + "slug": t.slug, + "name": t.name, + "description": t.description, + "feature_image": t.feature_image, # fixed key + "visibility": t.visibility, + "deleted_at": t.deleted_at, + } + + +def _post_to_public(p: Post) -> Dict[str, Any]: + """ + Shape a Post to the public JSON used by the app, mirroring GhostClient._normalise_post. + """ + # Primary author: explicit or first available + primary_author = p.primary_author or (p.authors[0] if p.authors else None) + + # Primary tag: prefer explicit relationship, otherwise first public/non-deleted tag + primary_tag = getattr(p, "primary_tag", None) + if primary_tag is None: + public_tags = [ + t for t in (p.tags or []) + if t.deleted_at is None and (t.visibility or "public") == "public" + ] + primary_tag = public_tags[0] if public_tags else None + + return { + "id": p.id, + "ghost_id": p.ghost_id, + "slug": p.slug, + "title": p.title, + "html": p.html, + "is_page": p.is_page, + "excerpt": p.custom_excerpt or p.excerpt, + "custom_excerpt": p.custom_excerpt, + "published_at": p.published_at, + "updated_at": p.updated_at, + "visibility": p.visibility, + "status": p.status, + "deleted_at": p.deleted_at, + "feature_image": p.feature_image, + "user_id": p.user_id, + "publish_requested": p.publish_requested, + "primary_author": _author_to_public(primary_author), + "primary_tag": _tag_to_public(primary_tag) if primary_tag else None, + "tags": [ + _tag_to_public(t) + for t in (p.tags or []) + if t.deleted_at is None and (t.visibility or "public") == "public" + ], + "authors": [ + _author_to_public(a) + for a in (p.authors or []) + if a and a.deleted_at is None + ], + } + + +class DBClient: + """ + Drop-in replacement for GhostClient, but served from our mirrored tables. + Call methods with an AsyncSession. + """ + + def __init__(self, session: AsyncSession): + self.sess = session + + async def list_posts( + self, + limit: int = 10, + page: int = 1, + selected_tags: Optional[Sequence[str]] = None, + selected_authors: Optional[Sequence[str]] = None, + search: Optional[str] = None, + drafts: bool = False, + drafts_user_id: Optional[int] = None, + exclude_covered_tag_ids: Optional[Sequence[int]] = None, + ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + List published posts, optionally filtered by tags/authors and a search term. + When drafts=True, lists draft posts instead (filtered by drafts_user_id if given). + Returns (posts, pagination). + """ + + # ---- base visibility filters + if drafts: + base_filters = [ + Post.deleted_at.is_(None), + Post.status == "draft", + Post.is_page.is_(False), + ] + if drafts_user_id is not None: + base_filters.append(Post.user_id == drafts_user_id) + else: + base_filters = [ + Post.deleted_at.is_(None), + Post.status == "published", + Post.is_page.is_(False), + ] + + q = select(Post).where(*base_filters) + + # ---- TAG FILTER (matches any tag on the post) + if selected_tags: + tag_slugs = list(selected_tags) + q = q.where( + Post.tags.any( + and_( + Tag.slug.in_(tag_slugs), + Tag.deleted_at.is_(None), + ) + ) + ) + + # ---- EXCLUDE-COVERED FILTER ("etc" mode: posts NOT covered by any group) + if exclude_covered_tag_ids: + covered_sq = ( + select(PostTag.post_id) + .join(Tag, Tag.id == PostTag.tag_id) + .where( + Tag.id.in_(list(exclude_covered_tag_ids)), + Tag.deleted_at.is_(None), + ) + ) + q = q.where(Post.id.notin_(covered_sq)) + + # ---- AUTHOR FILTER (matches primary or any author) + if selected_authors: + author_slugs = list(selected_authors) + q = q.where( + or_( + Post.primary_author.has( + and_( + Author.slug.in_(author_slugs), + Author.deleted_at.is_(None), + ) + ), + Post.authors.any( + and_( + Author.slug.in_(author_slugs), + Author.deleted_at.is_(None), + ) + ), + ) + ) + + # ---- SEARCH FILTER (title OR excerpt OR plaintext contains) + if search: + term = f"%{search.strip().lower()}%" + q = q.where( + or_( + func.lower(func.coalesce(Post.title, "")).like(term), + func.lower(func.coalesce(Post.excerpt, "")).like(term), + func.lower(func.coalesce(Post.plaintext,"")).like(term), + ) + ) + + # ---- ordering + if drafts: + q = q.order_by(desc(Post.updated_at)) + else: + q = q.order_by(desc(Post.published_at)) + + # ---- pagination math + if page < 1: + page = 1 + offset_val = (page - 1) * limit + + # ---- total count with SAME filters (including tag/author/search) + q_no_limit = q.with_only_columns(Post.id).order_by(None) + count_q = select(func.count()).select_from(q_no_limit.subquery()) + total = int((await self.sess.execute(count_q)).scalar() or 0) + + # ---- eager load relationships to avoid N+1 / greenlet issues + q = ( + q.options( + joinedload(Post.primary_author), + joinedload(Post.primary_tag), + selectinload(Post.authors), + selectinload(Post.tags), + ) + .limit(limit) + .offset(offset_val) + ) + + rows: List[Post] = list((await self.sess.execute(q)).scalars()) + posts = [_post_to_public(p) for p in rows] + + # ---- search_count: reflect same filters + search (i.e., equals total once filters applied) + search_count = total + + pages_total = (total + limit - 1) // limit if limit else 1 + pagination = { + "page": page, + "limit": limit, + "pages": pages_total, + "total": total, + "search_count": search_count, + "next": page + 1 if page < pages_total else None, + "prev": page - 1 if page > 1 else None, + } + + return posts, pagination + + async def posts_by_slug( + self, + slug: str, + include: Sequence[str] = ("tags", "authors"), + fields: Sequence[str] = ( + "id", + "slug", + "title", + "html", + "excerpt", + "custom_excerpt", + "published_at", + "feature_image", + ), + include_drafts: bool = False, + ) -> List[Dict[str, Any]]: + """ + Return posts (usually 1) matching this slug. + + Only returns published, non-deleted posts by default. + When include_drafts=True, also returns draft posts (for admin access). + + Eager-load related objects via selectinload/joinedload so we don't N+1 when + serializing in _post_to_public(). + """ + + # Build .options(...) dynamically based on `include` + load_options = [] + + # Tags + if "tags" in include: + load_options.append(selectinload(Post.tags)) + if hasattr(Post, "primary_tag"): + # joinedload is fine too; selectin keeps a single extra roundtrip + load_options.append(selectinload(Post.primary_tag)) + + # Authors + if "authors" in include: + if hasattr(Post, "primary_author"): + load_options.append(selectinload(Post.primary_author)) + if hasattr(Post, "authors"): + load_options.append(selectinload(Post.authors)) + + filters = [Post.deleted_at.is_(None), Post.slug == slug] + if not include_drafts: + filters.append(Post.status == "published") + + q = ( + select(Post) + .where(*filters) + .order_by(desc(Post.published_at)) + .options(*load_options) + ) + + result = await self.sess.execute(q) + rows: List[Post] = list(result.scalars()) + + return [(_post_to_public(p), p) for p in rows] + + async def list_tags( + self, + limit: int = 5000, + page: int = 1, + is_page=False, + ) -> List[Dict[str, Any]]: + """ + Return public, not-soft-deleted tags. + Include published_post_count = number of published (not deleted) posts using that tag. + """ + + if page < 1: + page = 1 + offset_val = (page - 1) * limit + + # Subquery: count published posts per tag + tag_post_counts_sq = ( + select( + PostTag.tag_id.label("tag_id"), + func.count().label("published_post_count"), + ) + .select_from(PostTag) + .join(Post, Post.id == PostTag.post_id) + .where( + Post.deleted_at.is_(None), + Post.published_at.is_not(None), + Post.is_page.is_(is_page), + ) + .group_by(PostTag.tag_id) + .subquery() + ) + + q = ( + select( + Tag, + func.coalesce(tag_post_counts_sq.c.published_post_count, 0).label( + "published_post_count" + ), + ) + .outerjoin( + tag_post_counts_sq, + tag_post_counts_sq.c.tag_id == Tag.id, + ) + .where( + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + func.coalesce(tag_post_counts_sq.c.published_post_count, 0) > 0, + ) + .order_by(desc(func.coalesce(tag_post_counts_sq.c.published_post_count, 0)), asc(Tag.name)) + .limit(limit) + .offset(offset_val) + ) + + result = await self.sess.execute(q) + + # result will return rows like (Tag, published_post_count) + rows = list(result.all()) + + tags = [ + { + "id": tag.ghost_id, + "slug": tag.slug, + "name": tag.name, + "description": tag.description, + "feature_image": tag.feature_image, + "visibility": tag.visibility, + "published_post_count": count, + } + for (tag, count) in rows + ] + + return tags + + async def list_authors( + self, + limit: int = 5000, + page: int = 1, + is_page=False, + ) -> List[Dict[str, Any]]: + """ + Return non-deleted authors. + Include published_post_count = number of published (not deleted) posts by that author + (counted via Post.primary_author_id). + """ + + if page < 1: + page = 1 + offset_val = (page - 1) * limit + + # Subquery: count published posts per primary author + author_post_counts_sq = ( + select( + Post.primary_author_id.label("author_id"), + func.count().label("published_post_count"), + ) + .where( + Post.deleted_at.is_(None), + Post.published_at.is_not(None), + Post.is_page.is_(is_page), + ) + .group_by(Post.primary_author_id) + .subquery() + ) + + q = ( + select( + Author, + func.coalesce(author_post_counts_sq.c.published_post_count, 0).label( + "published_post_count" + ), + ) + .outerjoin( + author_post_counts_sq, + author_post_counts_sq.c.author_id == Author.id, + ) + .where( + Author.deleted_at.is_(None), + ) + .order_by(asc(Author.name)) + .limit(limit) + .offset(offset_val) + ) + + result = await self.sess.execute(q) + rows = list(result.all()) + + authors = [ + { + "id": a.ghost_id, + "slug": a.slug, + "name": a.name, + "bio": a.bio, + "profile_image": a.profile_image, + "cover_image": a.cover_image, + "website": a.website, + "location": a.location, + "facebook": a.facebook, + "twitter": a.twitter, + "published_post_count": count, + } + for (a, count) in rows + ] + + return authors + + async def count_drafts(self, user_id: Optional[int] = None) -> int: + """Count draft (non-page, non-deleted) posts, optionally for a single user.""" + q = select(func.count()).select_from(Post).where( + Post.deleted_at.is_(None), + Post.status == "draft", + Post.is_page.is_(False), + ) + if user_id is not None: + q = q.where(Post.user_id == user_id) + return int((await self.sess.execute(q)).scalar() or 0) + + async def list_tag_groups_with_counts(self) -> List[Dict[str, Any]]: + """ + Return all tag groups with aggregated published post counts. + Each group dict includes a `tag_slugs` list and `tag_ids` list. + Count = distinct published posts having ANY member tag. + Ordered by sort_order, name. + """ + # Subquery: distinct published post IDs per tag group + post_count_sq = ( + select( + TagGroupTag.tag_group_id.label("group_id"), + func.count(func.distinct(PostTag.post_id)).label("post_count"), + ) + .select_from(TagGroupTag) + .join(PostTag, PostTag.tag_id == TagGroupTag.tag_id) + .join(Post, Post.id == PostTag.post_id) + .where( + Post.deleted_at.is_(None), + Post.published_at.is_not(None), + Post.is_page.is_(False), + ) + .group_by(TagGroupTag.tag_group_id) + .subquery() + ) + + q = ( + select( + TagGroup, + func.coalesce(post_count_sq.c.post_count, 0).label("post_count"), + ) + .outerjoin(post_count_sq, post_count_sq.c.group_id == TagGroup.id) + .order_by(asc(TagGroup.sort_order), asc(TagGroup.name)) + ) + + rows = list((await self.sess.execute(q)).all()) + + groups = [] + for tg, count in rows: + # Fetch member tag slugs + ids for this group + tag_rows = list( + (await self.sess.execute( + select(Tag.slug, Tag.id) + .join(TagGroupTag, TagGroupTag.tag_id == Tag.id) + .where( + TagGroupTag.tag_group_id == tg.id, + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + ) + )).all() + ) + groups.append({ + "id": tg.id, + "name": tg.name, + "slug": tg.slug, + "feature_image": tg.feature_image, + "colour": tg.colour, + "sort_order": tg.sort_order, + "post_count": count, + "tag_slugs": [r[0] for r in tag_rows], + "tag_ids": [r[1] for r in tag_rows], + }) + + return groups + + async def count_etc_posts(self, assigned_tag_ids: List[int]) -> int: + """ + Count published posts not covered by any tag group. + Includes posts with no tags and posts whose tags are all unassigned. + """ + base = [ + Post.deleted_at.is_(None), + Post.published_at.is_not(None), + Post.is_page.is_(False), + ] + if assigned_tag_ids: + covered_sq = ( + select(PostTag.post_id) + .join(Tag, Tag.id == PostTag.tag_id) + .where( + Tag.id.in_(assigned_tag_ids), + Tag.deleted_at.is_(None), + ) + ) + base.append(Post.id.notin_(covered_sq)) + + q = select(func.count()).select_from(Post).where(*base) + return int((await self.sess.execute(q)).scalar() or 0) + + async def list_drafts(self) -> List[Dict[str, Any]]: + """Return all draft (non-page, non-deleted) posts, newest-updated first.""" + q = ( + select(Post) + .where( + Post.deleted_at.is_(None), + Post.status == "draft", + Post.is_page.is_(False), + ) + .order_by(desc(Post.updated_at)) + .options( + joinedload(Post.primary_author), + joinedload(Post.primary_tag), + selectinload(Post.authors), + selectinload(Post.tags), + ) + ) + rows: List[Post] = list((await self.sess.execute(q)).scalars()) + return [_post_to_public(p) for p in rows] diff --git a/bp/blog/routes.py b/bp/blog/routes.py new file mode 100644 index 0000000..8fcf86b --- /dev/null +++ b/bp/blog/routes.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +#from quart import Blueprint, g + +import json +import os + +from quart import ( + request, + render_template, + make_response, + g, + Blueprint, + redirect, + url_for, +) +from .ghost_db import DBClient # adjust import path +from db.session import get_session +from .filters.qs import makeqs_factory, decode +from .services.posts_data import posts_data + +from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache +from suma_browser.app.utils.htmx import is_htmx_request +from suma_browser.app.authz import require_admin +from utils import host_url + +def register(url_prefix, title): + blogs_bp = Blueprint("blog", __name__, url_prefix) + + from .web_hooks.routes import ghost_webhooks + blogs_bp.register_blueprint(ghost_webhooks) + + from .ghost.editor_api import editor_api_bp + blogs_bp.register_blueprint(editor_api_bp) + + + + from ..post.routes import register as register_blog + blogs_bp.register_blueprint( + register_blog(), + ) + + from .admin.routes import register as register_tag_groups_admin + blogs_bp.register_blueprint(register_tag_groups_admin()) + + + @blogs_bp.before_app_serving + async def init(): + from .ghost.ghost_sync import ( + sync_all_content_from_ghost, + sync_all_membership_from_ghost, + ) + + async with get_session() as s: + await sync_all_content_from_ghost(s) + await sync_all_membership_from_ghost(s) + await s.commit() + + @blogs_bp.before_request + def route(): + g.makeqs_factory = makeqs_factory + + + @blogs_bp.context_processor + async def inject_root(): + return { + "blog_title": title, + "qs": makeqs_factory()(), + "unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + } + + SORT_MAP = { + "newest": "published_at DESC", + "oldest": "published_at ASC", + "az": "title ASC", + "za": "title DESC", + "featured": "featured DESC, published_at DESC", + } + + @blogs_bp.get("/") + async def home(): + + q = decode() + + # Drafts filter requires login; ignore if not logged in + show_drafts = bool(q.drafts and g.user) + is_admin = bool((g.get("rights") or {}).get("admin")) + drafts_user_id = None if (not show_drafts or is_admin) else g.user.id + + # For the draft count badge: admin sees all drafts, non-admin sees own + count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False) + + data = await posts_data( + g.s, 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, + ) + + context = { + **data, + "selected_tags": q.selected_tags, + "selected_authors": q.selected_authors, + "selected_groups": q.selected_groups, + "sort": q.sort, + "search": q.search, + "view": q.view, + "drafts": q.drafts if show_drafts else None, + } + + # Determine which template to use based on request type and pagination + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/blog/index.html", **context) + elif q.page > 1: + # HTMX pagination: just blog cards + sentinel + html = await render_template("_types/blog/_cards.html", **context) + else: + # HTMX navigation (page 1): main panel + OOB elements + #main_panel = await render_template("_types/blog/_main_panel.html", **context) + html = await render_template("_types/blog/_oob_elements.html", **context) + #html = oob_elements + main_panel + + return await make_response(html) + + @blogs_bp.get("/new/") + @require_admin + async def new_post(): + if not is_htmx_request(): + html = await render_template("_types/blog_new/index.html") + else: + html = await render_template("_types/blog_new/_oob_elements.html") + return await make_response(html) + + @blogs_bp.post("/new/") + @require_admin + async def new_post_save(): + from .ghost.ghost_posts import create_post + from .ghost.lexical_validator import validate_lexical + from .ghost.ghost_sync import sync_single_post + + form = await request.form + title = form.get("title", "").strip() or "Untitled" + lexical_raw = form.get("lexical", "") + status = form.get("status", "draft") + feature_image = form.get("feature_image", "").strip() + custom_excerpt = form.get("custom_excerpt", "").strip() + feature_image_caption = form.get("feature_image_caption", "").strip() + + # Validate + try: + lexical_doc = json.loads(lexical_raw) + except (json.JSONDecodeError, TypeError): + html = await render_template( + "_types/blog_new/index.html", + save_error="Invalid JSON in editor content.", + ) + return await make_response(html, 400) + + ok, reason = validate_lexical(lexical_doc) + if not ok: + html = await render_template( + "_types/blog_new/index.html", + save_error=reason, + ) + return await make_response(html, 400) + + # Create in Ghost + ghost_post = await create_post( + title=title, + lexical_json=lexical_raw, + status=status, + feature_image=feature_image or None, + custom_excerpt=custom_excerpt or None, + feature_image_caption=feature_image_caption or None, + ) + + # Sync to local DB + await sync_single_post(g.s, ghost_post["id"]) + await g.s.flush() + + # Set user_id on the newly created post + from models.ghost_content import Post + from sqlalchemy import select + local_post = (await g.s.execute( + select(Post).where(Post.ghost_id == ghost_post["id"]) + )).scalar_one_or_none() + if local_post and local_post.user_id is None: + local_post.user_id = g.user.id + await g.s.flush() + + # Clear blog listing cache + await invalidate_tag_cache("blog") + + # Redirect to the edit page (post is likely a draft, so public detail would 404) + return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"]))) + + + @blogs_bp.get("/drafts/") + async def drafts(): + return redirect(host_url(url_for("blog.home")) + "?drafts=1") + + return blogs_bp \ No newline at end of file diff --git a/bp/blog/services/posts_data.py b/bp/blog/services/posts_data.py new file mode 100644 index 0000000..de17767 --- /dev/null +++ b/bp/blog/services/posts_data.py @@ -0,0 +1,137 @@ +from ..ghost_db import DBClient # adjust import path +from sqlalchemy import select +from models.ghost_content import PostLike +from models.calendars import CalendarEntry, CalendarEntryPost +from quart import g + +async def posts_data( + session, + page, search, sort, selected_tags, selected_authors, liked, + drafts=False, drafts_user_id=None, count_drafts_for_user_id=None, + selected_groups=(), + ): + client = DBClient(session) + + # --- Tag-group resolution --- + tag_groups = await client.list_tag_groups_with_counts() + + # Collect all assigned tag IDs across groups + all_assigned_tag_ids = [] + for grp in tag_groups: + all_assigned_tag_ids.extend(grp["tag_ids"]) + + # Build slug-lookup for groups + group_by_slug = {grp["slug"]: grp for grp in tag_groups} + + # Resolve selected group → post filtering + # Groups and tags are mutually exclusive — groups override tags when set + effective_tags = selected_tags + etc_mode_tag_ids = None # set when "etc" is selected + if selected_groups: + group_slug = selected_groups[0] + if group_slug == "etc": + # etc = posts NOT covered by any group (includes untagged) + etc_mode_tag_ids = all_assigned_tag_ids + effective_tags = () + elif group_slug in group_by_slug: + effective_tags = tuple(group_by_slug[group_slug]["tag_slugs"]) + + # Compute "etc" virtual group + etc_count = await client.count_etc_posts(all_assigned_tag_ids) + if etc_count > 0 or (selected_groups and selected_groups[0] == "etc"): + tag_groups.append({ + "id": None, + "name": "etc", + "slug": "etc", + "feature_image": None, + "colour": None, + "sort_order": 999999, + "post_count": etc_count, + "tag_slugs": [], + "tag_ids": [], + }) + + posts, pagination = await client.list_posts( + limit=10, + page=page, + selected_tags=effective_tags, + selected_authors=selected_authors, + search=search, + drafts=drafts, + drafts_user_id=drafts_user_id, + exclude_covered_tag_ids=etc_mode_tag_ids, + ) + + # Get all post IDs in this batch + post_ids = [p["id"] for p in posts] + + # Add is_liked field to each post for current user + if g.user: + # Fetch all likes for this user and these posts in one query + liked_posts = await session.execute( + select(PostLike.post_id).where( + PostLike.user_id == g.user.id, + PostLike.post_id.in_(post_ids), + PostLike.deleted_at.is_(None), + ) + ) + liked_post_ids = {row[0] for row in liked_posts} + + # Add is_liked to each post + for post in posts: + post["is_liked"] = post["id"] in liked_post_ids + else: + # Not logged in - no posts are liked + for post in posts: + post["is_liked"] = False + + # Fetch associated entries for each post + # Get all confirmed entries associated with these posts + from sqlalchemy.orm import selectinload + entries_result = await session.execute( + select(CalendarEntry, CalendarEntryPost.post_id) + .join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id) + .options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar + .where( + CalendarEntryPost.post_id.in_(post_ids), + CalendarEntryPost.deleted_at.is_(None), + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "confirmed" + ) + .order_by(CalendarEntry.start_at.asc()) + ) + + # Group entries by post_id + entries_by_post = {} + for entry, post_id in entries_result: + if post_id not in entries_by_post: + entries_by_post[post_id] = [] + entries_by_post[post_id].append(entry) + + # Add associated_entries to each post + for post in posts: + post["associated_entries"] = entries_by_post.get(post["id"], []) + + tags=await client.list_tags( + limit=50000 + ) + authors=await client.list_authors( + limit=50000 + ) + + # Draft count for the logged-in user (None → admin sees all) + draft_count = 0 + if count_drafts_for_user_id is not False: + draft_count = await client.count_drafts(user_id=count_drafts_for_user_id) + + return { + "posts": posts, + "page": pagination.get("page", page), + "total_pages": pagination.get("pages", 1), + "search_count": pagination.get("search_count"), + "tags": tags, + "authors": authors, + "draft_count": draft_count, + "tag_groups": tag_groups, + "selected_groups": selected_groups, + } diff --git a/bp/blog/web_hooks/routes.py b/bp/blog/web_hooks/routes.py new file mode 100644 index 0000000..6722632 --- /dev/null +++ b/bp/blog/web_hooks/routes.py @@ -0,0 +1,120 @@ +# suma_browser/webhooks.py +from __future__ import annotations +import os +from quart import Blueprint, request, abort, Response, g + +from ..ghost.ghost_sync import ( + sync_single_member, + sync_single_page, + sync_single_post, + sync_single_author, + sync_single_tag, +) +from suma_browser.app.redis_cacher import clear_cache +from suma_browser.app.csrf import csrf_exempt + +ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook") + +def _check_secret(req) -> None: + expected = os.getenv("GHOST_WEBHOOK_SECRET") + if not expected: + # if you don't set a secret, we allow anything (dev mode) + return + got = req.args.get("secret") or req.headers.get("X-Webhook-Secret") + if got != expected: + abort(401) + +def _extract_id(data: dict, key: str) -> str | None: + """ + key is "post", "tag", or "user"/"author". + Ghost usually sends { key: { current: { id: ... }, previous: { id: ... } } } + We'll try current first, then previous. + """ + block = data.get(key) or {} + cur = block.get("current") or {} + prev = block.get("previous") or {} + return cur.get("id") or prev.get("id") + + +@csrf_exempt +@ghost_webhooks.route("/member/", methods=["POST"]) +#@ghost_webhooks.post("/member/") +async def webhook_member() -> Response: + _check_secret(request) + + data = await request.get_json(force=True, silent=True) or {} + ghost_id = _extract_id(data, "member") + if not ghost_id: + abort(400, "no member id") + + # sync one post + #async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py + await sync_single_member(g.s, ghost_id) + return Response(status=204) + +@csrf_exempt +@ghost_webhooks.post("/post/") +@clear_cache(tag='blog') +async def webhook_post() -> Response: + _check_secret(request) + + data = await request.get_json(force=True, silent=True) or {} + ghost_id = _extract_id(data, "post") + if not ghost_id: + abort(400, "no post id") + + # sync one post + #async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py + await sync_single_post(g.s, ghost_id) + + return Response(status=204) + +@csrf_exempt +@ghost_webhooks.post("/page/") +@clear_cache(tag='blog') +async def webhook_page() -> Response: + _check_secret(request) + + data = await request.get_json(force=True, silent=True) or {} + ghost_id = _extract_id(data, "page") + if not ghost_id: + abort(400, "no page id") + + # sync one post + #async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py + await sync_single_page(g.s, ghost_id) + + return Response(status=204) + +@csrf_exempt +@ghost_webhooks.post("/author/") +@clear_cache(tag='blog') +async def webhook_author() -> Response: + _check_secret(request) + + data = await request.get_json(force=True, silent=True) or {} + # Ghost calls them "user" in webhook payload in many versions, + # and you want authors in your mirror. We'll try both keys. + ghost_id = _extract_id(data, "user") or _extract_id(data, "author") + if not ghost_id: + abort(400, "no author id") + + #async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] + await sync_single_author(g.s, ghost_id) + + return Response(status=204) + +@csrf_exempt +@ghost_webhooks.post("/tag/") +@clear_cache(tag='blog') +async def webhook_tag() -> Response: + _check_secret(request) + + data = await request.get_json(force=True, silent=True) or {} + ghost_id = _extract_id(data, "tag") + if not ghost_id: + abort(400, "no tag id") + + #async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] + await sync_single_tag(g.s, ghost_id) + return Response(status=204) diff --git a/bp/coop_api.py b/bp/coop_api.py new file mode 100644 index 0000000..e68e4e6 --- /dev/null +++ b/bp/coop_api.py @@ -0,0 +1,83 @@ +""" +Internal JSON API for the coop app. + +These endpoints are called by other apps (market, cart) over HTTP +to fetch Ghost CMS content and menu items without importing blog services. +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from models.menu_item import MenuItem +from suma_browser.app.csrf import csrf_exempt + + +def register() -> Blueprint: + bp = Blueprint("coop_api", __name__, url_prefix="/internal") + + @bp.get("/menu-items") + @csrf_exempt + async def menu_items(): + """ + Return all active menu items as lightweight JSON. + Called by market and cart apps to render the nav. + """ + result = await g.s.execute( + select(MenuItem) + .where(MenuItem.deleted_at.is_(None)) + .options(selectinload(MenuItem.post)) + .order_by(MenuItem.sort_order.asc(), MenuItem.id.asc()) + ) + items = result.scalars().all() + + return jsonify( + [ + { + "id": mi.id, + "post": { + "title": mi.post.title if mi.post else None, + "slug": mi.post.slug if mi.post else None, + "feature_image": mi.post.feature_image if mi.post else None, + }, + } + for mi in items + ] + ) + + @bp.get("/post/") + @csrf_exempt + async def post_by_slug(slug: str): + """ + Return a Ghost post's key fields by slug. + Called by market app for the landing page. + """ + from suma_browser.app.bp.blog.ghost_db import DBClient + + client = DBClient(g.s) + posts = await client.posts_by_slug(slug, include_drafts=False) + + if not posts: + return jsonify(None), 404 + + post, original_post = posts[0] + + return jsonify( + { + "post": { + "id": post.get("id"), + "title": post.get("title"), + "html": post.get("html"), + "custom_excerpt": post.get("custom_excerpt"), + "feature_image": post.get("feature_image"), + "slug": post.get("slug"), + }, + "original_post": { + "id": getattr(original_post, "id", None), + "title": getattr(original_post, "title", None), + }, + } + ) + + return bp diff --git a/bp/menu_items/__init__.py b/bp/menu_items/__init__.py new file mode 100644 index 0000000..be248ab --- /dev/null +++ b/bp/menu_items/__init__.py @@ -0,0 +1,3 @@ +from .routes import register + +__all__ = ["register"] diff --git a/bp/menu_items/routes.py b/bp/menu_items/routes.py new file mode 100644 index 0000000..a6a0296 --- /dev/null +++ b/bp/menu_items/routes.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from quart import Blueprint, render_template, make_response, request, jsonify, g + +from suma_browser.app.authz import require_admin +from .services.menu_items import ( + get_all_menu_items, + get_menu_item_by_id, + create_menu_item, + update_menu_item, + delete_menu_item, + search_pages, + MenuItemError, +) +from suma_browser.app.utils.htmx import is_htmx_request + +def register(): + bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') + + async def get_menu_items_nav_oob(): + """Helper to generate OOB update for root nav menu items""" + menu_items = await get_all_menu_items(g.s) + + nav_oob = await render_template( + "_types/menu_items/_nav_oob.html", + menu_items=menu_items, + ) + return nav_oob + + @bp.get("/") + @require_admin + async def list_menu_items(): + """List all menu items""" + menu_items = await get_all_menu_items(g.s) + + + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template( + "_types/menu_items/index.html", + menu_items=menu_items, + ) + else: + + html = await render_template( + "_types/menu_items/_oob_elements.html", + menu_items=menu_items, + ) + #html = await render_template("_types/root/settings/_oob_elements.html") + + + return await make_response(html) + + @bp.get("/new/") + @require_admin + async def new_menu_item(): + """Show form to create new menu item""" + html = await render_template( + "_types/menu_items/_form.html", + menu_item=None, + ) + return await make_response(html) + + @bp.post("/") + @require_admin + async def create_menu_item_route(): + """Create a new menu item""" + form = await request.form + post_id = form.get("post_id") + + if not post_id: + return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422 + + try: + post_id = int(post_id) + except ValueError: + return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422 + + try: + menu_item = await create_menu_item(g.s, post_id) + await g.s.flush() + + # Get updated list and nav OOB + menu_items = await get_all_menu_items(g.s) + nav_oob = await get_menu_items_nav_oob() + + html = await render_template( + "_types/menu_items/_list.html", + menu_items=menu_items, + ) + return await make_response(html + nav_oob, 200) + + except MenuItemError as e: + return jsonify({"message": str(e), "errors": {}}), 400 + + @bp.get("//edit/") + @require_admin + async def edit_menu_item(item_id: int): + """Show form to edit menu item""" + menu_item = await get_menu_item_by_id(g.s, item_id) + if not menu_item: + return await make_response("Menu item not found", 404) + + html = await render_template( + "_types/menu_items/_form.html", + menu_item=menu_item, + ) + return await make_response(html) + + @bp.put("//") + @require_admin + async def update_menu_item_route(item_id: int): + """Update a menu item""" + form = await request.form + post_id = form.get("post_id") + + if not post_id: + return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422 + + try: + post_id = int(post_id) + except ValueError: + return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422 + + try: + menu_item = await update_menu_item(g.s, item_id, post_id=post_id) + await g.s.flush() + + # Get updated list and nav OOB + menu_items = await get_all_menu_items(g.s) + nav_oob = await get_menu_items_nav_oob() + + html = await render_template( + "_types/menu_items/_list.html", + menu_items=menu_items, + ) + return await make_response(html + nav_oob, 200) + + except MenuItemError as e: + return jsonify({"message": str(e), "errors": {}}), 400 + + @bp.delete("//") + @require_admin + async def delete_menu_item_route(item_id: int): + """Delete a menu item""" + success = await delete_menu_item(g.s, item_id) + + if not success: + return await make_response("Menu item not found", 404) + + await g.s.flush() + + # Get updated list and nav OOB + menu_items = await get_all_menu_items(g.s) + nav_oob = await get_menu_items_nav_oob() + + html = await render_template( + "_types/menu_items/_list.html", + menu_items=menu_items, + ) + return await make_response(html + nav_oob, 200) + + @bp.get("/pages/search/") + @require_admin + async def search_pages_route(): + """Search for pages to add as menu items""" + query = request.args.get("q", "").strip() + page = int(request.args.get("page", 1)) + per_page = 10 + + pages, total = await search_pages(g.s, query, page, per_page) + has_more = (page * per_page) < total + + html = await render_template( + "_types/menu_items/_page_search_results.html", + pages=pages, + query=query, + page=page, + has_more=has_more, + ) + return await make_response(html) + + @bp.post("/reorder/") + @require_admin + async def reorder_menu_items_route(): + """Reorder menu items""" + from .services.menu_items import reorder_menu_items + + form = await request.form + item_ids_str = form.get("item_ids", "") + + if not item_ids_str: + return jsonify({"message": "No items to reorder", "errors": {}}), 400 + + try: + item_ids = [int(id.strip()) for id in item_ids_str.split(",") if id.strip()] + except ValueError: + return jsonify({"message": "Invalid item IDs", "errors": {}}), 400 + + await reorder_menu_items(g.s, item_ids) + await g.s.flush() + + # Get updated list and nav OOB + menu_items = await get_all_menu_items(g.s) + nav_oob = await get_menu_items_nav_oob() + + html = await render_template( + "_types/menu_items/_list.html", + menu_items=menu_items, + ) + return await make_response(html + nav_oob, 200) + + return bp diff --git a/bp/menu_items/services/menu_items.py b/bp/menu_items/services/menu_items.py new file mode 100644 index 0000000..cd979f8 --- /dev/null +++ b/bp/menu_items/services/menu_items.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from models.menu_item import MenuItem +from models.ghost_content import Post + + +class MenuItemError(ValueError): + """Base error for menu item service operations.""" + + +async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]: + """ + Get all menu items (excluding deleted), ordered by sort_order. + Eagerly loads the post relationship. + """ + from sqlalchemy.orm import selectinload + + result = await session.execute( + select(MenuItem) + .where(MenuItem.deleted_at.is_(None)) + .options(selectinload(MenuItem.post)) + .order_by(MenuItem.sort_order.asc(), MenuItem.id.asc()) + ) + return list(result.scalars().all()) + + +async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None: + """Get a menu item by ID (excluding deleted).""" + from sqlalchemy.orm import selectinload + + result = await session.execute( + select(MenuItem) + .where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None)) + .options(selectinload(MenuItem.post)) + ) + return result.scalar_one_or_none() + + +async def create_menu_item( + session: AsyncSession, + post_id: int, + sort_order: int | None = None +) -> MenuItem: + """ + Create a new menu item. + If sort_order is not provided, adds to end of list. + """ + # Verify post exists and is a page + post = await session.scalar( + select(Post).where(Post.id == post_id) + ) + if not post: + raise MenuItemError(f"Post {post_id} does not exist.") + + if not post.is_page: + raise MenuItemError("Only pages can be added as menu items, not posts.") + + # If no sort_order provided, add to end + if sort_order is None: + max_order = await session.scalar( + select(func.max(MenuItem.sort_order)) + .where(MenuItem.deleted_at.is_(None)) + ) + sort_order = (max_order or 0) + 1 + + # Check for duplicate (same post, not deleted) + existing = await session.scalar( + select(MenuItem).where( + MenuItem.post_id == post_id, + MenuItem.deleted_at.is_(None) + ) + ) + if existing: + raise MenuItemError(f"Menu item for this page already exists.") + + menu_item = MenuItem( + post_id=post_id, + sort_order=sort_order + ) + session.add(menu_item) + await session.flush() + + # Reload with post relationship + await session.refresh(menu_item, ["post"]) + + return menu_item + + +async def update_menu_item( + session: AsyncSession, + item_id: int, + post_id: int | None = None, + sort_order: int | None = None +) -> MenuItem: + """Update an existing menu item.""" + menu_item = await get_menu_item_by_id(session, item_id) + if not menu_item: + raise MenuItemError(f"Menu item {item_id} not found.") + + if post_id is not None: + # Verify post exists and is a page + post = await session.scalar( + select(Post).where(Post.id == post_id) + ) + if not post: + raise MenuItemError(f"Post {post_id} does not exist.") + + if not post.is_page: + raise MenuItemError("Only pages can be added as menu items, not posts.") + + # Check for duplicate (same post, different menu item) + existing = await session.scalar( + select(MenuItem).where( + MenuItem.post_id == post_id, + MenuItem.id != item_id, + MenuItem.deleted_at.is_(None) + ) + ) + if existing: + raise MenuItemError(f"Menu item for this page already exists.") + + menu_item.post_id = post_id + + if sort_order is not None: + menu_item.sort_order = sort_order + + await session.flush() + await session.refresh(menu_item, ["post"]) + + return menu_item + + +async def delete_menu_item(session: AsyncSession, item_id: int) -> bool: + """Soft delete a menu item.""" + menu_item = await get_menu_item_by_id(session, item_id) + if not menu_item: + return False + + menu_item.deleted_at = func.now() + await session.flush() + + return True + + +async def reorder_menu_items( + session: AsyncSession, + item_ids: list[int] +) -> list[MenuItem]: + """ + Reorder menu items by providing a list of IDs in desired order. + Updates sort_order for each item. + """ + items = [] + for index, item_id in enumerate(item_ids): + menu_item = await get_menu_item_by_id(session, item_id) + if menu_item: + menu_item.sort_order = index + items.append(menu_item) + + await session.flush() + + return items + + +async def search_pages( + session: AsyncSession, + query: str, + page: int = 1, + per_page: int = 10 +) -> tuple[list[Post], int]: + """ + Search for pages (not posts) by title. + Returns (pages, total_count). + """ + # Build search filter + filters = [ + Post.is_page == True, # noqa: E712 + Post.status == "published", + Post.deleted_at.is_(None) + ] + + if query: + filters.append(Post.title.ilike(f"%{query}%")) + + # Get total count + count_result = await session.execute( + select(func.count(Post.id)).where(*filters) + ) + total = count_result.scalar() or 0 + + # Get paginated results + offset = (page - 1) * per_page + result = await session.execute( + select(Post) + .where(*filters) + .order_by(Post.title.asc()) + .limit(per_page) + .offset(offset) + ) + pages = list(result.scalars().all()) + + return pages, total diff --git a/bp/post/admin/routes.py b/bp/post/admin/routes.py new file mode 100644 index 0000000..bebd5f9 --- /dev/null +++ b/bp/post/admin/routes.py @@ -0,0 +1,462 @@ +from __future__ import annotations + + +from quart import ( + render_template, + make_response, + Blueprint, + g, + request, + redirect, + url_for, +) +from suma_browser.app.authz import require_admin, require_post_author +from suma_browser.app.utils.htmx import is_htmx_request +from utils import host_url + +def register(): + bp = Blueprint("admin", __name__, url_prefix='/admin') + + + @bp.get("/") + @require_admin + async def admin(slug: str): + from suma_browser.app.utils.htmx import is_htmx_request + + # Determine which template to use based on request type + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/post/admin/index.html") + else: + # HTMX request: main panel + OOB elements + html = await render_template("_types/post/admin/_oob_elements.html") + + return await make_response(html) + + @bp.get("/data/") + @require_admin + async def data(slug: str): + if not is_htmx_request(): + html = await render_template( + "_types/post_data/index.html", + ) + else: + html = await render_template( + "_types/post_data/_oob_elements.html", + ) + + return await make_response(html) + + @bp.get("/entries/calendar//") + @require_admin + async def calendar_view(slug: str, calendar_id: int): + """Show calendar month view for browsing entries""" + from models.calendars import Calendar + from sqlalchemy import select + from datetime import datetime, timezone + from quart import request + import calendar as pycalendar + from ...calendar.services.calendar_view import parse_int_arg, add_months, build_calendar_weeks + from ...calendar.services import get_visible_entries_for_period + from quart import session as qsession + from ..services.entry_associations import get_post_entry_ids + + # Get month/year from query params + today = datetime.now(timezone.utc).date() + month = parse_int_arg("month") + year = parse_int_arg("year") + + if year is None: + year = today.year + if month is None or not (1 <= month <= 12): + month = today.month + + # Load calendar + result = await g.s.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.deleted_at.is_(None)) + ) + calendar_obj = result.scalar_one_or_none() + if not calendar_obj: + return await make_response("Calendar not found", 404) + + # Build calendar data + prev_month_year, prev_month = add_months(year, month, -1) + next_month_year, next_month = add_months(year, month, +1) + prev_year = year - 1 + next_year = year + 1 + + weeks = build_calendar_weeks(year, month) + month_name = pycalendar.month_name[month] + weekday_names = [pycalendar.day_abbr[i] for i in range(7)] + + # Get entries for this month + period_start = datetime(year, month, 1, tzinfo=timezone.utc) + next_y, next_m = add_months(year, month, +1) + period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc) + + user = getattr(g, "user", None) + session_id = qsession.get("calendar_sid") + + visible = await get_visible_entries_for_period( + sess=g.s, + calendar_id=calendar_obj.id, + period_start=period_start, + period_end=period_end, + user=user, + session_id=session_id, + ) + + # Get associated entry IDs for this post + post_id = g.post_data["post"]["id"] + associated_entry_ids = await get_post_entry_ids(g.s, post_id) + + html = await render_template( + "_types/post/admin/_calendar_view.html", + calendar=calendar_obj, + year=year, + month=month, + month_name=month_name, + weekday_names=weekday_names, + weeks=weeks, + prev_month=prev_month, + prev_month_year=prev_month_year, + next_month=next_month, + next_month_year=next_month_year, + prev_year=prev_year, + next_year=next_year, + month_entries=visible.merged_entries, + associated_entry_ids=associated_entry_ids, + ) + return await make_response(html) + + @bp.get("/entries/") + @require_admin + async def entries(slug: str): + from ..services.entry_associations import get_post_entry_ids + from models.calendars import Calendar + from sqlalchemy import select + + post_id = g.post_data["post"]["id"] + associated_entry_ids = await get_post_entry_ids(g.s, post_id) + + # Load ALL calendars (not just this post's calendars) + result = await g.s.execute( + select(Calendar) + .where(Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + all_calendars = result.scalars().all() + + # Load entries and post for each calendar + for calendar in all_calendars: + await g.s.refresh(calendar, ["entries", "post"]) + if not is_htmx_request(): + html = await render_template( + "_types/post_entries/index.html", + all_calendars=all_calendars, + associated_entry_ids=associated_entry_ids, + ) + else: + html = await render_template( + "_types/post_entries/_oob_elements.html", + all_calendars=all_calendars, + associated_entry_ids=associated_entry_ids, + ) + + return await make_response(html) + + @bp.post("/entries//toggle/") + @require_admin + async def toggle_entry(slug: str, entry_id: int): + from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries + from models.calendars import Calendar + from sqlalchemy import select + from quart import jsonify + + post_id = g.post_data["post"]["id"] + is_associated, error = await toggle_entry_association(g.s, post_id, entry_id) + + if error: + return jsonify({"message": error, "errors": {}}), 400 + + await g.s.flush() + + # Return updated association status + associated_entry_ids = await get_post_entry_ids(g.s, post_id) + + # Load ALL calendars + result = await g.s.execute( + select(Calendar) + .where(Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + all_calendars = result.scalars().all() + + # Load entries and post for each calendar + for calendar in all_calendars: + await g.s.refresh(calendar, ["entries", "post"]) + + # Fetch associated entries for nav display + associated_entries = await get_associated_entries(g.s, post_id) + + # Load calendars for this post (for nav display) + calendars = ( + await g.s.execute( + select(Calendar) + .where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + ).scalars().all() + + # Return the associated entries admin list + OOB update for nav entries + admin_list = await render_template( + "_types/post/admin/_associated_entries.html", + all_calendars=all_calendars, + associated_entry_ids=associated_entry_ids, + ) + + nav_entries_oob = await render_template( + "_types/post/admin/_nav_entries_oob.html", + associated_entries=associated_entries, + calendars=calendars, + post=g.post_data["post"], + ) + + return await make_response(admin_list + nav_entries_oob) + + @bp.get("/settings/") + @require_post_author + async def settings(slug: str): + from ...blog.ghost.ghost_posts import get_post_for_edit + + ghost_id = g.post_data["post"]["ghost_id"] + ghost_post = await get_post_for_edit(ghost_id) + save_success = request.args.get("saved") == "1" + + if not is_htmx_request(): + html = await render_template( + "_types/post_settings/index.html", + ghost_post=ghost_post, + save_success=save_success, + ) + else: + html = await render_template( + "_types/post_settings/_oob_elements.html", + ghost_post=ghost_post, + save_success=save_success, + ) + + return await make_response(html) + + @bp.post("/settings/") + @require_post_author + async def settings_save(slug: str): + from ...blog.ghost.ghost_posts import update_post_settings + from ...blog.ghost.ghost_sync import sync_single_post + from suma_browser.app.redis_cacher import invalidate_tag_cache + + ghost_id = g.post_data["post"]["ghost_id"] + form = await request.form + + updated_at = form.get("updated_at", "") + + # Build kwargs — only include fields that were submitted + kwargs: dict = {} + + # Text fields + for field in ( + "slug", "custom_template", "meta_title", "meta_description", + "canonical_url", "og_image", "og_title", "og_description", + "twitter_image", "twitter_title", "twitter_description", + "feature_image_alt", + ): + val = form.get(field) + if val is not None: + kwargs[field] = val.strip() + + # Select fields + visibility = form.get("visibility") + if visibility is not None: + kwargs["visibility"] = visibility + + # Datetime + published_at = form.get("published_at", "").strip() + if published_at: + kwargs["published_at"] = published_at + + # Checkbox fields: present = True, absent = False + kwargs["featured"] = form.get("featured") == "on" + kwargs["email_only"] = form.get("email_only") == "on" + + # Tags — comma-separated string → list of {"name": "..."} dicts + tags_str = form.get("tags", "").strip() + if tags_str: + kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()] + else: + kwargs["tags"] = [] + + # Update in Ghost + await update_post_settings( + ghost_id=ghost_id, + updated_at=updated_at, + **kwargs, + ) + + # Sync to local DB + await sync_single_post(g.s, ghost_id) + await g.s.flush() + + # Clear caches + await invalidate_tag_cache("blog") + await invalidate_tag_cache("post.post_detail") + + return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1") + + @bp.get("/edit/") + @require_post_author + async def edit(slug: str): + from ...blog.ghost.ghost_posts import get_post_for_edit + from models.ghost_membership_entities import GhostNewsletter + from sqlalchemy import select as sa_select + + ghost_id = g.post_data["post"]["ghost_id"] + ghost_post = await get_post_for_edit(ghost_id) + save_success = request.args.get("saved") == "1" + + newsletters = (await g.s.execute( + sa_select(GhostNewsletter).order_by(GhostNewsletter.name) + )).scalars().all() + + if not is_htmx_request(): + html = await render_template( + "_types/post_edit/index.html", + ghost_post=ghost_post, + save_success=save_success, + newsletters=newsletters, + ) + else: + html = await render_template( + "_types/post_edit/_oob_elements.html", + ghost_post=ghost_post, + save_success=save_success, + newsletters=newsletters, + ) + + return await make_response(html) + + @bp.post("/edit/") + @require_post_author + async def edit_save(slug: str): + import json + from ...blog.ghost.ghost_posts import update_post + from ...blog.ghost.lexical_validator import validate_lexical + from ...blog.ghost.ghost_sync import sync_single_post + from suma_browser.app.redis_cacher import invalidate_tag_cache + + ghost_id = g.post_data["post"]["ghost_id"] + form = await request.form + title = form.get("title", "").strip() + lexical_raw = form.get("lexical", "") + updated_at = form.get("updated_at", "") + status = form.get("status", "draft") + publish_mode = form.get("publish_mode", "web") + newsletter_slug = form.get("newsletter_slug", "").strip() or None + feature_image = form.get("feature_image", "").strip() + custom_excerpt = form.get("custom_excerpt", "").strip() + feature_image_caption = form.get("feature_image_caption", "").strip() + + # Validate the lexical JSON + try: + lexical_doc = json.loads(lexical_raw) + except (json.JSONDecodeError, TypeError): + from ...blog.ghost.ghost_posts import get_post_for_edit + ghost_post = await get_post_for_edit(ghost_id) + html = await render_template( + "_types/post_edit/index.html", + ghost_post=ghost_post, + save_error="Invalid JSON in editor content.", + ) + return await make_response(html, 400) + + ok, reason = validate_lexical(lexical_doc) + if not ok: + from ...blog.ghost.ghost_posts import get_post_for_edit + ghost_post = await get_post_for_edit(ghost_id) + html = await render_template( + "_types/post_edit/index.html", + ghost_post=ghost_post, + save_error=reason, + ) + return await make_response(html, 400) + + # Update in Ghost (content save — no status change yet) + ghost_post = await update_post( + ghost_id=ghost_id, + lexical_json=lexical_raw, + title=title or None, + updated_at=updated_at, + feature_image=feature_image, + custom_excerpt=custom_excerpt, + feature_image_caption=feature_image_caption, + ) + + # Publish workflow + is_admin = bool((g.get("rights") or {}).get("admin")) + publish_requested_msg = None + + # Guard: if already emailed, force publish_mode to "web" to prevent re-send + already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status")) + if already_emailed and publish_mode in ("email", "both"): + publish_mode = "web" + + if status == "published" and ghost_post.get("status") != "published" and not is_admin: + # Non-admin requesting publish: don't send status to Ghost, set local flag + publish_requested_msg = "Publish requested — an admin will review." + elif status and status != ghost_post.get("status"): + # Status is changing — determine email params based on publish_mode + email_kwargs: dict = {} + if status == "published" and publish_mode in ("email", "both") and newsletter_slug: + email_kwargs["newsletter_slug"] = newsletter_slug + email_kwargs["email_segment"] = "all" + if publish_mode == "email": + email_kwargs["email_only"] = True + + from ...blog.ghost.ghost_posts import update_post as _up + ghost_post = await _up( + ghost_id=ghost_id, + lexical_json=lexical_raw, + title=None, + updated_at=ghost_post["updated_at"], + status=status, + **email_kwargs, + ) + + # Sync to local DB + await sync_single_post(g.s, ghost_id) + await g.s.flush() + + # Handle publish_requested flag on the local post + from models.ghost_content import Post + from sqlalchemy import select as sa_select + local_post = (await g.s.execute( + sa_select(Post).where(Post.ghost_id == ghost_id) + )).scalar_one_or_none() + if local_post: + if publish_requested_msg: + local_post.publish_requested = True + elif status == "published" and is_admin: + local_post.publish_requested = False + await g.s.flush() + + # Clear caches + await invalidate_tag_cache("blog") + await invalidate_tag_cache("post.post_detail") + + # Redirect to GET to avoid resubmit warning on refresh (PRG pattern) + redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1" + if publish_requested_msg: + redirect_url += "&publish_requested=1" + return redirect(redirect_url) + + + return bp diff --git a/bp/post/routes.py b/bp/post/routes.py new file mode 100644 index 0000000..3d8640e --- /dev/null +++ b/bp/post/routes.py @@ -0,0 +1,158 @@ +from __future__ import annotations + + +from quart import ( + render_template, + make_response, + g, + Blueprint, + abort, + url_for, +) +from .services.post_data import post_data +from .services.post_operations import toggle_post_like +from models.calendars import Calendar +from sqlalchemy import select + +from suma_browser.app.redis_cacher import cache_page, clear_cache + + +from .admin.routes import register as register_admin +from config import config +from suma_browser.app.utils.htmx import is_htmx_request + +def register(): + bp = Blueprint("post", __name__, url_prefix='/') + bp.register_blueprint( + register_admin() + ) + + # Calendar blueprints now live in the events service. + # Post pages link to events_url() instead of embedding calendars. + + @bp.url_value_preprocessor + def pull_blog(endpoint, values): + g.post_slug = values.get("slug") + + @bp.before_request + async def hydrate_post_data(): + slug = getattr(g, "post_slug", None) + if not slug: + return # not a blog route or no slug in this URL + + is_admin = bool((g.get("rights") or {}).get("admin")) + # Always include drafts so we can check ownership below + p_data = await post_data(slug, g.s, include_drafts=True) + if not p_data: + abort(404) + return + + # Access control for draft posts + if p_data["post"].get("status") != "published": + if is_admin: + pass # admin can see all drafts + elif g.user and p_data["post"].get("user_id") == g.user.id: + pass # author can see their own drafts + else: + abort(404) + return + + g.post_data = p_data + + @bp.context_processor + async def context(): + p_data = getattr(g, "post_data", None) + if p_data: + from .services.entry_associations import get_associated_entries + + db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer + calendars = ( + await g.s.execute( + select(Calendar) + .where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + ).scalars().all() + + # Fetch associated entries for nav display + associated_entries = await get_associated_entries(g.s, db_post_id) + + return { + **p_data, + "base_title": f"{config()['title']} {p_data['post']['title']}", + "calendars": calendars, + "associated_entries": associated_entries, + } + else: + return {} + + @bp.get("/") + @cache_page(tag="post.post_detail") + async def post_detail(slug: str): + # Determine which template to use based on request type + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/post/index.html") + else: + # HTMX request: main panel + OOB elements + html = await render_template("_types/post/_oob_elements.html") + + return await make_response(html) + + @bp.post("/like/toggle/") + @clear_cache(tag="post.post_detail", tag_scope="user") + async def like_toggle(slug: str): + from utils import host_url + + # Get post_id from g.post_data + if not g.user: + html = await render_template( + "_types/browse/like/button.html", + slug=slug, + liked=False, + like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), + item_type='post', + ) + resp = make_response(html, 403) + return resp + + post_id = g.post_data["post"]["id"] + user_id = g.user.id + + liked, error = await toggle_post_like(g.s, user_id, post_id) + + if error: + resp = make_response(error, 404) + return resp + + html = await render_template( + "_types/browse/like/button.html", + slug=slug, + liked=liked, + like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), + item_type='post', + ) + return html + + @bp.get("/entries/") + async def get_entries(slug: str): + """Get paginated associated entries for infinite scroll in nav""" + from .services.entry_associations import get_associated_entries + from quart import request + + page = int(request.args.get("page", 1)) + post_id = g.post_data["post"]["id"] + + result = await get_associated_entries(g.s, post_id, page=page, per_page=10) + + html = await render_template( + "_types/post/_entry_items.html", + entries=result["entries"], + page=result["page"], + has_more=result["has_more"], + ) + return await make_response(html) + + return bp + + diff --git a/bp/post/services/entry_associations.py b/bp/post/services/entry_associations.py new file mode 100644 index 0000000..fa34077 --- /dev/null +++ b/bp/post/services/entry_associations.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.sql import func + +from models.calendars import CalendarEntry, CalendarEntryPost, Calendar +from models.ghost_content import Post + + +async def toggle_entry_association( + session: AsyncSession, + post_id: int, + entry_id: int +) -> tuple[bool, str | None]: + """ + Toggle association between a post and calendar entry. + Returns (is_now_associated, error_message). + """ + # Check if entry exists (don't filter by deleted_at - allow associating with any entry) + entry = await session.scalar( + select(CalendarEntry).where(CalendarEntry.id == entry_id) + ) + if not entry: + return False, f"Calendar entry {entry_id} not found in database" + + # Check if post exists + post = await session.scalar( + select(Post).where(Post.id == post_id) + ) + if not post: + return False, "Post not found" + + # Check if association already exists + existing = await session.scalar( + select(CalendarEntryPost).where( + CalendarEntryPost.entry_id == entry_id, + CalendarEntryPost.post_id == post_id, + CalendarEntryPost.deleted_at.is_(None) + ) + ) + + if existing: + # Remove association (soft delete) + existing.deleted_at = func.now() + await session.flush() + return False, None + else: + # Create association + association = CalendarEntryPost( + entry_id=entry_id, + post_id=post_id + ) + session.add(association) + await session.flush() + return True, None + + +async def get_post_entry_ids( + session: AsyncSession, + post_id: int +) -> set[int]: + """ + Get all entry IDs associated with this post. + Returns a set of entry IDs. + """ + result = await session.execute( + select(CalendarEntryPost.entry_id) + .where( + CalendarEntryPost.post_id == post_id, + CalendarEntryPost.deleted_at.is_(None) + ) + ) + return set(result.scalars().all()) + + +async def get_associated_entries( + session: AsyncSession, + post_id: int, + page: int = 1, + per_page: int = 10 +) -> dict: + """ + Get paginated associated entries for this post. + Returns dict with entries, total_count, and has_more. + """ + # Get all associated entry IDs + entry_ids_result = await session.execute( + select(CalendarEntryPost.entry_id) + .where( + CalendarEntryPost.post_id == post_id, + CalendarEntryPost.deleted_at.is_(None) + ) + ) + entry_ids = set(entry_ids_result.scalars().all()) + + if not entry_ids: + return { + "entries": [], + "total_count": 0, + "has_more": False, + "page": page, + } + + # Get total count + from sqlalchemy import func + total_count = len(entry_ids) + + # Get paginated entries ordered by start_at desc + # Only include confirmed entries + offset = (page - 1) * per_page + result = await session.execute( + select(CalendarEntry) + .where( + CalendarEntry.id.in_(entry_ids), + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "confirmed" # Only confirmed entries in nav + ) + .order_by(CalendarEntry.start_at.desc()) + .limit(per_page) + .offset(offset) + ) + entries = result.scalars().all() + + # Recalculate total_count based on confirmed entries only + total_count = len(entries) + offset # Rough estimate + if len(entries) < per_page: + total_count = offset + len(entries) + + # Load calendar relationship for each entry + for entry in entries: + await session.refresh(entry, ["calendar"]) + if entry.calendar: + await session.refresh(entry.calendar, ["post"]) + + has_more = len(entries) == per_page # More accurate check + + return { + "entries": entries, + "total_count": total_count, + "has_more": has_more, + "page": page, + } diff --git a/bp/post/services/post_data.py b/bp/post/services/post_data.py new file mode 100644 index 0000000..0d0d225 --- /dev/null +++ b/bp/post/services/post_data.py @@ -0,0 +1,42 @@ +from ...blog.ghost_db import DBClient # adjust import path +from sqlalchemy import select +from models.ghost_content import PostLike +from quart import g + +async def post_data(slug, session, include_drafts=False): + client = DBClient(session) + posts = (await client.posts_by_slug(slug, include_drafts=include_drafts)) + + if not posts: + # 404 page (you can make a template for this if you want) + return None + post, original_post = posts[0] + + # Check if current user has liked this post + is_liked = False + if g.user: + liked_record = await session.scalar( + select(PostLike).where( + PostLike.user_id == g.user.id, + PostLike.post_id == post["id"], + PostLike.deleted_at.is_(None), + ) + ) + is_liked = liked_record is not None + + # Add is_liked to post dict + post["is_liked"] = is_liked + + tags=await client.list_tags( + limit=50000 + ) # <-- new + authors=await client.list_authors( + limit=50000 + ) + + return { + "post": post, + "original_post": original_post, + "tags": tags, + "authors": authors, + } diff --git a/bp/post/services/post_operations.py b/bp/post/services/post_operations.py new file mode 100644 index 0000000..e4bb102 --- /dev/null +++ b/bp/post/services/post_operations.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from models.ghost_content import Post, PostLike + + +async def toggle_post_like( + session: AsyncSession, + user_id: int, + post_id: int, +) -> tuple[bool, Optional[str]]: + """ + Toggle a post like for a given user using soft deletes. + Returns (liked_state, error_message). + - If error_message is not None, an error occurred. + - liked_state indicates whether post is now liked (True) or unliked (False). + """ + + # Verify post exists + post_exists = await session.scalar( + select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None)) + ) + if not post_exists: + return False, "Post not found" + + # Check if like exists (not deleted) + existing = await session.scalar( + select(PostLike).where( + PostLike.user_id == user_id, + PostLike.post_id == post_id, + PostLike.deleted_at.is_(None), + ) + ) + + if existing: + # Unlike: soft delete the like + await session.execute( + update(PostLike) + .where( + PostLike.user_id == user_id, + PostLike.post_id == post_id, + PostLike.deleted_at.is_(None), + ) + .values(deleted_at=func.now()) + ) + return False, None + else: + # Like: add a new like + new_like = PostLike( + user_id=user_id, + post_id=post_id, + ) + session.add(new_like) + return True, None diff --git a/bp/snippets/__init__.py b/bp/snippets/__init__.py new file mode 100644 index 0000000..be248ab --- /dev/null +++ b/bp/snippets/__init__.py @@ -0,0 +1,3 @@ +from .routes import register + +__all__ = ["register"] diff --git a/bp/snippets/routes.py b/bp/snippets/routes.py new file mode 100644 index 0000000..ca27ab7 --- /dev/null +++ b/bp/snippets/routes.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from quart import Blueprint, render_template, make_response, request, g, abort +from sqlalchemy import select, or_ +from sqlalchemy.orm import selectinload + +from suma_browser.app.authz import require_login +from suma_browser.app.utils.htmx import is_htmx_request +from models import Snippet + + +VALID_VISIBILITY = frozenset({"private", "shared", "admin"}) + + +async def _visible_snippets(session): + """Return snippets visible to the current user (own + shared + admin-if-admin).""" + uid = g.user.id + is_admin = g.rights.get("admin") + + 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() + + return rows + + +def register(): + bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") + + @bp.get("/") + @require_login + async def list_snippets(): + """List snippets visible to the current user.""" + snippets = await _visible_snippets(g.s) + is_admin = g.rights.get("admin") + + if not is_htmx_request(): + html = await render_template( + "_types/snippets/index.html", + snippets=snippets, + is_admin=is_admin, + ) + else: + html = await render_template( + "_types/snippets/_oob_elements.html", + snippets=snippets, + is_admin=is_admin, + ) + + return await make_response(html) + + @bp.delete("//") + @require_login + async def delete_snippet(snippet_id: int): + """Delete a snippet. Owners delete their own; admins can delete any.""" + snippet = await g.s.get(Snippet, snippet_id) + if not snippet: + abort(404) + + is_admin = g.rights.get("admin") + if snippet.user_id != g.user.id and not is_admin: + abort(403) + + await g.s.delete(snippet) + await g.s.flush() + + snippets = await _visible_snippets(g.s) + html = await render_template( + "_types/snippets/_list.html", + snippets=snippets, + is_admin=is_admin, + ) + return await make_response(html) + + @bp.patch("//visibility/") + @require_login + async def patch_visibility(snippet_id: int): + """Change snippet visibility. Admin only.""" + if not g.rights.get("admin"): + abort(403) + + snippet = await g.s.get(Snippet, snippet_id) + if not snippet: + abort(404) + + form = await request.form + visibility = form.get("visibility", "").strip() + + if visibility not in VALID_VISIBILITY: + abort(400) + + snippet.visibility = visibility + await g.s.flush() + + snippets = await _visible_snippets(g.s) + html = await render_template( + "_types/snippets/_list.html", + snippets=snippets, + is_admin=True, + ) + return await make_response(html) + + return bp diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..dd1352c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Optional: wait for Postgres to be reachable +if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then + echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..." + for i in {1..60}; do + (echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true + sleep 1 + done +fi + +# Run DB migrations (uses alembic.ini/env.py to resolve the DB URL) +echo "Running Alembic migrations..." +alembic upgrade head + +# Clear Redis page cache on deploy +if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then + echo "Flushing Redis cache..." + python3 -c " +import redis, os +r = redis.from_url(os.environ['REDIS_URL']) +r.flushall() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +# APP_MODULE can be overridden per-service (e.g. apps.market.app:app) +echo "Starting Hypercorn (${APP_MODULE:-suma_browser.app.app:app})..." +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-suma_browser.app.app:app}" --bind 0.0.0.0:${PORT:-8000} diff --git a/templates/_types/auth/_main_panel.html b/templates/_types/auth/_main_panel.html new file mode 100644 index 0000000..d394dd0 --- /dev/null +++ b/templates/_types/auth/_main_panel.html @@ -0,0 +1,49 @@ +
    +
    + + {% if error %} +
    + {{ error }} +
    + {% endif %} + + {# Account header #} +
    +
    +

    Account

    + {% if g.user %} +

    {{ g.user.email }}

    + {% if g.user.name %} +

    {{ g.user.name }}

    + {% endif %} + {% endif %} +
    +
    + + +
    +
    + + {# Labels #} + {% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %} + {% if labels %} +
    +

    Labels

    +
    + {% for label in labels %} + + {{ label.name }} + + {% endfor %} +
    +
    + {% endif %} + +
    +
    diff --git a/templates/_types/auth/_nav.html b/templates/_types/auth/_nav.html new file mode 100644 index 0000000..ffa2730 --- /dev/null +++ b/templates/_types/auth/_nav.html @@ -0,0 +1,7 @@ +{% import 'macros/links.html' as links %} +{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + newsletters +{% endcall %} +{% call links.link(cart_url('/orders/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + orders +{% endcall %} diff --git a/templates/_types/auth/_newsletter_toggle.html b/templates/_types/auth/_newsletter_toggle.html new file mode 100644 index 0000000..4320f58 --- /dev/null +++ b/templates/_types/auth/_newsletter_toggle.html @@ -0,0 +1,17 @@ +
    + +
    diff --git a/templates/_types/auth/_newsletters_panel.html b/templates/_types/auth/_newsletters_panel.html new file mode 100644 index 0000000..2c2c548 --- /dev/null +++ b/templates/_types/auth/_newsletters_panel.html @@ -0,0 +1,46 @@ +
    +
    + +

    Newsletters

    + + {% if newsletter_list %} +
    + {% for item in newsletter_list %} +
    +
    +

    {{ item.newsletter.name }}

    + {% if item.newsletter.description %} +

    {{ item.newsletter.description }}

    + {% endif %} +
    +
    + {% if item.un %} + {% with un=item.un %} + {% include "_types/auth/_newsletter_toggle.html" %} + {% endwith %} + {% else %} + {# No subscription row yet — show an off toggle that will create one #} +
    + +
    + {% endif %} +
    +
    + {% endfor %} +
    + {% else %} +

    No newsletters available.

    + {% endif %} + +
    +
    diff --git a/templates/_types/auth/_oob_elements.html b/templates/_types/auth/_oob_elements.html new file mode 100644 index 0000000..cafb113 --- /dev/null +++ b/templates/_types/auth/_oob_elements.html @@ -0,0 +1,29 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{# Header with app title - includes cart-mini, navigation, and market-specific header #} + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}} + + {% from '_types/root/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/auth/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include oob.main %} +{% endblock %} + + diff --git a/templates/_types/auth/check_email.html b/templates/_types/auth/check_email.html new file mode 100644 index 0000000..822b58e --- /dev/null +++ b/templates/_types/auth/check_email.html @@ -0,0 +1,33 @@ +{% extends "_types/root/index.html" %} +{% block content %} +
    +
    +

    Check your email

    + +

    + If an account exists for + {{ email }}, + you’ll receive a link to sign in. It expires in 15 minutes. +

    + + {% if email_error %} + + {% endif %} + +

    + + ← Back + +

    +
    +
    +{% endblock %} diff --git a/templates/_types/auth/header/_header.html b/templates/_types/auth/header/_header.html new file mode 100644 index 0000000..9f9e451 --- /dev/null +++ b/templates/_types/auth/header/_header.html @@ -0,0 +1,12 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='auth-row', oob=oob) %} + {% call links.link(url_for('auth.account'), hx_select_search ) %} + +
    account
    + {% endcall %} + {% call links.desktop_nav() %} + {% include "_types/auth/_nav.html" %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/auth/index copy.html b/templates/_types/auth/index copy.html new file mode 100644 index 0000000..cd4d6d3 --- /dev/null +++ b/templates/_types/auth/index copy.html @@ -0,0 +1,18 @@ +{% extends "_types/root/_index.html" %} + + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('auth-header-child', '_types/auth/header/_header.html') %} + {% block auth_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include "_types/auth/_nav.html" %} +{% endblock %} + +{% block content %} + {% include '_types/auth/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/auth/index.html b/templates/_types/auth/index.html new file mode 100644 index 0000000..3c66bf1 --- /dev/null +++ b/templates/_types/auth/index.html @@ -0,0 +1,18 @@ +{% extends oob.extends %} + + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row(oob.child_id, oob.header) %} + {% block auth_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include oob.nav %} +{% endblock %} + +{% block content %} + {% include oob.main %} +{% endblock %} diff --git a/templates/_types/auth/login.html b/templates/_types/auth/login.html new file mode 100644 index 0000000..dc5ef8a --- /dev/null +++ b/templates/_types/auth/login.html @@ -0,0 +1,46 @@ +{% extends "_types/root/index.html" %} +{% block content %} +
    +
    +

    Sign in

    +

    + Enter your email and we’ll email you a one-time sign-in link. +

    + + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + +
    + + +
    + + +
    +
    +
    +{% endblock %} diff --git a/templates/_types/blog/_action_buttons.html b/templates/_types/blog/_action_buttons.html new file mode 100644 index 0000000..0ea7fa2 --- /dev/null +++ b/templates/_types/blog/_action_buttons.html @@ -0,0 +1,51 @@ +{# New Post + Drafts toggle — shown in aside (desktop + mobile) #} +
    + {% if has_access('blog.new_post') %} + {% set new_href = url_for('blog.new_post')|host %} + + New Post + + {% endif %} + {% if g.user and (draft_count or drafts) %} + {% if drafts %} + {% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %} + + Drafts + {{ draft_count }} + + {% else %} + {% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %} + + Drafts + {{ draft_count }} + + {% endif %} + {% endif %} +
    diff --git a/templates/_types/blog/_card.html b/templates/_types/blog/_card.html new file mode 100644 index 0000000..1011676 --- /dev/null +++ b/templates/_types/blog/_card.html @@ -0,0 +1,115 @@ +{% import 'macros/stickers.html' as stick %} +
    + {# ❤️ like button - OUTSIDE the link, aligned with image top #} + {% if g.user %} +
    + {% set slug = post.slug %} + {% set liked = post.is_liked or False %} + {% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %} + {% set item_type = 'post' %} + {% include "_types/browse/like/button.html" %} +
    + {% endif %} + + {% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %} + +
    +

    + {{ post.title }} +

    + + {% if post.status == "draft" %} +
    + Draft + {% if post.publish_requested %} + Publish requested + {% endif %} +
    + {% if post.updated_at %} +

    + Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }} +

    + {% endif %} + {% elif post.published_at %} +

    + Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }} +

    + {% endif %} + +
    + + {% if post.feature_image %} +
    + +
    + {% endif %} + {% if post.custom_excerpt %} +

    + {{ post.custom_excerpt }} +

    + {% else %} + {% if post.excerpt %} +

    + {{ post.excerpt }} +

    + {% endif %} + {% endif %} +
    + + {# Associated Entries - Scrollable list #} + {% if post.associated_entries %} + + + + {% endif %} + + {% include '_types/blog/_card/at_bar.html' %} + +
    diff --git a/templates/_types/blog/_card/at_bar.html b/templates/_types/blog/_card/at_bar.html new file mode 100644 index 0000000..f226d92 --- /dev/null +++ b/templates/_types/blog/_card/at_bar.html @@ -0,0 +1,19 @@ +
    + {% if post.tags %} +
    +
    in
    +
      + {% include '_types/blog/_card/tags.html' %} +
    +
    + {% endif %} +
    + {% if post.authors %} +
    +
    by
    +
      + {% include '_types/blog/_card/authors.html' %} +
    +
    + {% endif %} +
    diff --git a/templates/_types/blog/_card/author.html b/templates/_types/blog/_card/author.html new file mode 100644 index 0000000..7ddddf7 --- /dev/null +++ b/templates/_types/blog/_card/author.html @@ -0,0 +1,21 @@ +{% macro author(author) %} + {% if author %} + {% if author.profile_image %} + {{ author.name }} + {% else %} +
    + {# optional fallback circle with first letter +
    + {{ author.name[:1] }} +
    #} + {% endif %} + + + {{ author.name }} + + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/blog/_card/authors.html b/templates/_types/blog/_card/authors.html new file mode 100644 index 0000000..5b8911d --- /dev/null +++ b/templates/_types/blog/_card/authors.html @@ -0,0 +1,32 @@ +{# --- AUTHORS LIST STARTS HERE --- #} + {% if post.authors and post.authors|length %} + {% for a in post.authors %} + {% for author in authors if author.slug==a.slug %} +
  • + + {% if author.profile_image %} + {{ author.name }} + {% else %} + {# optional fallback circle with first letter #} +
    + {{ author.name[:1] }} +
    + {% endif %} + + + {{ author.name }} + +
    +
  • + {% endfor %} + {% endfor %} + {% endif %} + + {# --- AUTHOR LIST ENDS HERE --- #} \ No newline at end of file diff --git a/templates/_types/blog/_card/tag.html b/templates/_types/blog/_card/tag.html new file mode 100644 index 0000000..137cb0c --- /dev/null +++ b/templates/_types/blog/_card/tag.html @@ -0,0 +1,19 @@ +{% macro tag(tag) %} + {% if tag %} + {% if tag.feature_image %} + {{ tag.name }} + {% else %} +
    + {{ tag.name[:1] }} +
    + {% endif %} + + + {{ tag.name }} + + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/blog/_card/tag_group.html b/templates/_types/blog/_card/tag_group.html new file mode 100644 index 0000000..21c9974 --- /dev/null +++ b/templates/_types/blog/_card/tag_group.html @@ -0,0 +1,22 @@ +{% macro tag_group(group) %} + {% if group %} + {% if group.feature_image %} + {{ group.name }} + {% else %} +
    + {{ group.name[:1] }} +
    + {% endif %} + + + {{ group.name }} + + {% endif %} +{% endmacro %} diff --git a/templates/_types/blog/_card/tags.html b/templates/_types/blog/_card/tags.html new file mode 100644 index 0000000..2ea7ad1 --- /dev/null +++ b/templates/_types/blog/_card/tags.html @@ -0,0 +1,17 @@ +{% import '_types/blog/_card/tag.html' as dotag %} +{# --- TAG LIST STARTS HERE --- #} + {% if post.tags and post.tags|length %} + {% for t in post.tags %} + {% for tag in tags if tag.slug==t.slug %} +
  • + + {{dotag.tag(tag)}} + +
  • + {% endfor %} + {% endfor %} + {% endif %} + {# --- TAG LIST ENDS HERE --- #} \ No newline at end of file diff --git a/templates/_types/blog/_card_tile.html b/templates/_types/blog/_card_tile.html new file mode 100644 index 0000000..f03ca16 --- /dev/null +++ b/templates/_types/blog/_card_tile.html @@ -0,0 +1,59 @@ +
    + {% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %} + + {% if post.feature_image %} +
    + +
    + {% endif %} + +
    +

    + {{ post.title }} +

    + + {% if post.status == "draft" %} +
    + Draft + {% if post.publish_requested %} + Publish requested + {% endif %} +
    + {% if post.updated_at %} +

    + Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }} +

    + {% endif %} + {% elif post.published_at %} +

    + Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }} +

    + {% endif %} + + {% if post.custom_excerpt %} +

    + {{ post.custom_excerpt }} +

    + {% elif post.excerpt %} +

    + {{ post.excerpt }} +

    + {% endif %} +
    +
    + + {% include '_types/blog/_card/at_bar.html' %} +
    diff --git a/templates/_types/blog/_cards.html b/templates/_types/blog/_cards.html new file mode 100644 index 0000000..82eee98 --- /dev/null +++ b/templates/_types/blog/_cards.html @@ -0,0 +1,111 @@ +{% for post in posts %} + {% if view == 'tile' %} + {% include "_types/blog/_card_tile.html" %} + {% else %} + {% include "_types/blog/_card.html" %} + {% endif %} +{% endfor %} +{% if page < total_pages|int %} + + + + + +{% else %} +
    End of results
    +{% endif %} + diff --git a/templates/_types/blog/_main_panel.html b/templates/_types/blog/_main_panel.html new file mode 100644 index 0000000..350999d --- /dev/null +++ b/templates/_types/blog/_main_panel.html @@ -0,0 +1,48 @@ + + {# View toggle bar - desktop only #} + + + {# Cards container - list or grid based on view #} + {% if view == 'tile' %} +
    + {% include "_types/blog/_cards.html" %} +
    + {% else %} +
    + {% include "_types/blog/_cards.html" %} +
    + {% endif %} +
    diff --git a/templates/_types/blog/_oob_elements.html b/templates/_types/blog/_oob_elements.html new file mode 100644 index 0000000..2aa02cb --- /dev/null +++ b/templates/_types/blog/_oob_elements.html @@ -0,0 +1,40 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/header/_oob_.html' import root_header with context %} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}} + + {% from '_types/root/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + + +{# Filter container - blog doesn't have child_summary but still needs this element #} +{% block filter %} + {% include "_types/blog/mobile/_filter/summary.html" %} +{% endblock %} + +{# Aside with filters #} +{% block aside %} + {% include "_types/blog/desktop/menu.html" %} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/root/_nav.html' %} + {% include '_types/root/_nav_panel.html' %} +{% endblock %} + + +{% block content %} + {% include '_types/blog/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog/admin/tag_groups/_edit_header.html b/templates/_types/blog/admin/tag_groups/_edit_header.html new file mode 100644 index 0000000..ade4ee9 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_edit_header.html @@ -0,0 +1,9 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='tag-groups-edit-row', oob=oob) %} + {% from 'macros/admin_nav.html' import admin_nav_item %} + {{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/blog/admin/tag_groups/_edit_main_panel.html b/templates/_types/blog/admin/tag_groups/_edit_main_panel.html new file mode 100644 index 0000000..7d1fa96 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_edit_main_panel.html @@ -0,0 +1,79 @@ +
    + + {# --- Edit group form --- #} +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + {# --- Tag checkboxes --- #} +
    + +
    + {% for tag in all_tags %} + + {% endfor %} +
    +
    + +
    + +
    +
    + + {# --- Delete form --- #} +
    + + +
    + +
    diff --git a/templates/_types/blog/admin/tag_groups/_edit_oob.html b/templates/_types/blog/admin/tag_groups/_edit_oob.html new file mode 100644 index 0000000..116bc7b --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_edit_oob.html @@ -0,0 +1,17 @@ +{% extends 'oob_elements.html' %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}} + {{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}} + + {% from '_types/root/settings/header/_header.html' import header_row with context %} + {{header_row(oob=True)}} +{% endblock %} + +{% block mobile_menu %} +{% endblock %} + +{% block content %} + {% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog/admin/tag_groups/_header.html b/templates/_types/blog/admin/tag_groups/_header.html new file mode 100644 index 0000000..d9c3095 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_header.html @@ -0,0 +1,9 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='tag-groups-row', oob=oob) %} + {% from 'macros/admin_nav.html' import admin_nav_item %} + {{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/blog/admin/tag_groups/_main_panel.html b/templates/_types/blog/admin/tag_groups/_main_panel.html new file mode 100644 index 0000000..1c8b8f4 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_main_panel.html @@ -0,0 +1,73 @@ +
    + + {# --- Create new group form --- #} +
    + +

    New Group

    +
    + + + +
    + + +
    + + {# --- Existing groups list --- #} + {% if groups %} +
      + {% for group in groups %} +
    • + {% if group.feature_image %} + {{ group.name }} + {% else %} +
      + {{ group.name[:1] }} +
      + {% endif %} +
      + + {{ group.name }} + + {{ group.slug }} +
      + order: {{ group.sort_order }} +
    • + {% endfor %} +
    + {% else %} +

    No tag groups yet.

    + {% endif %} + + {# --- Unassigned tags --- #} + {% if unassigned_tags %} +
    +

    Unassigned Tags ({{ unassigned_tags|length }})

    +
    + {% for tag in unassigned_tags %} + + {{ tag.name }} + + {% endfor %} +
    +
    + {% endif %} + +
    diff --git a/templates/_types/blog/admin/tag_groups/_oob_elements.html b/templates/_types/blog/admin/tag_groups/_oob_elements.html new file mode 100644 index 0000000..cb00363 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/_oob_elements.html @@ -0,0 +1,16 @@ +{% extends 'oob_elements.html' %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}} + + {% from '_types/root/settings/header/_header.html' import header_row with context %} + {{header_row(oob=True)}} +{% endblock %} + +{% block mobile_menu %} +{% endblock %} + +{% block content %} + {% include '_types/blog/admin/tag_groups/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog/admin/tag_groups/edit.html b/templates/_types/blog/admin/tag_groups/edit.html new file mode 100644 index 0000000..5fefbc6 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/edit.html @@ -0,0 +1,13 @@ +{% extends '_types/blog/admin/tag_groups/index.html' %} + +{% block tag_groups_header_child %} + {% from '_types/root/_n/macros.html' import header with context %} + {% call header() %} + {% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %} + {{ header_row() }} + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog/admin/tag_groups/index.html b/templates/_types/blog/admin/tag_groups/index.html new file mode 100644 index 0000000..680b051 --- /dev/null +++ b/templates/_types/blog/admin/tag_groups/index.html @@ -0,0 +1,20 @@ +{% extends '_types/root/settings/index.html' %} + +{% block root_settings_header_child %} + {% from '_types/root/_n/macros.html' import header with context %} + {% call header() %} + {% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %} + {{ header_row() }} +
    + {% block tag_groups_header_child %} + {% endblock %} +
    + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/blog/admin/tag_groups/_main_panel.html' %} +{% endblock %} + +{% block _main_mobile_menu %} +{% endblock %} diff --git a/templates/_types/blog/desktop/menu.html b/templates/_types/blog/desktop/menu.html new file mode 100644 index 0000000..57dba58 --- /dev/null +++ b/templates/_types/blog/desktop/menu.html @@ -0,0 +1,19 @@ +{% import '_types/browse/desktop/_filter/search.html' as s %} +{{ s.search(current_local_href, search, search_count, hx_select) }} +{% include '_types/blog/_action_buttons.html' %} +
    + {% include '_types/blog/desktop/menu/tag_groups.html' %} + {% include '_types/blog/desktop/menu/authors.html' %} +
    + +
    + +
    + + \ No newline at end of file diff --git a/templates/_types/blog/desktop/menu/authors.html b/templates/_types/blog/desktop/menu/authors.html new file mode 100644 index 0000000..de939e0 --- /dev/null +++ b/templates/_types/blog/desktop/menu/authors.html @@ -0,0 +1,62 @@ + {% import '_types/blog/_card/author.html' as doauthor %} + + {# Author filter bar #} + + diff --git a/templates/_types/blog/desktop/menu/tag_groups.html b/templates/_types/blog/desktop/menu/tag_groups.html new file mode 100644 index 0000000..e23a879 --- /dev/null +++ b/templates/_types/blog/desktop/menu/tag_groups.html @@ -0,0 +1,70 @@ + {# Tag group filter bar #} + diff --git a/templates/_types/blog/desktop/menu/tags.html b/templates/_types/blog/desktop/menu/tags.html new file mode 100644 index 0000000..c20b5bc --- /dev/null +++ b/templates/_types/blog/desktop/menu/tags.html @@ -0,0 +1,59 @@ + {% import '_types/blog/_card/tag.html' as dotag %} + + {# Tag filter bar #} + + diff --git a/templates/_types/blog/header/_header.html b/templates/_types/blog/header/_header.html new file mode 100644 index 0000000..67325b9 --- /dev/null +++ b/templates/_types/blog/header/_header.html @@ -0,0 +1,7 @@ + +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='blog-row', oob=oob) %} +
    + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/blog/index.html b/templates/_types/blog/index.html new file mode 100644 index 0000000..5978020 --- /dev/null +++ b/templates/_types/blog/index.html @@ -0,0 +1,37 @@ +{% extends '_types/root/_index.html' %} + +{% block meta %} + {{ super() }} + +{% endblock %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('root-blog-header', '_types/blog/header/_header.html') %} + {% block root_blog_header %} + {% endblock %} + {% endcall %} +{% endblock %} + + +{% block aside %} + {% include "_types/blog/desktop/menu.html" %} +{% endblock %} + +{% block filter %} + {% include "_types/blog/mobile/_filter/summary.html" %} +{% endblock %} + +{% block content %} + {% include '_types/blog/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog/mobile/_filter/_hamburger.html b/templates/_types/blog/mobile/_filter/_hamburger.html new file mode 100644 index 0000000..10e0b9c --- /dev/null +++ b/templates/_types/blog/mobile/_filter/_hamburger.html @@ -0,0 +1,13 @@ +
    + + + + + + + + +
    diff --git a/templates/_types/blog/mobile/_filter/summary.html b/templates/_types/blog/mobile/_filter/summary.html new file mode 100644 index 0000000..4ed013b --- /dev/null +++ b/templates/_types/blog/mobile/_filter/summary.html @@ -0,0 +1,14 @@ +{% import 'macros/layout.html' as layout %} + +{% call layout.details('/filter', 'md:hidden') %} + {% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %} + {% include '_types/blog/mobile/_filter/summary/tag_groups.html' %} + {% include '_types/blog/mobile/_filter/summary/authors.html' %} + {% endcall %} + {% include '_types/blog/_action_buttons.html' %} +
    + {% include '_types/blog/desktop/menu/tag_groups.html' %} + {% include '_types/blog/desktop/menu/authors.html' %} +
    +{% endcall %} + \ No newline at end of file diff --git a/templates/_types/blog/mobile/_filter/summary/authors.html b/templates/_types/blog/mobile/_filter/summary/authors.html new file mode 100644 index 0000000..32796d9 --- /dev/null +++ b/templates/_types/blog/mobile/_filter/summary/authors.html @@ -0,0 +1,31 @@ +{% if selected_authors and selected_authors|length %} +
      + {% for st in selected_authors %} + {% for author in authors %} + {% if st == author.slug %} +
    • + {% if author.profile_image %} + {{ author.name }} + {% else %} + {# optional fallback circle with first letter #} +
      + {{ author.name[:1] }} +
      + {% endif %} + + + {{ author.name }} + + + {{author.published_post_count}} + +
    • + {% endif %} + {% endfor %} + {% endfor %} +
    +{% endif %} \ No newline at end of file diff --git a/templates/_types/blog/mobile/_filter/summary/tag_groups.html b/templates/_types/blog/mobile/_filter/summary/tag_groups.html new file mode 100644 index 0000000..7bf142e --- /dev/null +++ b/templates/_types/blog/mobile/_filter/summary/tag_groups.html @@ -0,0 +1,33 @@ +{% if selected_groups and selected_groups|length %} +
      + {% for sg in selected_groups %} + {% for group in tag_groups %} + {% if sg == group.slug %} +
    • + {% if group.feature_image %} + {{ group.name }} + {% else %} +
      + {{ group.name[:1] }} +
      + {% endif %} + + + {{ group.name }} + + + {{group.post_count}} + +
    • + {% endif %} + {% endfor %} + {% endfor %} +
    +{% endif %} diff --git a/templates/_types/blog/mobile/_filter/summary/tags.html b/templates/_types/blog/mobile/_filter/summary/tags.html new file mode 100644 index 0000000..df6169d --- /dev/null +++ b/templates/_types/blog/mobile/_filter/summary/tags.html @@ -0,0 +1,31 @@ +{% if selected_tags and selected_tags|length %} +
      + {% for st in selected_tags %} + {% for tag in tags %} + {% if st == tag.slug %} +
    • + {% if tag.feature_image %} + {{ tag.name }} + {% else %} + {# optional fallback circle with first letter #} +
      + {{ tag.name[:1] }} +
      + {% endif %} + + + {{ tag.name }} + + + {{tag.published_post_count}} + +
    • + {% endif %} + {% endfor %} + {% endfor %} +
    +{% endif %} \ No newline at end of file diff --git a/templates/_types/blog/not_found.html b/templates/_types/blog/not_found.html new file mode 100644 index 0000000..525c188 --- /dev/null +++ b/templates/_types/blog/not_found.html @@ -0,0 +1,22 @@ +{% extends '_types/root/_index.html' %} + +{% block content %} +
    +
    📝
    +

    Post Not Found

    +

    + The post "{{ slug }}" could not be found. +

    + + ← Back to Blog + +
    +{% endblock %} diff --git a/templates/_types/blog_drafts/_main_panel.html b/templates/_types/blog_drafts/_main_panel.html new file mode 100644 index 0000000..8cb0b7a --- /dev/null +++ b/templates/_types/blog_drafts/_main_panel.html @@ -0,0 +1,55 @@ +
    + +
    +

    Drafts

    + {% set new_href = url_for('blog.new_post')|host %} + + New Post + +
    + + {% if drafts %} + + {% else %} +

    No drafts yet.

    + {% endif %} + +
    diff --git a/templates/_types/blog_drafts/_oob_elements.html b/templates/_types/blog_drafts/_oob_elements.html new file mode 100644 index 0000000..8d9790b --- /dev/null +++ b/templates/_types/blog_drafts/_oob_elements.html @@ -0,0 +1,12 @@ +{% extends 'oob_elements.html' %} + +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{% block oobs %} + {% from '_types/blog/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + +{% block content %} + {% include '_types/blog_drafts/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog_drafts/index.html b/templates/_types/blog_drafts/index.html new file mode 100644 index 0000000..6ce38f1 --- /dev/null +++ b/templates/_types/blog_drafts/index.html @@ -0,0 +1,11 @@ +{% extends '_types/root/_index.html' %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('root-blog-header', '_types/blog/header/_header.html') %} + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/blog_drafts/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog_new/_main_panel.html b/templates/_types/blog_new/_main_panel.html new file mode 100644 index 0000000..5523068 --- /dev/null +++ b/templates/_types/blog_new/_main_panel.html @@ -0,0 +1,259 @@ +{# ── Error banner ── #} +{% if save_error %} +
    + Save failed: {{ save_error }} +
    +{% endif %} + +
    + + + + + + {# ── Feature image ── #} +
    + {# Empty state: add link #} +
    + +
    + + {# Filled state: image preview + controls #} + + + {# Upload spinner overlay #} + + + {# Hidden file input #} + +
    + + {# ── Title ── #} + + + {# ── Excerpt ── #} + + + {# ── Editor mount point ── #} +
    + + {# ── Status + Save footer ── #} +
    + + + +
    +
    + +{# ── Koenig editor assets ── #} + + + + diff --git a/templates/_types/blog_new/_oob_elements.html b/templates/_types/blog_new/_oob_elements.html new file mode 100644 index 0000000..61e78f5 --- /dev/null +++ b/templates/_types/blog_new/_oob_elements.html @@ -0,0 +1,12 @@ +{% extends 'oob_elements.html' %} + +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{% block oobs %} + {% from '_types/blog/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + +{% block content %} + {% include '_types/blog_new/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/blog_new/index.html b/templates/_types/blog_new/index.html new file mode 100644 index 0000000..3c802d4 --- /dev/null +++ b/templates/_types/blog_new/index.html @@ -0,0 +1,11 @@ +{% extends '_types/root/_index.html' %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('root-blog-header', '_types/blog/header/_header.html') %} + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/blog_new/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/menu_items/_form.html b/templates/_types/menu_items/_form.html new file mode 100644 index 0000000..15bb404 --- /dev/null +++ b/templates/_types/menu_items/_form.html @@ -0,0 +1,125 @@ + + + diff --git a/templates/_types/menu_items/_list.html b/templates/_types/menu_items/_list.html new file mode 100644 index 0000000..70f676c --- /dev/null +++ b/templates/_types/menu_items/_list.html @@ -0,0 +1,68 @@ +
    + {% if menu_items %} +
    + {% for item in menu_items %} +
    + {# Drag handle #} +
    + +
    + + {# Page image #} + {% if item.post.feature_image %} + {{ item.post.title }} + {% else %} +
    + {% endif %} + + {# Page title #} +
    +
    {{ item.post.title }}
    +
    {{ item.post.slug }}
    +
    + + {# Sort order #} +
    + Order: {{ item.sort_order }} +
    + + {# Actions #} +
    + + +
    +
    + {% endfor %} +
    + {% else %} +
    + +

    No menu items yet. Add one to get started!

    +
    + {% endif %} +
    diff --git a/templates/_types/menu_items/_main_panel.html b/templates/_types/menu_items/_main_panel.html new file mode 100644 index 0000000..bc502dd --- /dev/null +++ b/templates/_types/menu_items/_main_panel.html @@ -0,0 +1,20 @@ +
    +
    + +
    + + {# Form container #} + + + {# Menu items list #} + +
    diff --git a/templates/_types/menu_items/_nav_oob.html b/templates/_types/menu_items/_nav_oob.html new file mode 100644 index 0000000..f8bdd5c --- /dev/null +++ b/templates/_types/menu_items/_nav_oob.html @@ -0,0 +1,29 @@ +{% set _app_slugs = {'market': market_url('/'), 'cart': cart_url('/')} %} + diff --git a/templates/_types/menu_items/_oob_elements.html b/templates/_types/menu_items/_oob_elements.html new file mode 100644 index 0000000..c242593 --- /dev/null +++ b/templates/_types/menu_items/_oob_elements.html @@ -0,0 +1,23 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-settings-header-child', 'menu_items-header-child', '_types/menu_items/header/_header.html')}} + + {% from '_types/root/settings/header/_header.html' import header_row with context %} + {{header_row(oob=True)}} + +{% endblock %} + +{% block mobile_menu %} +{#% include '_types/root/settings/_nav.html' %#} +{% endblock %} + +{% block content %} + {% include '_types/menu_items/_main_panel.html' %} +{% endblock %} + diff --git a/templates/_types/menu_items/_page_search_results.html b/templates/_types/menu_items/_page_search_results.html new file mode 100644 index 0000000..df36d0d --- /dev/null +++ b/templates/_types/menu_items/_page_search_results.html @@ -0,0 +1,44 @@ +{% if pages %} +
    + {% for post in pages %} +
    + + {# Page image #} + {% if post.feature_image %} + {{ post.title }} + {% else %} +
    + {% endif %} + + {# Page info #} +
    +
    {{ post.title }}
    +
    {{ post.slug }}
    +
    +
    + {% endfor %} + + {# Infinite scroll sentinel #} + {% if has_more %} +
    + Loading more... +
    + {% endif %} +
    +{% elif query %} +
    + No pages found matching "{{ query }}" +
    +{% endif %} diff --git a/templates/_types/menu_items/header/_header.html b/templates/_types/menu_items/header/_header.html new file mode 100644 index 0000000..55a18d6 --- /dev/null +++ b/templates/_types/menu_items/header/_header.html @@ -0,0 +1,9 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='menu_items-row', oob=oob) %} + {% from 'macros/admin_nav.html' import admin_nav_item %} + {{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours, aclass='') }} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/menu_items/index.html b/templates/_types/menu_items/index.html new file mode 100644 index 0000000..5bcf7da --- /dev/null +++ b/templates/_types/menu_items/index.html @@ -0,0 +1,20 @@ +{% extends '_types/root/settings/index.html' %} + +{% block root_settings_header_child %} + {% from '_types/root/_n/macros.html' import header with context %} + {% call header() %} + {% from '_types/menu_items/header/_header.html' import header_row with context %} + {{ header_row() }} + + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/menu_items/_main_panel.html' %} +{% endblock %} + +{% block _main_mobile_menu %} +{% endblock %} diff --git a/templates/_types/post/_entry_container.html b/templates/_types/post/_entry_container.html new file mode 100644 index 0000000..3c3965a --- /dev/null +++ b/templates/_types/post/_entry_container.html @@ -0,0 +1,24 @@ +
    +
    + {% include '_types/post/_entry_items.html' with context %} +
    +
    + + diff --git a/templates/_types/post/_entry_items.html b/templates/_types/post/_entry_items.html new file mode 100644 index 0000000..d913fe3 --- /dev/null +++ b/templates/_types/post/_entry_items.html @@ -0,0 +1,43 @@ +{# Get entries from either direct variable or associated_entries dict #} +{% set entry_list = entries if entries is defined else (associated_entries.entries if associated_entries is defined else []) %} +{% set current_page = page if page is defined else (associated_entries.page if associated_entries is defined else 1) %} +{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %} + +{% for entry in entry_list %} + + {% if entry.calendar.post.feature_image %} + {{ entry.calendar.post.title }} + {% else %} +
    + {% endif %} +
    +
    {{ entry.name }}
    +
    + {{ entry.start_at.strftime('%b %d, %Y at %H:%M') }} + {% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %} +
    +
    +
    +{% endfor %} + +{# Load more entries one at a time until container is full #} +{% if has_more_entries %} +
    +
    +{% endif %} diff --git a/templates/_types/post/_main_panel.html b/templates/_types/post/_main_panel.html new file mode 100644 index 0000000..318d234 --- /dev/null +++ b/templates/_types/post/_main_panel.html @@ -0,0 +1,62 @@ +{# Main panel fragment for HTMX navigation - post article content #} +
    + {# ❤️ like button - always visible in top right of article #} + {% if g.user %} +
    + {% set slug = post.slug %} + {% set liked = post.is_liked or False %} + {% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %} + {% set item_type = 'post' %} + {% include "_types/browse/like/button.html" %} +
    + {% endif %} + + {# Draft indicator + edit link #} + {% if post.status == "draft" %} +
    + Draft + {% if post.publish_requested %} + Publish requested + {% endif %} + {% set is_admin = (g.get("rights") or {}).get("admin") %} + {% if is_admin or (g.user and post.user_id == g.user.id) %} + {% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %} + + Edit + + {% endif %} +
    + {% endif %} + + {% if post.custom_excerpt %} +
    + {{post.custom_excerpt|safe}} +
    + {% endif %} + + {% if post.feature_image %} +
    + +
    + {% endif %} +
    + {% if post.html %} + {{post.html|safe}} + {% endif %} +
    +
    +
    diff --git a/templates/_types/post/_meta.html b/templates/_types/post/_meta.html new file mode 100644 index 0000000..c4ef2ad --- /dev/null +++ b/templates/_types/post/_meta.html @@ -0,0 +1,124 @@ +{# --- social/meta_post.html --- #} +{# Context expected: + site, post, request +#} + +{# Visibility → robots #} +{% set is_public = (post.visibility == 'public') %} +{% set is_published = (post.status == 'published') %} +{% set robots_here = 'index,follow' if (is_public and is_published and not post.email_only) else 'noindex,nofollow' %} + +{# Compute canonical early so both this file and base can use it #} +{% set _site_url = site().url.rstrip('/') if site and site().url else '' %} +{% set _post_path = request.path if request else ('/posts/' ~ (post.slug or post.uuid)) %} +{% set canonical = post.canonical_url or (_site_url ~ _post_path if _site_url else (request.url if request else None)) %} + +{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #} +{% set robots_override = robots_here %} +{% include 'social/meta_base.html' %} + +{# ---- Titles / descriptions ---- #} +{% set og_title = post.og_title or base_title %} +{% set tw_title = post.twitter_title or base_title %} + +{# Description best-effort, trimmed #} +{% set desc_source = post.meta_description + or post.og_description + or post.twitter_description + or post.custom_excerpt + or post.excerpt + or (post.plaintext if post.plaintext else (post.html|striptags if post.html else '')) %} +{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %} + +{# Image priority #} +{% set image_url = post.og_image + or post.twitter_image + or post.feature_image + or (site().default_image if site and site().default_image else None) %} + +{# Dates #} +{% set published_iso = post.published_at.isoformat() if post.published_at else None %} +{% set updated_iso = post.updated_at.isoformat() if post.updated_at + else (post.created_at.isoformat() if post.created_at else None) %} + +{# Authors / tags #} +{% set primary_author = post.primary_author %} +{% set authors = post.authors or ([primary_author] if primary_author else []) %} +{% set tag_names = (post.tags or []) | map(attribute='name') | list %} +{% set is_article = not post.is_page %} + +{{ base_title }} + +{% if canonical %}{% endif %} + +{# ---- Open Graph ---- #} + + + + +{% if canonical %}{% endif %} +{% if image_url %}{% endif %} +{% if is_article and published_iso %}{% endif %} +{% if is_article and updated_iso %} + + +{% endif %} +{% if is_article and post.primary_tag and post.primary_tag.name %} + +{% endif %} +{% if is_article %} + {% for t in tag_names %} + + {% endfor %} +{% endif %} + +{# ---- Twitter ---- #} + +{% if site and site().twitter_site %}{% endif %} +{% if primary_author and primary_author.twitter %} + +{% endif %} + + +{% if image_url %}{% endif %} + +{# ---- JSON-LD author value (no list comprehensions) ---- #} +{% if authors and authors|length == 1 %} + {% set author_value = {"@type": "Person", "name": authors[0].name} %} +{% elif authors %} + {% set ns = namespace(arr=[]) %} + {% for a in authors %} + {% set _ = ns.arr.append({"@type": "Person", "name": a.name}) %} + {% endfor %} + {% set author_value = ns.arr %} +{% else %} + {% set author_value = none %} +{% endif %} + +{# ---- JSON-LD using combine for optionals ---- #} +{% set jsonld = { + "@context": "https://schema.org", + "@type": "BlogPosting" if is_article else "WebPage", + "mainEntityOfPage": canonical, + "headline": base_title, + "description": description, + "image": image_url, + "datePublished": published_iso, + "author": author_value, + "publisher": { + "@type": "Organization", + "name": site().title if site and site().title else "", + "logo": {"@type": "ImageObject", "url": site().logo if site and site().logo else image_url} + } +} %} + +{% if updated_iso %} + {% set jsonld = jsonld | combine({"dateModified": updated_iso}) %} +{% endif %} +{% if tag_names %} + {% set jsonld = jsonld | combine({"keywords": tag_names | join(", ")}) %} +{% endif %} + + diff --git a/templates/_types/post/_nav.html b/templates/_types/post/_nav.html new file mode 100644 index 0000000..5cc37ae --- /dev/null +++ b/templates/_types/post/_nav.html @@ -0,0 +1,16 @@ +{% import 'macros/links.html' as links %} + {# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #} + {% if (associated_entries and associated_entries.entries) or calendars %} +
    + {% include '_types/post/admin/_nav_entries.html' %} +
    + {% endif %} + + {# Admin link #} + {% if post and has_access('blog.post.admin.admin') %} + {% import 'macros/links.html' as links %} + {% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + + {% endcall %} + {% endif %} diff --git a/templates/_types/post/_oob_elements.html b/templates/_types/post/_oob_elements.html new file mode 100644 index 0000000..d8bda2c --- /dev/null +++ b/templates/_types/post/_oob_elements.html @@ -0,0 +1,36 @@ +{% extends 'oob_elements.html' %} + + +{# OOB elements for HTMX navigation - all elements that need updating #} +{# Import shared OOB macros #} +{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + + + +{% block oobs %} + {% from '_types/root/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + +{% from '_types/root/_n/macros.html' import header with context %} +{% call header(id='root-header-child', oob=True) %} + {% call header() %} + {% from '_types/post/header/_header.html' import header_row with context %} + {{header_row()}} +
    + +
    + {% endcall %} +{% endcall %} + + +{# Mobile menu #} + +{% block mobile_menu %} + {% include '_types/post/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/post/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/post/admin/_associated_entries.html b/templates/_types/post/admin/_associated_entries.html new file mode 100644 index 0000000..d9fe853 --- /dev/null +++ b/templates/_types/post/admin/_associated_entries.html @@ -0,0 +1,50 @@ +
    +

    Associated Entries

    + {% if associated_entry_ids %} +
    + {% for calendar in all_calendars %} + {% for entry in calendar.entries %} + {% if entry.id in associated_entry_ids and entry.deleted_at is none %} + + {% endif %} + {% endfor %} + {% endfor %} +
    + {% else %} +
    No entries associated yet. Browse calendars below to add entries.
    + {% endif %} +
    diff --git a/templates/_types/post/admin/_calendar_view.html b/templates/_types/post/admin/_calendar_view.html new file mode 100644 index 0000000..1aa5734 --- /dev/null +++ b/templates/_types/post/admin/_calendar_view.html @@ -0,0 +1,88 @@ +
    + {# Month/year navigation #} +
    + +
    + + {# Calendar grid #} +
    + + +
    + {% for week in weeks %} + {% for day in week %} +
    +
    {{ day.date.day }}
    + + {# Entries for this day #} +
    + {% for e in month_entries %} + {% if e.start_at.date() == day.date and e.deleted_at is none %} + {% if e.id in associated_entry_ids %} + {# Associated entry - show with delete button #} +
    + {{ e.name }} + +
    + {% else %} + {# Non-associated entry - clickable to add #} + + {% endif %} + {% endif %} + {% endfor %} +
    +
    + {% endfor %} + {% endfor %} +
    +
    +
    diff --git a/templates/_types/post/admin/_main_panel.html b/templates/_types/post/admin/_main_panel.html new file mode 100644 index 0000000..58d5238 --- /dev/null +++ b/templates/_types/post/admin/_main_panel.html @@ -0,0 +1,7 @@ +{# Main panel fragment for HTMX navigation - post admin #} +
    +
    +
    diff --git a/templates/_types/post/admin/_nav.html b/templates/_types/post/admin/_nav.html new file mode 100644 index 0000000..fc206fe --- /dev/null +++ b/templates/_types/post/admin/_nav.html @@ -0,0 +1,16 @@ +{% import 'macros/links.html' as links %} +{% call links.link(url_for('blog.post.calendars.home', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + calendars +{% endcall %} +{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + entries +{% endcall %} +{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + data +{% endcall %} +{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + edit +{% endcall %} +{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} + settings +{% endcall %} \ No newline at end of file diff --git a/templates/_types/post/admin/_nav_entries.html b/templates/_types/post/admin/_nav_entries.html new file mode 100644 index 0000000..8d9cbd9 --- /dev/null +++ b/templates/_types/post/admin/_nav_entries.html @@ -0,0 +1,66 @@ + + {# Left scroll arrow - desktop only #} + + + {# Entries and Calendars container #} +
    +
    + {# Associated Entries #} + {% if associated_entries and associated_entries.entries %} + {% include '_types/post/_entry_items.html' with context %} + {% endif %} + + {# Calendars #} + {% for calendar in calendars %} + {% set local_href=url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=calendar.slug) %} + {% set href=local_href|host %} + + +
    {{calendar.name}}
    +
    + {% endfor %} +
    +
    + + + + {# Right scroll arrow - desktop only #} + diff --git a/templates/_types/post/admin/_nav_entries_oob.html b/templates/_types/post/admin/_nav_entries_oob.html new file mode 100644 index 0000000..cc6d3b8 --- /dev/null +++ b/templates/_types/post/admin/_nav_entries_oob.html @@ -0,0 +1,14 @@ +{# OOB swap for nav entries and calendars when toggling associations or editing calendars #} +{% import 'macros/links.html' as links %} + +{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #} +{% if (associated_entries and associated_entries.entries) or calendars %} +
    + {% include '_types/post/admin/_nav_entries.html' %} +
    +{% else %} + {# Empty placeholder to remove nav items when all are disassociated/deleted #} +
    +{% endif %} diff --git a/templates/_types/post/admin/_oob_elements.html b/templates/_types/post/admin/_oob_elements.html new file mode 100644 index 0000000..4bd3b74 --- /dev/null +++ b/templates/_types/post/admin/_oob_elements.html @@ -0,0 +1,22 @@ +{% extends "oob_elements.html" %} +{# OOB elements for post admin page #} + +{# Import shared OOB macros #} +{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('post-header-child', 'post-admin-header-child', '_types/post/admin/header/_header.html')}} + + {% from '_types/post/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + +{% block mobile_menu %} + {% include '_types/post/admin/_nav.html' %} +{% endblock %} + +{% block content %} +nowt +{% endblock %} \ No newline at end of file diff --git a/templates/_types/post/admin/header/_header.html b/templates/_types/post/admin/header/_header.html new file mode 100644 index 0000000..2708e4f --- /dev/null +++ b/templates/_types/post/admin/header/_header.html @@ -0,0 +1,13 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='post-admin-row', oob=oob) %} + {% call links.link( + url_for('blog.post.admin.admin', slug=post.slug), + hx_select_search) %} + {{ links.admin() }} + {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/post/admin/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/post/admin/index.html b/templates/_types/post/admin/index.html new file mode 100644 index 0000000..fb1de5f --- /dev/null +++ b/templates/_types/post/admin/index.html @@ -0,0 +1,18 @@ +{% extends '_types/post/index.html' %} +{% import 'macros/layout.html' as layout %} + +{% block post_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %} + {% block post_admin_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/post/admin/_nav.html' %} +{% endblock %} + +{% block content %} +nowt +{% endblock %} diff --git a/templates/_types/post/header/_header.html b/templates/_types/post/header/_header.html new file mode 100644 index 0000000..16bdf45 --- /dev/null +++ b/templates/_types/post/header/_header.html @@ -0,0 +1,19 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='post-row', oob=oob) %} + {% call links.link(url_for('blog.post.post_detail', slug=post.slug), hx_select_search ) %} + {% if post.feature_image %} + + {% endif %} + + {{ post.title | truncate(160, True, '…') }} + + {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/post/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/post/index.html b/templates/_types/post/index.html new file mode 100644 index 0000000..56ed99c --- /dev/null +++ b/templates/_types/post/index.html @@ -0,0 +1,25 @@ +{% extends '_types/root/_index.html' %} +{% import 'macros/layout.html' as layout %} +{% block meta %} + {% include '_types/post/_meta.html' %} +{% endblock %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('post-header-child', '_types/post/header/_header.html') %} + {% block post_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/post/_nav.html' %} +{% endblock %} + + +{% block aside %} +{% endblock %} + +{% block content %} + {% include '_types/post/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/post_entries/_main_panel.html b/templates/_types/post_entries/_main_panel.html new file mode 100644 index 0000000..342041e --- /dev/null +++ b/templates/_types/post_entries/_main_panel.html @@ -0,0 +1,48 @@ +
    + + {# Associated Entries List #} + {% include '_types/post/admin/_associated_entries.html' %} + + {# Calendars Browser #} +
    +

    Browse Calendars

    + {% for calendar in all_calendars %} +
    + + {% if calendar.post.feature_image %} + {{ calendar.post.title }} + {% else %} +
    + {% endif %} +
    +
    + + {{ calendar.name }} +
    +
    + {{ calendar.post.title }} +
    +
    +
    +
    +
    Loading calendar...
    +
    +
    + {% else %} +
    No calendars found.
    + {% endfor %} +
    +
    \ No newline at end of file diff --git a/templates/_types/post_entries/_nav.html b/templates/_types/post_entries/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/post_entries/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/post_entries/_oob_elements.html b/templates/_types/post_entries/_oob_elements.html new file mode 100644 index 0000000..3ef5559 --- /dev/null +++ b/templates/_types/post_entries/_oob_elements.html @@ -0,0 +1,28 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('post-admin-header-child', 'post_entries-header-child', '_types/post_entries/header/_header.html')}} + + {% from '_types/post/admin/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/post_entries/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include "_types/post_entries/_main_panel.html" %} +{% endblock %} + + diff --git a/templates/_types/post_entries/header/_header.html b/templates/_types/post_entries/header/_header.html new file mode 100644 index 0000000..019c000 --- /dev/null +++ b/templates/_types/post_entries/header/_header.html @@ -0,0 +1,17 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='post_entries-row', oob=oob) %} + {% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %} + +
    + entries +
    + {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/post_entries/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} + + + diff --git a/templates/_types/post_entries/index.html b/templates/_types/post_entries/index.html new file mode 100644 index 0000000..382d297 --- /dev/null +++ b/templates/_types/post_entries/index.html @@ -0,0 +1,19 @@ +{% extends '_types/post/admin/index.html' %} + + + +{% block post_admin_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('post-admin-header-child', '_types/post_entries/header/_header.html') %} + {% block post_entries_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/post_entries/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/post_entries/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/root/settings/_main_panel.html b/templates/_types/root/settings/_main_panel.html new file mode 100644 index 0000000..9f4c9a8 --- /dev/null +++ b/templates/_types/root/settings/_main_panel.html @@ -0,0 +1,2 @@ +
    +
    diff --git a/templates/_types/root/settings/_nav.html b/templates/_types/root/settings/_nav.html new file mode 100644 index 0000000..f9d4420 --- /dev/null +++ b/templates/_types/root/settings/_nav.html @@ -0,0 +1,5 @@ +{% from 'macros/admin_nav.html' import admin_nav_item %} +{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours) }} +{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours) }} +{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours) }} +{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours) }} diff --git a/templates/_types/root/settings/_oob_elements.html b/templates/_types/root/settings/_oob_elements.html new file mode 100644 index 0000000..fbe1bf3 --- /dev/null +++ b/templates/_types/root/settings/_oob_elements.html @@ -0,0 +1,26 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/header/_oob_.html' import root_header with context %} + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-header-child', 'root-settings-header-child', '_types/root/settings/header/_header.html')}} + + {% from '_types/root/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} +{% include '_types/root/settings/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include '_types/root/settings/_main_panel.html' %} +{% endblock %} + diff --git a/templates/_types/root/settings/cache/_header.html b/templates/_types/root/settings/cache/_header.html new file mode 100644 index 0000000..64f8535 --- /dev/null +++ b/templates/_types/root/settings/cache/_header.html @@ -0,0 +1,9 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='cache-row', oob=oob) %} + {% from 'macros/admin_nav.html' import admin_nav_item %} + {{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours, aclass='') }} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/root/settings/cache/_main_panel.html b/templates/_types/root/settings/cache/_main_panel.html new file mode 100644 index 0000000..854012d --- /dev/null +++ b/templates/_types/root/settings/cache/_main_panel.html @@ -0,0 +1,14 @@ +
    +
    +
    + + +
    +
    +
    +
    diff --git a/templates/_types/root/settings/cache/_oob_elements.html b/templates/_types/root/settings/cache/_oob_elements.html new file mode 100644 index 0000000..5989bf7 --- /dev/null +++ b/templates/_types/root/settings/cache/_oob_elements.html @@ -0,0 +1,16 @@ +{% extends 'oob_elements.html' %} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-settings-header-child', 'cache-header-child', '_types/root/settings/cache/_header.html')}} + + {% from '_types/root/settings/header/_header.html' import header_row with context %} + {{header_row(oob=True)}} +{% endblock %} + +{% block mobile_menu %} +{% endblock %} + +{% block content %} + {% include '_types/root/settings/cache/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/root/settings/cache/index.html b/templates/_types/root/settings/cache/index.html new file mode 100644 index 0000000..05706f8 --- /dev/null +++ b/templates/_types/root/settings/cache/index.html @@ -0,0 +1,20 @@ +{% extends '_types/root/settings/index.html' %} + +{% block root_settings_header_child %} + {% from '_types/root/_n/macros.html' import header with context %} + {% call header() %} + {% from '_types/root/settings/cache/_header.html' import header_row with context %} + {{ header_row() }} +
    + {% block cache_header_child %} + {% endblock %} +
    + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/root/settings/cache/_main_panel.html' %} +{% endblock %} + +{% block _main_mobile_menu %} +{% endblock %} diff --git a/templates/_types/root/settings/header/_header.html b/templates/_types/root/settings/header/_header.html new file mode 100644 index 0000000..69e7c72 --- /dev/null +++ b/templates/_types/root/settings/header/_header.html @@ -0,0 +1,11 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='root-settings-row', oob=oob) %} + {% call links.link(url_for('settings.home'), hx_select_search) %} + {{ links.admin() }} + {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/root/settings/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/root/settings/index.html b/templates/_types/root/settings/index.html new file mode 100644 index 0000000..1773f3d --- /dev/null +++ b/templates/_types/root/settings/index.html @@ -0,0 +1,18 @@ +{% extends '_types/root/_index.html' %} +{% import 'macros/layout.html' as layout %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('root-settings-header-child', '_types/root/settings/header/_header.html') %} + {% block root_settings_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/root/settings/_nav.html' %} +{% endblock %} + +{% block content %} + {% include '_types/root/settings/_main_panel.html' %} +{% endblock %} \ No newline at end of file diff --git a/templates/_types/snippets/_list.html b/templates/_types/snippets/_list.html new file mode 100644 index 0000000..2b982ca --- /dev/null +++ b/templates/_types/snippets/_list.html @@ -0,0 +1,73 @@ +
    + {% if snippets %} +
    + {% for s in snippets %} +
    + {# Name #} +
    +
    {{ s.name }}
    +
    + {% if s.user_id == g.user.id %} + You + {% else %} + User #{{ s.user_id }} + {% endif %} +
    +
    + + {# Visibility badge #} + {% set badge_colours = { + 'private': 'bg-stone-200 text-stone-700', + 'shared': 'bg-blue-100 text-blue-700', + 'admin': 'bg-amber-100 text-amber-700', + } %} + + {{ s.visibility }} + + + {# Admin: inline visibility select #} + {% if is_admin %} + + {% endif %} + + {# Delete button #} + {% if s.user_id == g.user.id or is_admin %} + + {% endif %} +
    + {% endfor %} +
    + {% else %} +
    + +

    No snippets yet. Create one from the blog editor.

    +
    + {% endif %} +
    diff --git a/templates/_types/snippets/_main_panel.html b/templates/_types/snippets/_main_panel.html new file mode 100644 index 0000000..73b50b7 --- /dev/null +++ b/templates/_types/snippets/_main_panel.html @@ -0,0 +1,9 @@ +
    +
    +

    Snippets

    +
    + +
    + {% include '_types/snippets/_list.html' %} +
    +
    diff --git a/templates/_types/snippets/_oob_elements.html b/templates/_types/snippets/_oob_elements.html new file mode 100644 index 0000000..a1377cf --- /dev/null +++ b/templates/_types/snippets/_oob_elements.html @@ -0,0 +1,18 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{% block oobs %} + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-settings-header-child', 'snippets-header-child', '_types/snippets/header/_header.html')}} + + {% from '_types/root/settings/header/_header.html' import header_row with context %} + {{header_row(oob=True)}} +{% endblock %} + +{% block mobile_menu %} +{% endblock %} + +{% block content %} + {% include '_types/snippets/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/snippets/header/_header.html b/templates/_types/snippets/header/_header.html new file mode 100644 index 0000000..0882518 --- /dev/null +++ b/templates/_types/snippets/header/_header.html @@ -0,0 +1,9 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='snippets-row', oob=oob) %} + {% from 'macros/admin_nav.html' import admin_nav_item %} + {{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }} + {% call links.desktop_nav() %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/templates/_types/snippets/index.html b/templates/_types/snippets/index.html new file mode 100644 index 0000000..90f0106 --- /dev/null +++ b/templates/_types/snippets/index.html @@ -0,0 +1,20 @@ +{% extends '_types/root/settings/index.html' %} + +{% block root_settings_header_child %} + {% from '_types/root/_n/macros.html' import header with context %} + {% call header() %} + {% from '_types/snippets/header/_header.html' import header_row with context %} + {{ header_row() }} +
    + {% block snippets_header_child %} + {% endblock %} +
    + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/snippets/_main_panel.html' %} +{% endblock %} + +{% block _main_mobile_menu %} +{% endblock %}