Ajoute un champ de recherche insensible aux accents sur l'écran des prêts en cours, visible dès qu'il y a plus d'un prêt. Filtrage en mémoire, sans colonne normalisée : la liste est courte par construction, une forme dédiée en base serait disproportionnée. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
130 KiB
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)
- Catalogue de livres physiques — liste, ajout, édition, suppression
- Catalogue de livres numériques (ebooks) — même chose, avec un champ format distinct du physique
- 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)
- 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
- 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 |
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 compilé en
WebAssembly, appelé depuis wwwroot/js/scanner-camera.js. Les assets viennent du paquet
NuGet 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 :
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
DOMExceptionperd sonnameen 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 ;
localhostest 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
DOMExceptionperd sonnameen 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 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 :
ListerSouhaitsAsyncappelaithttp.GetFromJsonAsyncdirectement, sans passer parEssayerAsync. Elle était donc le seul point de lecture hors du dispositif hors-ligne : pas de repli sur instantané, pas deSignalerEchecReseau, et l'exception HTTP remontait telle quelle jusqu'à l'écran.- 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épond204 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.onLinen'ayant jamais changé et aucun événementonlinen'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 deLivreDto.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.
- 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.
- 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}.jsonrenvoie 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}.jsonpour 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)
- ⚠️ Bug connu identifié lors des tests avec BookLogr : l'endpoint
- 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=falseest 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=falsegarantissant 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, seulementcontributionsetby_statement. Le repli implémenté passe par/works/{id}.json→ clés d'auteurs →/authors/{id}.json, puis en dernier recoursby_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 |
|
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 sur0.0.0.0— défense en profondeur, à traiter comme une contrainte dure danssystemd.serviceetnginx.conf. Un service exposé directement sur le réseau permettrait de forgerYNH_USERet 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, forceBlazorFingerprintBlazorJset empreinteblazor.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 uneTypeError; - 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 envoieSKIP_WAITINGau worker en attente, qui appelleself.skipWaiting()(ajouté àservice-worker.published.js), puiscontrollerchangerecharge.
⚠️ 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.
navigator.serviceWorker.getRegistrations()renvoie[], etregister()échoue enTypeError: … An unknown error occurred when fetching the script.- Un
fetch('/service-worker.js')depuis la même page renvoie200 text/javascript, 3 335 octets. Le fichier est donc bien servi. - 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. - Décisif : journalisation
Microsoft.AspNetCoreenInformation, puis unfetchet unregistersur la même URL portant chacun un repère distinct. Le serveur journaliseRequest starting … ?repere=fetchBet 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 :
NomNormalisegarde l'ordre de saisie — c'est ce que la recherche parcourt en sous-chaîne ;CleRegroupementtrie les mots, ce qui rendZola, ÉmileetÉmile Zolaidentiques. 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 sansAjouteParsont perdus : les attribuer serait une invention. - L'ancien
Livres.Auteurdevient 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, niNOM, Prénomni 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 :
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:00en 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
NULLpour 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
.txts'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.
- 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é. - Auteur réel de la notice.
allexige 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'undc:creatornettoyé corresponde au nom demandé au sens deRapprochementAuteurs.
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 deNormalisationTexte, 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 :
Numeroest 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é
Catalogue / Auteurs / Séries / Revues / Prêts / Envies. À 320 px — le plus étroit des
téléphones réalistes — les six libellés occupent 312 px sans troncature, huit de marge.
⚠️ C'est la limite : un septième onglet, ou un libellé plus long que « Catalogue », imposera de
regrouper plutôt que d'ajouter.
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 (
SetNullsur 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. Vérifié à 320 px : les cinq libellés tiennent sans troncature
(258 px sur 320), au prix d'un
font-sizepassé de 0.85 à 0.8 rem. - 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.
- 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 tient dans quatre onglets, en bas de l'écran
MainLayout porte une barre fixe Catalogue / Auteurs / Prêts / Envies. Elle remplace 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 d'onglet a été retiré — « Retour au catalogue »,
« Retour aux auteurs », « Ma liste d'envies ». Ce qui reste est ce qu'aucun onglet ne sait faire
(« Ses livres chez vous », qui est un catalogue restreint).
⚠️ En bas, pas sous le bandeau : le pouce atteint le bas de l'écran. Les deux barres fixes
coexistent grâce à --mb-onglets-hauteur, partagée entre MainLayout.razor.css et app.css —
sans elle, la barre d'actions recouvrirait les onglets. Vérifié à 375 px : onglets 770-812,
actions 706-770, aucun chevauchement.
⚠️ NavLinkMatch.All sur « Catalogue » est obligatoire : son href est la racine, et sans
cela l'onglet resterait allumé sur les quatre écrans.
Les onglets restent actifs hors-ligne : les quatre écrans se consultent depuis leurs instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.
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. Un petit
logo-bandeau.svg (blanc, net à toute résolution) accompagne « MaBibli » dans le 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.
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 starteden 5 s, timeout de 60 s largement suffisant), base créée dansdata_dir. - Écoute sur
127.0.0.1uniquement — la contrainte dure de « Intégration SSO » est tenue en production, vérifiée parss -tlnp. - Mode WAL confirmé sur le serveur (
PRAGMA journal_mode→wal) : la justification du.backupdescripts/backupn'est pas théorique. Les fichiers-wal/-shmn'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 :
removeetremove --purgefonctionnent tous deux. Sans--purge,data_dirsurvit — donc réinstaller après unremoveretrouve 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 mabibli_ynh/PUBLICATION.md. Elle n'est
pas recopiée ici : ce sont des gestes, pas des décisions.
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
- Scaffolder le projet Blazor WebAssembly PWA (
dotnet new blazorwasm --pwa) — commande vérifiée valide en .NET 10, l'option--pwaexiste toujours - 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
- Mettre en place le modèle EF Core + SQLite + migrations
- Implémenter le service de lookup ISBN (OpenLibrary avec le double-appel titre+auteur)
- CRUD livres (physique/numérique, statuts de lecture)
- Gestion des prêts
- Intégration scan caméra (ZXing.Net + interop caméra minimal)
Cache hors-ligne pour la consultation— fait le 2026-08-18 (voir « Stratégie hors-ligne »)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
RunAOTCompilationseulement 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.