fix #29 : envoyer le lien magique par email (envoyer_mail_smtp)

This commit is contained in:
Cedric Abonnel
2026-05-13 23:41:58 +02:00
commit 8a85c15372
129 changed files with 22818 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
# Architecture du cache — Moteur Folio
## Contexte et problème initial
Le moteur stocke chaque article dans un sous-répertoire `data/{uuid}/` contenant deux fichiers :
- `meta.json` — métadonnées (titre, slug, catégorie, cover, dates…)
- `index.md` — contenu Markdown
Avec 1 000+ articles, chaque vue de page déclenchait **3 appels à `getAll()`** (via `getBySlug()`, `getCategories()` et directement pour les articles liés), ce qui représentait ~6 000 lectures de fichiers par requête. La page mettait **+5 secondes** à charger.
---
## Les quatre niveaux de cache
### 1. Cache mémoire de requête — `$allCache` et `$searchIndexCache`
**Scope** : durée de vie d'une requête PHP (in-process).
`ArticleManager` mémoïse deux tableaux en propriétés privées :
- `$allCache` — résultat de `loadAll()` (tous les articles avec contenu)
- `$searchIndexCache` — contenu de `search_index.json`
```php
// Premier appel : scan disque + construction du tableau
$this->allCache = $this->loadAll();
// Appels suivants dans la même requête : tableau déjà en mémoire
return $this->allCache;
```
**Invalidation** : `writeMeta()` et `delete()` mettent les deux propriétés à `null`.
---
### 2. Cache disque par article — `_cache/articles/{uuid}.json`
**Scope** : persistant entre les requêtes, jusqu'à modification de l'article.
`loadArticle()` vérifie si le cache est plus récent que `meta.json` avant de lire les sources :
```
_cache/articles/{uuid}.json <-- filemtime >= meta.json ? → utiliser le cache
→ lire meta.json + index.md, écrire le cache
```
Le fichier cache contient toutes les données de l'article (métadonnées + contenu), ce qui réduit les lectures de **2 fichiers à 1** par article chargé.
**Invalidation** : `writeMeta()` supprime `_cache/articles/{uuid}.json` avant d'écrire le nouveau `meta.json`. `delete()` le supprime aussi.
---
### 3. Index slug → UUID — `_cache/slug_index.json`
**Scope** : persistant, mis à jour incrémentalement.
Permet à `getBySlug()` de trouver un article en **O(1)** (lecture de l'index + lecture du cache article) au lieu de parcourir tous les articles.
```
slug_index.json : {"mon-article": "uuid-xxxx", "autre-article": "uuid-yyyy", ...}
```
**Construction** : à la première utilisation, `buildSlugIndex()` lit le `search_index.json` (un seul fichier) pour construire la correspondance. Si le search_index n'existe pas encore, il tombe en repli sur `loadAll()`.
**Invalidation** : `writeMeta()` supprime le fichier (reconstruction automatique à la prochaine requête). `delete()` fait de même.
> Suppression plutôt que mise à jour incrémentale : la reconstruction depuis `search_index.json` est quasi instantanée (lecture d'un seul fichier JSON), donc il n'y a pas d'intérêt à maintenir des mises à jour partielles.
---
### 4. Index de recherche — `search_index.json`
**Scope** : persistant, reconstruit après chaque modification d'article.
Fichier JSON plat contenant un tableau de tous les articles avec leurs champs essentiels et leur texte brut pré-calculé (`plain`, sans syntaxe Markdown). Utilisé pour :
- La recherche plein-texte (`SearchEngine`)
- La liste des articles publiés pour les articles liés/similaires (évite `getAll()`)
- Les catégories (`getCategories()`)
- La construction du slug index
**Champs stockés** :
```json
{
"uuid": "...",
"slug": "...",
"title": "...",
"category": "...",
"author": "...",
"cover": "...",
"published": true,
"published_at": "2026-01-15 10:00:00",
"created_at": "2026-01-14 09:30:00",
"updated_at": "2026-01-15 10:00:00",
"plain": "texte brut de l'article sans markdown..."
}
```
**Rebuild automatique** : si le fichier ne contient pas le champ `cover` (format antérieur à la v2 du cache), `getSearchIndex()` déclenche automatiquement un rebuild.
**Invalidation** : `rebuildSearchIndex()` (appelé par `create()`, `update()`, `delete()`).
---
## Chemin d'une requête de vue d'article après optimisation
```
GET /post/{slug}
├── getBySlug(slug)
│ ├── Lire slug_index.json [1 lecture]
│ ├── → UUID trouvé
│ └── getByUuid(uuid)
│ └── Lire _cache/articles/{uuid}.json [1 lecture]
├── getCategories()
│ └── getSearchIndex() [lecture de search_index.json, mise en cache mémoire]
├── $_allPublished (articles liés + similaires)
│ └── getSearchIndex() [déjà en cache mémoire → 0 lecture]
├── scorePool(mots_du_titre, $_allPublished)
│ └── Tokenisation unique par article, pas de re-calcul par mot
└── getBacklinks(slug)
└── Lire _cache/backlinks.json [1 lecture]
Total : ~4 lectures de fichiers, indépendamment du nombre total d'articles.
```
Avant optimisation (1 062 articles) : ~6 300 lectures de fichiers.
---
## Performances mesurées
| Scénario | Avant | Après |
|---|---|---|
| Cold cache (aucun cache disque) | +5 s | ~0,6 s |
| Warm cache (cache disque présent) | +5 s | ~0,4 s |
---
## Scalabilité
| Volume d'articles | Lectures de fichiers par vue (après) |
|---|---|
| 1 000 | ~4 |
| 100 000 | ~4 |
| 500 000 | ~4 |
Le nombre de lectures est **constant** : le chemin de vue ne dépend plus du nombre total d'articles, seulement de la présence des fichiers de cache.
La seule opération encore en O(N) est `rebuildSearchIndex()`, mais elle n'est déclenchée que sur écriture (création, modification, suppression d'article), jamais sur lecture.
---
## Invalidation — résumé
| Événement | Caches invalidés |
|---|---|
| `writeMeta()` (toute écriture d'article) | `$allCache`, `$searchIndexCache`, cache article (`{uuid}.json`), slug index |
| `delete()` | idem + suppression physique du cache article |
| `rebuildSearchIndex()` | `$searchIndexCache` (remplacé par les nouvelles données) |
---
## Maintenance
### Vider manuellement les caches disque
En cas de besoin (migration, incohérence) :
```bash
ssh varlog "sudo rm -rf /var/www/lan.acegrp.varlog/data/_cache/articles/"
ssh varlog "sudo rm /var/www/lan.acegrp.varlog/data/_cache/slug_index.json"
```
Les caches se reconstruisent automatiquement à la première requête suivante.
### Forcer un rebuild du search_index
Modifier et sauvegarder n'importe quel article depuis l'interface admin déclenche un rebuild complet. Il n'existe pas de commande CLI dédiée pour l'instant.