# CLAUDE.md — Contexte projet MaBibli

Ce fichier donne le contexte complet du projet à Claude Code. Lis-le en entier avant de commencer à coder.

Voir aussi **`IDEES.md`** : améliorations identifiées mais **non encore actées**. Rien n'y fait autorité — ce fichier-ci reste la référence.

## Objectif du projet

Application self-hosted de gestion de bibliothèque personnelle, à héberger sur YunoHost, accessible depuis smartphone et PC.

## Fonctionnalités attendues (v1)

1. **Catalogue de livres physiques** — liste, ajout, édition, suppression
2. **Catalogue de livres numériques (ebooks)** — même chose, avec un champ format distinct du physique
3. **Gestion de prêts**
   - Prêter un livre à une personne (nom, date de prêt)
   - Marquer comme récupéré (date de retour)
   - Historique des prêts passés par livre (pas juste l'état courant)
4. **Récupération automatique des infos via ISBN**
   - Scan caméra du code-barres (EAN-13 / ISBN)
   - Saisie manuelle de l'ISBN
   - Dans les deux cas : appel à une ou plusieurs API pour pré-remplir titre, auteur, éditeur, couverture
5. **Statuts de lecture** — à lire / en cours / lu (au minimum), assignable à chaque livre

## Décisions techniques actées

| Sujet | Décision | Pourquoi |
|---|---|---|
| Langage backend | C# / ASP.NET Core | Choix de l'utilisateur, typage fort |
| Frontend | Blazor WebAssembly | Support PWA quasi natif (`dotnet new blazorwasm --pwa`), tout en C#, offline partiel |
| Base de données | SQLite + Entity Framework Core | Fichier unique, pas de serveur DB séparé, adapté à un usage perso/familial |
| Auth / multi-utilisateur | SSO YunoHost via **en-têtes SSOwat** (`YNH_USER`) | Pas de login custom. OIDC **écarté** : non documenté par YunoHost (vérifié le 2026-08-17). Voir « Intégration SSO » |
| Portée des données | **Collection commune** à tous les utilisateurs, avec traçabilité de qui a ajouté chaque livre | Usage familial : une bibliothèque de foyer, pas des collections étanches. Laisse la possibilité de cloisonner plus tard sans migration lourde |
| Statut de lecture | **Par utilisateur**, pas commun (décidé le 2026-08-17, après la phase 3) | Le livre est commun, sa lecture est personnelle : deux membres du foyer lisent le même exemplaire à des rythmes différents. Sort `Statut` de `Livre` vers une table dédiée |
| Prêts | **Communs au foyer**, jamais filtrés par utilisateur ; un seul prêt ouvert par livre, garanti par index unique partiel | Un livre absent l'est pour tout le monde, et n'importe qui doit pouvoir noter son retour. Voir « Prêts » |
| Auteurs | **Table dédiée** avec nom normalisé, remplaçant le champ texte libre | Nécessaire au regroupement par auteur et à la recherche insensible aux accents. Regroupement **automatique seulement quand c'est sûr**, sinon proposé à l'utilisateur |
| Liste d'envies | **Table dédiée `LivreSouhaite`**, personnelle (par `YNH_USER`), jamais dans `Livres` | Un livre souhaité n'est pas possédé. Dans `Livres`, il entrerait dans le catalogue, les compteurs et les prêts, et il faudrait répéter « et qui n'est pas souhaité » à chaque lecture. Voir « Liste d'envies » |
| Bibliographie par auteur | **SRU BnF, index `bib.author`**, avec post-filtre obligatoire sur l'auteur réel de la notice | Vérifié le 2026-08-18. `all` rapproche les mots sur l'ensemble des auteurs d'une notice : sans post-filtre, « Émile Zola » remonte l'œuvre de sa fille. Voir « Bibliographie par auteur » |
| Export de la liste d'envies | **`.txt` et `.csv`**, produits côté serveur, sans dépendance | Les deux usages d'IDEES.md diffèrent : le texte s'emporte en librairie, le CSV s'ouvre dans un tableur. Voir « Export » |
| Ebooks | **Fiches uniquement**, pas de stockage de fichiers | Inventaire, pas hébergement. Évite l'espace disque YunoHost, les sauvegardes lourdes, et garde le cache hors-ligne léger |
| Architecture serveur | **x86_64** → publish `linux-x64` | Serveur PC/VPS confirmé par l'utilisateur |
| Production des binaires | **Compilation locale + release manuelle**, via un script réutilisable en CI plus tard | Ne pas se bloquer sur l'outillage ; Gitea Actions nécessiterait un runner, non vérifié |
| Cache hors-ligne | **IndexedDB** (pas le cache du service worker) | Seule option permettant recherche et tri hors-ligne sur toute la bibliothèque, et l'affichage de la date de dernière synchro |
| Notices BnF multiples | **Demander systématiquement** à l'utilisateur | Exactitude de l'édition privilégiée sur la vitesse de saisie en série |
| AOT WebAssembly | **Désactivé par défaut**, à réévaluer après mesure | Le mode interprété devrait suffire (~5-10 ms/frame estimés) ; ne pas payer le coût de build avant d'avoir constaté un problème |
| Hébergement | YunoHost, installation **native** (pas Docker) | YunoHost déconseille Docker pour ses apps (moins fiable, plus lourd) ; installation native = meilleures perfs sur petit matériel |
| Packaging YunoHost | S'inspirer de [`radarr_ynh`](https://github.com/YunoHost-Apps/radarr_ynh) | Radarr est aussi en .NET, packagé sans Docker sur YunoHost. Leur `manifest.toml` montre un déploiement **self-contained** (`dotnet publish -r linux-x64 --self-contained`), donc pas besoin d'installer `dotnet-runtime` via apt côté serveur — le binaire embarque son propre runtime |
| Scan ISBN | **zbar** (LGPL-2.1) compilé en WebAssembly, via les assets du paquet `ZBar.Blazor` ; notre JS garde la caméra | ZXing.Net **ne lisait pas** des codes-barres que zbar lit sur le même livre (2026-08-19). Plus léger de surcroît. Voir la section dédiée ci-dessous |
| Consultation hors-ligne | **Instantanés JSON en IndexedDB**, lecture seule, implémenté le 2026-08-18 | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » |

## Scan du code-barres — zbar (décision actée le 2026-08-19)

Le décodage EAN-13 se fait par **[zbar](https://github.com/mchehab/zbar) compilé en
WebAssembly**, appelé depuis `wwwroot/js/scanner-camera.js`. Les assets viennent du paquet
NuGet [`ZBar.Blazor`](https://www.nuget.org/packages/ZBar.Blazor) (LGPL-2.1).

### Pourquoi ZXing.Net a été abandonné

**Constat d'usage** : l'ISBN `9782846391009` n'était pas décodé par l'application, alors qu'il
l'était par une application d'essai utilisant zbar, **sur le même livre et le même appareil**.

⚠️ **Le raisonnement qui avait écarté cette piste était faux, et la faute est instructive.**
Ce fichier concluait que « le décodeur n'y est pour rien » à partir de mesures de **vitesse**
(4-6 ms/frame en WASM interprété). Or **la vitesse ne dit rien du taux de réussite** : un
décodeur peut être rapide et rater. Les mesures ne pouvaient pas soutenir cette conclusion.

Ne jamais reconduire ce raccourci : pour juger un décodeur, il faut mesurer **ce qu'il lit**,
pas ce qu'il coûte.

### Ce qui est établi, et ce qui ne l'est pas

**Établi** : zbar lit ce code-barres, ZXing.Net ne le lit pas, dans les mêmes conditions.

**Non établi** : que le moteur soit *seul* en cause. L'ancienne implémentation ne faisait pas que
décoder, elle **recadrait** sur la bande centrale (45 % de la hauteur) après réduction à 640 px,
pour limiter ce qui traversait le pont JS→C#. Ce recadrage était la seconde cause possible.
La bascule l'a supprimé — l'image entière est désormais analysée, à 960 px de large — donc la
question ne se pose plus.

### Ce que la bascule change dans le code

| | Avant (ZXing.Net) | Après (zbar) |
|---|---|---|
| Décodage | C#, `IsbnScanner.TryDecode` | JS, `window.zbar.scanImageData` |
| Pixels sur le pont JS→C# | **un `byte[]` par frame** | aucun — seule la valeur décodée passe |
| Zone analysée | bande centrale, 640 px | image entière, 960 px |
| Caméra | `scanner-camera.js` | `scanner-camera.js`, **inchangé** |

`IsbnScanner.cs`, `BancEssaiScan.cs` et `IsbnScannerTests.cs` ont disparu.

⚠️ **Coût assumé : six tests de décodage en moins.** Ils s'exécutaient en C# sur des images
générées ; le décodeur étant maintenant en JS, ils n'ont plus d'équivalent dans xUnit. Le
décodage a été vérifié dans le navigateur, sur un EAN-13 rendu en canvas — `9782846391009`
ressort bien en `ZBAR_EAN13`. C'est une vérification, pas un garde-fou permanent.

### ⚠️ On n'utilise PAS le composant `ZBarCamera` du paquet — et il ne faut pas y revenir

Son `camera.js` ouvre la caméra lui-même et **avale les erreurs** :

```js
navigator.mediaDevices.getUserMedia(constraints).then(...).catch(function (error) {
    console.log(error);   // et c'est tout
});
```

On perdrait d'un coup :

- les messages qui distinguent **permission refusée**, **aucune caméra**, **caméra occupée** et
  **contexte non sécurisé** — ce que ce fichier documente comme durement acquis (une
  `DOMException` perd son `name` en traversant le pont, d'où les codes de statut) ;
- `facingMode: { ideal: 'environment' }`, donc la **caméra arrière** sur téléphone : le composant
  demande `{ video: true }`, c'est-à-dire la caméra frontale par défaut ;
- l'indication de résolution `1280×720`.

Le paquet est donc référencé **pour ses assets statiques seulement** (`zbar.js`, `zbar.wasm`).
Vérifié après bascule : caméra refusée → « L'accès à la caméra a été refusé… », message intact.

### `zbar.wasm` est chargé à la demande, pas au démarrage

`scanner-camera.js` injecte `_content/ZBar.Blazor/zbar.js` **à la première ouverture du
scanner**. Personne ne télécharge 139 Ko pour consulter sa bibliothèque.

⚠️ `zbar.js` **n'est pas un module ES** : il pose `window.zbar`. D'où l'injection d'une balise
`<script>` plutôt qu'un `import()`.

### Poids : mesuré par comparaison de deux publish complets

Somme brotli, `MaBibli.Api` publié en `Release`, avant et après :

| | ZXing.Net | zbar |
|---|---|---|
| `_framework` (téléchargé au démarrage) | 3 281 558 o | **2 978 700 o** |
| `_content` (à la demande) | 0 | 146 841 o |
| **Total** | 3 281 558 o | **3 125 541 o** |

**−152 Kio au total, et −296 Kio au démarrage.** L'écart ne vient pas du moteur mais de la
**traîne** que ZXing.Net imposait au trimmer :

| Assembly | ZXing.Net | zbar |
|---|---|---|
| `zxing.wasm` | 192 495 o | absent |
| `System.Text.RegularExpressions` | 98 374 o | **7 137 o** |
| `System.Runtime.Numerics` | 30 701 o | absent |
| `ZBar.Blazor` (assembly .NET, inutilisé mais embarqué) | absent | 21 232 o |

`System.Text.RegularExpressions` retombe à sa taille trimmée parce que plus rien n'utilise
`Regex` : c'est ZXing qui l'imposait. ⚠️ L'assembly `ZBar.Blazor` est embarqué bien qu'aucun de
ses types ne soit utilisé — 21 Ko dont on se passerait, mais le paquet reste la manière propre
d'obtenir `zbar.wasm` et sa licence.

**Leçon confirmée, dans les deux sens** : le coût d'une bibliothèque n'est pas le poids de son
assembly. Toujours comparer deux publish.

### L'add-on EAN-2 — désactivé par défaut, et c'est le piège (2026-08-19)

Le code-barres d'un périodique porte, à côté du code principal, un petit code à deux chiffres :
l'**EAN-2**, qui est normativement le **numéro de parution**. C'est le candidat naturel pour
pré-remplir le numéro d'une revue.

⚠️ **Il ne suffit pas de cesser de le filtrer : zbar ne le produit pas.** Mesuré le 2026-08-19,
sur un EAN-13 `9772466671438` et un EAN-2 `43` rendus côte à côte dans un canvas :

| Scanner | Symboles lus |
|---|---|
| par défaut | `ZBAR_EAN13=9772466671438` |
| après `setConfig(ZBAR_EAN2, ZBAR_CFG_ENABLE, 1)` | `ZBAR_EAN2=43` **et** `ZBAR_EAN13=9772466671438` |

La ligne de configuration est donc indispensable, et ce fichier affirmait le contraire avant
mesure — « `decoder()` les écarte » laissait croire qu'ils arrivaient.

**L'EAN-5 reste désactivé** : il porte un **prix**. Le confondre avec un numéro créerait des
numéros « 00595 » que personne ne comprendrait. Le filtre du `decoder()` le mentionne quand même,
par prudence.

⚠️ **Ce qui reste non vérifié, et qui est dit à l'utilisateur** : que les magazines réels portent
bien leur numéro de parution dans cet add-on. La chaîne de décodage est prouvée, l'usage
éditorial ne l'est pas — cela demande un exemplaire devant la caméra. Le numéro lu est donc
**proposé** dans un champ modifiable, sous un message qui invite à le comparer à la couverture
(« Vérifiez-le sur la couverture »), et jamais enregistré tout seul.

⚠️ Un add-on lu ne doit pas **déteindre** sur la saisie suivante : `_parutionLue` est remis à zéro
à chaque saisie manuelle et à chaque ouverture du scanner. Sans cela, le numéro d'un magazine
serait proposé pour le suivant.

### Ce qui reste vrai de l'ancienne section

- Le scan caméra exige **HTTPS** (garanti par YunoHost ; `localhost` est considéré comme sûr).
  Tester depuis un téléphone en pointant l'IP locale du PC (`http://192.168.x.x`) **échouera
  toujours** — ce n'est pas un contexte sécurisé, et le composant le dit.
- **Une `DOMException` perd son `name` en traversant le pont JS→C#.** Le JS doit renvoyer un
  code de statut ; parser le message côté C# ne marche pas.
- **L'import du module se fait sur un chemin nu** (`./js/scanner-camera.js`), les empreintes WASM
  étant désactivées (voir « Empreintes WASM désactivées »).

### Ce que zbar apporte en plus

- Il remonte le **format** du symbole (`ZBAR_EAN13`, `ZBAR_ISBN13`, `ZBAR_EAN5`…), là où nous
  déduisons le type du préfixe.
- Il sait décoder les **add-ons EAN-2 (numéro de parution) et EAN-5 (prix)** imprimés à côté du
  code principal. ⚠️ **Mais ils sont DÉSACTIVÉS par défaut** — voir « L'add-on EAN-2 » ci-dessous.

## Saisie d'un code-barres — décisions actées le 2026-08-18 (2ᵉ série)

### La douchette USB est le vrai remède au scan raté sur PC

Constaté en usage : sur PC, la webcam décode mal l'EAN-13. Le décodeur n'y est pour rien
(4-6 ms/frame mesurés) — c'est l'optique : mise au point fixe, cadrage large, peu de lumière,
là où un capteur de téléphone fait le point à 10 cm.

**Une douchette USB se présente au système comme un clavier** : elle « tape » les chiffres puis
`Entrée`. Elle ne demande donc **aucune permission, aucun HTTPS, aucun décodage** — rien de ce
qui rend le scan caméra fragile. Tout ce qu'elle exige du code tient en deux points, et les deux
sont maintenant tenus par `AjoutIsbn.razor` :

| Exigence | Mise en œuvre |
|---|---|
| Le champ ISBN est **focalisé à l'ouverture** | `FocusAsync` dans `OnAfterRenderAsync`, une seule fois par entrée dans l'étape de saisie |
| Le champ **valide sur `Entrée`** | déjà présent (`SurTouche`) |

⚠️ **Ne jamais déclencher la recherche à chaque frappe.** Une douchette tape plus vite qu'un
humain : chaque caractère partirait en requête réseau. Le champ se lie bien sur `oninput`, mais
seul `Entrée` (ou le bouton) lance le lookup.

⚠️ **Ne pas reprendre le focus à chaque rendu** : le drapeau `_focusAFaire` existe pour ça.
Sans lui, le curseur serait arraché à l'utilisateur en pleine frappe manuelle.

Le préfixe/suffixe que certaines douchettes ajoutent ne demande aucun traitement :
`IsbnUtils.Normaliser` ne garde que les chiffres et le `X` final.

Pistes **non retenues pour l'instant**, si la douchette ne suffisait pas : choix de la caméra,
résolution plus haute, zoom/torche, dépôt d'une photo à décoder.

### Après un scan réussi, la recherche s'enchaîne — et ça doit se voir

**Décidé : enchaînement automatique**, pas de bouton de confirmation. Le champ garde le code lu,
donc un décodage erroné se corrige et se relance ; et c'est le seul comportement cohérent avec la
douchette, qui envoie `Entrée` d'elle-même.

⚠️ Le défaut d'origine n'était pas l'absence d'enchaînement — il existait déjà — mais son
**invisibilité** : l'écran retombait sur la saisie avec un bouton « Chercher » intact, et l'on
croyait que le scan n'avait rien déclenché. D'où une étape `Recherche` à part entière, qui
affiche le code interrogé. Ne pas la supprimer en la prenant pour du décor.

### Le préfixe `977` est un magazine, pas un livre — et on le dit

Un EAN-13 en `977` désigne une **publication en série** : il porte un **ISSN**, donc un *titre de
revue*, là où un livre porte `978`/`979`. Aucune recherche `bib.isbn` ne peut aboutir. Avant
correction, `9772466671438` traversait toute la cascade pour finir sur « aucun résultat » — ce
qui envoie chercher un défaut là où il n'y en a pas.

⚠️ **L'interception se fait avant la validation ISBN**, dans `IsbnLookupService` : un code de
périodique est un EAN-13 **parfaitement valide** (`EstIsbn13Valide` le confirme), seul son
préfixe le trahit. Un test le verrouille : ni la BnF ni OpenLibrary ne sont interrogées sur
l'ISBN.

Structure du code, vérifiée le 2026-08-18 :

```
977 | 2466671 | 43 | 8
     └ ISSN sans sa clé   └ parution   └ clé EAN-13
```

La clé de l'ISSN **n'est pas dans le code-barres**, elle se recalcule (somme pondérée 8→2,
modulo 11, `X` si 10) — même principe que la clé ISBN-10, sur sept chiffres. `CodePeriodique`
fait les deux.

**Décidé : on nomme la revue** plutôt que de se contenter d'un message générique. La BnF indexe
l'ISSN sous `bib.issn`, sans clé, avec le tiret :

```
bib.issn all "2466-6718"  → 1 notice
  dc:title = Médor (Bruxelles)          ⚠ le parenthésé est un qualificatif de
  dc:title = Médor : les yeux ouverts…    désambiguïsation, pas une ville — il se retire
  dc:date  = 2015                         (NettoyageIsbd.TitrePeriodique)
```

Deux points à ne pas « corriger » :

- **Une notice de revue porte plusieurs `dc:title`.** Le premier est le titre clé, le second
  l'accroche commerciale avec sa mention de responsabilité. C'est le **premier** qu'on retient.
- **Une BnF muette ou injoignable n'est pas un échec** : l'ISSN se déduit du code-barres, on le
  donne quand même. Retomber sur « aucun résultat » serait reproduire le bug d'origine.

**Ce qui n'est délibérément pas fait** : cataloguer la revue. Le modèle de données n'a ni numéro
ni date de parution, et les deux chiffres de parution du code ne sont pas exploitables comme
numéro fiable — douze numéros d'un même magazine partagent leur ISSN et créeraient douze fiches
identiques. L'écran bascule donc sur la saisie manuelle avec le **titre** pré-rempli, et
**surtout pas l'ISBN** : ranger un code de périodique dans `Livre.Isbn` ferait échouer tout
lookup ultérieur sur cette fiche.

### Les ISBN s'affichent avec des tirets — table officielle, français exact

`9782846391009` → `978-2-84639-100-9`. **Purement de l'affichage** : la valeur stockée reste nue,
et les tirets ne franchissent jamais la frontière de la base ni celle des requêtes aux sources —
la BnF indexe l'ISBN **sans** tiret. Le seul point d'entrée est `FormatageIsbn.Afficher` ; un test
vérifie que `IsbnUtils.Normaliser` défait exactement ce que `Afficher` a posé.

Les tranches ne se devinent pas : elles dépendent du groupe linguistique **puis** de l'éditeur, et
sont publiées par l'agence internationale. Les tables embarquées sont extraites du
[`RangeMessage.xml`](https://www.isbn-international.org/export_rangemessage.xml) officiel, version
du 2026-08-18 — **ne pas les réécrire de mémoire**, plusieurs tranches ne sont pas celles qu'on
suppose (`8400000-8999999` vaut 5 chiffres d'éditeur, pas 6, et c'est précisément la tranche du
cas remonté).

⚠️ **Le principe tenu n'est pas « un découpage approché » mais « aucune coupure fausse ».**
IDEES.md envisageait d'assumer une erreur d'un chiffre hors du français ; ce n'était pas
nécessaire :

| Cas | Découpage | Pourquoi |
|---|---|---|
| `978-2` (francophone) | **complet**, éditeur compris | 19 tranches, exactes |
| autres groupes | groupe et clé **seulement** (`978-0-26203384-8`) | la frontière éditeur/publication demanderait ~220 tables ; on l'omet au lieu de la deviner |
| pas un ISBN valide | **inchangé** | un code mal saisi doit se voir mal saisi, pas déguisé par des tirets |

Une tranche en moins se lit encore ; une tranche fausse trompe. C'est aussi ce qui borne le poids
embarqué dans le WASM : seule la table francophone est présente, la collection l'étant aussi.

**Où c'est appliqué** : fiche livre, liste d'envies, écran d'ajout par ISBN (code recherché et
provenance d'un candidat), export `.txt` et export `.csv`. Le catalogue **n'affiche pas d'ISBN**
et n'a donc rien à découper — ne pas en ajouter un pour la circonstance.

⚠️ Le CSV y gagne plus que de la lisibilité : **un ISBN nu de treize chiffres est lu comme un
nombre par Excel**, affiché « 9,78225E+12 », derniers chiffres perdus. Les tirets en font un
texte. Ne pas « simplifier » en remettant la valeur nue dans l'export.

Les champs de **saisie** gardent la valeur nue : découper pendant la frappe se battrait avec
l'utilisateur, et la douchette tape des chiffres.

## Stratégie hors-ligne — décidé : consultation seule

**Besoin réel** : consulter la bibliothèque **déjà enregistrée** sans réseau (liste des livres, statuts, prêts en cours). Il ne s'agit **pas** d'enrichir de nouveaux livres hors-ligne — le lookup ISBN exige de toute façon un accès réseau.

⚠️ **Piège à ne pas sous-estimer** : « les données sont déjà en local » n'est vrai qu'au sens *serveur*. En Blazor WebAssembly, le code tourne dans le navigateur, alors que SQLite vit côté serveur YunoHost. Le service worker de la PWA met en cache les *assets* (HTML/CSS/WASM), mais **pas les réponses de l'API**. Sans travail explicite, l'app se lancera hors-ligne et affichera **une liste vide**.

**Décision** : cache client des réponses `GET` de l'API (IndexedDB, ou cache du service worker), en **lecture seule**.

- Pas de file d'attente d'écritures, pas de synchronisation, pas de résolution de conflits.
- Hors-ligne, l'interface doit **désactiver explicitement** les actions d'écriture (ajout, édition, prêt) plutôt que de les laisser échouer silencieusement, et indiquer que les données affichées proviennent du cache.
- SQLite compilé en WASM côté client a été **écarté** : ne se justifierait que si l'écriture hors-ligne devenait nécessaire.

### Ce qui a été implémenté le 2026-08-18

| Pièce | Rôle |
|---|---|
| `wwwroot/js/cache-hors-ligne.js` | IndexedDB (base `mabibli`, magasin `instantanes`) : ranger, relire, et signaler les bascules `online`/`offline`. Aucune logique métier |
| `Services/CacheHorsLigne.cs` | Sérialisation C# des instantanés ; **avale toute panne du stockage** (navigation privée, quota) — ne pas pouvoir cacher n'empêche pas de fonctionner en ligne |
| `Services/EtatReseau.cs` | État réseau visible + date de dernière synchronisation, avec deux événements distincts |
| `Services/FiltreLivresLocal.cs` | Recherche, filtres et tri hors-ligne sur l'instantané |
| `Services/ServiceLivresApi.cs` | Lectures avec repli sur le cache, écritures refusées |

**Cinq instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue
**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`, et `souhaits` depuis le
2026-08-18 (voir « La liste d'envies est le cinquième instantané »). C'est exactement ce qui
justifie IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne
restituerait que les URL déjà visitées, donc **une recherche jamais tapée en ligne ne rendrait
rien**. Vérifié en exécution — hors-ligne, chercher un auteur jamais affiché auparavant remonte
bien son livre.

L'instantané est réécrit à chaque lecture non filtrée réussie **et après chaque écriture**
(rafraîchissement en tâche de fond) : sans cela, une coupure juste après un ajout montrerait un
catalogue d'avant.

### Le filtre existe en deux exemplaires, et c'est assumé

`FiltreLivres` (serveur, sur `IQueryable<Livre>`, colonnes normalisées déjà calculées) et
`FiltreLivresLocal` (navigateur, sur des `LivreDto` qui n'en portent pas) **ne peuvent pas être
le même code**. La normalisation est refaite à la volée côté client — sans coût perceptible sur
une bibliothèque de foyer — mais par **les mêmes fonctions** (`NormalisationTexte`,
`RapprochementAuteurs.Cle`), et le tri est **ordinal** pour reproduire ce que fait SQLite sur une
colonne sans collation.

⚠️ Le garde-fou est un test qui **confronte les deux implémentations** sur le même jeu de données
et 17 jeux de critères (`FiltreLivresLocalTests`). Une divergence silencieuse serait pire qu'un
cache absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque.

### ⚠️ `navigator.onLine` ne suffit pas — le piège coûte le retour en ligne

`navigator.onLine` **ne vaut que par sa négation** : « faux » est fiable, « vrai » ne prouve rien
(portail captif, serveur arrêté, Wi-Fi sans Internet). D'où deux notions distinctes dans
`EtatReseau`, et il faut tenir les deux :

| Propriété | Sens | Usage |
|---|---|---|
| `EnLigne` | navigateur en ligne **et** dernier appel réussi | ce que l'interface affiche et ce qui active les boutons |
| `TenterLeReseau` | navigateur en ligne, **même si le dernier appel a échoué** | faut-il tenter un appel HTTP |

**Constaté en essai avant correction** : quand la panne vient du *serveur*, `navigator.onLine`
n'a jamais changé, donc aucun événement `online` ne viendra jamais — et une lecture qui
court-circuitait sur `EnLigne` ne retentait plus rien. L'application restait bloquée sur le cache
**jusqu'au rechargement de la page**. Le prix de la correction est d'une requête qui échoue par
lecture tant que le serveur est absent : elle échoue vite, et l'affichage retombe sur le cache.

Vérifié après correction : serveur arrêté puis redémarré, **sans aucun événement `online`**, la
navigation suivante a fait disparaître le bandeau, réactivé les actions et fait apparaître un
livre ajouté côté serveur pendant la coupure.

### ⚠️ Deux événements, sinon la boucle infinie

`EtatReseau` expose `Change` (bascule en ligne ↔ hors ligne) **et** `SynchroChange` (nouvelle date
de synchronisation). Les écrans se rechargent sur `Change` uniquement. Avec un événement unique,
un rechargement écrirait un instantané → nouvel événement → nouveau rechargement, sans fin.

### La liste d'envies est le cinquième instantané — et pourquoi elle manquait

Symptôme remonté en usage : `net_http_message_not_success_statuscode_reason, 404, Not Found`
en plein écran, réseau coupé. **Deux défauts distincts**, et il faut retenir les deux :

1. `ListerSouhaitsAsync` appelait `http.GetFromJsonAsync` **directement**, sans passer par
   `EssayerAsync`. Elle était donc le seul point de lecture hors du dispositif hors-ligne : pas
   de repli sur instantané, pas de `SignalerEchecReseau`, et l'exception HTTP remontait telle
   quelle jusqu'à l'écran.
2. L'écran affichait `ex.Message`. ⚠️ **Aucun message HTTP brut ne doit atteindre l'utilisateur** :
   il n'a aucun moyen d'interpréter un « 404 » qui, ici, ne veut même pas dire ce qu'il dit.

La correction rend la liste d'envies conforme au reste : lecture avec repli sur `souhaits`,
écritures passées par `EcrireAsync`, et rafraîchissement de l'instantané après chaque écriture.

**Pourquoi elle mérite un instantané et pas une simple erreur polie** : la liste s'emporte **en
librairie**, précisément là où le réseau manque. C'est exactement l'usage que servait l'export
`.txt` — et cet export est produit **par le serveur**, donc mort hors-ligne. Sans instantané, la
fonctionnalité n'existait pas au seul endroit où elle sert.

⚠️ **C'est le seul instantané personnel.** Catalogue, auteurs et prêts sont communs au foyer ;
`souhaits` ne contient que ce que le serveur a rendu à l'appelant. Ne jamais lui ajouter de
paramètre d'utilisateur : l'API ne sait pas produire la liste d'un autre, et c'est voulu.

Trois pièges rencontrés en chemin :

- **La suppression ne passe pas par `EcrireAsync`.** Elle répond `204 No Content`, et
  désérialiser un corps vide lèverait une exception là où tout s'est bien passé. Elle reprend
  la forme de la suppression d'un livre.
- **Les liens d'export basculent en boutons désactivés hors-ligne.** Un `<a>` ne se désactive
  pas : laissé actif, il quitterait l'application pour une page d'erreur du navigateur.
- **Hors-ligne, « liste vide » et « jamais synchronisée » sont indiscernables.** L'écran dit les
  deux au lieu d'affirmer la première — même règle que pour une fiche absente de l'instantané.

#### Vérifié en exécution le 2026-08-18

API lancée, une envie créée, puis **serveur réellement arrêté** :

- la liste s'affiche depuis l'instantané `souhaits`, avec le bandeau « Données enregistrées
  aujourd'hui à 22:20 » ;
- **plus aucun message HTTP** — le 404 a disparu de l'écran ;
- « Chercher », « Ajouter à ma liste » et « Retirer » désactivés avec le motif hors-ligne ;
  « Exporter en texte » et « Exporter en CSV » désactivés avec **leur** motif, distinct
  (« produit par le serveur ») ;
- serveur redémarré : le bandeau disparaît, les liens d'export redeviennent des `<a>` et
  « Retirer » se réactive **à la navigation suivante**, `navigator.onLine` n'ayant jamais changé
  et aucun événement `online` n'ayant donc été émis. Le piège documenté plus haut se vérifie
  aussi sur cet écran.

### Ce que le hors-ligne ne couvre pas, volontairement

- **L'historique des prêts d'un livre** (`GET /api/livres/{id}/prets`) : une requête par livre pour
  une information rarement consultée. En revanche l'état *courant* vient de `LivreDto.PreteA`,
  donc de l'instantané — c'est lui qui répond à « où est ce livre ? », la seule question qui se
  pose devant l'étagère. Le composant **dit** que l'historique est indisponible plutôt que
  d'afficher une liste vide, qui se lirait « jamais prêté ».
- **Les rapprochements d'auteurs** : une liste de décisions à prendre, or aucune décision ne peut
  être enregistrée hors-ligne.
- **Le lookup ISBN et le scan** : ils interrogent la BnF et OpenLibrary. L'écran le dit
  explicitement au lieu de laisser expirer un délai d'attente incompréhensible.
- Une fiche absente de l'instantané affiche « pas dans les données enregistrées sur cet
  appareil », **pas** « n'existe plus » : hors-ligne, les deux ne se distinguent pas.

### Ce qui a été observé, réseau coupé

Éprouvé sur un `publish Release` servi par l'API, en coupant réellement l'accès à `/api/*`
(même origine, donc même IndexedDB) puis en **rechargeant** la page :

- démarrage à froid : les 9 livres s'affichent, bandeau « Hors ligne. Données enregistrées
  aujourd'hui à 13:59. » — instantanés écrits à 13:59:32, page rechargée à 14:00 ;
- recherche sur tout le fonds : `saint-exupery` (auteur jamais affiché auparavant), `bete` → « La
  Bête humaine », `emile` → les Zola, `zola emile` (ordre inversé) → les mêmes ;
- filtres format et statut opérants sur l'instantané ;
- « Ajouter par ISBN », « Saisie manuelle », « Éditer », les quatre boutons de statut, « Prêter »,
  « Rendu » et « Ajouter au catalogue » : **désactivés**, chacun portant sa raison ;
- « Prêts en cours » et « Auteurs » servis depuis le cache, le nom d'utilisateur aussi.

## Sources de données ISBN — point d'attention important

**Ne pas dépendre d'une seule source, et éviter Google Books si possible** (préférence explicite de l'utilisateur : pas de dépendance à Google).

Stratégie : interroger plusieurs sources **libres et gratuites, sans clé API obligatoire**, en cascade (si la première ne répond pas ou renvoie des données incomplètes, essayer la suivante).

**Ordre décidé : BnF d'abord, OpenLibrary ensuite.** La collection est majoritairement francophone, or OpenLibrary (Internet Archive) est très fourni sur l'édition anglophone mais **lacunaire sur le fonds français** — éditions françaises récentes et poches d'éditeurs modestes y manquent souvent, ou n'ont qu'un titre sans auteur. Une source unique lacunaire ruinerait l'intérêt du scan, qui est précisément d'éviter la saisie manuelle.

1. **BnF** — source principale, via son API **SRU**, gratuite et sans clé. Le dépôt légal français garantit structurellement la meilleure couverture possible sur le francophone. **API testée et validée le 2026-08-17** — voir la section « API BnF » ci-dessous pour les pièges, dont un bloquant.
2. **OpenLibrary** (`https://openlibrary.org/isbn/{isbn}.json`) — source de secours, pour les livres étrangers et tout ce que la BnF ne connaît pas
   - ⚠️ **Bug connu identifié lors des tests avec BookLogr** : l'endpoint `/isbn/{isbn}.json` renvoie souvent le titre mais l'**auteur est juste une référence** (`/authors/OL...A`), pas le nom directement. Il faut faire un **second appel** vers `/authors/{id}.json` pour récupérer le nom. Un projet qui oublie ce second appel se retrouve avec titre rempli mais auteur vide (symptôme exact observé et diagnostiqué chez BookLogr) — **ne pas reproduire ce bug**.
   - La couverture est un service séparé : `https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg` (peut exister même si la fiche bibliographique est incomplète)
3. **Fallback ultérieur envisageable** : Wikidata. Ne pas l'implémenter tant que la cascade BnF → OpenLibrary n'a pas montré ses limites en usage réel.

**Google Books reste écarté** conformément à la préférence de l'utilisateur, sauf changement d'avis explicite de sa part.

Quelle que soit la source, prévoir que le formulaire de saisie manuelle reste **toujours accessible** pour compléter ou corriger une fiche incomplète.

## API BnF — validée le 2026-08-17

Endpoint SRU, gratuit, **sans clé API**, réponse en ~240 ms :

```
https://catalogue.bnf.fr/api/SRU
  ?version=1.2
  &operation=searchRetrieve
  &query=bib.isbn all "{isbn}"
  &recordSchema=dublincore
  &maximumRecords=5
```

**Utiliser `recordSchema=dublincore`, pas MARC.** Le Dublin Core renvoie directement `dc:title`, `dc:creator`, `dc:publisher`, `dc:date`, `dc:language`, `dc:format` — bien plus simple à mapper que l'UNIMARC. Le nombre de résultats se lit dans `<srw:numberOfRecords>`.

### ⚠️ Piège bloquant : ISBN-13 vs ISBN-10

**La BnF indexe l'ISBN tel qu'imprimé sur le livre.** Les ouvrages publiés avant 2007 ne portent qu'un **ISBN-10** et sont donc **introuvables par leur ISBN-13**, alors que le scanner de code-barres lit toujours un EAN-13.

Mesuré sur un échantillon de livres français dont l'existence a été confirmée via OpenLibrary :

| ISBN-13 | Recherche ISBN-13 | Recherche ISBN-10 |
|---|---|---|
| 9782070612758 (Le Petit Prince, 2007) | **1 notice** | 0 |
| 9782253004226 (Germinal, Livre de poche) | 0 | **3 notices** |
| 9782080704092 (Le Horla, Flammarion) | 0 | **1 notice** |

Sans conversion, on perd la majorité du fonds ancien — précisément les livres d'une bibliothèque constituée. **Toujours interroger les deux formes** : ISBN-13 d'abord, puis ISBN-10 converti si aucun résultat.

Conversion ISBN-13 → ISBN-10 (uniquement pour le préfixe `978`) : retirer `978`, garder les 9 chiffres, recalculer la clé (somme pondérée 10→2, modulo 11, `X` si le reste vaut 10).

### Autres pièges confirmés

- **Pas de couverture.** Le Dublin Core BnF n'en fournit aucune. Utiliser OpenLibrary pour l'image, **quelle que soit la source des métadonnées** : `https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg?default=false`. Le `?default=false` est **indispensable** — sans lui, OpenLibrary renvoie une image placeholder au lieu d'un 404. Testé : disponible pour les 4 ISBN de l'échantillon, y compris ceux absents d'OpenLibrary côté bibliographique.
- **Plusieurs notices pour un même ISBN.** Germinal en renvoie 3 (rééditions successives partageant l'ISBN, éditeurs et années différents). Ne pas prendre aveuglément la première : soit proposer le choix à l'utilisateur, soit retenir la plus récente via `dc:date`. À trancher à l'implémentation.
- **Ponctuation ISBD à nettoyer.** Les champs ne sont pas exploitables bruts. Règles validées :

| Champ | Brut | Nettoyé |
|---|---|---|
| `dc:title` | `Germinal / Émile Zola ; préface d'Armand Lanoux` | `Germinal` — couper au premier ` / ` |
| `dc:creator` | `Zola, Émile (1840-1902). Auteur du texte` | `Émile Zola` — retirer les dates entre parenthèses, le rôle après le point, puis inverser `Nom, Prénom` |
| `dc:publisher` | `le Livre de poche (Paris)` | `le Livre de poche` — retirer la ville en fin de chaîne |

- **Couvertures OpenLibrary : 502 intermittents.** Mesuré en phase 2 — trois appels consécutifs sur le même ISBN ont donné 200, 200, puis 502. Ne **jamais** valider l'URL par un HEAD avant de l'afficher : on supprimerait au hasard des couvertures existantes. L'URL est émise systématiquement, et **l'interface doit gérer l'image cassée** (`onerror`), le `?default=false` garantissant un 404 franc plutôt qu'un placeholder silencieux.
- **OpenLibrary : le double appel auteur ne suffit pas toujours.** Cas réel (*Introduction to Algorithms*) : l'édition n'a aucun champ `authors`, seulement `contributions` et `by_statement`. Le repli implémenté passe par `/works/{id}.json` → clés d'auteurs → `/authors/{id}.json`, puis en dernier recours `by_statement`. Sans ce repli, l'auteur serait vide — c'est la même classe de bug que celui de BookLogr, sous une autre forme.
- **Livres étrangers absents**, comme attendu du dépôt légal français : *Introduction to Algorithms* et *Effective Java* introuvables sous les deux formes d'ISBN. C'est exactement le rôle d'OpenLibrary en second rideau — la cascade est donc bien nécessaire, pas seulement confortable.

## Intégration SSO YunoHost

**Mécanisme retenu : en-têtes HTTP injectés par SSOwat.** nginx authentifie le visiteur via le portail YunoHost, puis transmet l'identité à l'application dans des en-têtes que l'app se contente de lire :

| En-tête | Contenu |
|---|---|
| `YNH_USER` | nom d'utilisateur authentifié |
| `YNH_USER_EMAIL` | email |
| `YNH_USER_FULLNAME` | nom complet |

`YNH_USER_FULLNAME` évite une requête LDAP supplémentaire pour l'affichage.

**OIDC a été écarté** (vérifié le 2026-08-17) : la documentation de packaging YunoHost ne documente aucun fournisseur OpenID Connect pour les apps. Les seuls mécanismes officiels sont LDAP direct et ces en-têtes.

### Points de vigilance

- La doc YunoHost indique que ces en-têtes sont **protégés contre l'injection depuis le client** (SSOwat les écrase). Malgré cela, faire écouter le service .NET **uniquement sur `127.0.0.1`**, jamais sur `0.0.0.0` — défense en profondeur, à traiter comme une contrainte dure dans `systemd.service` et `nginx.conf`. Un service exposé directement sur le réseau permettrait de forger `YNH_USER` et de contourner tout le portail.
- **Limite connue** : se déconnecter du portail YunoHost ne déconnecte pas des apps, chacune conservant sa propre session/cookie.
- En développement local, il n'y a pas de SSOwat : prévoir un utilisateur simulé (en-tête forcé ou configuration de dev) plutôt que de désactiver l'auth.

## Architecture de déploiement YunoHost

### Trois projets .NET, un seul service

Le client Blazor WebAssembly n'est **pas** un serveur : compilé, ce ne sont que des fichiers statiques (HTML/CSS/`.wasm`) **servis par l'API**. Un seul processus tourne donc sur le serveur.

```
DÉVELOPPEMENT              COMPILATION                DÉPLOIEMENT
MaBibli.Client  ─┐
MaBibli.Shared  ─┼──►  dotnet publish  ──►  un dossier  ──►  un service systemd
MaBibli.Api     ─┘        MaBibli.Api        unique           sur 127.0.0.1:PORT
```

`MaBibli.Api` référence `MaBibli.Client` ; à la compilation, les fichiers du client atterrissent dans le `wwwroot` de l'API.

Commande de publication cible :

```
dotnet publish MaBibli.Api -c Release -r linux-x64 --self-contained
```

Le self-contained embarque le runtime .NET dans le binaire : **aucun `dotnet-runtime` à installer** côté serveur, pas de conflit de versions. C'est le modèle de `radarr_ynh`.

### Empreintes WASM désactivées — pourquoi (ex-bloquant du publish, résolu le 2026-08-18)

**Ne pas réactiver `OverrideHtmlAssetPlaceholders` ni les empreintes WASM sans relire ce qui suit.**

Le `index.html` de `MaBibli.Client` ne contient **ni import map, ni placeholder `#[.{fingerprint}]`** : il référence directement `_framework/blazor.webassembly.js`. C'est délibéré, et `MaBibli.Client.csproj` porte `<WasmFingerprintAssets>false</WasmFingerprintAssets>` pour que les fichiers servis portent bien ces noms stables.

#### Le symptôme d'origine

`dotnet publish MaBibli.Api -c Release` produisait un `wwwroot/index.html` aux placeholders **non substitués** (`<script type="importmap"></script>` vide, `src="_framework/blazor.webassembly#[.{fingerprint}].js"` littéral), alors que seuls les noms empreintés existaient sur disque. La racine répondait **200**, le script de démarrage **404**, l'application publiée restait **blanche**. `dotnet publish MaBibli.Client` seul, lui, produisait un `index.html` correct.

#### La cause réelle (SDK .NET 10.0.300)

Elle est dans `Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets`. La cible de **publication** `GenerateHtmlAssetPlaceholdersPublishStaticWebAssets` reçoit la liste des fichiers HTML à réécrire via `HtmlFiles="@(_HtmlStaticWebAssets)"` — or cet item n'est **jamais alimenté par le chemin de publication** : il l'est uniquement par la cible de **build** `ResolveHtmlAssetPlaceholdersBuildConfiguration`.

Quand on publie le client seul, le build a tourné dans la même instance MSBuild, l'item est rempli, la réécriture a lieu. Quand c'est l'**API** qui publie, elle demande au projet client ses assets de publication dans une instance où la cible de build n'a **pas** tourné : `@(_HtmlStaticWebAssets)` est vide, la tâche de réécriture ne produit **aucun fichier**, aucun asset HTML calculé n'entre dans `staticwebassets.publish.json` — et c'est alors le fichier source `MaBibli.Client/wwwroot/index.html` qui est recopié tel quel par `ComputeResolvedFilesToPublishList`, placeholders compris. Vérifié : le manifeste de publication de l'API ne contient **aucun** asset `.html`.

C'est pour cela que `<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>` sur l'API ne changeait rien : la cible s'exécutait bien, mais sur une liste vide.

#### La correction retenue

Supprimer le besoin de réécriture plutôt que réparer la réécriture : sans import map ni placeholder, il n'y a plus rien à substituer, et le fichier source recopié tel quel est déjà le bon.

- `<WasmFingerprintAssets>false</WasmFingerprintAssets>` sur le client ;
- **pas** de `OverrideHtmlAssetPlaceholders` (c'est lui qui, à `true`, force `BlazorFingerprintBlazorJs` et empreinte `blazor.webassembly.js` — la tentative précédente n'avait échoué que parce que les deux propriétés étaient combinées) ;
- `index.html` : `<script src="_framework/blazor.webassembly.js">`, sans `<script type="importmap">` ni `<link rel="preload" id="webassembly">` (deux balises que seule la machinerie de placeholders sait remplir).

Le cache-busting reste assuré par le service worker, qui compare les empreintes de `service-worker-assets.js`.

**Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur.

### Le service worker est le SEUL cache-busting du projet — conséquences

Les empreintes étant désactivées, `blazor.webassembly.js` et `dotnet.js` portent des noms
stables : rien d'autre que le service worker n'empêche de servir éternellement une version
périmée. Vérifié que la chaîne tient, sans navigateur, en publiant deux fois avec une seule
ligne de différence dans `app.css` :

| | Publication A | Publication B |
|---|---|---|
| `service-worker-assets.js` → `version` | `Oc+bE5e+` | `jBwHvKfd` |
| Première ligne de `service-worker.js` | `/* Manifest version: Oc+bE5e+ */` | `/* Manifest version: jBwHvKfd */` |

Le point important est la **seconde ligne** : le SDK écrit la version dans le corps même de
`service-worker.js`. Le navigateur compare ce fichier **octet à octet** à chaque vérification de
mise à jour — il n'a donc pas à deviner que `service-worker-assets.js` a changé. Nouveau worker →
nouveau nom de cache (`offline-cache-{version}`) → tous les assets refetchés. Un changement de
code C# suffit aussi (empreintes des `.wasm`), constaté : `Oc+bE5e+` → `wCvu+Chi`.

**Ce que le mécanisme d'origine ne réglait pas** : un nouveau worker *attend* que **tous** les
onglets de l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
personne comprenne pourquoi. D'où `wwwroot/js/mise-a-jour.js` :

- il **vérifie le support avant d'appeler `navigator.serviceWorker`** — absent en contexte non
  sécurisé (http sur une IP locale), où l'appel direct levait une `TypeError` ;
- il **journalise un échec d'enregistrement** au lieu de l'avaler (c'est ce qui a permis
  d'élucider le point ci-dessous) ;
- il appelle `registration.update()` à chaque chargement, et affiche un bandeau « Mettre à jour »
  quand une version est prête ; le clic envoie `SKIP_WAITING` au worker en attente, qui appelle
  `self.skipWaiting()` (ajouté à `service-worker.published.js`), puis `controllerchange` recharge.

⚠️ Le rechargement sur `controllerchange` est **conditionné à un clic** : cet événement survient
aussi à la toute première installation, et recharger à ce moment-là serait un clignotement
inexplicable. Ne pas retirer le drapeau.

### ⚠️ Le service worker ne s'enregistre pas dans le navigateur d'automatisation — c'est l'environnement

Symptôme constaté depuis la phase 3, cause établie le 2026-08-18. **Ne pas repartir en chasse au
bug de configuration PWA** : les quatre observations ci-dessous vont toutes dans le même sens.

1. `navigator.serviceWorker.getRegistrations()` renvoie `[]`, et `register()` échoue en
   `TypeError: … An unknown error occurred when fetching the script.`
2. Un `fetch('/service-worker.js')` **depuis la même page** renvoie `200 text/javascript`,
   3 335 octets. Le fichier est donc bien servi.
3. **Toutes** les URL échouent identiquement, y compris `/index.html` — or un HTML *récupéré*
   échouerait avec une erreur de type MIME, pas avec « fetching the script ». L'échec est donc
   **avant** la requête.
4. Décisif : journalisation `Microsoft.AspNetCore` en `Information`, puis un `fetch` et un
   `register` sur la même URL portant chacun un repère distinct. Le serveur journalise
   `Request starting … ?repere=fetchB` et **rien du tout** pour `?repere=registerB`. La requête
   d'enregistrement **ne quitte jamais le navigateur**.

Le navigateur en question n'est pas un Chrome ordinaire : `navigator.userAgent` indique
`Claude/1.30096.1 Chrome/148 Electron/42.7.0` — un hôte Electron, dont la couche d'interception
réseau ne sert pas les requêtes de script de service worker.

**Conséquence à assumer** : le démarrage hors-ligne *complet* (coquille HTML/WASM servie par le
service worker) n'est **pas vérifiable ici**. Ce qui a été vérifié pour de bon, c'est tout le
reste — coquille servie par un serveur statique sur la **même origine**, API réellement
injoignable, et l'application repart de son cache IndexedDB. À confirmer dans un navigateur
ordinaire : charger l'application, vérifier dans les outils de développement (Application →
Service Workers) que le worker est `activated`, cocher « Offline », puis recharger.

### Deux dépôts distincts

| Dépôt | Contenu | Rôle |
|---|---|---|
| `mabibli` | Le code C#, les 3 projets | Ce qui est développé |
| `mabibli_ynh` | `manifest.toml`, scripts, conf nginx/systemd | Comment l'installer sur YunoHost |

Le paquet `_ynh` **ne contient aucun code C#** : il porte des instructions d'installation et une URL vers une archive compilée, avec son empreinte SHA256.

```
mabibli_ynh/
├── manifest.toml          ← identité, version, URL du binaire + sha256
├── conf/
│   ├── systemd.service    ← lancement du service, port
│   └── nginx.conf         ← reverse proxy + intégration SSO
└── scripts/
    ├── install / remove
    ├── upgrade
    └── backup / restore
```

### Chaîne de publication

Compilation **locale**, puis dépôt manuel de l'archive en release sur le Gitea de l'utilisateur, et mise à jour du `sha256` dans le `manifest.toml`. Prévoir un **script de build** encapsulant ces étapes, écrit pour être réutilisable tel quel dans une CI (Gitea Actions) si l'utilisateur bascule plus tard.

**Ne jamais compiler sur le serveur** à l'installation : cela imposerait le SDK .NET complet sur la machine YunoHost, pour une compilation lente — l'inverse exact de ce que permet le self-contained.

## Modèle de données

Schéma en vigueur depuis la migration `StatutPersonnelEtTableAuteurs` (2026-08-18).

```
Livre
├── Id
├── Isbn                      (index)
├── Titre
├── TitreNormalise            (index — minuscules sans accents, recalculé à chaque écriture)
├── Editeur
├── Format : Physique | Numerique
├── TypeDocument : NonPrecise | Roman | BandeDessinee   (défaut = NonPrecise, PAS Roman)
├── CoverUrl
├── DateAjout
└── AjoutePar (YNH_USER — traçabilité, PAS un cloisonnement)
        ⚠ pas de colonne Statut, pas de colonne Auteur

Auteur
├── Id
├── Nom                       (affichage : « Émile Zola »)
├── NomNormalise              (index — recherche : « emile zola »)
└── CleRegroupement           (index UNIQUE — mots normalisés triés : « emile zola »)

LivreAuteur                   (n-n : un livre peut avoir plusieurs auteurs)
├── LivreId  ─┐ clé primaire composite
├── AuteurId ─┘   FK Livre = cascade, FK Auteur = restrict
├── Position                  (rang sur la couverture, à partir de 0)
└── Role : NonPrecise | Scenario | Dessin | Couleurs
        ⚠ sur le LIEN, pas sur l'auteur : on scénarise un album et on dessine le suivant

StatutLecture                 (le statut est PERSONNEL)
├── Id
├── LivreId                   (FK vers Livre, cascade)
├── Utilisateur               (YNH_USER)
├── Statut : ALire | EnCours | Lu
├── DateMaj
└── UNIQUE (LivreId, Utilisateur)
        absence de ligne = « non commencé »

RapprochementRefuse           (mémoire des « non » de l'utilisateur)
├── Id
├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord)
└── AuteurBId ─┘   pour qu'un refus vaille dans les deux sens

Revue                         (magazines — COMMUNS au foyer ; PAS des livres)
├── Id
├── Titre
├── TitreNormalise            (index UNIQUE — une revue, une fiche)
├── Issn                      (index UNIQUE PARTIEL, WHERE Issn IS NOT NULL)
├── Editeur
├── DateAjout
└── AjoutePar                 (trace, pas une frontière)

NumeroRevue                   (recensé seulement : ni prêt, ni statut de lecture)
├── Id
├── RevueId                   (FK Revue, cascade — un numéro n'existe que par sa revue)
├── Numero                    (obligatoire : « 43 », « hors-série 7 »)
├── NumeroNormalise
├── DateParution              (UTC, nullable)
├── Note
├── DateAjout
└── UNIQUE (RevueId, NumeroNormalise)

Serie                         (sagas et cycles — COMMUNS au foyer, comme le catalogue)
├── Id
├── Titre
├── TitreNormalise            (index UNIQUE — une série, une fiche)
├── SerieParenteId : int?     (auto-référence : un cycle EST une série de séries)
├── Position                  (rang dans la série parente)
├── DateAjout
└── AjoutePar                 (trace, PAS une frontière — comme Livre.AjoutePar)

ElementSerie                  (une PLACE dans l'ordre de lecture, occupée ou non)
├── Id
├── SerieId                   (FK Serie, cascade)
├── Position                  (ordre de LECTURE, stocké — jamais déduit d'une année)
├── LivreId : int?            (FK Livre, ⚠ SetNull — NULL = un tome qu'on ne possède pas)
└── Titre                     (toujours renseigné, pour survivre à la suppression du livre)
        UNIQUE (SerieId, LivreId) WHERE LivreId IS NOT NULL

Pret
├── Id
├── LivreId                   (FK Livre, cascade — index simple pour l'historique)
├── Emprunteur                (nom, texte libre)
├── DatePret                  (UTC)
├── DateRetour                (UTC, nullable — NULL tant que non rendu)
└── UNIQUE (LivreId) WHERE DateRetour IS NULL
        un seul prêt ouvert par livre ; les prêts clos se répètent librement

LivreSouhaite                 (la liste d'envies est PERSONNELLE — voir sa section)
├── Id
├── Utilisateur               (YNH_USER — une FRONTIÈRE, contrairement à AjoutePar)
├── Titre
├── TitreNormalise            (clé d'ŒUVRE : « / » et « : » coupés — CleOeuvre)
├── Auteur                    (texte libre — PAS de FK vers Auteur, volontairement)
├── AuteurNormalise           (jamais NULL, sinon l'unicité ci-dessous ne tient pas)
├── Editeur / Annee / Isbn / CoverUrl / Note
├── DateAjout
├── Rang                      (ordre CHOISI, 0 = le plus désiré — personnel comme le reste)
└── UNIQUE (Utilisateur, TitreNormalise, AuteurNormalise)
        une œuvre par personne ; deux personnes peuvent souhaiter le même livre
```

Garder `Pret` comme table séparée (pas un champ sur `Livre`) pour conserver l'historique complet des prêts passés, pas juste l'état actuel.

**`AjoutePar` est une information, pas une frontière.** La bibliothèque est commune : ne **jamais** filtrer les requêtes de lecture sur ce champ. Il sert à savoir qui a saisi le livre (et implicitement à qui il appartient), pas à restreindre l'accès. Ce choix permet de basculer plus tard vers des bibliothèques cloisonnées sans migration de schéma.

**Le statut de lecture n'appartient plus à `Livre`.** Il vit dans une table par utilisateur (`LivreId` + `YNH_USER` + statut, unicité sur le couple). Un livre sans ligne pour l'utilisateur courant est simplement « non commencé ». Ne jamais réintroduire de colonne `Statut` sur `Livre` : elle redeviendrait commune à tout le foyer.

**L'auteur n'est plus un champ texte sur `Livre`.** Une table dédiée porte le nom d'affichage et une forme normalisée servant au regroupement et à la recherche. Le regroupement automatique ne s'applique qu'aux variantes **sûres** (casse, accents, initiales, ordre nom/prénom) ; les rapprochements ambigus — « Hamilton » seul vers « Peter F. Hamilton » — doivent être **proposés**, jamais appliqués silencieusement : une fusion erronée est difficile à défaire.

### Recherche sans accents : pourquoi des colonnes normalisées

**SQLite ne sait pas ignorer les accents.** Sa collation `NOCASE` ne couvre que l'ASCII et il n'existe pas de `unaccent()` intégré. La seule façon d'obtenir « Emile » → « Émile » sans charger tout le catalogue en mémoire est de stocker la forme mise à plat dans une colonne dédiée, et d'y chercher un terme passé par **exactement la même** fonction (`NormalisationTexte`).

Conséquence à ne pas oublier : **toute écriture doit recalculer ces colonnes** (`Livre.RecalculerFormes()`, `Auteur.RecalculerFormes()`).

Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance :

- `NomNormalise` garde l'ordre de saisie — c'est ce que la recherche parcourt en sous-chaîne ;
- `CleRegroupement` trie les mots, ce qui rend `Zola, Émile` et `Émile Zola` identiques. C'est elle qui porte l'index **unique**, donc l'invariant « un auteur, une fiche ».

La clé ne sait pas rapprocher les **initiales** (`P.F. Hamilton` ↔ `Peter F. Hamilton`) : cela demande d'aligner les mots un à un, ce qui n'a pas d'équivalent SQL. Ce cas est traité en mémoire par `RapprochementAuteurs.Comparer`, sur une table qui compte au plus quelques centaines de lignes.

### Ligatures : « œ » vaut « oe » — corrigé le 2026-08-19

`NormalisationTexte` décompose en **NFD**, qui sépare les accents mais **laisse les ligatures
intactes** : `L'Œuvre` et `L'oeuvre` ne se rencontraient jamais. Zola a écrit *L'Œuvre*, que la
BnF orthographie avec la ligature — le livre était donc introuvable à qui tape « oeuvre », et
n'apparaissait pas grisé dans la bibliographie de son auteur.

**Table explicite plutôt que NFKD**, qui aurait fait bien davantage : `²` → `2`, `Ⅻ` → `XII`,
espaces insécables, formes de présentation. Une normalisation de recherche doit rester
prévisible : on ne défait que ce dont le besoin est constaté, soit `œ`/`Œ` et `æ`/`Æ`.

⚠️ **La ligature vaut deux lettres, elle ne coupe pas le mot** : `cœur` → `coeur`, pas
`co eur`. La ponctuation, elle, sépare toujours (`P.F.` → `p f`).

#### Changer cette fonction déplace des invariants en base

C'est ce qui avait fait repousser la correction, et le risque était réel : **sept colonnes
normalisées** en dépendent, dont **trois portent une unicité** (`Auteur.CleRegroupement`,
`LivreSouhaite (Utilisateur, TitreNormalise, AuteurNormalise)`, `Serie.TitreNormalise`,
`Revue.TitreNormalise`, `NumeroRevue (RevueId, NumeroNormalise)`).

`ServiceRenormalisation` a donc été étendu aux tables apparues après lui — envies, séries,
revues, numéros —, qui calculaient leurs formes **à l'écriture** et que rien ne rattrapait.

⚠️ **Une collision d'unicité au démarrage empêcherait le serveur de se lancer.** Si la nouvelle
règle rend identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), l'écriture
échoue — et elle échoue *avant* que l'application ne réponde. Le service détecte donc la
collision avant d'écrire : **la ligne la plus ancienne prend la nouvelle forme, les suivantes
gardent l'ancienne**, et un avertissement est journalisé. Mal normalisée vaut infiniment mieux
que supprimée, ou qu'un serveur mort.

Le jeu des clés occupées part de l'état **actuel** de la base, et chaque ligne **libère la
sienne** avant de réserver la nouvelle : sans cette libération, toute ligne verrait sa nouvelle
valeur « déjà prise » dès qu'elle égale son ancienne, et rien ne serait jamais corrigé. Deux
tests couvrent le cas de collision et l'idempotence.

Les auteurs, eux, sont **fusionnés** et non laissés en double : c'est le comportement déjà en
place pour les variantes d'accent, et il vaut pour les ligatures sans rien changer.

### Reprise des données existantes

La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRenormalisation` finit le travail en C# au démarrage** — idempotent, il sert aussi de filet si les règles de normalisation changent un jour.

- L'ancien `Livres.Statut`, commun à tout le foyer, est **rattaché à `AjoutePar`**, seule personne que la base associe au livre. Les statuts des livres sans `AjoutePar` sont **perdus** : les attribuer serait une invention.
- L'ancien `Livres.Auteur` devient une fiche par valeur distincte. SQLite ne regroupant qu'à la casse près, les variantes accentuées ressortent en double — c'est le passage C# qui les réunit, en gardant le nom d'affichage le plus présentable (accents conservés, ni `NOM, Prénom` ni capitales intégrales).

**Les prêts ne concernent en pratique que les livres physiques** — les ebooks étant de simples fiches, il n'y a pas d'objet à prêter. `Emprunteur` reste un **texte libre**, sans lien avec les comptes YunoHost : on suit les prêts à des personnes extérieures au foyer, pas les échanges entre utilisateurs de l'app.

## Prêts — implémenté le 2026-08-18

### Un prêt ouvert par livre, garanti par la base

Un exemplaire sorti ne se prête pas une seconde fois. Le service le vérifie et renvoie un message
nommant celui qui l'a déjà (« Ce livre est déjà prêté à Marie »), mais entre cette vérification et
l'insertion il reste une fenêtre. Elle est fermée par un **index unique partiel** :

```sql
CREATE UNIQUE INDEX IX_Prets_LivreId_EnCours ON Prets (LivreId) WHERE DateRetour IS NULL;
```

Le filtre est ce qui rend la chose possible : seules les lignes ouvertes sont indexées, donc
l'historique reste libre d'accumuler autant de prêts clos que nécessaire sur le même livre.
Vérifié sur base réelle : un second `INSERT` ouvert échoue en `UNIQUE constraint failed`, un
second prêt **clos** passe.

⚠️ Deux index cohabitent sur `Prets.LivreId` et ce n'est pas une redondance : `IX_Prets_LivreId`
sert l'historique (toutes les lignes), l'index partiel sert la contrainte. **Ils doivent porter un
nom explicite** — EF Core identifie un index par ses colonnes, et sans nom distinct le second
déclaré *remplace* purement et simplement le premier dans la migration générée.

### Le prêt est commun, contrairement au statut de lecture

Symétrique inverse de la décision sur les statuts, et il faut tenir les deux :

| | Statut de lecture | Prêt |
|---|---|---|
| Portée | **personnel** (par `YNH_USER`) | **commun au foyer** |
| Pourquoi | deux personnes lisent le même exemplaire à leur rythme | un livre absent l'est pour tout le monde |

Aucun point d'entrée des prêts ne reçoit d'identité — le service n'en prend même pas en
paramètre, pour qu'on ne puisse pas s'en servir par inadvertance. N'importe qui doit pouvoir
noter le retour d'un livre qu'il a récupéré.

`LivreDto.PreteA` / `PreteDepuis` portent l'état courant pour l'étiquette du catalogue ;
l'historique complet se demande à part (`GET /api/livres/{id}/prets`) pour ne pas alourdir chaque
liste.

### Points d'entrée

| Méthode | Route | Rôle |
|---|---|---|
| `GET` | `/api/prets/en-cours` | Ce qui n'est pas à la maison, **du prêt le plus ancien au plus récent** |
| `POST` | `/api/livres/{id}/prets` | Prêter (400 si déjà sorti, numérique, ou emprunteur vide) |
| `GET` | `/api/livres/{id}/prets` | Historique, du plus récent au plus ancien (404 si livre inconnu, `[]` si jamais prêté) |
| `POST` | `/api/prets/{id}/retour` | Clore le prêt (400 s'il l'est déjà) |

L'ordre croissant des prêts en cours est délibéré : ce qu'on cherche dans cette vue, c'est le
livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier.

### Décisions prises là où CLAUDE.md était muet

- **Supprimer un livre emporte son historique de prêts** (cascade, déjà déclarée, désormais
  couverte par un test). Un historique orphelin — « quelqu'un a emprunté quelque chose » — ne se
  lit plus. L'écran de suppression avertit en plus quand le livre est actuellement dehors.
- **La date de prêt est modifiable, la date de retour non.** On note souvent un prêt après coup
  (« Paul a mon Zola depuis Noël ») ; un retour se constate au moment où il a lieu. Les deux
  refusent une date future, et un retour antérieur à son prêt.
- **Rendre un prêt déjà clos est refusé** plutôt que silencieusement ignoré : réécrire la date
  remplacerait une information exacte par une approximative.
- **Toutes les dates sont stockées en UTC**, comme `DateAjout`. Le `<input type="date">` produit
  une date sans fuseau : le client la déclare **locale** avant de la convertir
  (`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe.
  Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST).

## Rôles des auteurs — migration `RolesDesAuteurs` (2026-08-19)

Besoin venu de la bande dessinée : savoir **qui écrit et qui dessine**. `LivreAuteur.Role` porte
`NonPrecise` (0), `Scenario`, `Dessin`, `Couleurs` — un troisième nom figurant souvent sur un
album.

### ⚠️ Le rôle vit sur le LIEN, pas sur l'auteur

C'est la décision structurante, et elle se justifie par un cas banal : le même auteur scénarise
un album et **dessine** le suivant. Un rôle posé sur `Auteur` obligerait à créer deux fiches pour
une seule personne — exactement ce que l'index unique `CleRegroupement` interdit, et ce que tout
le travail de rapprochement cherche à éviter.

Conséquence dans `ServiceCatalogue` : `ResoudreAsync` ne reçoit **que des noms**, et les rôles
sont réappliqués ensuite. ⚠️ Ils se retrouvent **par clé d'auteur résolue**, jamais par position :
`ResoudreAsync` écarte les noms vides et fusionne les variantes, donc sa liste peut être plus
courte que la saisie — un rapprochement par index décalerait tous les rôles suivants. Un test
couvre le cas « Maupassant, Guy de » rapproché de « Guy de Maupassant ».

### Deux règles d'interface, héritées telles quelles

- **Les rôles n'apparaissent qu'à partir de deux auteurs.** Un roman n'a qu'un signataire dont le
  rôle ne se pose pas ; la question « qui écrit, qui dessine ? » ne se pose qu'à plusieurs.
- **Seul ce qui a été choisi s'affiche** : « Dobbs (scénario), Christophe Regnault (dessin) »,
  et rien du tout quand le rôle est `NonPrecise`. Même règle que le format et le type de document.

### ⚠️ Le piège : le champ des noms se relie à chaque frappe

`EnregistrementLivre.Auteur` (la vue « une seule ligne ») **reconstruit la liste entière** à
chaque caractère tapé. Sans précaution, corriger une faute de frappe remettrait tous les rôles à
« non précisé » sous les yeux de l'utilisateur.

`Decouper` reprend donc les rôles des auteurs déjà saisis, en rapprochant sur la forme
**normalisée** du nom — ce qui les fait survivre à un accent ajouté après coup (vérifié en
exécution : « Christophe » → « Christophé » garde son rôle). Un nom réellement différent, lui,
repart à zéro : on ne devine pas qu'il s'agit de la même personne.

**Ce qui n'est délibérément pas fait** : ni traducteur, ni préfacier, ni photographe. La liste
reste courte tant que le besoin n'est pas constaté — une valeur s'ajoute sans migration, la
renommer une fois posée sur des centaines de liens, non.

## Type de document — migration `TypeDeDocument` (2026-08-19)

Besoin remonté : distinguer une BD d'un roman. Une BD était déjà catalogable telle quelle (elle
a un ISBN, la BnF la connaît), mais rien ne permettait de la **reconnaître** — `Format` dit
physique ou numérique, pas roman ou bande dessinée.

`Livre.TypeDocument` : **`NonPrecise` (0), `Roman`, `BandeDessinee`**. Trois valeurs, et c'est
délibéré : une valeur s'ajoute **sans migration** (une énumération se stocke en entier), alors
que renommer ou fusionner des valeurs déjà posées sur des centaines de fiches, non. Dans le
doute, on n'ajoute pas.

### ⚠️ Le défaut est « non précisé », et surtout pas « roman »

C'est ce qui distingue cette migration de `RangDesEnvies`, qui avait dû reconduire un ordre en
SQL : ici un simple `AddColumn` suffit, **parce que la valeur par défaut ne prétend rien**. Un
défaut « Roman » aurait écrit quelque chose de faux sur chaque fiche existante — elles n'ont
pas été saisies avec cette question en tête.

Deux conséquences tenues partout :

- **l'interface n'affiche que ce qui a été choisi** — étiquette au catalogue et ligne sur la
  fiche seulement si `TypeDocument != NonPrecise`, dans le prolongement exact de « le format ne
  s'affiche que pour les ebooks ». Sans cela, presque chaque ligne porterait « Non précisé » ;
- **le formulaire ne présélectionne rien d'autre** que « Non précisé ». ⚠️ Symétrique inverse du
  format, qui **doit** rester sur *Physique* : là-bas l'absence d'étiquette signifie « physique »,
  ici elle signifie « on ne sait pas ».

### `NonPrecise` est une valeur de filtre à part entière

Contrairement au statut de lecture, dont l'absence n'est **pas** un statut. « Qu'est-ce que je
n'ai pas encore rangé ? » est précisément la question qui se pose après avoir introduit ce champ,
et c'est la seule façon d'y répondre. Le filtre suit pour le reste les règles déjà actées : la
ligne se replie derrière « Filtrer », compte dans le compteur du bouton, et **disparaît tant que
le fonds n'a qu'un seul type** — comme la ligne des formats, et avec la même précaution (les
types présents ne se déduisent que d'un chargement `EstSansCritere`).

⚠️ **Le filtre existe en deux exemplaires** (`FiltreLivres` côté base, `FiltreLivresLocal` côté
navigateur) : toute nouvelle dimension doit être ajoutée **aux deux**, et au test qui les
confronte — quatre jeux de critères ont été ajoutés à `FiltreLivresLocalTests`, dont un croisant
type, format et recherche. Une divergence silencieuse ferait chercher hors-ligne autre chose
qu'en ligne.

**Ce qui n'est délibérément pas fait** : ni « magazine » dans l'énumération — le catalogage des
périodiques demande un ISSN et une table de numéros, pas une étiquette de plus (voir IDEES.md) —
ni les champs propres à la BD (série, tome, scénariste/dessinateur), le modèle mettant tous les
auteurs dans `LivreAuteur` sans distinguer les rôles.

## Doublons du catalogue — avertir, jamais refuser (2026-08-19)

Défaut remonté en usage : rien n'empêchait de rescanner un livre déjà catalogué, et
`ServiceCatalogue.CreerAsync` ne faisait aucun contrôle.

**Décidé : un avertissement confirmable**, pas un index unique. `HasIndex(l => l.Isbn)` reste
délibérément **non unique**, et il ne faut pas le rendre unique « pour bien faire » :

- **posséder deux exemplaires est légitime** — on garde le sien et on prête l'autre. C'est
  précisément le cas qu'un index unique rendrait impossible, sans recours ;
- **l'ISBN est facultatif** (livres anciens, tirages sans ISBN) : il ne peut pas porter à lui
  seul l'unicité, et SQLite tient deux `NULL` pour distincts de toute façon.

⚠️ Le contraste avec la liste d'envies est voulu : `LivreSouhaite` **refuse** le doublon par
index unique. Une envie décrit une œuvre, dont on ne souhaite pas deux exemplaires ; un livre
décrit un **objet**, qu'on peut posséder en double. Ne pas uniformiser les deux.

### Deux critères, dont aucun n'est une clé

| Critère | Attrape | Rate, ou signale à tort |
|---|---|---|
| **ISBN identique** | le rescan du même livre, cas visé | rien quand l'ISBN manque |
| **`CleOeuvre` + un auteur commun** | la saisie manuelle sans ISBN, le sous-titre, l'ordre `Nom, Prénom` | le poche à côté du grand format — deux exemplaires bien réels |

L'auteur est **exigé en plus du titre** (au sens de `RapprochementAuteurs`) : sans lui, deux
recueils « Nouvelles » sans rapport se signaleraient l'un l'autre. Deux fiches **sans aucun**
auteur concordent, le titre étant alors tout ce dont on dispose.

⚠️ **La clé d'œuvre est un préfixe de `TitreNormalise`** — la troncature du sous-titre précède
la normalisation, qui préserve l'ordre des caractères. C'est ce qui permet de dégrossir en SQL
sur la colonne indexée (`== cle` ou `StartsWith(cle + " ")`) puis de vérifier l'égalité exacte
des clés en mémoire. Sans cette seconde passe, « Germinal les années noires » serait signalé
comme doublon de « Germinal » ; sans la première, il faudrait charger tout le catalogue à
chaque ajout.

### 409, et surtout pas 400

`POST /api/livres` répond **409 Conflict** avec `DoublonsLivre` (message + les fiches en cause),
et `?confirmerDoublon=true` enregistre la même saisie sans plus rien demander.

⚠️ **Un doublon n'est pas une erreur**, et le code de statut n'est pas un détail : la saisie est
valide, c'est le catalogue qui contient déjà quelque chose. D'où un **troisième état** dans
`ResultatEcriture` et dans `ResultatCreation` côté client, au lieu d'un message logé dans
`Erreur` — un texte rouge dirait « c'est raté » là où l'écran doit proposer « ajouter quand
même ». Le 409 traverse donc `EnvoyerAsync` sans passer par le traitement d'erreur générique,
qui l'aurait aplati en « L'enregistrement a échoué (409) ».

### L'écran montre les fiches, il ne les décrit pas

`AvertissementDoublon` remplace le formulaire tant que la question n'est pas tranchée (la saisie
attend dans `_saisie` et revient intacte si l'on renonce), et affiche chaque livre en cause
**en lien vers sa fiche** : la vraie question est « est-ce bien le même livre ? », et on n'y
répond qu'en regardant celui qui est déjà là. Le prêt en cours y figure — un exemplaire dehors
est justement une raison d'en vouloir un second.

**Ce qui n'est délibérément pas fait** : le contrôle ne porte que sur la **création**. Une
édition qui rendrait deux fiches identiques est un geste délibéré sur une fiche existante, pas
un scan répété par mégarde.

## Liste d'envies — implémentée le 2026-08-18

### Une table à part, et non un statut de plus

`LivreSouhaite` ne dépend pas de `Livre`. Le choix se joue sur un invariant, pas sur le confort :
un souhait logé dans `Livres` serait entré **mécaniquement** dans le catalogue, dans les
compteurs, dans les prêts et dans le cache hors-ligne, et il aurait fallu ajouter « et qui n'est
pas souhaité » à **chaque** lecture. Un invariant qu'on réécrit à chaque requête finit par être
oublié quelque part. Ici les deux tables ne se croisent pas — vérifié en exécution : une envie
n'apparaît ni au catalogue, ni dans la recherche, ni dans le compteur de livres d'un auteur.

C'est aussi la seule forme qui accepte une envie **sans édition arrêtée** : on souhaite une
œuvre, on possède un exemplaire. Un souhait n'a donc ni `Format`, ni prêt, et son ISBN est
facultatif.

```
LivreSouhaite                 (la liste d'envies est PERSONNELLE)
├── Id
├── Utilisateur               (YNH_USER — ⚠️ une vraie FRONTIÈRE, voir ci-dessous)
├── Titre
├── TitreNormalise            (clé d'ŒUVRE : sous-titre coupé — voir CleOeuvre)
├── Auteur                    (texte libre, PAS une FK vers Auteur)
├── AuteurNormalise           (jamais NULL : SQLite tient deux NULL pour distincts)
├── Editeur / Annee / Isbn / CoverUrl / Note
├── DateAjout
├── INDEX (Utilisateur)
└── UNIQUE (Utilisateur, TitreNormalise, AuteurNormalise)
```

### `Utilisateur` est une frontière, contrairement à `Livre.AjoutePar`

C'est le point à ne pas confondre, les trois portées du projet coexistant désormais :

| Donnée | Portée | Filtre-t-on dessus ? |
|---|---|---|
| Catalogue (`AjoutePar`) | **commune** au foyer | **jamais** — ce serait un bug de conception |
| Prêt | **commun** au foyer | jamais |
| Statut de lecture | **personnel** | oui, sur l'appelant |
| **Liste d'envies** | **personnelle** | **oui, toujours, sans exception** |

Aucun point d'entrée n'accepte de nom d'utilisateur. Supprimer l'envie d'un autre renvoie **404**,
pas 403 : l'inexistence et l'appartenance à autrui sont volontairement indiscernables. Le sens
même de la fonctionnalité est de préparer un cadeau sans que l'autre le voie venir.

**L'auteur reste du texte libre**, pas une FK vers `Auteur` : cette table décrit qui est *dans la
bibliothèque*. Y insérer les auteurs souhaités les ferait apparaître sur `/auteurs` avec
« 0 livre » — et trahirait la liste d'envies de son propriétaire à tout le foyer.

### Export — `.txt` et `.csv`, aucun des deux ne demande de dépendance

IDEES.md décrit deux usages qui n'appellent pas le même fichier :

| Format | Usage | Forme |
|---|---|---|
| `.txt` | l'emporter en librairie | groupé **par auteur**, lisible tel quel sur un téléphone |
| `.csv` | le partager avant un anniversaire | tableau, pour se répartir les achats dans un tableur |

Deux points à ne pas « corriger » :

- **Séparateur point-virgule**, pas la virgule. Le CSV n'a qu'un travail — s'ouvrir dans un
  tableur — et Excel en locale française attend `;`. Un fichier à virgules atterrit entièrement
  dans la colonne A. La collection est francophone : c'est ce cas-là qu'il faut servir.
- **BOM UTF-8 en tête du CSV uniquement.** Sans lui Excel affiche « Ã‰mile Zola », soit à peu
  près chaque ligne d'un fonds français. Le `.txt` s'en passe : certains lecteurs simples
  l'affichent comme un caractère parasite.

Le fichier est produit **côté serveur** (`Results.File` pose un `Content-Disposition:
attachment`). ⚠️ Le lien de téléchargement **doit** porter l'attribut `download` : sans lui, le
routeur Blazor intercepte le clic comme une navigation interne et affiche sa page « introuvable »
au lieu de télécharger. Vérifié dans le navigateur.

### La liste s'ordonne à la main — migration `RangDesEnvies` (2026-08-18)

Une liste d'envies se lit « qu'est-ce que je veux le plus ? ». Elle était classée **par auteur
puis par titre** : l'alphabet répondait à une question que personne ne se pose. La colonne
`Rang` porte désormais l'ordre choisi, `0` en tête.

⚠️ **Le rang est personnel comme toute la table** : la numérotation repart de zéro pour chaque
`Utilisateur`, sans quoi l'ordre de l'un dépendrait de celui de l'autre.

**Une envie nouvelle se pose en fin de liste**, jamais en tête : on vient de la noter, on n'a
pas dit qu'on la voulait plus que les autres. La poser en tête déclasserait le choix de
l'utilisateur à chaque ajout.

#### La migration ne se réduit pas à `AddColumn`

⚠️ À `0` partout, l'ordre serait retombé sur l'`Id`, c'est-à-dire l'ordre d'ajout : **toutes les
listes existantes se seraient réordonnées** sous les yeux de leur propriétaire, sans qu'il ait
rien demandé. Le `migrationBuilder.Sql` qui suit l'`AddColumn` **reconduit exactement l'ordre
affiché jusqu'ici** — auteurs renseignés d'abord, puis par auteur, puis par titre.

Il s'appuie sur les **row values** de SQLite (`(a, b) < (c, d)`, disponibles depuis 3.15), qui
expriment « combien de lignes se classaient avant celle-ci » en une comparaison. L'`Id` ferme
le tri : sans lui, deux lignes égales recevraient le même rang.

⚠️ `ServiceSouhaitsTests` crée son schéma avec `EnsureCreated`, qui **ne joue aucune migration** :
ce SQL n'y passerait jamais. `MigrationRangDesEnviesTests` migre donc jusqu'à `ListeDEnvies`,
insère en SQL brut, puis applique `RangDesEnvies` et vérifie les rangs des deux utilisateurs.
Vérifié aussi en exécution sur la base de développement.

#### Un seul point d'entrée : `PUT /api/souhaits/ordre`

Il attend la liste **entière** des identifiants, dans l'ordre voulu. C'est ce dont a besoin le
glisser-déposer, et les flèches monter/descendre s'y ramènent — **une seule opération à
vérifier** plutôt qu'un « monter » et un « descendre » séparés.

Deux protections, toutes deux couvertes par des tests :

- **Le service relit la liste de l'appelant et ne se fie pas à ce que le client envoie.** Un
  identifiant appartenant à quelqu'un d'autre est ignoré, jamais renuméroté — le filtre fait
  partie de la clé, comme pour la suppression.
- **Une envie absente de l'ordre reçu est conservée**, rangée à la suite. La liste du client
  peut être périmée (envie ajoutée depuis un autre appareil) : mal classée vaut infiniment mieux
  que disparue.

#### Les flèches ne sont pas un ornement

⚠️ **Le glisser-déposer HTML5 ne fonctionne pas au doigt sur mobile.** Sans les flèches
monter/descendre, réordonner n'existerait que sur PC — c'est-à-dire pas là où l'on consulte sa
liste. Les deux gestes passent par le même code client (`DeplacerAsync`), la liste étant
réordonnée **avant** l'aller-retour réseau pour que la flèche réponde tout de suite ; un échec
recharge l'ordre du serveur plutôt que de laisser croire à un enregistrement.

**L'export CSV suit le rang** (utile pour se répartir les achats), le `.txt` reste **groupé par
auteur** : c'est ce qu'on épelle à un libraire. Les deux gardent leur usage.

### L'ajout d'une envie a son propre écran

`/souhaits/ajout`, séparé de `/souhaits` comme la fiche livre l'est de son édition : la liste ne
montre plus que la liste, et l'ajout a la place de proposer une vraie recherche. Trois voies,
dans l'ordre où le besoin se présente :

| Voie | Quand | Source |
|---|---|---|
| **Par titre** (défaut) | le cas **normal** — on souhaite un livre qu'on n'a pas en main | `bib.title`, auteur facultatif en `and` |
| **Par ISBN** | on l'a en main (librairie, chez quelqu'un) | cascade ISBN existante |
| **À la main** | aucune source ne le connaît | rien |

⚠️ **Le filtre de type de document est indispensable ici aussi.** Mesuré le 2026-08-18 :
`bib.title all "la bête humaine"` croisé avec Zola rend 20 notices dont **12 films** de Renoir,
un manuscrit et un multimédia. `ParserRecherche` réutilise donc la liste positive de
`BnfBibliographieParser`.

⚠️ **En revanche aucun post-filtre sur l'auteur**, contrairement à la bibliographie : la
contrainte de titre resserre déjà, l'auteur est facultatif, et surtout **l'utilisateur choisit
dans la liste en voyant les auteurs de chaque notice**. C'est ce qui laisse passer la BD signée
Dobbs tirée de *La bête humaine* — une adaptation se souhaite parfaitement, et rien ne trompe
puisque son auteur est affiché.

**`LivreSouhaite.CoverUrl` est enfin alimenté** : la couverture vient d'OpenLibrary dès qu'un
ISBN est connu, y compris pour les résultats d'une recherche par titre (l'ISBN est repris du
`dc:identifier` de la notice). **Sans ISBN, pas de couverture et on n'en invente pas** — la
vignette retombe sur son substitut à initiale. Décidé de **ne pas** offrir de champ « URL de
couverture » : il inviterait à coller des liens morts pour un gain nul.

## Une envie déjà au catalogue est signalée, jamais supprimée (2026-08-19)

Demande d'origine : quand un livre souhaité entre au catalogue, retirer l'envie correspondante.
**Décidé : on signale.** `SouhaitDto.Possede` / `LivreId` portent le rapprochement, et l'écran
des envies affiche une étiquette « Déjà au catalogue » **cliquable vers la fiche**.

Deux raisons, dont la première est structurelle :

- **le catalogue est commun, la liste d'envies personnelle.** L'envie n'est pas forcément celle
  de la personne qui a saisi le livre : la supprimer modifierait la liste d'un autre en silence,
  et lui ferait perdre sa note (« demandé à Noël »). L'API ne sait pas — délibérément — écrire
  dans la liste d'autrui, et lui ouvrir cette porte percerait un invariant tenu partout ailleurs ;
- **le rapprochement est faillible** (`CleOeuvre` : titre retraduit, tome, intégrale). Une
  suppression fondée sur un rapprochement faux est irréversible ; un signalement se corrige d'un
  coup d'œil — d'où l'étiquette qui **mène à la fiche**, pour vérifier avant de retirer.

C'est la même règle que le grisage de la bibliographie, dans l'autre sens : on **marque**, on ne
masque ni ne supprime jamais une ligne.

### Le critère est celui des doublons, et c'est voulu

ISBN identique (il tranche seul, désignant une édition précise), sinon même clé d'œuvre **et**
un auteur commun. La convention « deux jeux de noms tous deux vides concordent » vit désormais
en un seul endroit, `RapprochementAuteurs.PartagentUnAuteur`, partagée avec la détection de
doublons du catalogue — deux règles voisines qui divergeraient en silence seraient pires qu'une
règle imparfaite.

⚠️ `LivreSouhaite.TitreNormalise` **est déjà** la clé d'œuvre (voir l'entité) : rien à
recalculer de ce côté, contrairement au catalogue dont `TitreNormalise` garde le titre entier.

⚠️ **Le catalogue est chargé en entier** (projection minimale) pour ce rapprochement. Assumé à
l'échelle d'un foyer, et cohérent avec l'instantané hors-ligne qui l'emporte déjà tout entier
dans le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est là qu'il
faudrait dégrossir en SQL — pas renoncer au signalement.

⚠️ Piège rencontré, et pris par les tests seuls : `GetValueOrDefault` sur un
`Dictionary<int, int>` rend **zéro**, pas `null`. Toute envie se disait donc possédée, en
pointant vers un livre inexistant. Le compilateur ne pouvait rien dire — `0` est un `int`
parfaitement valide.

**Ce qui n'est délibérément pas fait** : les exports `.txt` et `.csv` ne portent pas la mention.
Ce sont des instantanés qu'on emporte, alors que le signalement appelle une action — vérifier,
puis retirer — qui a lieu dans l'application.

## Bibliographie par auteur — SRU BnF, validé le 2026-08-18

Le déclencheur décrit dans IDEES.md : depuis un auteur déjà présent, voir tout ce qu'il a écrit,
les livres possédés grisés, et marquer les autres comme souhaités.

```
https://catalogue.bnf.fr/api/SRU?version=1.2&operation=searchRetrieve
  &query=bib.author all "{nom}"&recordSchema=dublincore
  &maximumRecords=100&startRecord={1|101}
```

L'index **`bib.author`** fonctionne, sans clé, en ~0,85 s par page. Deux pages de 100 sont
lancées **en parallèle** (200 notices au plus).

### ⚠️ Deux filtres sont indispensables, et le second est critique

Sans eux, l'écran ment à l'utilisateur.

1. **Type de document.** L'index auteur ne distingue pas un livre d'un livre audio, d'un jeu ou
   d'un spectacle. Sur 200 notices de Werber : 180 imprimés, 13 enregistrements sonores, 3 images
   animées, 2 jeux, 1 spectacle. Liste **positive** (`texte imprimé`, `ressource électronique`) :
   un type inconnu est écarté.
2. **Auteur réel de la notice.** `all` exige que tous les mots soient présents — mais sur
   l'**ensemble** des auteurs d'une notice. Un livre signé d'un « Émile » quelconque et d'un
   « Zola » quelconque remonte donc aussi. Mesuré sur 200 notices d'« Émile Zola » : **67 écartées**,
   dont toute l'œuvre de sa fille **Denise Le Blond-Zola** (*Émile Zola raconté par sa fille*),
   qui parle de lui sans qu'il l'ait écrite. On exige donc qu'un `dc:creator` nettoyé corresponde
   au nom demandé au sens de `RapprochementAuteurs`.

**Coût assumé du second filtre** : les adaptations dont l'auteur n'est pas le scénariste
disparaissent (les BD tirées de Werber sont signées Corbeyran — 17 notices sur 200). C'est le bon
compromis : afficher l'œuvre d'un tiers sous le nom de l'auteur est une erreur visible et gênante,
en omettre une adaptation ne l'est pas.

### Ce que ça donne réellement

| Auteur | Notices annoncées | Lues | Retenues | **Œuvres distinctes** |
|---|---|---|---|---|
| Bernard Werber | 203 | 200 | 166 | **51** |
| Émile Zola | 2692 | 200 | 118 | **87** |
| Amélie Nothomb | 277 | 200 | 169 | **63** |

Le regroupement des rééditions est ce qui rend l'écran utilisable : sans lui, « Les fourmis »
occupe onze lignes. Le représentant retenu est la notice **la plus ancienne** (l'édition
originale situe l'œuvre dans la carrière), mais l'ISBN est pris sur n'importe quelle notice qui
en porte un — les éditions anciennes n'en ont souvent pas.

⚠️ **Le plafond de 200 notices est un extrait pour les auteurs très réédités** (Zola en annonce
2692). L'interface **doit** le dire (`BibliographieDto.Tronquee`) : une bibliographie partielle
présentée comme complète ferait croire qu'un livre n'existe pas.

### Le rapprochement se fait par TITRE, pas par ISBN — et il rate des choses

**Pourquoi pas l'ISBN** : il désigne une *édition*, pas une œuvre. Le poche, le grand format et
la réédition portent trois ISBN pour le même roman — mesuré : 183 notices de livres de Werber
pour 51 œuvres, trois éditions par œuvre en moyenne. Comparer les ISBN répondrait « vous ne
l'avez pas » à propos d'un livre posé sur l'étagère. L'ISBN ne sert donc qu'en **confirmation**.

`CleOeuvre` coupe la mention de responsabilité (` / `) puis le sous-titre (` : `) et met à plat.
C'est ce qui réunit « Les thanatonautes », « Les thanatonautes : roman » et
« Les thanatonautes / Bernard Werber ». **Ce qu'elle rate**, et qu'il ne faut pas prétendre
autrement :

- un titre **retraduit ou changé** n'est pas rapproché du tout ;
- une œuvre possédée **à l'intérieur d'une intégrale** n'est pas repérée ;
- un **tome** n'est pas rapproché de son recueil (`Troisième humanité` ≠ `Troisième humanité. Tome 1`) ;
- deux œuvres partageant leur **titre principal** sont confondues (risque borné : la comparaison
  n'a lieu qu'**à auteur donné**, jamais sur tout le catalogue) ;
- la **ligature `œ`** n'est pas réduite à `oe` — *L'Œuvre* de Zola échappe au rapprochement
  (limite héritée de `NormalisationTexte`, voir IDEES.md).

Conséquence de conception : l'écran **grise** ce qu'il reconnaît et ne **masque jamais** de ligne.
Un faux négatif se voit et se corrige d'un coup d'œil ; une ligne masquée à tort serait invisible.
L'interface énonce cette limite en bas de liste plutôt que de laisser l'utilisateur la découvrir.

### Une source muette n'est pas une bibliographie vide — corrigé le 2026-08-19

Symptôme remonté : sur « Robert A. Harper », l'écran affichait
`BnF injoignable (TaskCanceledException)` **puis**, juste en dessous, « La BnF ne connaît aucun
livre de cet auteur ». Deux phrases contradictoires, dont la seconde était **fausse** : on n'avait
pas pu poser la question, donc on ne savait rien.

C'est la pire des deux erreurs possibles ici — elle **ferme** la question au lieu de la laisser
ouverte, et avec une explication rassurante qui rend l'absence crédible.

`BibliographieDto.Etat` (`EtatSourceBibliographie`) distingue désormais quatre issues, et
`SourceMuette` est ce que l'interface doit consulter **avant** d'expliquer une absence de
résultats :

| État | Sens | Ce que fait l'écran |
|---|---|---|
| `Ok` | la BnF a répondu | affiche la liste, **même vide** — et explique alors l'absence |
| `DelaiDepasse` | pas de réponse à temps | dit la panne, propose **Réessayer** |
| `Injoignable` | pas jointe, ou erreur HTTP | idem |
| `ReponseIllisible` | réponse inexploitable | idem |

⚠️ **Les deux branches s'excluent dans le balisage.** « La BnF ne connaît aucun livre de cet
auteur » n'est rendu que sous `!SourceMuette`. Ne jamais les remettre en séquence.

⚠️ `DelaiDepasse` se distingue de `Injoignable` parce qu'il n'appelle pas la même chose : un
délai dépassé se retente **tout de suite**, et c'est le cas le plus fréquent. `HttpClient`
l'annonce par une `TaskCanceledException` dont l'exception *interne* est un `TimeoutException` —
sans ce test, il se confondrait avec une annulation de l'appelant.

⚠️ **Les motifs sont écrits pour un humain.** `BnfClient.MotifDeSourceMuette` est **public** à
dessein : ce texte atteint l'utilisateur, c'est un contrat, et des tests vérifient qu'il ne
contient ni « Exception », ni « Task », ni « Http », que les trois motifs diffèrent, et que celui
du délai invite à réessayer.

Un bouton **Réessayer** existe aussi sur l'échec de l'appel à *notre* serveur (délai du
navigateur) : sans lui, il fallait recharger la page entière.

#### ⚠️ Le diagnostic d'IDEES.md était faux sur un point — vérifié en exécution

IDEES.md tenait « Robert A. Harper » pour **deux** problèmes : un délai dépassé, **et** un auteur
qui n'aurait « effectivement aucune notice à son nom » dans l'index BnF, faute d'être un auteur
francophone. Le second n'existe pas.

Mesuré le 2026-08-19 sur la base de développement : la BnF annonce **85 notices** pour cet
auteur, 78 sont écartées (autre support, ou signées de quelqu'un d'autre), et il reste
**7 œuvres** — dont *La thérapie émotivo-rationnelle*, correctement reconnue comme déjà possédée.
Le premier chargement expirait, le second aboutissait : c'est **le même défaut observé deux
fois**, pris pour deux.

La leçon vaut au-delà du cas : une source intermittente produit exactement les symptômes d'une
source lacunaire. C'est précisément pourquoi il faut distinguer les deux en machine plutôt qu'à
l'œil.

**Conséquence sur OpenLibrary** : la justification qui la rendait urgente tombe. Elle reste une
piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas
du catalogue par `CleOeuvre`.

## Revues et magazines — migration `Revues` (2026-08-19)

Rouvre — et clôt — la décision du premier lot, qui écartait le catalogage des périodiques faute
de modèle. **Une fiche par revue, les numéros à l'intérieur** : douze numéros d'un même magazine
ne font pas douze fiches identiques, puisqu'ils partagent la fiche de leur revue.

### Une table à part, pour la troisième fois — et c'est le même argument

Comme `LivreSouhaite` et comme `Serie` : logée dans `Livres`, une revue entrerait
**mécaniquement** dans le catalogue, ses compteurs, la détection de doublons, les séries et les
bibliographies par auteur — et il faudrait ajouter « et qui n'est pas une revue » à **chaque**
lecture. Un invariant qu'on réécrit partout finit par être oublié quelque part.

Vérifié en exécution : la revue *Médor* n'apparaît ni au catalogue, ni dans une recherche sur
son nom.

⚠️ L'`Issn` vit sur `Revue`, **jamais** dans `Livre.Isbn` : c'était déjà la règle du flux `977`,
elle a maintenant un endroit où se ranger.

### Un numéro est recensé, rien de plus (décidé avec l'utilisateur le 2026-08-19)

Ni prêt, ni statut de lecture. Ces deux mécanismes sont attachés à `Livre` **par clé étrangère** :
les étendre demanderait une seconde table de prêts ou une parenté commune entre livres et
numéros, pour un usage non confirmé. Le choix est **réversible** — les ajouter plus tard ne
détruit rien de ce qui aura été saisi.

Deux détails qui ne sont pas des détails :

- **`Numero` est obligatoire.** Sans lui, deux numéros de la même revue seraient indiscernables,
  et l'unicité `(RevueId, NumeroNormalise)` ne voudrait rien dire.
- **Les numéros s'affichent du plus récent au plus ancien**, à l'inverse d'une série qui suit son
  ordre de lecture : devant un kiosque, la question est « ai-je le dernier ? ». ⚠️ Les numéros
  **sans date ferment la liste** au lieu de passer pour les plus vieux — une date inconnue n'est
  pas une date ancienne.

### « Créer ou retrouver », et non « créer »

`POST /api/revues` rend la revue existante quand l'ISSN **ou** le titre la désigne. C'est ce
qu'exige le flux du code-barres : scanner le numéro suivant du même magazine retombe forcément
sur la même revue, et le geste de l'utilisateur est toujours « voici un numéro de plus ».
Refuser le doublon l'obligerait à comprendre qu'il doit aller chercher la fiche lui-même.

Corollaire testé : **une revue saisie à la main apprend son ISSN au premier scan**, ce qui permet
de la retrouver ensuite. L'unicité de l'ISSN est un index **partiel** (`WHERE Issn IS NOT NULL`),
sans quoi les revues sans ISSN se bloqueraient les unes les autres — deux `NULL` étant distincts
pour SQLite, c'est exactement le piège déjà rencontré sur `LivreSouhaite.AuteurNormalise`.

### Le flux `977` ne remplit plus un formulaire de livre

Avant, un code de périodique basculait sur la saisie manuelle d'un **livre**, titre pré-rempli.
C'était le moins mauvais choix tant que les revues n'avaient pas de modèle. L'écran propose
maintenant **« Ouvrir la fiche de cette revue »**, qui la crée au besoin et y mène.

⚠️ **Le numéro se saisit à la main, et ce n'est pas un oubli** : les deux chiffres de parution du
code-barres ne sont pas un numéro fiable (déjà acté), et l'add-on **EAN-2** qui le porterait
vraiment n'a pas pu être vérifié — il faudrait un magazine réel devant la caméra. La piste reste
ouverte dans IDEES.md.

Vérifié en exécution sur `9772466671438` : la BnF nomme « Médor », la fiche est créée avec son
ISSN, deux numéros s'y ajoutent, et **rescanner le même code retombe sur la même fiche**.

### ~~Un sixième onglet, mesuré~~ — caduc depuis le 2026-08-20

`Catalogue / Auteurs / Séries / Revues / Prêts / Envies`. À 320 px, les six libellés occupaient
**312 px sans troncature**, huit de marge — c'était la limite, et elle a été atteinte.

⚠️ **La barre d'onglets n'existe plus** : la navigation est passée en haut, dans un menu qui se
déploie. Voir « Le menu est en haut ». La mesure ci-dessus reste instructive pour une seule
raison : elle montre qu'une rangée qui « tient tout juste » ne tient en réalité pas.

## Sagas et cycles — migration `SagasEtCycles` (2026-08-19)

Cas donné : *La Légende de Drizzt*. C'est la première notion du projet qui porte sur une
**relation entre livres** plutôt que sur un livre, et un champ texte « série » sur `Livre` n'en
couvrirait aucun aspect.

| Ce que le cas impose | Ce qui le porte |
|---|---|
| Un cycle contient des **séries**, qui contiennent des tomes | `Serie.SerieParenteId`, auto-référence : un cycle **est** une série de séries |
| L'**ordre de lecture** n'est pas celui de publication (*L'Elfe noir* est une préquelle) | `ElementSerie.Position`, **stockée**, jamais déduite d'une année |
| Un livre appartient à **plusieurs** regroupements | aucune unicité sur `LivreId` seul |
| Montrer les **trous** — « il vous manque le tome 3 » | `ElementSerie.LivreId` **nullable** |

### La table ne contient pas des livres, mais des places

⚠️ C'est la clé de tout l'écran : `ElementSerie` est une **position dans l'ordre de lecture**,
pas « un livre de la série ». Une place sans livre n'est pas une anomalie, c'est le trou qu'on
vient voir.

**Conséquence, décidée avec l'utilisateur le 2026-08-19** : les tomes non possédés **existent en
base** et se saisissent à la main. Aucune source ne donne l'ordre de lecture d'une saga — la BnF
ne l'expose pas, et l'ordre de publication est justement faux pour une préquelle. Sans saisie,
l'écran ne pourrait qu'ordonner ce qu'on a déjà, donc ne rien dire de ce qui manque.

⚠️ **`Titre` est renseigné même quand le livre est là**, et la clé étrangère est en **`SetNull`**,
surtout pas en cascade : supprimer un livre du catalogue laisse un **trou nommé** à sa place.
Une cascade effacerait le tome 3 de la saga parce qu'on a perdu son exemplaire. Un test le
verrouille.

⚠️ L'unicité `(SerieId, LivreId)` est un **index partiel**, comme celui des prêts en cours : les
places vides doivent pouvoir se répéter autant que nécessaire, et deux `NULL` passeraient de
toute façon. Un livre ne tient donc qu'une place **dans une série donnée**, mais vit dans
plusieurs séries.

### Portée : commune, sauf en un seul point

Les séries sont **communes au foyer**, comme le catalogue et les prêts : l'ordre de lecture est
une propriété de l'œuvre, il ne change pas selon qui regarde. Aucune méthode de `ServiceSeries`
ne prend d'utilisateur — **sauf `MettreEnEnviesAsync`**, et c'est là que les deux portées se
rencontrent :

> un tome manquant est **commun** ; l'envie qu'il devient est **personnelle**.

Deux membres du foyer peuvent donc vouloir le même tome absent, chacun dans sa liste. ⚠️ Aucun
auteur n'est transmis à l'envie créée : une place dans une saga n'en porte pas, et en inventer un
fausserait le rapprochement avec le catalogue. Le revers assumé : cette envie **ne sera pas**
marquée « déjà au catalogue » à l'achat du livre, la règle exigeant un auteur commun.

### Deux garde-fous qui n'ont l'air de rien

- **Supprimer un cycle ne détruit pas les séries qu'il contient** (`SetNull` sur la parenté) :
  ce sont de vraies séries de vrais livres, elles remontent simplement à la racine.
- **Une série ne peut pas devenir sa propre ancêtre.** Sans ce contrôle, l'arbre se refermerait
  sur lui-même et l'affichage boucherait à l'infini. La remontée de parenté est **bornée par le
  nombre de séries**, pour s'arrêter même si une boucle existait déjà en base.

### Une seule lecture, un seul instantané

`GET /api/series` rend **tout l'arbre à plat**, le client le rebâtit. Le détail d'une série s'y
lit sans second appel : un instantané hors-ligne (`series`) suffit donc à servir la liste, chaque
détail, **et** le « fait partie de » de la fiche livre — au lieu d'un instantané par série
consultée. C'est aussi ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte
avant la coupure, exactement comme le catalogue complet.

⚠️ Le rafraîchissement en tâche de fond (`RafraichirEnFondAsync`) **doit** inclure les séries,
sans quoi une coupure juste après un ajout de tome rendrait la série d'avant.

### L'écran, et ce qu'il refuse de faire

- **Un cinquième onglet.** (Caduc : la barre d'onglets a disparu le 2026-08-20.)
- **Des flèches, pas de glisser-déposer** : le drag & drop HTML5 ne fonctionne pas au doigt, et
  c'est sur téléphone qu'on consulte une saga. Même décision que pour la liste d'envies.
  ⚠️ Depuis le 2026-08-20, ces flèches ne vivent plus que sur `/series/{id}/ordre` — voir
  « L'ordre de lecture a son propre écran ».
- **Le titre affiché est celui du livre quand il est rattaché**, le titre saisi restant le filet.
  Rattacher le mauvais livre se voit donc immédiatement — et « Détacher » restitue le nom du tome.
- **Aucune détection automatique** de série depuis un titre (« Tome 3 »), aucune source tierce.
  Ce qui est affiché a été saisi ; rien n'est deviné.

## Interface — décisions actées le 2026-08-18

Retours d'usage d'`IDEES.md`, appliqués et donc retirés de ce fichier-là.

### La fiche d'un livre a deux modes, et le mode est dans l'URL

`/livres/{id}` consulte, `/livres/{id}/edition` modifie. Ouvrir une fiche depuis le catalogue ne
doit rien pouvoir casser — sur mobile on ouvre une fiche pour *regarder*, et l'ancienne page
plaçait un formulaire vivant sous le pouce.

Le mode n'est **pas** un booléen interne : le mettre dans l'adresse rend l'écran d'édition
partageable et fait du bouton « retour » du navigateur une sortie d'édition naturelle. Vérifié.

⚠️ **Piège Blazor à connaître.** Les deux `@page` visent le même composant avec le même `Id` :
passer de l'un à l'autre ne change **aucun paramètre**, et le routeur ne déclenche alors **aucun
nouveau rendu**. Constaté en exécution : l'URL passait à `/edition`, l'écran restait en
consultation (alors qu'un chargement direct de l'URL fonctionnait, ce qui masque le défaut).
Le composant s'abonne donc à `NavigationManager.LocationChanged` pour se redessiner lui-même.

### Ce qui reste manipulable sans entrer en édition

Deux exceptions assumées, parce que ce ne sont pas des modifications de la *fiche* :

| Action | Où | Pourquoi hors « Éditer » |
|---|---|---|
| Statut de lecture | bloc dédié sur la fiche en consultation | Action la plus fréquente de l'application, et elle ne touche qu'à **ma** lecture, pas à la fiche commune. L'enfouir derrière « Éditer » serait un recul |
| Prêter / marquer rendu | bloc « Prêt » sur la fiche en consultation | Gestes du quotidien, et le prêt est une donnée **du foyer** : n'importe qui doit pouvoir noter un retour sans ouvrir un formulaire d'édition |

Corollaire : le statut modifié depuis la consultation doit **rafraîchir la saisie du formulaire
d'édition**, sinon un passage ultérieur en édition réécrirait l'ancienne valeur.

La **suppression**, elle, n'est offerte que dans le mode édition : c'est le seul écran où l'on
vient pour changer quelque chose.

### La liste ne se manipule pas

Dans le catalogue, le statut de lecture est une **pastille en lecture seule**, sur la même ligne
que les autres étiquettes. Les trois boutons *À lire / En cours / Lu* directement actionnables
depuis chaque carte ont été retirés : trop faciles à déclencher par erreur en faisant défiler.

Pas de pastille = « non commencé », cohérent avec l'absence de ligne en base.

### Le format ne s'affiche que pour les ebooks

Un livre est **physique par défaut** : c'est le cas majoritaire, et l'étiquette sur chaque ligne
n'apprenait rien. Seul « Numérique » apparaît, dans la liste comme sur la fiche.

⚠️ Conséquence à ne pas casser : le formulaire d'ajout **doit** présélectionner *Physique*,
sinon on créerait des fiches sans format en croyant que « rien d'affiché = physique ». C'est
acquis sans code particulier (`Format.Physique` vaut 0, valeur par défaut de l'énumération) —
mais si l'énumération change d'ordre un jour, ce comportement tombe.

### La navigation est une liste de destinations, pas une rangée d'onglets

`MainLayout` porte les six destinations **Catalogue / Auteurs / Séries / Revues / Prêts /
Envies**. Elles remplacent les listes de liens que chaque page traînait dans sa barre d'actions :
cinq boutons y passaient sur deux ou trois lignes sur un téléphone, et « Envies » ne figurait pas
partout.

Conséquence tenue partout : **`.actions-flottantes` ne porte plus que des *actions***. Tout lien
qui doublonnait exactement une destination du menu a été retiré — « Retour au catalogue »,
« Retour aux auteurs », « Ma liste d'envies ». Ce qui reste est ce qu'aucune destination ne sait
faire (« Ses livres chez vous », qui est un catalogue *restreint*).

⚠️ `NavLinkMatch.All` sur « Catalogue » est **obligatoire** : son `href` est la racine, et sans
cela l'entrée resterait allumée sur tous les écrans.

Les entrées **restent actives hors-ligne** : les six écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.

⚠️ **La position, en revanche, a changé le 2026-08-20** — voir « Le menu est en haut », qui
renverse le « en bas, le pouce atteint le bas de l'écran » écrit ici le 2026-08-18. La variable
`--mb-onglets-hauteur` qui faisait cohabiter les deux barres fixes n'existe plus.

### Les filtres du catalogue se replient derrière un bouton

Deux rangées de segments occupaient en permanence le haut de l'écran pour un réglage qu'on
change rarement. Elles tiennent maintenant derrière « Filtrer », près du titre.

⚠️ **Le compteur sur le bouton (« Filtrer (1) ») est ce qui rend le repli sans danger** : un
filtre actif et invisible ferait conclure que des livres ont disparu. Il ne compte que ce qui
serait invisible une fois replié — ni la recherche, ni la restriction à un auteur, toutes deux
déjà visibles à l'écran.

**La ligne « Tous / Physique / Numérique » disparaît quand le fonds n'a qu'un seul format**,
dans le prolongement de « le format ne s'affiche que pour les ebooks ». Elle réapparaît d'
elle-même au premier ebook — vérifié en exécution.

⚠️ Les formats présents ne se déduisent que d'un chargement **sans aucun critère**
(`EstSansCritere`). Les tirer d'une liste filtrée escamoterait la ligne dès qu'une recherche ne
remonte qu'un format, et l'utilisateur ne pourrait plus l'élargir. Dans le doute, la ligne reste
affichée : un filtre inutile est bénin, un filtre escamoté ne l'est pas.

### Une seule entrée d'ajout, désactivée hors-ligne — pas masquée

« Saisie manuelle » quitte le catalogue : elle reste accessible un écran plus loin, depuis
« Ajouter un livre », qui la propose dès qu'un ISBN ne donne rien.

⚠️ **Décision confirmée le 2026-08-18** : IDEES.md proposait de *masquer* les entrées d'ajout
hors-ligne. C'est refusé, et c'est le maintien de la règle actée. Un bouton disparu est
indiscernable d'une fonction supprimée : l'utilisateur conclurait à une régression au lieu
d'apprendre que c'est le réseau — même raisonnement que « pas dans les données enregistrées sur
cet appareil » plutôt que « n'existe plus ». **Le désencombrement passe par le regroupement**
(deux boutons en un), jamais par l'effacement.

Vérifié serveur arrêté : un seul bouton, `disabled`, portant son motif ; les quatre onglets
intacts ; les livres servis par l'instantané.

### Couverture agrandissable au clic

Vignette et fiche : cliquer l'image l'affiche en grand sur fond sombre, fermeture au clic
extérieur ou par Échap. Le calque est focalisé à l'ouverture (`FocusAsync`) — sans focus, aucun
`keydown` ne lui parviendrait et Échap resterait sans effet.

**Le substitut à initiale n'est jamais cliquable** : le bouton déclencheur n'est rendu que
lorsqu'une URL de couverture existe. Sans couverture, il n'y a rien à agrandir.

## Habillage — retours d'usage du 2026-08-19 traités (2026-08-20)

Lot A d'IDEES.md (4ᵉ série). Quatre items traités ; le cinquième (allègement des couvertures,
WebP) reste volontairement hors scope, voir plus bas.

### A1 : ce n'était pas un bug de style, mais l'absence de revalidation HTTP

Les six onglets s'affichaient parfois en liens bruts, sans aucune règle de
`MainLayout.razor.css`. Cause retenue : `UseStaticFiles()` ne posait **aucun** en-tête
`Cache-Control` — un navigateur peut donc garder `MaBibli.Client.styles.css` en cache
heuristique, **indépendamment du service worker**. Le service worker revalide bien via
`cache: 'no-cache'`, mais seulement quand il parvient à s'activer, ce qui n'est pas garanti
(voir « Le service worker ne s'enregistre pas… »).

**Correction** : `Program.cs` pose désormais `Cache-Control: no-cache, must-revalidate` sur tous
les fichiers statiques (`StaticFileOptions.OnPrepareResponse`). Les empreintes WASM étant
désactivées (noms de fichiers stables, voir plus haut), c'est cette revalidation forcée qui
empêche un navigateur de servir indéfiniment une version périmée — le service worker reste une
seconde ligne de défense, pas la seule.

⚠️ `no-cache` ne veut pas dire « ne jamais mettre en cache » : ETag et Last-Modified, déjà posés
par défaut par `StaticFileMiddleware`, permettent une revalidation conditionnelle (304) sans
retélécharger un fichier inchangé.

### A2 : le filtre reprend les pastilles de couleur des cartes

Le filtre du catalogue (statut de lecture, type de document) affichait des segments neutres,
alors que `ChoixStatut` (statut personnel sur la fiche) colorait déjà ses segments avec
`Libelles.ClasseStatut`. Les segments et l'étiquette de la carte reprennent maintenant la même
classe — le filtre statut réutilise `ClasseStatut`, et une nouvelle méthode
`Libelles.ClasseTypeDocument` (bleu pour Roman, magenta pour BD, aucune couleur pour
« Non précisé », qui n'est pas une valeur choisie) est appliquée aux deux endroits : le segment
du filtre et l'étiquette `etiquette-type` de la carte. Le formulaire de saisie reprend la même
classe, pour ne pas diverger une troisième fois.

### A3 : une identité visuelle, encore le logo par défaut de Blazor

`favicon.png` / `icon-192.png` / `icon-512.png` étaient encore le logo « @ » violet du template
`dotnet new blazorwasm`. Remplacés par un pictogramme de livre ouvert (deux pages en V, tranche
centrale blanche), dans le bleu d'accent existant (`#1b3a5c`) — généré par script (Pillow), pas
par un outil de génération d'images IA, pour rester un simple dessin géométrique. Le même dessin
accompagne « MaBibli » dans le bandeau — ⚠️ **en SVG *en ligne* depuis le 2026-08-20**, et non
plus dans un `logo-bandeau.svg` séparé : voir « L'icône cassée du bandeau ».
`mabibli_ynh/logo.png` (256×256, fond transparent — convention du catalogue d'applications
YunoHost) reprend le même dessin.

### A4 : une couverture cassée ne doit plus rien pouvoir agrandir

Le `onerror` masquait déjà l'image (`image-cassee`), mais le bouton `.couverture-declencheur`
qui l'enveloppe restait cliquable : cliquer une vignette cassée ouvrait quand même le calque
d'agrandissement, avec la même image cassée dedans. Le `onerror` désactive maintenant aussi ce
bouton (`this.closest('.couverture-declencheur').disabled = true`), et `:disabled` porte un
curseur neutre plutôt que `zoom-in`.

### A5 : les couvertures se mettent en cache pour la consultation hors-ligne

Nouveau magasin IndexedDB `couvertures` (clé = URL OpenLibrary, valeur = `Blob`), à côté du
magasin `instantanes` existant — `cache-hors-ligne.js` passe en version 2.

⚠️ **En ligne, l'affichage ne change pas** : `<img src>` pointe directement sur l'URL réseau,
aussi vite qu'avant. La mise en cache (`Cache.MettreEnCacheCouvertureAsync`) est un
fire-and-forget déclenché en parallèle, jamais attendu — elle ne doit rien à la rapidité
d'affichage. Le fetch qu'elle déclenche profite en général du cache HTTP du navigateur (même URL
que l'`<img>` déjà chargée), donc pas de second téléchargement réel dans le cas courant.

**Hors ligne**, `Couverture.razor` tente d'abord `Cache.LireCouvertureCacheeAsync` (URL `blob:`
via `URL.createObjectURL`) et ne retombe sur l'URL réseau — qui échouera simplement, comme avant
cette fonctionnalité — que si rien n'a été mis en cache.

⚠️ **Le jeton de résolution compare `(Url, EnLigne)`, pas seulement `Url`** : passer en ligne →
hors ligne sans que l'URL change (le cas le plus courant, puisqu'on ne change pas de livre en
perdant le réseau) est précisément le cas qui aurait été raté avec une mémoïsation sur la seule
URL — la couverture serait restée invisible hors-ligne malgré un cache rempli.

**Vérifié** : OpenLibrary envoie `Access-Control-Allow-Origin: *` (mesuré via `curl -I`), donc un
`fetch()` cross-origin ordinaire suffit pour lire les octets — pas besoin d'un contournement en
réponse opaque (`mode: 'no-cors'`).

**Ce qui n'est délibérément pas fait** : l'allègement (redimensionner à la taille réellement
affichée, convertir en WebP). IDEES.md posait la question à mesurer avant de s'y engager, même
règle que pour l'AOT WASM — non fait tant que la taille du cache image n'a pas montré de
problème réel en usage.

## Lot B — consulter d'abord, modifier ensuite (2026-08-20)

`CarteLivre.razor` est le composant partagé des cartes du catalogue. Il reçoit un `LivreDto`,
porte la couverture, les auteurs et les étiquettes de format, type, statut, prêt et éditeur,
et le catalogue l'utilise lui-même. Une œuvre bibliographique possédée réutilise cette carte
quand son livre est disponible.

Pour une série, `/series/{id}` est la consultation : parenté, tomes, carte du livre rattaché,
réordonnancement, rattachement, ajout et retrait restent des gestes courants. Seuls le titre,
la parenté dans un cycle et la suppression sont dans `/series/{id}/edition`. Comme pour la
fiche livre, le composant s'abonne à `LocationChanged` car les routes partagent le même paramètre.

Pour les envies, `/souhaits` consulte et conserve l'export et la suppression courante.
`/souhaits/ordre` porte exclusivement le réordonnancement (flèches et glisser-déposer), afin
que ces contrôles ne puissent pas être déclenchés en faisant défiler la liste.

Une œuvre bibliographique non possédée n'a pas de fiche locale. Son titre est donc un bouton
qui déplie inline son ISBN et son nombre d'éditions, tandis que les œuvres possédées ouvrent
leur fiche via la carte partagée.

## Lot E — trouver ce qu'on veut souhaiter (2026-08-20)

L'écran `/souhaits/ajout` affiche désormais la couverture et l'ISBN formaté de chaque résultat
BnF. Le tri est local, sans nouvel appel réseau : date (récent en premier), titre ou éditeur.
L'ISBN reste nu dans les données envoyées et seul `FormatageIsbn.Afficher` intervient à l'écran.

La recherche par auteur expose un bouton **Nouveautés** depuis la bibliographie. Elle utilise un
endpoint dédié (`/api/auteurs/{id}/bibliographie/nouveautes`) afin de ne pas modifier le contrat
de la bibliographie générale. Les résultats sont les œuvres non possédées et non déjà souhaitées
dont la dernière édition BnF connue est postérieure à l'édition la plus récente possédée.

⚠️ Le représentant d'une œuvre reste la notice la plus ancienne : `OeuvreBibliographie.Annee`
reste donc cette date, tandis que `AnneeDerniereEdition` conserve le maximum des dates de toutes
les notices regroupées. C'est cette seconde valeur qui sert au filtre « paru depuis ». Le seuil
est recherché d'abord par ISBN (ISBN-13 et ISBN-10 sont rapprochés), puis par clé d'œuvre.

La bibliographie générale conserve son plafond de deux pages (200 notices). Pour les nouveautés,
la BnF est paginée jusqu'à **10 pages de 100 notices (1000 maximum)**, ou moins si le nombre
annoncé est inférieur. La première page donne le total, puis les pages nécessaires sont lancées
en parallèle. Ce bornage est volontaire : le SRU est classé par pertinence et ne permet pas de
déduire qu'une page suivante ne contient plus de date récente ; au-delà de 1000, la liste reste
un extrait signalé par `Tronquee`, sans boucle ni appels indéfinis. Une source muette conserve
les mêmes `EtatSourceBibliographie` et motifs que la bibliographie normale.

## Relais de couvertures — `GET /api/couvertures` (2026-08-20)

Le cache hors-ligne des couvertures (lot A5) ne fonctionnait **que pour OpenLibrary**, et ce
n'était pas un défaut d'implémentation mais une limite non vue : il lit les octets par
`fetch()`, ce qui exige un en-tête `Access-Control-Allow-Origin`. Or le formulaire livre offre un
champ **« URL de couverture »** libre — ces images s'affichaient parfaitement (une `<img>` n'a
que faire du CORS) sans jamais pouvoir être mises en cache.

⚠️ **Le symptôme visible n'était pas le bon** : des erreurs CORS en console, avalées par le
`catch`, donc réputées bénignes. La vraie conséquence est ailleurs, en deux temps : couverture
absente hors-ligne, **et** fetch retenté à *chaque* affichage puisque rien n'était jamais rangé.

**Décidé avec l'utilisateur le 2026-08-20 : relais côté serveur** plutôt que renoncer. L'image
passe par `GET /api/couvertures?url=…`, devient de **même origine**, et la question du CORS
disparaît pour tous les hébergeurs.

### ⚠️ Un proxy est une surface SSRF — deux verrous, aucun ne suffit seul

Le service écoute sur `127.0.0.1` et cohabite avec les autres applications du serveur YunoHost.
Un relais non borné permettrait de faire lire au serveur ce que l'appelant ne peut pas atteindre
lui-même — c'est-à-dire de contourner la contrainte d'écoute que ce fichier pose comme dure.

| Verrou | Ce qu'il empêche | Ce qu'il ne suffit pas à empêcher |
|---|---|---|
| **L'URL doit déjà être en base** (`Livres.CoverUrl` ou `LivresSouhaites.CoverUrl`, égalité exacte) | qu'on fasse chercher une URL choisie au moment de l'appel | qu'une URL interne ait été enregistrée dans une fiche |
| **La connexion ne s'ouvre que vers une adresse publiquement routable** (`GardeAdresses`) | tout le reste, y compris le cas ci-contre | — |

⚠️ **Le garde agit dans `SocketsHttpHandler.ConnectCallback`, pas avant la requête.** Résoudre le
nom d'abord puis laisser `HttpClient` résoudre à nouveau laisserait passer un **DNS rebinding** :
un nom qui répond une adresse publique à la vérification et `127.0.0.1` à la connexion. Ici la
socket se connecte **aux adresses déjà validées**, et à aucune autre.

⚠️ La liste des plages est **négative**, contrairement aux filtres de type de document du projet
qui sont positifs : ici, oublier une plage est une faille, alors qu'en écarter une de trop ne
coûte qu'une couverture non mise en cache. Sont refusés bouclage, `10/8`, `172.16/12`,
`192.168/16`, `169.254/16` (métadonnées d'hébergeur), `100.64/10`, `0/8`, multicast, `fe80::/10`,
`fc00::/7`, et **les IPv4 encapsulées en IPv6** (`::ffff:127.0.0.1`) — ce dernier cas est
exactement celui qu'on oublie.

⚠️ **Tout refus répond 404**, sans distinguer « pas en base » de « injoignable » ou « adresse
interdite ». Autrement le point d'entrée serait un **oracle** : on y lirait quelles URL
l'application connaît, et quelles adresses répondent depuis le serveur.

### ⚠️ Les redirections sont SUIVIES — la première version avait tort

Elles avaient d'abord été coupées, au motif qu'un 302 emmène vers une URL que rien n'a validée.
Mesuré : `covers.openlibrary.org` répond **302**, deux fois, avant d'aboutir sur `archive.org`.
Les couper refusait donc **les couvertures les plus courantes du projet**.

Les suivre reste sûr précisément parce que le garde est **à la connexion** : il s'applique à
chaque saut, cible de redirection comprise. C'est la position du garde, et non l'interdiction de
rediriger, qui ferme le SSRF. Bornées à 3 sauts.

### L'ordre des tentatives, côté client

`couvertureMettreEnCache` tente **d'abord l'URL directe** — elle profite du cache HTTP du
navigateur (même URL que l'`<img>` déjà chargée) et n'impose rien à notre serveur — et ne
retombe sur le relais que si le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne
conviendrait pas : son corps est illisible, donc impossible à ranger en IndexedDB.

Corollaire agréable : une fois le blob rangé, le `getKey` en tête de fonction court-circuite
tout. **L'erreur CORS n'apparaît donc qu'une fois par couverture et par appareil**, au lieu d'à
chaque affichage.

### Deux pièges rencontrés, et un troisième à ne pas oublier

- ⚠️ **`.Produces(200, contentType: "image/*")` fait échouer le DÉMARRAGE du serveur**, pas la
  compilation : l'annotation OpenAPI refuse les jokers. Le type varie avec l'image, il n'est
  donc pas annoté.
- ⚠️ **Le `Content-Length` ne borne rien à lui seul** : absent en *chunked*, et rien n'oblige un
  serveur distant à dire la vérité. La lecture est bornée pendant qu'elle se fait (5 Mio).
- ⚠️ **Un `dotnet run` déjà en cours garde le port** : le second démarre, échoue à écouter, et
  l'on mesure sans le savoir le binaire d'avant. Arrivé ici, et cela a produit trois résultats
  faux avant d'être vu.

### Vérifié en exécution

| Cas | Réponse |
|---|---|
| URL en base, hôte sans CORS | **200 image/jpeg, 15 777 o** |
| URL en base, OpenLibrary (2 redirections) | **200 image/jpeg, 49 153 o** |
| URL inconnue de la base | 404 |
| `http://` | 404 |
| `https://127.0.0.1:5030/favicon.png` | 404 |
| `https://169.254.169.254/…` | 404 |
| sans paramètre | 404 |

Et dans le navigateur : le magasin IndexedDB `couvertures` contient désormais **les deux**
images, dont celle de l'hébergeur sans CORS — impossible auparavant. Au rechargement suivant,
**aucune** requête vers cet hôte ni vers le relais. 479 tests au vert.

## Installation YunoHost — éprouvée en production le 2026-08-18

Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
mis à jour et sauvegardé sur un vrai serveur ; ce qui suit est ce que l'exercice a
appris, pas ce qu'on en attendait.

### La chaîne, telle qu'elle tourne

```
publier-release.sh  →  archive tar.gz (69 Mo, self-contained)  →  release Gitea
                    →  manifest.toml (amd64.url + amd64.sha256)
                    →  yunohost app install <url du dépôt _ynh>
```

Le script est la seule source des trois valeurs qui doivent rester cohérentes :
version, URL, sha256. ⚠️ Il ne réécrit **que** `manifest.toml` — les autres mentions
de l'URL (`README.md`, `Documentation=` de l'unité systemd) sont à traiter à la main.

⚠️ **Le dépôt du code doit être public.** `ynh_setup_source` télécharge sans jeton.
Gitea répond **404 et non 403** à un anonyme sur un dépôt privé : le symptôme est
rigoureusement identique à « la release n'existe pas », ce qui envoie chercher au
mauvais endroit. Contrôle qui tranche, hors session authentifiée :
`curl -fsSLI "<url du manifeste>"`.

⚠️ **YunoHost lit le manifeste depuis Gitea**, jamais la copie locale. Une correction
non poussée est une correction qui n'existe pas — constaté deux fois.

### Deux pièges systemd, tous deux invisibles hors d'un vrai serveur

Ni l'un ni l'autre ne peut sortir d'un `dotnet run` ou d'un lancement du publish à la
main : ils tiennent au gestionnaire de services, pas à l'application. C'est ce qui
justifie de tester l'installation réelle plutôt que le seul binaire.

| Piège | Symptôme | Correction |
|---|---|---|
| `Environment=` **découpe sur les espaces** | `ArgumentException: Format of the initialization string … at index 0` | guillemeter **toute** la ligne : `Environment="ConnectionStrings__MaBibli=Data Source=…"` |
| `ProtectHome=yes` masque `/home` | `SQLite Error 14: unable to open database file` | `ProtectHome=tmpfs` + `BindPaths=__DATA_DIR__` |

Le premier ne définissait pas une variable mais **deux** : `…__MaBibli=Data` et un
`Source=…` parasite. Le second est contre-intuitif parce que le `data_dir` de
YunoHost vit sous `/home/yunohost.app/<app>` : `ReadWritePaths=` **ne perce pas**
`ProtectHome` — mesuré sur une unité de test, `yes` + `ReadWritePaths` échoue,
`tmpfs` + `BindPaths` réussit. `tmpfs` garde l'essentiel du bénéfice : les répertoires
personnels du serveur restent invisibles au service.

### `install_dir` n'appartient pas à root, contrairement au manifeste

Le manifeste déclare `owner = "root:rwx"` pour que le service ne puisse pas réécrire
ses propres binaires. En pratique le helper `_ynh_apply_default_permissions` repasse
derrière avec `chown -R <app>:<app>`. Observé : `/var/www/mabibli` appartient à
`mabibli`.

**L'intention tient quand même**, mais par un seul mécanisme au lieu de deux :
`ProtectSystem=strict` met tout le système en lecture seule dans le namespace du
service, `ReadWritePaths=` ne listant que le `data_dir`. ⚠️ Ne pas retirer
`ProtectSystem=strict` en croyant que la propriété des fichiers protège encore.

### Ce qui a été vérifié en exécution

- **Installation** : archive téléchargée, sha256 contrôlé, migrations EF Core
  appliquées au premier démarrage (`Application started` en 5 s, timeout de 60 s
  largement suffisant), base créée dans `data_dir`.
- **Écoute sur `127.0.0.1` uniquement** — la contrainte dure de « Intégration SSO »
  est tenue en production, vérifiée par `ss -tlnp`.
- **Mode WAL confirmé sur le serveur** (`PRAGMA journal_mode` → `wal`) : la
  justification du `.backup` de `scripts/backup` n'est pas théorique. Les fichiers
  `-wal`/`-shm` n'existent pas au repos (SQLite fait un checkpoint à la fermeture de
  la dernière connexion) — leur absence dans un listing ne veut pas dire que le mode
  a changé.
- **Sauvegarde** : l'archive contient bien `mabibli-instantane.db` **à côté** de la
  base vivante et de ses `-wal`/`-shm`. C'est la restauration qui écarte ces derniers.
- **Mise à jour** : la sauvegarde de sécurité pré-upgrade **saute `data_dir`**
  (`BACKUP_CORE_ONLY`), comme prévu, et la bibliothèque survit au remplacement
  intégral d'`install_dir`.
- **Désinstallation** : `remove` et `remove --purge` fonctionnent tous deux. Sans
  `--purge`, `data_dir` survit — donc **réinstaller après un `remove` retrouve
  l'ancienne base**. Ce n'est pas une installation vierge, même si tout le reste est
  neuf : piège à connaître en phase d'essai.

**La marche à suivre — première mise en production, montée de version, retour arrière,
désinstallation — est écrite pas à pas dans `README.md`, section « Mettre en
production ».** Elle n'est pas recopiée ici : ce sont des gestes, pas des décisions.

⚠️ Depuis le 2026-08-20, **toute la documentation du projet vit dans `README.md`**, y
compris ce qui décrit le paquet YunoHost (`PUBLICATION.md` et `A_FAIRE.md` y ont été
fondus, `mabibli_ynh/README.md` n'est plus qu'un renvoi). Ne pas recréer de guide dans
`mabibli_ynh` : deux jeux de documentation pour une seule chaîne de publication
finissaient par diverger, sans qu'on sache lequel faisait autorité. Les seules
exceptions sont `mabibli_ynh/doc/DESCRIPTION.md` et `doc/ADMIN.md`, que **YunoHost lit
lui-même** pour les afficher dans son catalogue et son interface d'administration.

### Ce que l'installation impose et qui ne se contourne pas

**Un domaine entier** (`mabibli.mondomaine.tld`), pas un sous-chemin : le
`<base href="/">` et les empreintes de `service-worker-assets.js` sont figés à la
compilation, et rien n'est recompilé sur le serveur. Le manifeste déclare donc
l'application en `full_domain`.

## Historique du projet (pourquoi ces choix)

L'utilisateur a testé deux solutions existantes avant de se lancer dans un projet custom :

- **uBiblio** (Docker, Python) — gère prêts + scan ISBN, mais dépend de Google Books (clé API requise) pour l'autofill, ce qui ne convient pas à l'utilisateur qui veut éviter cette dépendance
- **BookLogr** (Docker, Python) — utilise OpenLibrary nativement mais a le bug décrit ci-dessus (auteur non récupéré)

Conclusion : aucune des deux solutions existantes ne coche toutes les cases (prêts + pas de dépendance Google + autofill fiable) → développement d'une solution sur mesure.

## Prochaines étapes suggérées

1. Scaffolder le projet Blazor WebAssembly PWA (`dotnet new blazorwasm --pwa`) — **commande vérifiée valide en .NET 10**, l'option `--pwa` existe toujours
2. Ajouter le **projet API ASP.NET Core** : le client WASM tourne dans le navigateur, SQLite vit côté serveur — une API est indispensable, elle n'était pas explicitée dans la version initiale de ce document
3. Mettre en place le modèle EF Core + SQLite + migrations
4. Implémenter le service de lookup ISBN (OpenLibrary avec le double-appel titre+auteur)
5. CRUD livres (physique/numérique, statuts de lecture)
6. Gestion des prêts
7. Intégration scan caméra (**ZXing.Net** + interop caméra minimal)
8. ~~Cache hors-ligne pour la consultation~~ — fait le 2026-08-18 (voir « Stratégie hors-ligne »)
9. ~~Packaging YunoHost~~ — fait le 2026-08-18, installé et mis à jour sur un serveur réel (voir « Installation YunoHost »)

## Questions ouvertes

**Tous les choix structurants ont été tranchés le 2026-08-17** — voir le tableau des décisions. Le cadrage est clos, le développement peut commencer.

Points à réévaluer en cours de route, sans blocage :

- **AOT WASM** : mesurer le scan sur un vrai téléphone une fois fonctionnel. Activer `RunAOTCompilation` seulement si la fluidité est insuffisante.
- **Runner Gitea Actions** : à vérifier le jour où l'utilisateur voudra automatiser les releases.
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.

## Lot C — séries, cycles et arborescence (2026-08-20)

Le cycle affiche désormais un arbre de profondeur non bornée par la base : `ArbreSerie.razor`
descend récursivement les séries à partir de la liste plate de `GET /api/series`. Le chemin déjà
rendu est transmis au composant afin qu'une boucle résiduelle en base coupe le rendu au lieu de
provoquer une récursion Razor infinie ; le garde-fou serveur de remontée reste borné par le nombre
de séries.

Les séries directement contenues dans un cycle se réordonnent par flèches via
`PUT /api/series/{id}/sous-series/ordre`. Les tomes conservent leur propre ordre et la règle
hors-ligne reste la même : les boutons d'écriture restent visibles mais désactivés avec
`EtatReseau.MotifHorsLigne`.

Le pliage/dépliage est un état local de l'écran (`HashSet<int>`), volontairement non persistant :
il s'agit d'un confort de lecture, pas d'une propriété de la série. Une série repliée montre son
résumé ; une série dépliée montre ses tomes et ses descendants.

Les compteurs « possédés / total » sont calculés côté client par `TotauxSeries`, en sommant
récursivement les éléments directs et ceux de tous les descendants. Le calcul est lui aussi
protégé contre une boucle.

Depuis une place manquante, un ISBN passe par la cascade BnF puis OpenLibrary existante. Le
candidat choisi peut créer un livre du catalogue puis rattacher la place, ou créer une envie avec
les métadonnées complètes, notamment l'auteur trouvé par le lookup. Les revues (préfixe `977`) ne
font pas partie de ce flux : `ElementSerie.LivreId` ne pointe que vers `Livre`.

## Lot D — bibliographie écran de travail (2026-08-20)

La bibliographie conserve maintenant les résultats réussis pendant la session du client. Ce cache
est volontairement **mémoire uniquement**, par auteur et par instance de `ServiceLivresApi` :
une bibliographie dépend de la réponse BnF et des souhaits personnels, et n'est pas un instantané
de la bibliothèque. Une réponse `EtatSourceBibliographie.Ok` remplace toujours l'entrée ; une
réponse `SourceMuette` n'est jamais mise en cache et ne consulte jamais une entrée précédente.
Ainsi un incident BnF ne peut pas figer une bibliographie vide.

L'écran possède un filtre replié derrière « Filtrer », avec compteur, distinguant tout le fonds,
les œuvres possédées et celles à découvrir. Les œuvres possédées réutilisent `CarteLivre` et
affichent donc le statut de lecture personnel déjà fourni par `LivreDto`.

Le masquage est une décision **personnelle**, comme la liste d'envies : il exprime qu'une œuvre
n'est pas pertinente pour le lecteur courant, et ne doit pas imposer ce choix au foyer. La table
`BibliographiesMasquees` est indexée par utilisateur, auteur et clé d'œuvre ; le compteur visible
et le filtre « Afficher les masquées » empêchent tout masquage silencieux. Masquer est une
exception explicite à la règle générale « griser, ne jamais masquer ».

Enfin, plusieurs œuvres non possédées peuvent être cochées puis ajoutées aux envies en une seule
action d'écran. Chaque ajout reprend le même rapprochement et les mêmes validations que l'ajout
unitaire ; les doublons sont rapportés sans effacer la sélection des autres œuvres.

## Lot F — enrichir la fiche d'un livre (F1 et F3, 2026-08-20)

### F1 : lien vers la notice source

`Livre.UrlNotice` conserve l'URL de la notice utilisée pour préremplir une fiche depuis un
lookup ISBN. La BnF expose dans `srw:recordIdentifier` un ARK stable
(`ark:/12148/cb…`) : il est transformé en `https://catalogue.bnf.fr/{ark}`. OpenLibrary expose
la clé d'édition (`/books/OL…M`) : elle devient
`https://openlibrary.org/{clé}`. Ce sont des pages de notice directement consultables, et non
les endpoints JSON.

L'URL traverse `CandidatLivre`, `EnregistrementLivre` et le client jusqu'à la création du livre.
Une saisie manuelle laisse la colonne nulle. Les livres déjà en base n'ont pas cette information :
aucun rétro-remplissage n'est possible puisque la notice choisie n'a pas été conservée à l'époque.
La fiche affiche « Voir la notice source » uniquement quand l'URL existe.

### F3 : renommer et fusionner un auteur

`PUT /api/auteurs/{id}` recalcule `NomNormalise` et `CleRegroupement`. Si la clé de regroupement
est déjà portée par une autre fiche, l'API répond `409` avec la fiche cible ; l'écran explique
la fusion et demande confirmation. La confirmation réutilise la fusion existante : tous les liens
`LivreAuteur` sont réattribués, les doublons de lien pour un même livre sont réunis en conservant
la position la plus précoce et un rôle renseigné plutôt que `NonPrecise`, puis la fiche source et
ses refus deviennent obsolètes. Sans collision, le renommage est direct. Cette opération est
volontairement distincte de la renormalisation automatique : c'est un choix explicite de
l'utilisateur.

### F2 : thèmes en étiquettes (2026-08-20)

Les thèmes décrivent l'œuvre (« dark fantasy », « space opera »), pas la relation personnelle
d'un lecteur à un livre. Ils sont donc **communs au foyer**, comme le catalogue, les auteurs et
les séries : `AjoutePar` reste une trace de saisie et ne crée aucune frontière. Les statuts de
lecture et les envies restent les seules données personnelles parce qu'elles expriment une
relation de l'utilisateur, et non une propriété de l'œuvre.

La saisie est **manuelle uniquement**. La BnF expose bien `dc:subject`, mais son vocabulaire
Rameau est une indexation documentaire professionnelle et ne correspond pas aux étiquettes
attendues par l'utilisateur. Aucun thème n'est donc déduit automatiquement d'un lookup ISBN ;
comme pour les rôles d'auteurs, l'utilisateur choisit ce qu'il veut réellement afficher.

Le modèle est une table dédiée `Theme` (`Nom`, `NomNormalise`) et une table n-n `LivreTheme`,
avec clé composite et suppression en cascade depuis le livre. La forme normalisée réutilise
`NormalisationTexte` et porte un index unique : « Fantasy » et « fantasy » partagent la même
fiche. Le formulaire accepte une liste séparée par des virgules, dédoublonnée avant résolution ;
le premier libellé saisi devient la forme affichée. Les thèmes apparaissent en étiquettes sur la
carte partagée `CarteLivre` et sur la fiche en consultation.

Le filtre par thème n'est **pas inclus dans F2**. Il aurait fallu ajouter une dimension aux deux
implémentations de filtrage (`FiltreLivres` et `FiltreLivresLocal`), au contrat de cache et aux
tests de confrontation ; cette extension est reportée à un lot ultérieur plutôt que d'introduire
un filtre partiel ou divergent.

## Lot G — filtrer les prêts par emprunteur (2026-08-20)

`Prets.razor` filtre désormais la liste des prêts en cours par nom d'emprunteur, en mémoire
côté client. **Pas de colonne normalisée ajoutée** : la liste des prêts en cours est courte par
construction (ce qui n'est pas à la maison, pas tout l'historique), une table déjà bornée en
pratique par la taille d'un foyer — ajouter une forme normalisée en base et l'entretenir via
`ServiceRenormalisation` aurait été disproportionné pour ce seul besoin, conformément à la
mise en garde d'IDEES.md.

La recherche passe par `NormalisationTexte.Normaliser`, comme partout ailleurs : insensible aux
accents et à la casse, cohérente avec le reste de l'application sans dupliquer de logique. Le
champ n'apparaît que s'il y a plus d'un prêt en cours — avec un seul prêt, filtrer n'apprendrait
rien, même raison que les lignes de filtre qui se cachent déjà ailleurs dans le catalogue quand
elles seraient inutiles.

## La mise à jour s'applique seule au démarrage — corrigé le 2026-08-20

Défaut remonté en usage sur l'**application installée sur téléphone** : bandeau « Une nouvelle
version est disponible », clic sans effet, chargement figé à **100 %**, puis un menu principal
d'une version antérieure au lot d'interface du 2026-08-18 (pas d'onglets en bas, filtres
dépliés, « Saisie manuelle » au catalogue).

### Ce que la capture d'écran prouve, et qu'aucune supposition ne remplaçait

Le bandeau s'affichait **sans son CSS** : texte brut en haut de l'écran, bouton par défaut, alors
que `#mb-maj` le pose en barre fixe **en bas**, fond bleu, bouton jaune. Or `js/mise-a-jour.js`
et la règle `#mb-maj` de `css/app.css` sont arrivés dans le **même commit** (`ebc5f95`). Le JS
était donc là, son CSS non — pendant que le cercle de chargement, issu du même `app.css`, était
bien stylé.

⚠️ **Le symptôme n'était donc pas « la mise à jour ne s'applique pas » mais « une mise à jour
s'est appliquée à moitié ».** Un blocage à 100 % est la signature exacte d'un assemblage
dépareillé : tout est téléchargé, le manifeste de démarrage et les `.wasm` ne concordent pas,
l'application ne démarre jamais. Chercher un défaut dans le bouton aurait manqué la cause.

### Le fallback n'hérite pas des `StaticFileOptions` — mesuré

Le lot A1 avait posé `Cache-Control: no-cache, must-revalidate` sur `UseStaticFiles`. Mesuré sur
un publish du code de l'époque :

| Requête | Cache-Control |
|---|---|
| `/index.html` | `no-cache, must-revalidate` ✅ |
| `/_framework/*` | `no-cache` (posé par `UseBlazorFrameworkFiles`) ✅ |
| **`/`** | **aucun** ❌ |

`MapFallbackToFile` a son **propre pipeline** et ne passe pas par les options du middleware
statique. Or `/` est le **`start_url` de la PWA**, la seule URL qu'ouvre l'application installée :
elle repartait en cache heuristique du navigateur (une fraction de l'âge du fichier), donc
potentiellement périmée pendant des jours. A1 avait couvert tous les fichiers statiques **sauf le
seul qui compte pour une PWA installée**. La coquille HTML décide de tout le reste : la servir
périmée suffit à mélanger deux versions sur l'appareil.

Correction : `app.MapFallbackToFile("index.html", optionsFichiersStatiques)`. Vérifié après
correction — `/`, `/livres/3` et `/souhaits` portent tous l'en-tête.

### L'enregistrement se résolvait contre l'URL du document, pas contre `<base>`

`register('service-worker.js')` se résout contre l'**URL du document**, et **non** contre
`<base href="/">`. Ouvrir l'application sur une route profonde visait donc
`/souhaits/service-worker.js` — vérifié : **404**, l'extension empêchant le fallback SPA de
répondre. L'enregistrement échouait alors silencieusement, **et avec lui tout le hors-ligne**.
Le chemin est désormais absolu, avec `scope: '/'` explicite.

### Trois maillons sans filet

`SKIP_WAITING` → `skipWaiting()` → `controllerchange` → `reload()`. Si un maillon manquait — un
worker en attente antérieur au gestionnaire de message, une reprise en main qui n'a pas lieu — le
bouton se grisait et **rien ne se passait, sans que rien ne le dise**. Deux ajouts :

- `self.clients.claim()` dans `onActivate` : `skipWaiting()` est censé reprendre les pages seul,
  mais c'est cette reprise qui déclenche `controllerchange`, donc le rechargement ;
- un rechargement **forcé** 5 s après `SKIP_WAITING` si le contrôleur n'a pas changé.

⚠️ **Le rechargement forcé est borné à une fois par session** (`sessionStorage`), et **renonce**
si le stockage est refusé (navigation privée). Sans ce garde-fou, une version qui n'arrive pas à
s'activer transformerait une mise à jour ratée en **boucle de rechargement**, c'est-à-dire en
application inutilisable — bien pire que le défaut d'origine.

### ⚠️ Le clic n'est plus obligatoire — décision inversée, et pourquoi

CLAUDE.md actait « le message ne vient que de `js/mise-a-jour.js`, après un clic explicite :
jamais tout seul, pour ne pas mélanger deux versions au milieu d'une session ». Le raisonnement
reste juste — recharger sous une saisie en cours fait perdre le formulaire — **mais il ne vaut
que pour une session déjà entamée**.

D'où `FENETRE_DEMARRAGE` (10 s) : une version prête dans les premières secondes après l'ouverture
s'applique **toute seule, sans bandeau** ; au-delà, on repasse par le bandeau. Ouvrir
l'application depuis l'écran d'accueil du téléphone tombe toujours dans cette fenêtre — c'est
exactement le cas qui ne se mettait plus à jour, et c'est celui où l'interruption ne coûte rien.

### Ce qui n'est pas vérifiable ici, et qu'il faut confirmer sur le téléphone

Le navigateur d'automatisation **n'enregistre aucun service worker** (voir la section dédiée) :
la chaîne complète n'a donc pas pu être éprouvée en exécution. Ce qui **est** vérifié : les
en-têtes sur toutes les routes, le 404 du chemin relatif, le démarrage de l'application avec le
nouveau script, et les 441 tests.

⚠️ **Un appareil déjà dans l'état dépareillé ne se répare pas tout seul** : son cache mélangé
précède le correctif. Il faut vider les données du site (ou désinstaller puis réinstaller la
PWA) **une fois**. Le correctif empêche d'y retomber, il ne défait pas ce qui est déjà en place.

## Lot H — retours d'usage du 2026-08-20 (5ᵉ série)

Seize points remontés en usage sur l'application installée. Ils se rangent en cinq
familles ; l'ordre ci-dessous est celui du traitement, pas celui de la remontée.

### Le menu est en haut — décision de 2026-08-18 renversée

Constat de l'utilisateur : la barre d'onglets du bas « s'affiche toujours très mal ».

⚠️ **Le raisonnement du 2026-08-18 reste juste, et il ne suffisait pas.** « Le pouce
atteint le bas de l'écran » est vrai ; mais une barre qui ne s'affiche pas correctement ne
se touche pas du tout. Ce qui manquait n'était pas la position, c'était **un état de
repli** : six onglets en `flex: 1 1 0` sur une barre fixe n'ont aucun comportement de
secours — dès que la place manque, ou que la feuille de style scopée n'est pas celle
attendue, les six libellés se collent, se tronquent ou s'empilent sans mise en forme.

Le remplacement suit le gabarit **Blazor par défaut**, pour cette raison précise : sans la
moindre feuille de style, il reste **une suite de liens lisibles les uns sous les autres**.

| Largeur | Menu | Bascule |
|---|---|---|
| < 40 rem (téléphone) | masqué, se déploie au clic | visible |
| ≥ 40 rem | rangée horizontale centrée sous le bandeau | masquée |

Le point de rupture est à **40 rem** et non aux 48 rem du reste de la feuille : six
libellés courts tiennent bien avant que la grille de cartes ne s'élargisse, et les cacher
derrière une bascule sur une tablette serait un clic de trop. Vérifié en exécution à
375 px (menu `none`, bascule `flex`) et à 1280 px (menu `row`, bascule `none`), sans
débordement horizontal ni dans un cas ni dans l'autre.

⚠️ Le menu **se referme sur `LocationChanged`** : `NavLink` ne referme rien de lui-même, et
un clic sur « Auteurs » laissait les six entrées empilées au-dessus de la liste atteinte.

Conséquence sur `app.css` : `--mb-onglets-hauteur` **a disparu**, et `.actions-flottantes`
se pose directement en `bottom: 0`. Plus rien ne l'accompagne en bas de l'écran.

### ⚠️ Les liens du menu n'étaient atteints par AUCUNE règle — `NavLink` est un composant

Symptôme remonté deux fois, la seconde capture d'écran à l'appui : « toujours les liens trop
moches et pas ressemblant à un menu ». Le `<nav>` prenait bien son fond bleu sombre, et les six
entrées restaient en **bleu Bootstrap souligné**, empilées sans marge — l'aspect exact d'un
`<a>` sans style.

**Ce n'était pas un cache, contrairement aux deux défauts précédents** (lot A1, icône du
bandeau), et c'est ce qui a fait perdre du temps : la feuille scopée était bien chargée, elle
habillait le bandeau juste au-dessus.

La cause est une règle de l'isolation CSS de Blazor :

> l'attribut de portée (`b-xxxx`) n'est posé que sur les éléments écrits dans le balisage du
> composant courant — **jamais sur ce que rend un composant enfant**.

`NavLink` **est un composant**. `.menu-lien` déclaré dans `MainLayout.razor.css` devenait
`.menu-lien[b-xxxx]`, et l'`<a>` rendu par `NavLink` ne porte pas cet attribut : la règle ne
correspondait à rien. `.menu` et `.bandeau`, eux, sont du balisage propre au composant, d'où le
fond sombre présent et les liens nus. C'est précisément pour cela que le gabarit Blazor par
défaut écrit ses règles de navigation avec `::deep`.

**Correction retenue : les règles du menu passent en feuille GLOBALE** (`wwwroot/css/app.css`),
plutôt que `::deep`. Les deux corrigent le bug ; la feuille globale corrige en plus une
fragilité connue — la navigation ne dépend alors ni du bundle `MaBibli.Client.styles.css` ni de
son cache, c'est-à-dire du fichier même qu'un appareil a déjà servi périmé ou pas du tout. **La
navigation est ce qui doit le moins pouvoir tomber.**

⚠️ Le point de rupture de **40 rem** est désormais écrit à **deux endroits** : `app.css` (le
menu passe en rangée) et `MainLayout.razor.css` (la bascule se masque — elle, c'est du balisage
propre, donc scopable). Les deux doivent rester identiques.

**Règle à retenir au-delà de ce cas** : toute règle scopée visant un élément rendu par un
composant enfant (`NavLink`, et tout composant du projet) est morte-née. Soit `::deep`, soit la
feuille globale.

Vérifié en exécution, styles calculés à l'appui : à 375 px, menu déployé, liens en `#e8edf3`,
`text-decoration: none`, 45 px de haut, l'entrée active en blanc gras avec sa bordure jaune ; à
1280 px, rangée horizontale centrée (six liens sur la même ligne à y = 55) et bascule
`display: none`, sans débordement horizontal.

### ⚠️ Le manifeste PWA se demandait sans cookie — bloqué par le portail

Relevé dans la console de l'application installée, le 2026-08-20 :

```
Access to manifest at 'https://akbar.nohost.me/yunohost/sso?r=…'
  (redirected from 'https://mabibli.akbar.nohost.me/manifest.webmanifest')
  blocked by CORS policy: No 'Access-Control-Allow-Origin' header
```

**Le navigateur demande le manifeste sans cookie par défaut** — c'est une requête CORS
anonyme, contrairement aux autres balises du `<head>`. SSOwat ne reconnaît donc aucune session
et redirige vers le portail YunoHost, qui est **une autre origine** : la réponse ne porte aucun
`Access-Control-Allow-Origin`, et le manifeste est purement et simplement jeté.

D'où `crossorigin="use-credentials"` sur le `<link rel="manifest">`, qui fait porter le cookie
de session à cette requête-là. C'est le remède standard de toute PWA derrière une
authentification par portail, et il vaut pour **toutes** les instances YunoHost, pas seulement
celle de l'utilisateur.

⚠️ **Le symptôme n'est pas une application cassée, et c'est ce qui le rend facile à ignorer** :
l'application fonctionne parfaitement dans le navigateur. Ce qui tombe, c'est ce que le
manifeste décide **à l'installation** — nom, icônes, `start_url`, mode `standalone`. Une PWA
installée depuis une page dont le manifeste n'a jamais été lu s'installe comme un simple
raccourci de navigateur.

⚠️ `index.html` est un fichier **statique**, pas du Razor : les commentaires y sont des
`<!-- -->`. Un `@* … *@` y resterait du texte.

Vérifié en exécution : `crossOrigin === "use-credentials"` sur le lien, manifeste servi en
`200 application/manifest+json`, et plus aucune erreur le concernant dans la console.

### Un retour explicite dans le bandeau

« Le déplacement est foireux » : d'un écran profond (bibliographie, fiche de tome, ajout
d'envie), il fallait deviner quelle destination du menu ramenait en arrière.

Le retour passe par l'**historique du navigateur**, jamais par une destination calculée :
un même écran est atteignable par plusieurs chemins — une fiche livre s'ouvre depuis le
catalogue, une bibliographie, une série ou une recherche.

⚠️ `history.back()` **seul ne suffit pas** : ouverte depuis l'écran d'accueil du téléphone,
la PWA démarre sur une pile d'un seul cran et « retour » **sortirait de l'application**.
D'où `js/navigation.js`, qui teste `history.length` — sans équivalent côté C# — et retombe
sur le catalogue.

### L'icône cassée du bandeau

`logo-bandeau.svg` était un fichier **séparé**, servi (ou non) indépendamment de
l'application : un appareil dont le cache précédait son ajout du 2026-08-20 recevait un
404, d'où l'icône cassée en haut à gauche. Le pictogramme est désormais **en ligne dans le
balisage** — un SVG en ligne ne peut pas manquer. Le fichier a été supprimé.

C'est la même famille de défaut que celle du lot A1 et de la mise à jour dépareillée : un
fichier statique à nom stable qu'un cache peut servir — ou ne pas servir — indépendamment
du reste.

### ⚠️ La bibliographie rappelait la BnF à chaque clic

Symptôme : « masquer/afficher prend énormément de temps, ainsi que le bouton Je le veux ».

Cause, et elle est instructive : chaque écriture appelait `ChargerAsync()`, c'est-à-dire
une **interrogation complète de la BnF** (≈1 s par page, jusqu'à **dix pages** pour les
nouveautés, qui ne sont pas mises en cache) suivie d'une **relecture de tout le catalogue**.
`ModifierBibliographieAsync` faisait de surcroît `_bibliographies.Clear()`, ce qui vidait
le cache de session mis en place au lot D.

**L'écran attendait une source distante pour apprendre un drapeau qu'il connaissait déjà.**
Masquer une œuvre ou l'ajouter à ses envies ne change *rien* à ce que la BnF sait de
l'auteur : seuls basculent des drapeaux **personnels**, que le client peut poser lui-même.

La correction est en deux endroits, et les deux sont nécessaires :

| Où | Quoi |
|---|---|
| `ServiceLivresApi.PatcherOeuvres` | corrige les bibliographies **en cache**, au lieu de les vider |
| `Bibliographie.MettreAJourOeuvre` | corrige la copie **que l'écran tient en main** |

⚠️ Le second n'est pas redondant : les **nouveautés ne sont pas dans le cache**, et c'est
précisément le cas le plus lent.

Mesuré en exécution après correction : **181 ms** pour « Je le veux », **226 ms** pour un
masquage groupé de deux œuvres — contre un rechargement BnF complet auparavant.

### La bibliographie est un écran où l'on coche

- **Case à gauche du titre, ligne centrée verticalement, boutons groupés à droite.**
  Alignés en haut, la case flottait au-dessus du texte et les boutons pendaient sous lui.
  Vérifié : case 16–46 px, actions 279–359 px, tous centrés sur la même ligne à 375 px.
- **La case est offerte sur TOUTE œuvre visible**, pas seulement sur celles à découvrir :
  masquer en lot suppose de pouvoir cocher ce qu'on possède déjà.
- **La barre de sélection est en haut, et collante.** On coche en descendant : une barre
  en bas obligeait soit à redescendre pour valider, soit — pire — recouvrait les dernières
  lignes qu'on cherchait justement à cocher. Elle porte « Ajouter aux envies (n) »,
  « Masquer (n) », « Réafficher (n) » et « Tout décocher », chaque compteur ne comptant
  que ce que son bouton peut réellement traiter.
- **« Nouveautés » et « Ses livres chez vous » remontent à côté du filtre** : ce sont trois
  façons de restreindre la même liste, et « Nouveautés » est ce qu'on veut *en ouvrant*
  l'écran, pas après avoir parcouru cinquante lignes. Le composant `BasculeBibliographie`
  évite d'en tenir deux copies (liste garnie, liste vide).

### Auteurs : chercher, trier, et aligner

- **Recherche et tri en mémoire, sans nouvel appel.** La liste des auteurs est déjà chargée
  en entier — c'est elle qui sert l'instantané hors-ligne — et la filtrer côté serveur
  aurait rendu la page inutilisable sans réseau. Recherche par **sous-chaîne du nom
  normalisé** (comme le catalogue), tri **ordinal sur la forme normalisée** (comme
  `FiltreLivresLocal`), pour qu'un appareil ne classe pas « Éluard » autrement qu'un autre.
- **Deux tris, et pas un troisième** : par nom (« où est untel ? ») et par nombre de livres
  (« qui ai-je le plus ? »).
- **Trois colonnes dont une seule est élastique** : compteur à gauche sur **2,5 rem**
  (trois chiffres — personne ne possède mille livres d'un même auteur), nom au milieu,
  actions à droite. ⚠️ C'est la largeur **figée** des deux colonnes extrêmes qui fait tout
  l'intérêt : sans elle, chaque ligne plaçait son compteur et ses boutons ailleurs, et
  l'œil devait relire chaque ligne entière. Vérifié : bord droit du compteur à 56 px et
  colonne d'actions à 152–359 px, **identiques sur toutes les lignes**.
- Au passage, `champ-texte` du champ de renommage n'existait dans aucune feuille de style ;
  il est devenu `champ-saisie`.

### Filtre de prêt au catalogue

`CritereLivres.Prete` : `true` = ce qui est dehors, `false` = ce qui est à la maison,
`null` = tout. ⚠️ **Commun au foyer**, contrairement au statut de lecture : aucune identité
n'entre dans son évaluation. L'écran « Prêts » répondait déjà à « qu'est-ce qui est
dehors ? » ; ce filtre sert l'autre moitié — « qu'est-ce que j'ai réellement sous la
main ? », avant de promettre un livre à quelqu'un.

⚠️ Toute dimension de filtre s'ajoute **aux deux implémentations** et au test qui les
confronte. Quatre jeux de critères ajoutés, et surtout un livre au **prêt clos** dans le
jeu de données : c'est exactement là que le serveur (`DateRetour == null`) et le client
(`PreteA is not null`) auraient pu diverger en silence.

La ligne se replie derrière « Filtrer », compte dans le compteur du bouton, et **disparaît
tant que rien n'est prêté** — mêmes règles et mêmes précautions que les formats et les
types (déduction sur un chargement `EstSansCritere` uniquement).

### Les exports portent le rang d'envie

Le CSV suivait déjà l'ordre choisi, mais un ordre **implicite se perd au premier tri par
titre** dans un tableur : une colonne « Rang » l'ouvre désormais.

⚠️ Le `.txt` en avait bien plus besoin : son **groupement par auteur détruit l'ordre**
d'envie — c'est le prix, assumé, d'une liste rangée comme une librairie. Le rang est donc
réinscrit devant chaque titre (`  - [3] Titre`), sous une ligne qui l'explique.

Le rang imprimé est la **position dans la liste reçue**, pas la valeur brute de la colonne
`Rang` : celle-ci peut comporter des trous (une suppression ne renumérote pas), et ce qu'on
veut lire est « troisième de ma liste ».

### ⚠️ Les couvertures des envies : le défaut n'était pas où on le cherchait

« Dans la liste d'envies, aucune image n'est trouvée, ce qui est bizarre. »

Ce n'était ni le composant `Couverture`, ni OpenLibrary : **seul l'écran « ajouter une
envie » remplissait `CoverUrl`**. Or la plupart des envies arrivent par la bibliographie
d'un auteur ou par un tome manquant d'une série — deux chemins qui transmettent bien un
ISBN, mais aucune image.

Le repli est posé **à la lecture** (`ServiceSouhaits.Projeter`), et non à l'écriture : il
vaut alors aussi pour les envies **déjà enregistrées**, sans migration ni rattrapage. La
règle « sans ISBN, pas de couverture, et on n'en invente pas » est intacte — on n'invente
rien, on applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.

### L'arbre des séries ne montre que la descendance

« Quand je parle de tree, je pensais vraiment à quelque chose de beaucoup plus simple. »

L'arbre dépliait la **liste complète des tomes** de chaque nœud, cartes de livres
comprises : un cycle de quatre séries occupait plusieurs écrans, et l'on ne voyait plus ce
qu'un arbre sert à voir — **qui contient quoi**. Il ne porte plus que le titre,
l'avancement cumulé et le lien vers la fiche ; les tomes ont leur écran.

⚠️ **Corollaire : tout est déplié par défaut**, et l'on replie ce dont on ne veut pas.
L'inverse obligeait à ouvrir chaque nœud pour découvrir s'il contenait quelque chose,
c'est-à-dire à faire à la main le travail de l'arbre.

`/series` **ne charge plus le catalogue entier** : l'arbre n'en a plus l'usage.

Vérifié en exécution sur *La Légende de Drizzt* : les trois lignes tiennent à 375 px,
indentées de 16 px et 42 px, et le cycle totalise bien « 0 sur 5 tomes » (3 + 2).

### L'ordre de lecture a son propre écran

« Je ne veux pas pouvoir modifier l'ordre sans le vouloir. »

Nouvelle route **`/series/{id}/ordre`**, sur le modèle exact de `/souhaits/ordre`. Les
flèches — tomes **et** sous-séries d'un cycle — n'existent plus qu'ici : offertes en
consultation, elles se déclenchaient au défilement du pouce, et l'ordre de lecture d'une
saga changeait sans qu'on l'ait voulu.

⚠️ Le mode est dans l'**adresse**, pas dans un booléen interne : convention du projet depuis
la fiche livre, et elle fait du bouton « retour » du navigateur une sortie naturelle. Les
trois routes partageant le même paramètre, le composant reste abonné à `LocationChanged`.

L'écran d'ordre **ne fait que réordonner** : ni actions par tome, ni ajout de tome. Le
numéro de rang, lui, reste visible en consultation — c'est une information, pas une action.

Le lien « Changer l'ordre » n'apparaît qu'à partir de deux tomes ou deux sous-séries :
réordonner un élément unique n'a pas de sens.

### Une seule entrée d'ajout : « Ajouter un ouvrage » (décidé avec l'utilisateur)

Question posée telle quelle par l'utilisateur : refaire une page « ajouter une revue »
calquée sur celle des livres, ou renommer « livre » en « ouvrage » et regrouper ?
**Tranché : une entrée unique**, atteinte depuis le catalogue **et** depuis les revues.

Le geste réel est « j'ai un truc avec un code-barres », pas « je vais cataloguer un
livre » : obliger à savoir d'avance ce qu'on tient était un détour, d'autant que le
décodage distingue déjà un `978`/`979` d'un `977`.

⚠️ **Le MODÈLE ne bouge pas, et il ne doit pas bouger** : une revue reste une `Revue`,
jamais un `Livre`. Fondre les deux tables obligerait à répéter « et qui n'est pas une
revue » à chaque lecture du catalogue, des compteurs, de la détection de doublons, des
séries et des bibliographies par auteur. C'est l'ergonomie qui est regroupée, pas le
schéma.

| Route | Rôle |
|---|---|
| `/ajout` (alias `/ajout/isbn`) | scan et saisie de code, pour livre comme pour magazine |
| `/ajout/manuel` | saisie manuelle d'un **livre** |
| `/revues/ajout` | saisie manuelle d'une **revue** (titre, ISSN, éditeur) |

⚠️ La saisie manuelle est le **seul** endroit où l'entrée unique doit reposer la question :
sans code à lire, rien ne peut trancher à la place de l'utilisateur. D'où deux boutons
distincts, et non un formulaire à bascule.

Le champ « nom d'une nouvelle revue » a quitté le milieu de `/revues`. `/revues/ajout`
enchaîne sur la **fiche** de la revue plutôt que de revenir à la liste : le geste n'est
jamais « créer une revue », c'est toujours « ranger un numéro ».

Vérifié en exécution : `9772466671438` saisi depuis « Ajouter un ouvrage » nomme toujours
« Médor » et propose d'ouvrir sa fiche.

## Les couvertures viennent du cache AUSSI en ligne — règle A5 renversée (2026-08-20)

Constat d'usage : sur téléphone, après un rechargement, les vignettes n'apparaissent pas tout de
suite — alors que deux couvertures dont l'URL avait été collée à la main (CDN de la Fnac)
s'affichaient sans attendre.

⚠️ **L'hypothèse naturelle était fausse, et c'est ce qu'il faut retenir** : « garder l'URL en base
pour charger plus vite » ne pouvait rien donner — `Livre.CoverUrl` **est** en base depuis
toujours, il n'y avait aucune résolution à supprimer. La comparaison avec la Fnac désignait la
vraie cause : `covers.openlibrary.org` répond **302, deux fois**, avant d'aboutir sur
`archive.org` (déjà mesuré au lot du relais), là où une URL de CDN va droit au fichier. Trois
allers-retours par vignette, dix vignettes à l'écran, sur un lien mobile.

Seconde cause, dans notre code : `Couverture.razor` ne lisait le magasin IndexedDB `couvertures`
que **hors ligne**. En ligne, chaque rechargement repartait du réseau pour une image déjà rangée
sur l'appareil.

**Décidé avec l'utilisateur : le cache est consulté d'abord, en ligne comme hors ligne. Le réseau
n'est sollicité que si rien n'est en cache.**

⚠️ **Ceci renverse la règle du lot A5** — « en ligne, l'affichage utilise l'URL réseau telle
quelle : rien ne doit ralentir la voie rapide ». Elle n'était pas absurde, elle était mal
calibrée : ce qu'on protégeait était une requête réseau avec redirections, ce qu'on refusait
était une lecture IndexedDB locale. La seconde est largement plus rapide que la première. Le
sens du mot « rapide » avait été supposé, pas mesuré.

Le fire-and-forget de mise en cache subsiste, mais **seulement quand le cache est vide** : rien
ne sert de le relancer pour une image déjà rangée (`couvertureMettreEnCache` court-circuitait
déjà sur son `getKey`, c'est maintenant explicite côté C#).

### ⚠️ Le corollaire à ne pas oublier : révoquer les `blob:`

`URL.createObjectURL` retient son Blob en mémoire **jusqu'à révocation ou fermeture de la page**.
Tant que le cache ne servait qu'hors-ligne, la fuite restait bornée à une consultation
ponctuelle ; avec une vignette par carte sur tous les écrans, elle ne l'est plus. `Couverture`
implémente donc `IAsyncDisposable` et libère aussi lorsqu'elle change d'URL
(`couvertureLiberer` → `URL.revokeObjectURL`).

### Vérifié en exécution

| Point | Résultat |
|---|---|
| `src` des vignettes au chargement | **`blob:`** pour les deux couvertures |
| Requêtes vers `editions-ambre.fr`, `openlibrary.org`, `/api/couvertures` | **aucune** |
| Contenu réellement servi par le `blob:` | `image/jpeg`, 15 777 o, **500×500** |
| URL d'objet après navigation vers un autre écran | **révoquée** (fetch rejeté) |

479 tests au vert.

⚠️ `naturalWidth` reste à 0 dans le navigateur d'automatisation : la fenêtre ne compose pas
d'image, donc `loading="lazy"` ne déclenche jamais le décodage. Ce n'est pas un défaut de la
couverture — le blob a été décodé à la main par `createImageBitmap` pour le prouver. Ne pas
partir en chasse là-dessus.
