# 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 | **ZXing.Net** (C#, Apache 2.0) exécuté dans le WASM ; le JS ne fournit que les pixels caméra | Décodage en C#, réutilisable hors navigateur si le projet évolue en scanner de bibliothèque. 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 — ZXing.Net (décision actée)

Le décodage EAN-13 se fait en **C# avec [ZXing.Net](https://www.nuget.org/packages/ZXing.Net)** (`micjahn`, Apache 2.0), et non avec une bibliothèque JS type `html5-qrcode`.

### Pourquoi

- **Réutilisable hors navigateur.** Si le projet évolue vers un scanner de bibliothèque (app native, scan en masse, décodage d'une photo côté serveur), le code de décodage se transpose tel quel. Une bibliothèque JS serait à réécrire intégralement.
- **Un seul langage**, cohérent avec le reste de la stack.
- L'argument « offline » n'entre **pas** en compte ici : `html5-qrcode` fonctionne aussi hors-ligne (fichier JS servi par la PWA, aucun appel réseau). Ce n'est pas un critère de départage.

### Mesures réelles (validées sur .NET 10, publish Blazor WASM OK)

| Mesure | Résultat |
|---|---|
| Décodage EAN-13 propre (380×160) | 0,04 ms/frame |
| **Pire cas** : frame 640×480 bruitée sans code-barres (échec) | 0,53 ms/frame |
| Poids de l'assembly `zxing.wasm` seul | +192 Ko (brotli) |

Le pire cas est le chiffre qui gouverne le framerate : la majorité des frames caméra ne contiennent pas de code-barres lisible, et c'est l'échec de décodage qui coûte le plus cher.

### Mesures en WASM réel — faites le 2026-08-18, phase scan

L'estimation « ~5-10 ms/frame en interprété » ci-dessus n'était qu'une extrapolation. Elle a
été **vérifiée dans un vrai navigateur** (Chromium 148, x86_64 de bureau), sur le publish
`Release` du client, en appelant le décodeur depuis la console via `BancEssaiScan` :

| Configuration | Pire cas 640×288 (bande visée) | Pire cas 640×480 |
|---|---|---|
| Publish `Release`, interprété | **4,4 – 6,4 ms/frame** | 6,7 – 7,8 ms/frame |
| Build `Debug`, interprété | 17,7 ms/frame | 28,7 ms/frame |

**L'estimation était bonne** : le mode interprété tient largement les 10-15 fps visés (une
frame toutes les 80 ms n'utilise que ~6 % du budget). **Aucune raison d'activer l'AOT** —
il reste à confirmer sur un téléphone, sensiblement plus lent qu'un x86_64 de bureau.

⚠️ Ne jamais juger la fluidité sur un build `Debug` : il est **4 à 5× plus lent** que le
`Release`, de quoi conclure à tort qu'il faut l'AOT.

### ⚠️ Le surcoût de payload réel est bien supérieur à 192 Ko

Mesuré par différence entre deux publish `Release` complets (somme brotli de `_framework`) :
**+351 Ko** au total, dont ~4,7 Ko de code applicatif. Le coût imputable à ZXing.Net est donc
d'environ **+346 Ko brotli**, soit **1,8× le poids de son propre assembly**. Le surplus vient
des assemblies BCL que le trimmer ne peut plus retirer :

| Assembly | Delta brotli |
|---|---|
| `zxing.wasm` | +192 495 o |
| `System.Text.RegularExpressions` | +91 237 o (de 7 Ko à 98 Ko : ZXing utilise Regex, tout le moteur reste) |
| `System.Runtime.Numerics` | +30 701 o (nouveau) |
| `System.Private.CoreLib` | +17 586 o |
| divers (`Threading`, `Collections`, `InteropServices`…) | ~14 Ko |

Ne pas reprendre « +192 Ko » comme coût du scan : c'est le poids de l'assembly, pas celui
de la fonctionnalité.

### Pièges à connaître

- **Le JS interop ne disparaît pas.** `getUserMedia` et `<canvas>`/`getImageData` sont des API web sans équivalent C#. Prévoir ~30 lignes de JS maison dont le seul rôle est de pousser un `byte[]` vers C#. Toute la logique de décodage reste en C#.
- **Ne pas perdre de temps à essayer de réduire la taille via un reader ciblé.** Remplacer `MultiFormatReader` par `EAN13Reader` pour aider le trimmer **ne change rien** : mesuré à 192 495 octets à l'octet près dans les deux cas. ZXing.Net n'est pas trim-friendly.
- `RGBLuminanceSource` accepte directement le buffer RGBA du canvas (`BitmapFormat.RGBA32`) — **aucune bibliothèque d'image nécessaire** (pas de SkiaSharp ni ImageSharp).
- Le scan caméra exige **HTTPS** (garanti par YunoHost en prod ; en dev, `localhost` est considéré comme sûr). Corollaire vérifié : tester le scan 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é. Le composant détecte ce cas et le dit explicitement.
- **Une `DOMException` perd son `name` en traversant le pont JS→C#** : `getUserMedia` refusé remonte en C# sous la forme « Permission denied undefined », sans `NotAllowedError`. Or c'est ce nom qui distingue « permission refusée » de « pas de caméra » de « caméra occupée ». Le JS doit donc **attraper l'erreur et renvoyer un code de statut** ; parser le message côté C# ne marche pas.
- **L'import du module JS se fait sur un chemin nu** (`./js/scanner-camera.js`). Il a longtemps porté une chaîne de requête `?m=1` : l'import map généré par Blazor réécrivait le chemin vers un nom empreinté que l'API hôte ne servait pas (elle utilise `UseStaticFiles`, qui ignore les points d'entrée empreintés), et l'import partait en 404. Depuis que les empreintes WASM sont désactivées (voir « Empreintes WASM désactivées »), **il n'y a plus d'import map du tout** et le contournement a été retiré. Vérifié en développement et sur le publish self-contained : le module se charge dans les deux cas.

### Squelette validé

```csharp
using ZXing;
using ZXing.Common;

public static class IsbnScanner
{
    static readonly MultiFormatReader Reader = new()
    {
        Hints = new Dictionary<DecodeHintType, object>
        {
            [DecodeHintType.POSSIBLE_FORMATS] = new List<BarcodeFormat>
            {
                BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
            },
            [DecodeHintType.TRY_HARDER] = true,
        },
    };

    /// rgba : buffer brut issu de ctx.getImageData(...).data
    public static string? TryDecode(byte[] rgba, int width, int height)
    {
        var source = new RGBLuminanceSource(
            rgba, width, height, RGBLuminanceSource.BitmapFormat.RGBA32);
        return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text;
    }
}
```

## 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 |

**Quatre instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue
**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`. 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.

### 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
├── 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)

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

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
└── 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.

### 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).

## 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.

## 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.

## 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.

### 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.

## 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 (`manifest.toml`, `conf/systemd.service`, `conf/nginx.conf`, `scripts/install`) en s'inspirant de radarr_ynh

## 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.
