"""Publication de pages — A29 : une seule implémentation, les routers déléguent. Les trois surfaces divergeaient avant cette passe : - `/api/pages/{id}/publish` (sharing, consommateur principal — le front) : slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau - `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE (pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session - `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire, `is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage) Canonical (comportement du front) : `is_published` + `published` + `publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 — avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne regardait qu'une colonne, donc l'état publié ne « tenait » nulle part). `share_mode`/`is_shared` restent la propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel. Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques issues d'un seul chemin). """ from __future__ import annotations import logging import re import secrets import unicodedata from fastapi import HTTPException from app.db import get_conn from app.services.automations import fire_event logger = logging.getLogger(__name__) def slugify(title: str) -> str: """URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD).""" slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii") slug = re.sub(r"[^\w\s-]", "", slug.lower()) slug = re.sub(r"[-\s]+", "-", slug).strip("-") return slug def _unique_slug(conn, page_id: int, title: str) -> str: """Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire.""" base = slugify(title) or f"p-{secrets.token_urlsafe(8)}" slug, counter = base, 1 while conn.execute( "SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id) ).fetchone(): slug = f"{base}-{counter}" counter += 1 return slug def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]: """Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas.""" with get_conn() as conn: page = conn.execute( "SELECT id, title FROM pages WHERE id=?", (page_id,) ).fetchone() if not page: raise HTTPException(404, "Page not found") slug = explicit_slug or _unique_slug(conn, page_id, page["title"]) # v7.69.6 : les deux drapeaux sont maintenus ensemble — le share # dialog écrit ``published``, les routes /publish écrivaient seulement # ``is_published``, et chaque lecteur ne regardait qu'une colonne : # l'état publié ne « tenait » nulle part. conn.execute( "UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id) ) conn.commit() return slug, page["title"] or "" def unpublish(page_id: int) -> None: """Dépublie : 404 si absente, sinon `is_published=0` + slug vidé.""" with get_conn() as conn: if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): raise HTTPException(404, "Page not found") conn.execute( "UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,) ) conn.commit() async def fire_published(page_id: int, slug: str) -> None: """Événement `page.published` — l'échec d'eventing n'échoue jamais la route.""" try: await fire_event("page.published", {"page_id": page_id, "slug": slug}) except Exception: logger.exception("publish page.published") async def fire_unpublished(page_id: int) -> None: try: await fire_event("page.unpublished", {"page_id": page_id}) except Exception: logger.exception("publish page.unpublished")