Aller au contenu

Contenu de travail, contenu publié, révisions et corbeille

Cette page explique pourquoi Kirexo sépare le contenu qu'un auteur édite de celui que voient ses lecteurs, comment l'historique de publication est conservé, comment les onglets restent synchronisés, et pourquoi la corbeille se purge au bout de 30 jours. Pour le mode opératoire côté navigateur, voir le guide Écrire, enregistrer et publier un article.

Deux contenus pour un même article

Un article Kirexo porte en réalité deux contenus distincts :

  • le contenu de travail — le markdown que l'auteur édite en continu, stocké dans le champ content de l'entité Article (src/Entity/Article.php) ;
  • le contenu publié — une copie figée de ce qu'ont vu les lecteurs à un instant donné, conservée dans une entité séparée, ArticleRevision (src/Entity/ArticleRevision.php).

L'autosave (déclenché à chaque pause de frappe ou au blur) écrit uniquement dans le contenu de travail. Tant que l'auteur ne publie pas, ses lecteurs ne voient rien changer : on peut donc enregistrer en permanence, sans risque de diffuser une phrase à moitié écrite.

Pourquoi cette séparation plutôt qu'un seul champ « contenu » ? Parce qu'écrire et publier sont deux gestes différents. L'écriture est continue, brouillonne, et doit être sauvegardée souvent pour ne rien perdre. La publication est un acte délibéré, qui engage : ce qui est publié doit rester stable jusqu'à la prochaine décision de l'auteur, indépendamment de ses retouches en cours.

Les révisions sont immuables

À chaque publication ou republication, Kirexo crée une ArticleRevision : un snapshot du titre et du contenu au moment de l'acte. Cette entité n'a aucun setter — tout est posé au constructeur et ne change plus jamais. Une révision publiée ne « dérive » donc pas quand l'auteur continue d'éditer son contenu de travail.

L'Article garde un pointeur vers sa révision courante (currentRevision) et la collection de toutes ses révisions (revisions). De là découle l'indicateur hasUnpublishedChanges() : il compare le contenu de travail à celui de la révision courante. S'ils diffèrent, c'est qu'il y a des modifications non publiées — ce qui justifie l'apparition du bouton « Publier les modifications » dans l'éditeur. Un brouillon jamais publié n'a pas de révision courante : il n'affiche donc pas cet indicateur.

La distinction publier / republier suit cette logique :

  • publier fait entrer l'article dans l'état Published et fixe sa date de publication ;
  • republier crée une nouvelle révision à partir du contenu de travail courant sans toucher à la date de première publication.

Le cycle de vie est un workflow

Les transitions d'un article (publish, republish, unpublish, trash, restore) ne sont pas codées en dur dans les controllers : elles sont décrites par un workflow Symfony de type state_machine (config/packages/workflow.yaml), dont les états sont l'énumération ArticleStatus (src/Entity/Enum/ArticleStatus.php) : Draft, Published, Unpublished, Trashed.

Le service ArticlePublisher (src/Service/Article/ArticlePublisher.php) est le point d'entrée unique de ces transitions. Il délègue au workflow, qui sert de garde-fou : une transition interdite (par exemple publier un article en corbeille) lève une exception native plutôt que d'aboutir à un état incohérent. Deux invariants métier sont posés par le service au passage :

  • trash enregistre la date d'entrée en corbeille (trashedAt) — point de départ du compte à rebours de purge ;
  • unpublish exige une URL de redirection, posée sur l'article avant la transition, pour ne jamais laisser un lien publié pointer dans le vide.

Synchronisation temps réel multi-onglets

Un même auteur peut ouvrir son article dans plusieurs onglets ou sur plusieurs appareils. Pour éviter qu'un onglet n'écrase silencieusement le travail d'un autre, Kirexo synchronise le contenu de travail en temps réel via Mercure.

Le hub Mercure est embarqué dans FrankenPHP

