Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
"""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")
|