Kirexo n'a pas de serveur de synchronisation séparé : le hub Mercure est hébergé directement par le worker FrankenPHP (la directive mercure du frankenphp/Caddyfile), sur le même process que l'application. C'est un bénéfice direct du worker mode déjà décrit dans l'architecture : pas de brique d'infrastructure supplémentaire à déployer, une latence minimale, et une seule clé de signature partagée entre l'application (qui publie) et le hub (qui distribue).

Un canal privé par article

Chaque article a son propre topic privé, dont l'IRI a la forme article/{id}. La forme de cette IRI est centralisée dans ArticleUpdateNotifier (src/Service/Article/ArticleUpdateNotifier.php) — un seul endroit la connaît, partagé entre l'émission de l'autorisation et la publication.

Le flux est le suivant :

  1. À l'ouverture de l'éditeur (AppArticleEditController, src/Controller/AppArticleEditController.php), le serveur pose un cookie JWT qui autorise ce navigateur à s'abonner au topic de cet article — et à lui seul. La publication, elle, reste toujours côté serveur.
  2. À chaque autosave (AppArticleAutosaveController, src/Controller/AppArticleAutosaveController.php), ArticleUpdateNotifier::notifyUpdated() publie un update privé sur ce topic, contenant le nouveau contenu.
  3. Les autres onglets, abonnés via leur EventSource (cookie JWT envoyé en withCredentials), reçoivent l'update et se mettent à jour — sauf l'onglet en cours de saisie, qu'on ne réécrit jamais sous les doigts de l'auteur.

Le topic est privé : sans le cookie JWT émis pour cet article précis, aucun client ne peut écouter le canal. Un brouillon en cours ne fuite pas.

La synchronisation est best-effort

La publication sur le hub est best-effort : elle ne conditionne jamais la sauvegarde. L'autosave persiste d'abord le contenu de travail, puis tente de notifier les autres onglets. Si le hub est indisponible (panne, ou en développement un certificat TLS non approuvé), ArticleUpdateNotifier (src/Service/Article/ArticleUpdateNotifier.php) journalise un avertissement et poursuit — la sauvegarde a déjà réussi côté serveur, seul le rafraîchissement temps réel des autres onglets est perdu. Aucune frappe n'est jamais sacrifiée à un problème de synchronisation.

La reprise hors-ligne

L'autosave côté navigateur tolère les coupures réseau. Quand la connexion est absente, la sauvegarde n'est pas tentée : le dernier contenu est mis en attente (les états intermédiaires périmés sont écartés — une sauvegarde obsolète n'a aucun intérêt) et l'auteur est prévenu. Au retour en ligne, la sauvegarde en attente est rejouée automatiquement. En cas d'échec réseau ponctuel, le navigateur retente avec un délai croissant jusqu'à réussir, de sorte qu'aucune frappe n'est perdue dès lors que la connexion finit par revenir.

La purge de la corbeille à 30 jours

Mettre un article à la corbeille ne le supprime pas tout de suite : il bascule dans l'état Trashed et reçoit une date d'entrée en corbeille (trashedAt). Ce délai laisse à l'auteur le temps de revenir sur sa décision — la transition restore le ramène en brouillon et réinitialise ce compteur.

Mais une corbeille qui ne se vide jamais finit par accumuler du contenu mort indéfiniment. Kirexo tranche en faveur d'une purge automatique à 30 jours : tout article resté en corbeille au-delà de ce seuil est définitivement supprimé, lui et tout son historique de révisions (la clé étrangère article_revision.article_id est en ON DELETE CASCADE).

Cette purge est portée par une tâche planifiée : la commande app:articles:purge-trashed (src/Command/PurgeTrashedArticlesCommand.php) porte l'attribut #[AsPeriodicTask(frequency: '1 day')] du composant Symfony Scheduler, qui la déclenche une fois par jour. Plutôt qu'un planificateur système externe (cron) à configurer sur le serveur, la planification vit dans le code, versionnée avec lui — la même commande reste exécutable à la main au besoin. Le détail d'exécution est documenté dans la Référence de app:articles:purge-trashed.

Voir aussi