40 Commits
Author SHA1 Message Date
mathieuandClaude Opus 5 455c2b711a Retirer d'IDEES.md une entrée déjà traitée
« Titres BnF contenant le nom de l'auteur » y figurait encore comme
correction envisagée, alors que NettoyageIsbd.RetirerAuteurEnFin la met
en œuvre depuis la phase du lookup ISBN — avec des tests sur le cas réel
qui l'avait motivée (ISBN 9782749942636, « … Aurélien Barrau » recollé
en fin de titre sans séparateur ISBD).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:15:09 +02:00
mathieuandClaude Opus 5 5cf566bc33 Traiter les retours d'usage du 2026-08-18 (2ᵉ série)
Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.

1. Douchette USB, ISSN et enchaînement du scan
   - Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
     douchette USB, qui se présente au système comme un clavier. Remède
     au scan caméra, qui rate sur la webcam d'un PC (optique, pas
     décodage).
   - Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
     revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
     parfaitement valide), l'ISSN est déduit du code puis nommé via
     bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
     « aucun résultat ».
   - L'enchaînement après scan existait déjà mais était invisible : une
     étape « Recherche » affiche désormais le code interrogé.

2. ISBN affichés avec des tirets
   Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
   sont pas celles qu'on suppose. Le francophone est découpé en entier,
   ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
   Corrige au passage l'export CSV, où un ISBN nu était lu comme un
   nombre par Excel.

3. Hors-ligne : la liste d'envies
   ListerSouhaitsAsync était le seul point de lecture hors du dispositif
   hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
   instantané, écritures refusées, plus aucun message HTTP. Même défaut
   corrigé sur la bibliographie.

4. Navigation par onglets
   Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
   d'actions ne portent plus que des actions. Filtres repliés derrière un
   bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
   format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.

5. Liste d'envies : ordre, recherche, couvertures
   Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
   jusqu'ici : sans lui, les listes existantes se seraient réordonnées
   toutes seules. Réordonnancement par flèches et glisser-déposer (le
   drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
   écran, avec recherche par titre (bib.title) et couvertures enfin
   alimentées.

6. Bibliographie : une source muette n'est pas une liste vide
   L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
   livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
   s'excluent désormais, et un bouton Réessayer est offert.

   Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
   point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
   annoncées) ; c'était le même délai dépassé observé deux fois, pris
   pour deux causes distinctes.

380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:14:01 +02:00
mathieu 2f1e854ea3 Ajout fichier config de rider 2026-08-18 21:09:32 +02:00
mathieuandClaude Opus 5 bfbd90fabd Documenter l'installation YunoHost éprouvée en production
Section « Installation YunoHost » : la chaîne réelle, les pièges de
publication (dépôt privé qui répond 404, manifeste lu depuis Gitea), les
deux pièges systemd avec leur mesure, l'écart entre le propriétaire
d'install_dir déclaré et celui appliqué, et ce qui a été vérifié en
exécution — installation, WAL, sauvegarde, mise à jour, désinstallation.

L'étape 9 des prochaines étapes est faite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:05:42 +02:00
mathieuandClaude Opus 5 922fd024e7 Ajouter la licence AGPL-3.0-or-later
Licence confirmee par l'utilisateur. Le depot n'avait aucun fichier
LICENSE, alors que le paquet YunoHost declarait deja cette licence dans son
manifeste — les deux sont desormais coherents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:06:09 +02:00
mathieu 580d8469ac Fusionner le cache hors-ligne IndexedDB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	MaBibli.Client/wwwroot/css/app.css
2026-08-18 14:06:07 +02:00
mathieuandClaude Opus 5 b89b8ac964 Sortir du mode hors-ligne quand la panne venait du serveur, et documenter
Constate en essai : quand c'est le serveur qui tombe, navigator.onLine n'a
jamais change, donc aucun evenement « online » ne viendra jamais. Les lectures
court-circuitaient sur EnLigne et ne retentaient plus rien : l'application
restait sur le cache jusqu'au rechargement de la page.

EtatReseau distingue donc deux notions : EnLigne (ce que l'interface affiche et
ce qui active les boutons) et TenterLeReseau (faut-il tenter un appel, vrai des
que le navigateur a une connexion, meme apres un echec). Verifie : serveur
arrete puis redemarre sans aucun evenement « online », la navigation suivante
retire le bandeau, reactive les actions et fait apparaitre un livre ajoute
cote serveur pendant la coupure.

CLAUDE.md : architecture retenue, ce qui a ete observe reseau coupe, pourquoi
le filtre existe en deux exemplaires, ce que le hors-ligne ne couvre pas, la
chaine de mise a jour du service worker, et la cause etablie de son echec
d'enregistrement dans le navigateur d'automatisation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:04:33 +02:00
mathieuandClaude Opus 5 53dd8cb6bd Fusionner la liste d'envies personnelle et son export
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:04:27 +02:00
mathieuandClaude Opus 5 231b0b2a11 Acter la liste d'envies dans CLAUDE.md et nettoyer IDEES.md
Retire d'IDEES.md la section « Pour plus tard — livres souhaités », désormais
implémentée, et acte dans CLAUDE.md les trois décisions structurantes : table
dédiée pour la liste d'envies, SRU BnF par bib.author pour la bibliographie,
export en .txt et .csv.

Consigne dans CLAUDE.md ce que la source renvoie réellement (Werber 203 notices
-> 51 œuvres, Zola 2692 annoncées -> 87 œuvres sur 200 lues) et ce que le
rapprochement par titre rate, l'interface énonçant la même limite à l'écran.

Deux points relevés en passant et laissés à IDEES.md : la ligature « œ » que
NormalisationTexte ne réduit pas à « oe » (L'Œuvre de Zola y échappe, et le
défaut touche aussi la recherche du catalogue), et le plafond de 200 notices.

Corrige au passage une ponctuation bancale du bandeau de bibliographie, et
restreint le BOM UTF-8 au seul CSV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:03:29 +02:00
mathieuandClaude Opus 5 ebc5f95d49 Consulter la bibliotheque hors-ligne depuis un cache IndexedDB
Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le
navigateur alors que SQLite vit sur le serveur, et le service worker ne met en
cache que les assets. Sans travail explicite, l'application demarre hors-ligne
et affiche une bibliotheque vide.

- js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des
  bascules online/offline. Aucune logique metier.
- CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau
  combinant navigator.onLine (fiable seulement par sa negation) avec le sort
  reel des appels HTTP.
- ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont
  refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est
  ce qui rend la recherche hors-ligne possible sur tout le fonds.
- FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui
  confronte les deux implementations sur les memes donnees.
- Interface : pastille et bandeau d'etat avec la date de synchronisation, et
  actions d'ecriture desactivees avec leur raison plutot que boutons morts.
- js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker
  est le seul cache-busting du projet. L'enregistrement journalise desormais ses
  echecs, et un bandeau propose la nouvelle version sans attendre la fermeture
  de tous les onglets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:19 +02:00
mathieuandClaude Opus 5 8b8fe3be6e Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur
La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un
statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans
Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au
prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée
est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie
frontière : toute lecture filtre dessus.

Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux
filtres mesurés sur des réponses réelles sont indispensables — le type de
document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur
réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs :
« Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola.
Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51
œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui
désigne une édition et non une œuvre ; ses limites sont dites à l'écran.

Export en deux formats, tous deux du texte sans dépendance : .txt groupé par
auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le
tableur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:06 +02:00
mathieuandClaude Opus 5 3a5a3a7763 Fusionner les retours d'interface (fiche en lecture seule, couverture agrandissable, statut et format)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:14:41 +02:00
mathieuandClaude Opus 5 08e57fbbc4 Acter les choix d'interface et retirer les points traites d'IDEES.md
CLAUDE.md gagne une section « Interface — décisions actées » : les deux modes
de la fiche et le mode porté par l'URL, les deux exceptions assumées qui
restent hors édition (statut, prêts), la liste en lecture seule, le format
réservé aux ebooks et son corollaire sur le formulaire, l'agrandissement de
couverture. Y figure aussi le piège Blazor du routeur qui ne redessine pas
quand aucun paramètre ne change.

IDEES.md perd sa section « Interface — retours d'usage », désormais
entièrement traitée. La recherche sans accents y reste, mais annotée : elle
paraît réglée par la refonte du schéma — « Emile » remonte bien « Émile Zola »
sur l'API — sans avoir été traitée comme un point à part entière.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:13:46 +02:00
mathieuandClaude Opus 5 6b783bafa0 Appliquer les retours d'interface : fiche en lecture seule, liste allegee
Cinq retours d'IDEES.md, tous pensés pour le mobile d'abord.

Fiche du livre en deux modes, portés par deux routes : « /livres/{id} »
consulte, « /livres/{id}/edition » modifie. Ouvrir une fiche depuis le
catalogue ne place plus un formulaire vivant sous le pouce. Mettre le mode
dans l'adresse fait du retour navigateur une sortie d'édition naturelle.

Piège rencontré : les deux routes visent le même composant avec le même Id,
donc aucun paramètre ne change et le routeur ne redessine pas — l'URL passait
à « /edition » sans que l'écran bascule, alors qu'un chargement direct de
cette URL marchait, ce qui masque le défaut. Le composant s'abonne donc à
LocationChanged.

Restent hors « Éditer », parce que ce ne sont pas des modifications de la
fiche : le statut de lecture (action la plus fréquente, et personnelle) et
les prêts (gestes du quotidien, donnée du foyer). La suppression, elle, est
réservée au mode édition.

Dans la liste, le statut devient une pastille en lecture seule sur la ligne
des étiquettes ; les trois boutons actionnables par carte partaient trop
facilement en faisant défiler. Le format ne s'affiche plus que pour les
ebooks, le physique étant le cas par défaut — le formulaire le
présélectionne déjà, l'énumération valant 0.

Couverture agrandissable au clic, fermeture au clic extérieur ou par Échap
(le calque est focalisé à l'ouverture, sans quoi Échap resterait sans effet).
Le substitut à initiale n'est jamais cliquable : le déclencheur n'existe que
s'il y a une image.

Le regroupement par auteur existait déjà (table Auteur, page /auteurs, filtre
« ?auteur= ») ; y manquaient les liens depuis la fiche et les styles de la
page auteurs, jamais écrits — la liste sortait en puces brutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:13:37 +02:00
mathieuandClaude Opus 5 60de1d8784 Fusionner la gestion des prets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:45:50 +02:00
mathieuandClaude Opus 5 2a3dfb88de Gerer les prets : API, ecrans et contrainte en base
L'entite Pret existait depuis le squelette sans jamais etre exploitee. Cette
phase la met en service de bout en bout.

API
- GET  /api/prets/en-cours       ce qui n'est pas a la maison, du plus ancien
                                 au plus recent : on cherche le livre oublie,
                                 pas celui prete hier
- POST /api/livres/{id}/prets    preter
- GET  /api/livres/{id}/prets    historique complet, du plus recent au plus
                                 ancien
- POST /api/prets/{id}/retour    clore le pret sans le supprimer

Un livre deja sorti ne peut pas etre prete une seconde fois. Le service le
verifie et nomme celui qui l'a deja, mais entre sa verification et l'insertion
il reste une fenetre : un index unique PARTIEL (LivreId WHERE DateRetour IS
NULL) la ferme, tout en laissant l'historique accumuler autant de prets clos
que necessaire sur le meme livre.

Les ebooks sont refuses cote API, pas seulement grises dans l'interface : une
fiche n'a pas d'exemplaire a confier.

Les prets sont COMMUNS au foyer, symetrique inverse du statut de lecture. Le
service ne recoit meme pas d'identite, pour qu'on ne puisse pas s'en servir
par inadvertance.

Interface, pensee mobile d'abord
- ecran « Prets en cours » avec bouton « Rendu » a meme la liste
- bloc pret sur la fiche d'un livre : etat courant, action, puis historique
- etiquette « Prete a X » dans le catalogue
- la barre d'actions passe sur deux lignes plutot que de comprimer ses
  libelles maintenant qu'elle compte quatre entrees

Toutes les dates sont en UTC ; le client convertit la date locale du
<input type="date"> avant l'envoi, faute de quoi le pret se decalerait d'un
jour pour la moitie du globe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:45:01 +02:00
mathieuandClaude Opus 5 94a186f4a7 Fusionner la reparation du publish et le correctif NettoyageIsbd
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:44:07 +02:00
mathieuandClaude Opus 5 0fde244d0b Couper le role BnF meme quand le prenom finit par une initiale
NettoyageIsbd.Auteur("Cormen, Thomas H. Auteur du texte") renvoyait
« Thomas H. Auteur du texte Cormen » : le point de l initiale etait
protege, donc plus aucun point ne servait de separateur de role.

Ce qui tranche : dans « Nom, Prenom », la seule chose qui puisse suivre
une initiale et faire encore partie du nom est une autre initiale
(« Tolkien, J. R. R. »). Tout le reste est le role. Le point est conserve
quand il clot une initiale, pour ne pas amputer le prenom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:43:07 +02:00
mathieuandClaude Opus 5 9cb3ca535d Reparer la publication de l API (index.html cassé)
Le publish de MaBibli.Api recopiait index.html tel quel, placeholders
compris, et l application publiee restait blanche.

Cause : dans le SDK .NET 10, la cible de publication
GenerateHtmlAssetPlaceholdersPublishStaticWebAssets lit la liste des
fichiers HTML dans @(_HtmlStaticWebAssets), item alimente uniquement par
la cible de *build*. Quand c est l API qui demande au client ses assets de
publication, la cible de build n a pas tourne : la liste est vide, aucun
asset HTML calcule n entre dans le manifeste, et c est le fichier source
qui est recopie par ComputeResolvedFilesToPublishList.

Correction : supprimer le besoin de reecriture plutot que la reparer.
WasmFingerprintAssets=false et pas de OverrideHtmlAssetPlaceholders (c est
lui qui, a true, forcait l empreinte de blazor.webassembly.js) ; index.html
reference des noms stables, sans import map ni preload placeholder. Le
contournement « ?m=1 » de l import du module scanner devient inutile.

Verifie : publish self-contained lance, scripts references en 200,
application chargee dans un navigateur (catalogue affiche, module JS du
scanner charge, aucun 404).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:42:58 +02:00
mathieuandClaude Opus 5 ab878b4d3c Ignorer .claude/ (worktrees d agents)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:25:12 +02:00
mathieuandClaude Opus 5 d9e6cf425d Fusionner la phase base de donnees (statut par utilisateur, table Auteur)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:24:09 +02:00
mathieuandClaude Opus 5 49d1528988 Fusionner la phase scan camera (ZXing.Net)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:24:01 +02:00
mathieuandClaude Opus 5 30a5cc12cf Documenter le nouveau schema dans CLAUDE.md
Le bloc « Modele de donnees » decrivait encore Auteur et Statut comme des
colonnes de Livre. Il reflete desormais les cinq tables, leurs index et
leurs contraintes d'unicite.

Ajoute aussi ce qui ne se devine pas a la lecture du schema : pourquoi
des colonnes normalisees existent (SQLite ne sait pas ignorer les
accents), pourquoi Auteur en porte deux, et ce que la migration fait des
donnees existantes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:22:54 +02:00
mathieuandClaude Opus 5 b594a4b2c1 Sortir le statut de lecture et l'auteur de la table Livre
Deux changements de modele en une seule migration, plus la recherche qui
en depend.

Le statut de lecture devient personnel. Il etait une colonne de Livre,
donc partage par tout le foyer, alors que deux membres lisent le meme
exemplaire a des rythmes differents. Il vit desormais dans une table
(LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de
ligne vaut « non commence » : sur une bibliotheque de foyer la plupart
des couples n'ont aucun statut, et les materialiser tous multiplierait
les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien
n'est donc ecrit a la creation d'un livre.

Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de
l'enumeration — « À lire » — pour un livre sans ligne, rendant « non
commence » indiscernable d'un choix explicite. Le dictionnaire est
desormais typé Statut?.

L'auteur devient une table. Deux formes normalisees y cohabitent, et ce
n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la
recherche en sous-chaine, CleRegroupement trie les mots et porte l'index
unique, donc l'invariant « un auteur, une fiche ». Les initiales
echappent a la cle et sont traitees en memoire, sur une table qui compte
au plus quelques centaines de lignes.

Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre
pour Introduction to Algorithms — d'ou la table de liaison, avec une
position qui conserve l'ordre de la couverture.

Les rapprochements ambigus ne sont jamais appliques seuls : l'API les
liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour
que la suggestion ne revienne pas. Le couple refuse est range par
identifiant croissant, donc un refus vaut dans les deux sens.

Reprise des donnees existantes. L'ancien statut, commun, est rattache a
AjoutePar — seule personne que la base associe au livre. Les statuts des
livres sans AjoutePar sont perdus : les attribuer serait une invention.
Les trois valeurs sont reprises telles quelles, « À lire » compris, parce
que c'est ce que l'ancienne interface affichait. L'ancien champ auteur
devient une fiche par valeur distincte.

La migration ne peut pas tout faire : lower() de SQLite ne retire pas les
accents, donc « Émile Zola » et « emile zola » y restent deux fiches.
ServiceRenormalisation finit le travail en C# au demarrage, reunit ces
variantes, applique aussi la regle des initiales — sans quoi une base
heritee resterait eclatee la ou une saisie neuve aurait ete reunie
d'emblee — et garde le nom d'affichage le plus presentable. Il est
idempotent, et sert de filet si les regles de normalisation changent.

L'ordre de la migration compte : les colonnes condamnees sont recopiees
dans une table de transit avant d'etre supprimees, parce que supprimer
une colonne sous SQLite reconstruit la table.

Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et
trois variantes de Zola : prets intacts, statuts rattaches, les trois
Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par
« Peter F. Hamilton », « Hamilton » seul laisse en suggestion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:22:46 +02:00
mathieuandClaude Opus 5 4a8bc98f50 Retirer le nom de l'auteur colle en fin de titre BnF
Cas mesure, ISBN 9782749942636 :

  dc:title   = "Le plus grand defi ... (Nouvelle ed. augmentee) Aurélien Barrau"
  dc:creator = "Barrau, Aurélien (1973-....). Auteur du texte"

Le nom est recolle en fin de titre sans le separateur ISBD « / », donc la
regle de nettoyage existante ne peut rien y faire : la notice est sale a
la source.

L'heuristique est volontairement stricte — comparaison en fin de chaine
uniquement, contre un auteur deja nettoye, et seulement si le nom
commence sur une frontiere de mot. « Lettre a Émile Zola » n'est pas
touche, et un titre qui EST le nom de l'auteur (biographie, recueil) est
laisse intact plutot que reduit a rien.

La comparaison passe par CompareOptions.IgnoreNonSpace plutot que par une
normalisation NFD : elle ignore les accents sans changer les longueurs,
donc les indices calcules restent valables sur la chaine d'origine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:22:24 +02:00
mathieuandClaude Opus 5 493cf80615 Normaliser les textes et poser les regles de rapprochement d'auteurs
SQLite ne sait pas comparer sans tenir compte des accents : sa collation
NOCASE ne couvre que l'ASCII et il n'existe pas de unaccent() integre. La
seule facon d'obtenir « Emile » vers « Émile » sans charger tout le
catalogue en memoire est de stocker la forme mise a plat, et d'y chercher
un terme passe par exactement la meme fonction.

NormalisationTexte fait cette mise a plat. La ponctuation y devient une
espace au lieu de disparaitre : « P.F. Hamilton » doit donner trois mots,
sans quoi le rapprochement par initiales serait impossible.

RapprochementAuteurs porte la frontiere decidee dans CLAUDE.md. La cle de
regroupement — mots normalises tries — couvre casse, accents et ordre
inverse, et servira d'index unique. Les initiales abregees lui echappent :
elles demandent d'aligner les mots un a un, avec retour arriere, parce
qu'une correspondance gloutonne echouerait sur « P. Peter » face a
« Peter Paul ».

Le verdict est a trois niveaux, et l'asymetrie est deliberee : ne pas
fusionner laisse deux fiches a reunir d'un clic, alors qu'une fusion
erronee melange les livres de deux personnes et se defait mal. Un
patronyme seul face a un nom complet ressort donc « suggere », jamais
« certain ». Meme exigence sur l'alignement complet : « P. H. » et
« Peter Hamilton » s'alignent par les seules initiales, ce qui ne
concorde sur rien de verifiable — un mot entier commun est exige.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:22:14 +02:00
mathieuandClaude Opus 5 71d4eb6752 Documenter un bloquant : le publish de l'API casse index.html
Verifie en executant le binaire publie : la racine repond 200 mais les
scripts de demarrage renvoient 404, donc l'application publiee affiche une
page blanche. Les placeholders d'empreinte restent litteraux alors que
seuls les fichiers empreintes existent sur disque.

Preexistant depuis la phase 1 : ma verification d'alors se limitait au code
HTTP de la racine, ce qui ne prouve pas que l'application demarre.

Consigne les trois pistes deja essayees sans succes pour eviter qu'on les
retente, et signale que le developpement n'est pas affecte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:16:29 +02:00
mathieuandClaude Opus 5 3a00591117 Corriger deux blocages du scan trouves en navigateur, et consigner les mesures reelles
Les deux defauts ont ete constates en executant reellement l'application, pas par
relecture :

1. L'import du module JS partait en 404 en developpement. L'import map generee par
   Blazor reecrit « ./js/scanner-camera.js » vers un nom empreinte que l'API hote ne
   sert pas (UseStaticFiles ignore les points d'entree empreintes). Une chaine de
   requete sur le specifieur empeche la reecriture ; en publication rien n'est
   reecrit pour ce fichier, le comportement est donc identique dans les deux modes.

2. Les messages d'erreur camera etaient tous generiques. Une DOMException perd son
   « name » en traversant le pont JS vers C# : un refus de permission arrivait sous
   la forme « Permission denied undefined ». Le JS attrape donc l'erreur et renvoie
   un code de statut stable, que le C# traduit en message utile.

CLAUDE.md est mis a jour avec les mesures reelles obtenues en WASM, qui manquaient :
la performance de decodage (l'estimation de 5-10 ms/frame est confirmee, l'AOT reste
inutile) et le surcout de payload, nettement superieur aux 192 Ko documentes une fois
comptees les assemblies BCL que ZXing empeche le trimmer de retirer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:10:58 +02:00
mathieuandClaude Opus 5 f7ad9582f6 Ajouter le scan camera du code-barres ISBN
Le decodage EAN-13/EAN-8 est fait en C# par ZXing.Net (IsbnScanner), conformement
a la decision actee dans CLAUDE.md : le JavaScript ne fournit que les pixels de la
camera, aucune logique de decodage ne lui est confiee.

Le scan se greffe en amont du flux d'ajout par ISBN existant : des qu'un code est
lu et reconnu comme ISBN-13 valide, la recherche de notices est declenchee sans
que l'utilisateur ait a retaper quoi que ce soit. La saisie manuelle reste
accessible en permanence, y compris quand la camera echoue.

Points traites explicitement :
- camera arriere par defaut (facingMode "environment", en "ideal" pour ne pas
  echouer sur un PC sans camera arriere) ;
- liberation de la camera a l'annulation, au succes et au demontage du composant ;
- cadence limitee a ~12 images/s et decodage restreint a la bande centrale
  reduite a 640 px de large : inutile de decoder chaque frame en pleine taille ;
- messages distincts pour permission refusee, absence de camera, camera occupee
  et contexte non securise (getUserMedia exige HTTPS ou localhost : une IP de
  reseau local en http ne marchera jamais, c'est une source de confusion garantie).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:32:05 +02:00
mathieuandClaude Opus 5 b619de29c7 Acter le statut de lecture par utilisateur et la table Auteur
Le statut de lecture sort de `Livre` vers une table par utilisateur : le
livre reste commun au foyer, sa lecture devient personnelle. La decision
« collection commune » n'est pas remise en cause.

L'auteur passe d'un champ texte libre a une table dediee avec forme
normalisee, prerequis du regroupement par auteur et de la recherche
insensible aux accents. Le regroupement automatique est limite aux
variantes sures ; les rapprochements ambigus doivent etre proposes, pas
appliques silencieusement — une fusion erronee se defait mal.

IDEES.md : la liste d'envies sera personnelle elle aussi, et exportable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:21:37 +02:00
mathieuandClaude Opus 5 a314dcfc79 Recueillir les idees d'interface dans IDEES.md
Retours d'usage a traiter plus tard : agrandissement de la couverture au
clic, statut de lecture en lecture seule dans la liste et remonte au
niveau du type, affichage du format limite aux ebooks (physique par
defaut), fiche en lecture seule avec bouton d'edition, regroupement des
livres par auteur.

Documente aussi un cas de donnee sale mesure sur l'ISBN 9782749942636 :
la notice BnF colle le nom de l'auteur en fin de titre sans separateur
ISBD, donc la regle de nettoyage existante ne peut pas s'appliquer. Ce
n'est ni un doublon ni un defaut du parsing.

Garde en fin de fichier l'idee des livres souhaites (bibliographie par
auteur avec ouvrages possedes grises), hors perimetre v1, avec ses
implications sur le modele de donnees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:16:16 +02:00
mathieuandClaude Opus 5 fd6438b912 Phase 3 : CRUD des livres, statuts de lecture et identite SSOwat
API
- CRUD complet sur /api/livres : liste filtrable (format, statut) et
  cherchable (titre, auteur), consultation, creation, modification,
  changement de statut isole, suppression.
- Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au
  foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne
  puisse pas s'en servir par inadvertance. Deux tests verrouillent
  l'invariant, dont un par reflexion sur le type des criteres.
- AjoutePar est renseigne par le serveur a la creation, jamais par le
  client, et n'est pas reecrit a l'edition (pas plus que DateAjout).
- Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL,
  YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en
  developpement uniquement. Sans en-tete ni simulation, l'identite reste
  inconnue plutot qu'inventee. Expose par GET /api/moi.
- Migration appliquee au demarrage : pas d'etape manuelle a l'installation.

Interface Blazor, pensee mobile d'abord
- Catalogue unique (physiques et numeriques melanges, distingues par le
  format) avec recherche, filtres et changement de statut directement dans
  la liste.
- Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de
  choix les presente en mettant en avant editeur et annee, seuls elements
  qui les departagent. Le formulaire reste ensuite entierement modifiable.
- Ajout manuel, edition, suppression avec confirmation explicite.
- Couvertures : l'URL OpenLibrary n'est jamais validee par un appel
  prealable (502 intermittents mesures en phase 2) ; un attribut onerror
  masque l'image cassee et laisse apparaitre un substitut.

Decisions prises la ou CLAUDE.md etait muet
- Tri alphabetique par titre.
- Un ISBN saisi doit etre valide, mais reste facultatif.
- Ecran de choix affiche seulement a partir de deux candidats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:55:07 +02:00
mathieuandClaude Opus 5 aed03e6b6f Documenter deux constats operationnels issus de la phase 2
- Couvertures OpenLibrary : 502 intermittents mesures sur un meme ISBN.
  Ne pas valider l'URL par un HEAD ; c'est a l'interface de gerer l'image
  cassee.
- OpenLibrary : certaines editions n'ont aucun champ `authors`, le double
  appel documente ne suffit donc pas. Le repli via `/works/` est desormais
  implemente et documente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:26:48 +02:00
mathieuandClaude Opus 5 f8f30dbcf7 Phase 2 : service de lookup ISBN en cascade BnF puis OpenLibrary
Recuperation des metadonnees d'un livre a partir de son ISBN, cote serveur
uniquement (aucune interface, aucune persistance).

Cascade : BnF en ISBN-13, puis BnF en ISBN-10 converti, puis OpenLibrary.
La conversion 13 -> 10 est indispensable et non optionnelle : la BnF indexe
l'ISBN tel qu'imprime, les ouvrages d'avant 2007 ne portent qu'un ISBN-10 et
sont introuvables par l'EAN-13 que lit le scanner.

Toutes les notices trouvees sont remontees (jusqu'a 5) : un meme ISBN peut
correspondre a plusieurs reeditions, le choix revient a l'utilisateur.

Le double appel /isbn puis /authors d'OpenLibrary est bien fait, avec repli
sur l'oeuvre quand l'edition ne porte aucun auteur : c'est le bug de BookLogr
(titre rempli, auteur vide) qu'il ne faut pas reproduire.

La couverture vient toujours d'OpenLibrary, avec ?default=false pour obtenir
un 404 plutot qu'une image placeholder.

87 tests xUnit, sur fixtures enregistrees : aucune dependance au reseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:22:47 +02:00
mathieuandClaude Opus 5 4990e41f3e Phase 1 : squelette de la solution (3 projets .NET 10)
Mise en place de la structure de base, sans aucune fonctionnalité métier :

- MaBibli.sln à la racine, avec trois projets ciblant net10.0
- MaBibli.Client : Blazor WebAssembly PWA (dotnet new blazorwasm --pwa),
  pages d'exemple du modèle (Counter, Weather) retirées
- MaBibli.Api : ASP.NET Core, sert aussi les fichiers statiques du client
  (UseBlazorFrameworkFiles + MapFallbackToFile), via le package
  Microsoft.AspNetCore.Components.WebAssembly.Server
- MaBibli.Shared : entités et enums partagés (Livre, Pret, Format, Statut),
  conformes au modèle de données de CLAUDE.md — AjoutePar est une simple
  traçabilité, Pret est une table séparée pour garder l'historique complet
- EF Core + SQLite dans l'API : MaBibliDbContext, chaîne de connexion,
  et migration initiale InitialCreate vérifiée par un database update
- dotnet-tools.json : dotnet-ef épinglé en outil local

Vérifié : dotnet build sans avertissement, et
dotnet publish MaBibli.Api -c Release -r linux-x64 --self-contained
produit un dossier unique dont le wwwroot contient bien _framework/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:52:37 +02:00
mathieuandClaude Opus 5 4467456b8b Cloturer le cadrage : deploiement, build, cache et notices BnF
- Architecture serveur : x86_64, publish `linux-x64`.
- Production des binaires : compilation locale + release manuelle, via un
  script ecrit pour etre reutilisable en CI plus tard. Compiler sur le
  serveur est explicitement exclu.
- Cache hors-ligne : IndexedDB, retenu pour permettre recherche et tri
  hors-ligne sur toute la bibliotheque.
- Notices BnF multiples : demander systematiquement, l'exactitude de
  l'edition primant sur la vitesse de saisie.
- AOT WASM : desactive par defaut, a reevaluer apres mesure sur telephone.

Ajoute une section sur l'architecture de deploiement : les 3 projets .NET
ne produisent qu'un seul service (le client WASM est servi par l'API), et
le packaging vit dans un depot `mabibli_ynh` separe du code.

Le cadrage est desormais clos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:45:20 +02:00
mathieuandClaude Opus 5 a30197000f Valider l'API SRU de la BnF et documenter ses pieges
API testee : HTTP 200, ~240 ms, sans cle. Le schema `dublincore` evite
completement l'UNIMARC — mapping bien plus simple que prevu, contrairement
a ce que supposait la note precedente.

Piege bloquant identifie : la BnF indexe l'ISBN tel qu'imprime, donc les
livres d'avant 2007 ne sont trouvables que par leur ISBN-10, alors que le
scanner lit toujours un EAN-13. Mesure sur echantillon verifie via
OpenLibrary : 2 titres sur 3 introuvables en ISBN-13 mais trouves en
ISBN-10. Interroger systematiquement les deux formes.

Autres pieges confirmes et documentes :
- aucune couverture cote BnF -> passer par OpenLibrary, avec
  `?default=false` sans lequel un placeholder masque les absences
- plusieurs notices possibles pour un meme ISBN (3 pour Germinal)
- ponctuation ISBD a nettoyer sur titre / auteur / editeur, regles validees
- livres etrangers absents, ce qui confirme la necessite de la cascade

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:27:40 +02:00
mathieuandClaude Opus 5 a5392cedac Acter les cinq decisions structurantes (offline, portee, ISBN, SSO, ebooks)
- Hors-ligne : consultation seule via cache des reponses GET. SQLite WASM
  cote client ecarte. L'UI devra desactiver explicitement les ecritures
  hors-ligne plutot que de les laisser echouer.
- Portee des donnees : collection commune au foyer. `UtilisateurId`
  (proprietaire) devient `AjoutePar` (tracabilite) — ne jamais filtrer les
  lectures dessus.
- Sources ISBN : BnF (SRU) en principale, OpenLibrary en secours. Motif :
  OpenLibrary est lacunaire sur le fonds francais. API BnF pas encore
  testee, a valider avant implementation.
- SSO : en-tetes SSOwat `YNH_USER` / `YNH_USER_EMAIL` / `YNH_USER_FULLNAME`.
  OIDC ecarte, non documente par YunoHost (verifie ce jour). Corrige au
  passage le nom d'en-tete, qui n'est pas `Remote-User`.
- Ebooks : fiches uniquement, pas de stockage de fichiers. Les prets ne
  concernent donc que les livres physiques.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:21:38 +02:00
mathieuandClaude Opus 5 d56061db6b Sortir les sources du tarball et acter ZXing.Net pour le scan ISBN
Le dépôt ne versionnait qu'une archive `mabibli-init.tar.gz`, ce qui
empêchait git de suivre le contenu des fichiers. Les trois fichiers sont
désormais à plat et l'archive est supprimée.

Scan ISBN : remplacement de `html5-qrcode` par ZXing.Net (C#, Apache 2.0).
Validé concrètement en .NET 10 — round-trip EAN-13 OK, publish Blazor WASM
OK, 0,53 ms/frame dans le pire cas (échec sur frame 640x480 bruitée),
+192 Ko brotli sur le payload. Motif principal : le décodage reste
réutilisable hors navigateur si le projet évolue en scanner de
bibliothèque.

Clarification du besoin hors-ligne : consultation de la bibliothèque
existante uniquement. Signale au passage que SQLite vit côté serveur et
qu'un cache client sera nécessaire — point d'architecture non résolu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:01:46 +02:00
mathieu 36cd4a4559 Initial project structure with requirements and Claude Code context 2026-08-17 20:48:40 +02:00
226 changed files with 63757 additions and 24502 deletions
-11
View File
@@ -1,11 +0,0 @@
# Le contexte de build ne doit porter que les sources : recopier les `bin/` et `obj/` de
# la machine de développement ralentit le build et, pire, peut faire réutiliser des
# artefacts compilés pour un autre système.
**/bin/
**/obj/
.git/
.idea/
.claude/
*.db
*.db-wal
*.db-shm
-3
View File
@@ -13,9 +13,6 @@ publish/
# IDE
.vs/
.vscode/
.idea/
*.user
*.DotSettings.user
*.swp
# OS
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Rider ignored files
/modules.xml
/contentModel.xml
/projectSettingsUpdater.xml
/.idea.MaBibli.iml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+1
View File
@@ -0,0 +1 @@
MaBibli
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="mabibli" uuid="5eb43769-66c1-4c25-8eca-b2d220710b99">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/MaBibli.Api/mabibli.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1319 -184
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
# Image pour héberger MaBibli AILLEURS que sur YunoHost (Synology, NAS, VPS…).
#
# ⚠️ Ce n'est PAS le chemin de déploiement de référence : celui-ci reste le paquet
# `mabibli_ynh`, qui déploie un publish self-contained sans conteneur (voir README,
# « Mettre en production »). Cette image existe pour les hébergements qui n'ont pas de
# portail SSO — et elle n'apporte donc AUCUNE authentification. Lire impérativement
# « Installer ailleurs que sur YunoHost » dans le README avant de l'exposer.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# `MaBibli.Api` référence `MaBibli.Client` : le client Blazor WebAssembly est compilé au
# passage et atterrit dans `wwwroot/`. Un seul projet à publier suffit pour les trois.
# Aucun `dotnet workload install` n'est nécessaire : le SDK .NET 10 résout l'outillage
# WebAssembly tout seul (vérifié — aucune charge de travail n'est installée en local).
COPY . .
RUN dotnet publish MaBibli.Api --configuration Release --output /app
# Pas de `--self-contained` ici, contrairement au paquet YunoHost : l'image `aspnet`
# embarque déjà le runtime. Le self-contained sert à ne rien exiger d'un serveur qu'on
# ne maîtrise pas ; dans un conteneur, la question ne se pose pas.
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# La base vit dans un volume, jamais à côté des binaires : c'est ce qui la fait survivre
# au remplacement de l'image. Même règle que le `data_dir` de YunoHost.
ENV ConnectionStrings__MaBibli="Data Source=/data/mabibli.db"
VOLUME ["/data"]
# L'image `aspnet` écoute déjà sur 8080 : poser `ASPNETCORE_URLS` ne ferait qu'écraser ce
# réglage et journaliser un avertissement à chaque démarrage. Pour changer de port,
# `ASPNETCORE_HTTP_PORTS`. ⚠️ Écouter sur toutes les interfaces est sûr DANS un conteneur —
# c'est la publication du port qui décide de l'exposition, voir `compose.yaml`.
EXPOSE 8080
ENTRYPOINT ["dotnet", "MaBibli.Api.dll"]
+112
View File
@@ -0,0 +1,112 @@
# IDEES.md — améliorations envisagées
Ce fichier recueille les idées et corrections identifiées en cours de route, **pas encore implémentées**. Il n'a pas valeur de décision : rien ici n'est acté tant que ce n'est pas repris dans `CLAUDE.md` ou traité dans une phase.
Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé volontairement : il sera fait quand les fonctionnalités seront en place.
---
## Qualité des données
### Recherche insensible aux accents
« Emile » ne trouve pas « Émile ». Gênant sur un fonds francophone.
**Semble réglé par la refonte du schéma** (colonnes normalisées, voir `CLAUDE.md`) : vérifié le 2026-08-18 sur l'API, `recherche=Emile` remonte bien les livres d'« Émile Zola ». Laissé ici faute d'avoir été traité comme un point à part entière — à confirmer sur un fonds réel avant de le retirer.
---
## Recherche : la ligature « œ » n'est pas réduite à « oe »
Repéré le 2026-08-18 en implémentant la liste d'envies. `NormalisationTexte.Normaliser`
décompose en **NFD**, qui sépare les accents mais laisse les ligatures intactes — seul NFKD les
défait. Conséquence : `L'Œuvre` et `L'oeuvre` ne se rencontrent jamais.
Ce n'est pas propre à la liste d'envies : la même fonction alimente la **recherche du catalogue**
et la **clé unique des auteurs**. Zola a écrit *L'Œuvre*, que la BnF orthographie avec la
ligature ; un utilisateur qui tape « oeuvre » ne le trouvera pas, et ne verra pas non plus le
livre grisé dans la bibliographie.
Correction envisagée : passer en NFKD, ou traiter explicitement `œ`/`Œ` et `æ`/`Æ`. **Non fait
volontairement** — changer cette fonction déplace deux invariants en base (les colonnes
normalisées et l'index unique `CleRegroupement`). `ServiceRenormalisation` sait recalculer les
colonnes au démarrage, mais la fusion d'auteurs que la nouvelle règle provoquerait mérite d'être
regardée avant. Documenté par un test (`CleOeuvreTests.Ne_reduit_pas_la_ligature_oe`) pour que
la limite reste visible.
---
## Bibliographie : ce qui reste à creuser
La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées :
- **Plafond de 200 notices.** Suffisant pour un auteur contemporain (Werber : 203, Nothomb : 277),
très insuffisant pour un classique (Zola : 2692). L'écran le dit, mais on pourrait charger les
pages suivantes à la demande plutôt que d'annoncer un extrait.
- **OpenLibrary en second rideau.** La BnF ne connaît pas les auteurs étrangers non traduits ;
l'écran affiche alors une liste vide et l'explique. OpenLibrary expose les œuvres d'un auteur
(`/authors/{id}/works.json`) et pourrait prendre le relais — à ne faire que si le cas se
présente réellement en usage.
---
# Retours d'usage du 2026-08-18 (2ᵉ série)
Demandes et anomalies remontées après quelques jours d'usage réel. **Rien n'est acté** :
ce qui suit est la matière brute, classée par sujet, avec ce que le diagnostic a déjà établi.
## Scan et saisie d'ISBN
### Le scan caméra rate souvent sur ordinateur
**La douchette USB est faite** (voir `CLAUDE.md`, « La douchette USB est le vrai remède ») :
champ focalisé à l'ouverture, validation sur `Entrée`. Reste à confirmer en usage réel qu'elle
suffit.
Si elle ne suffit pas, pistes non traitées : choisir la caméra quand il y en a plusieurs,
demander une résolution plus haute, exposer un curseur de zoom/torche là où l'API le permet,
et laisser **déposer une photo** du code-barres à décoder (ZXing lit un fichier aussi bien
qu'une frame).
### ISBN `9782846391009` : le scan n'aboutit pas
**La donnée existe pourtant côté BnF** — vérifié le 2026-08-18 :
```
bib.isbn all "9782846391009" → 1 notice
dc:title = La thérapie émotivo-rationnelle / Albert Ellis et Robert A. Harper
dc:creator = Ellis, Albert (1913-2007). Auteur du texte
dc:creator = Harper, Robert A. (1915-2004). Auteur du texte
(la forme ISBN-10 « 2846391009 » ne renvoie rien : ici c'est bien l'ISBN-13 qui indexe)
```
L'échec est donc **au décodage de l'image**, pas au lookup. Rejoint le point ci-dessus.
⚠️ À reproduire en saisissant l'ISBN à la main avant de conclure : si la saisie manuelle échoue
aussi, le défaut est ailleurs et ce diagnostic tombe.
## Auteurs et bibliographie
### Homonymes : « Between two worlds », un autre Robert Harper
Constaté le 2026-08-19 en vérifiant la bibliographie de Robert A. Harper : parmi les 7 œuvres
retenues figure *Between two worlds : a new introduction to geography* (1973, Houghton Mifflin),
qui est d'un **géographe homonyme**, pas du psychothérapeute.
Le post-filtre d'auteur ne peut rien : `RapprochementAuteurs` compare des **noms**, et ces
deux-là portent le même. Départager demanderait les dates de vie (`dc:creator` les porte souvent :
« Harper, Robert A. (1915-2004) »), ce que le parser jette aujourd'hui au nettoyage ISBD.
⚠️ Ne pas en faire une urgence : une œuvre en trop se voit et s'ignore, contrairement à une œuvre
manquante. Piste si le cas se répète : conserver les dates extraites de `dc:creator` et écarter
une notice dont les dates contredisent celles majoritairement observées pour l'auteur.
### OpenLibrary en second rideau — la justification est retombée
Le cas « Robert A. Harper » semblait l'imposer : il s'est révélé être un **délai dépassé**, pas un
trou de couverture (voir `CLAUDE.md`). La BnF connaît bien cet auteur.
La piste reste valable pour les auteurs étrangers **non traduits**, avec ses deux difficultés
inchangées : résoudre un nom vers un identifiant OpenLibrary (`/search/authors.json`), étape sans
équivalent BnF et sensible aux homonymes ; et des œuvres remontées **en langue originale**, donc
non rapprochables du catalogue par `CleOeuvre` — l'écran afficherait une liste où presque rien ne
serait marqué « possédé ». À ne faire que si le cas se présente réellement.
@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace MaBibli.Api.Data;
/// <summary>
/// Fabrique le <see cref="MaBibliDbContext"/> pour les <b>outils</b> (<c>dotnet ef</c>), sans
/// démarrer l'application.
/// </summary>
/// <remarks>
/// Sans elle, <c>dotnet ef</c> exécute <c>Program.cs</c> pour retrouver les services — et
/// <c>Program.cs</c> appelle <c>Database.Migrate()</c> au démarrage. Écrire une migration
/// suppose alors d'ouvrir la base de développement, ce qui est au mieux inutile et au pire
/// bloquant (constaté sur un système de fichiers réseau, où le verrou SQLite ne se prend
/// jamais : l'outil attendait cinq minutes puis renonçait).
/// <para>
/// ⚠️ La chaîne de connexion ci-dessous ne sert <b>qu'à la génération du code</b> : produire
/// une migration n'ouvre aucune base. Elle n'a donc pas à correspondre à quoi que ce soit, et
/// surtout pas à la base de production, qui est configurée par <c>ConnectionStrings__MaBibli</c>
/// dans l'unité systemd.
/// </para>
/// </remarks>
public sealed class FabriqueDbContextConception : IDesignTimeDbContextFactory<MaBibliDbContext>
{
public MaBibliDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
.UseSqlite("Data Source=mabibli-conception.db")
.Options;
return new MaBibliDbContext(options);
}
}
-191
View File
@@ -11,10 +11,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
public DbSet<LivreAuteur> LivreAuteurs => Set<LivreAuteur>();
public DbSet<Theme> Themes => Set<Theme>();
public DbSet<LivreTheme> LivreThemes => Set<LivreTheme>();
public DbSet<StatutLecture> StatutsLecture => Set<StatutLecture>();
public DbSet<RapprochementRefuse> RapprochementsRefuses => Set<RapprochementRefuse>();
@@ -28,37 +24,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
/// </summary>
public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>();
/// <summary>
/// Envies de revues et de numéros, <b>personnelles</b>. Table sœur de
/// <see cref="LivresSouhaites"/> : un numéro n'a pas d'auteur, et se distingue par son
/// numéro — deux choses que la clé d'unicité des envies de livres ne sait pas dire.
/// </summary>
public DbSet<RevueSouhaitee> RevuesSouhaitees => Set<RevueSouhaitee>();
public DbSet<BibliographieMasquee> BibliographiesMasquees => Set<BibliographieMasquee>();
/// <summary>
/// Sagas, cycles et séries. <b>Communs au foyer</b>, comme le catalogue : l'ordre de lecture
/// est une propriété de l'œuvre, pas du lecteur.
/// </summary>
public DbSet<Serie> Series => Set<Serie>();
public DbSet<ElementSerie> ElementsSerie => Set<ElementSerie>();
/// <summary>
/// Revues et magazines. <b>Table séparée des <see cref="Livres"/></b> : une revue n'est pas
/// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons.
/// </summary>
public DbSet<Revue> Revues => Set<Revue>();
public DbSet<NumeroRevue> NumerosRevue => Set<NumeroRevue>();
/// <summary>
/// Articles à la une d'un numéro. Table à part de <see cref="Themes"/> : un titre d'article
/// est unique à sa parution et ne se réutilise jamais, là où un thème est un vocabulaire.
/// </summary>
public DbSet<ArticleUne> ArticlesUne => Set<ArticleUne>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -67,7 +32,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
{
livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired();
livre.Property(l => l.UrlNotice);
livre.HasIndex(l => l.Isbn);
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
@@ -111,30 +75,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
lien.HasIndex(la => la.AuteurId);
});
modelBuilder.Entity<Theme>(theme =>
{
theme.Property(t => t.Nom).IsRequired();
theme.Property(t => t.NomNormalise).IsRequired();
theme.HasIndex(t => t.NomNormalise).IsUnique();
});
modelBuilder.Entity<LivreTheme>(lien =>
{
lien.HasKey(lt => new { lt.LivreId, lt.ThemeId });
lien.HasOne(lt => lt.Livre)
.WithMany(l => l.Themes)
.HasForeignKey(lt => lt.LivreId)
.OnDelete(DeleteBehavior.Cascade);
lien.HasOne(lt => lt.Theme)
.WithMany(t => t.Livres)
.HasForeignKey(lt => lt.ThemeId)
.OnDelete(DeleteBehavior.Restrict);
lien.HasIndex(lt => lt.ThemeId);
});
modelBuilder.Entity<StatutLecture>(statut =>
{
statut.Property(s => s.Utilisateur).IsRequired();
@@ -181,137 +121,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
.IsUnique();
});
modelBuilder.Entity<RevueSouhaitee>(envie =>
{
envie.Property(e => e.Utilisateur).IsRequired();
envie.Property(e => e.Titre).IsRequired();
envie.Property(e => e.TitreNormalise).IsRequired();
// Jamais nullable, exactement pour la raison qui l'impose à AuteurNormalise : SQLite
// tient deux NULL pour distincts, et « Médor, pas de numéro » s'ajouterait autant de
// fois qu'on cliquerait.
envie.Property(e => e.NumeroNormalise).IsRequired().HasDefaultValue(string.Empty);
envie.HasIndex(e => e.Utilisateur, "IX_RevuesSouhaitees_Utilisateur");
// Le numéro fait partie de la clé : souhaiter le 43 ET le 44 de la même revue est le
// cas normal, alors que noter deux fois le même numéro est une fausse manœuvre.
// L'utilisateur ouvre l'index : deux personnes peuvent vouloir la même parution.
envie.HasIndex(
e => new { e.Utilisateur, e.TitreNormalise, e.NumeroNormalise },
"IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
});
modelBuilder.Entity<BibliographieMasquee>(masquee =>
{
masquee.Property(m => m.Utilisateur).IsRequired();
masquee.Property(m => m.TitreNormalise).IsRequired();
masquee.HasIndex(
m => new { m.AuteurId, m.Utilisateur, m.TitreNormalise },
"IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
masquee.HasIndex(m => new { m.Utilisateur, m.AuteurId });
});
modelBuilder.Entity<Serie>(serie =>
{
serie.Property(s => s.Titre).IsRequired();
serie.Property(s => s.TitreNormalise).IsRequired();
// Une série, une fiche. Voir l'entité : l'unicité est GLOBALE et non par parent,
// le parent étant nullable et deux NULL étant distincts pour SQLite.
serie.HasIndex(s => s.TitreNormalise).IsUnique();
// SetNull et non Cascade : supprimer un cycle ne doit pas emporter les trilogies
// qu'il contient — ce sont de vraies séries de vrais livres, qui se tiennent seules.
serie.HasOne(s => s.SerieParente)
.WithMany(s => s.SousSeries)
.HasForeignKey(s => s.SerieParenteId)
.OnDelete(DeleteBehavior.SetNull);
serie.HasIndex(s => s.SerieParenteId);
});
modelBuilder.Entity<ElementSerie>(element =>
{
element.Property(e => e.Titre).IsRequired();
element.HasOne(e => e.Serie)
.WithMany(s => s.Elements)
.HasForeignKey(e => e.SerieId)
.OnDelete(DeleteBehavior.Cascade);
// ⚠️ SetNull, surtout pas Cascade : supprimer un livre du catalogue doit laisser sa
// place dans la saga, devenue un trou nommé. Cascade effacerait le tome 3 de la
// série parce qu'on a prêté puis perdu son exemplaire.
element.HasOne(e => e.Livre)
.WithMany()
.HasForeignKey(e => e.LivreId)
.OnDelete(DeleteBehavior.SetNull);
element.HasIndex(e => e.SerieId, "IX_ElementsSerie_SerieId");
// Un même livre ne tient qu'une place dans une série donnée. Index PARTIEL, comme
// pour les prêts : les positions sans livre (les trous) doivent pouvoir se répéter
// autant que nécessaire, et deux NULL passeraient de toute façon.
// Un livre peut en revanche appartenir à PLUSIEURS séries — une intégrale, une
// série, un cycle — d'où l'absence d'unicité sur LivreId seul.
element.HasIndex(e => new { e.SerieId, e.LivreId }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
});
modelBuilder.Entity<Revue>(revue =>
{
revue.Property(r => r.Titre).IsRequired();
revue.Property(r => r.TitreNormalise).IsRequired();
revue.HasIndex(r => r.TitreNormalise).IsUnique();
// Index PARTIEL : une revue peut n'avoir aucun ISSN (saisie à la main), et deux
// NULL étant distincts pour SQLite, une unicité simple ne dirait rien. Filtré, il
// garantit qu'un ISSN connu ne désigne qu'une fiche — ce qui permet au flux « 977 »
// de retrouver la revue déjà créée au lieu d'en faire une seconde.
revue.HasIndex(r => r.Issn, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
});
modelBuilder.Entity<NumeroRevue>(numero =>
{
numero.Property(n => n.Numero).IsRequired();
numero.Property(n => n.NumeroNormalise).IsRequired();
numero.HasOne(n => n.Revue)
.WithMany(r => r.Numeros)
.HasForeignKey(n => n.RevueId)
.OnDelete(DeleteBehavior.Cascade);
// Un numéro ne se possède qu'une fois par revue : rescanner le même exemplaire ne
// doit pas allonger la liste. Contrairement aux livres, un second exemplaire d'un
// numéro de magazine n'a pas d'usage identifié — et l'index se retire si un jour si.
numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique();
});
modelBuilder.Entity<ArticleUne>(article =>
{
article.Property(a => a.Titre).IsRequired();
article.Property(a => a.TitreNormalise).IsRequired();
// Cascade, comme les numéros sous leur revue : un article à la une n'existe que par
// la parution qui le porte. Rien de comparable à ElementSerie, dont la place doit
// survivre au livre.
article.HasOne(a => a.NumeroRevue)
.WithMany(n => n.Articles)
.HasForeignKey(a => a.NumeroRevueId)
.OnDelete(DeleteBehavior.Cascade);
// Le même article ne s'annonce pas deux fois à la une du même numéro. L'unicité est
// bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
article.HasIndex(a => new { a.NumeroRevueId, a.TitreNormalise }).IsUnique();
});
modelBuilder.Entity<Pret>(pret =>
{
pret.Property(p => p.Emprunteur).IsRequired();
@@ -0,0 +1,108 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260817195059_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LivreId");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Prets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Livres",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Isbn = table.Column<string>(type: "TEXT", nullable: true),
Titre = table.Column<string>(type: "TEXT", nullable: false),
Auteur = table.Column<string>(type: "TEXT", nullable: true),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
Format = table.Column<int>(type: "INTEGER", nullable: false),
Statut = table.Column<int>(type: "INTEGER", nullable: false),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Livres", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Prets",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
Emprunteur = table.Column<string>(type: "TEXT", nullable: false),
DatePret = table.Column<DateTime>(type: "TEXT", nullable: false),
DateRetour = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Prets", x => x.Id);
table.ForeignKey(
name: "FK_Prets_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Livres_Isbn",
table: "Livres",
column: "Isbn");
migrationBuilder.CreateIndex(
name: "IX_Prets_LivreId",
table: "Prets",
column: "LivreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Prets");
migrationBuilder.DropTable(
name: "Livres");
}
}
}
@@ -0,0 +1,242 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260818000335_StatutPersonnelEtTableAuteurs")]
partial class StatutPersonnelEtTableAuteurs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LivreId");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,278 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <summary>
/// Sort le statut de lecture et l'auteur de la table <c>Livres</c>.
/// </summary>
/// <remarks>
/// <b>Ce que deviennent les données existantes.</b>
/// <list type="bullet">
/// <item>
/// <c>Livres.Statut</c> était commun à tout le foyer. Il est <b>rattaché à
/// <c>AjoutePar</c></b>, la seule personne que la base associe au livre. Les statuts des
/// livres sans <c>AjoutePar</c> (saisis sans identité) sont <b>perdus</b> : les attribuer à
/// quelqu'un serait une invention, et les garder pour tout le monde serait exactement le
/// défaut que cette migration corrige. Les trois valeurs sont reprises telles quelles, « À
/// lire » compris : c'est ce que l'ancienne interface affichait, et le taire changerait ce
/// que l'utilisateur voit.
/// </item>
/// <item>
/// <c>Livres.Auteur</c> devient une ligne de <c>Auteurs</c> par valeur distincte, plus un lien
/// en position 0. Le regroupement se fait ici sur une simple mise en minuscules — SQLite ne
/// sait pas retirer les accents. La normalisation complète, et la fusion des variantes qu'elle
/// révèle, sont faites en C# au démarrage suivant (voir <c>ServiceRenormalisation</c>).
/// </item>
/// </list>
/// L'ordre des opérations compte : tout est recopié <b>avant</b> que les anciennes colonnes
/// ne soient supprimées.
/// </remarks>
public partial class StatutPersonnelEtTableAuteurs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Mise à l'abri du contenu des deux colonnes condamnées. Supprimer une colonne sous
// SQLite reconstruit la table : on le fait pendant que Livres n'a pas encore
// d'enfants à préserver, et on recopie ensuite depuis cette table de transit.
migrationBuilder.Sql(
"""
CREATE TABLE _RepriseLivres AS
SELECT Id, Auteur, Statut FROM Livres;
""");
migrationBuilder.DropColumn(
name: "Auteur",
table: "Livres");
migrationBuilder.DropColumn(
name: "Statut",
table: "Livres");
migrationBuilder.AddColumn<string>(
name: "TitreNormalise",
table: "Livres",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "Auteurs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nom = table.Column<string>(type: "TEXT", nullable: false),
NomNormalise = table.Column<string>(type: "TEXT", nullable: false),
CleRegroupement = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Auteurs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RapprochementsRefuses",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AuteurAId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurBId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "StatutsLecture",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Statut = table.Column<int>(type: "INTEGER", nullable: false),
DateMaj = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StatutsLecture", x => x.Id);
table.ForeignKey(
name: "FK_StatutsLecture_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LivreAuteurs",
columns: table => new
{
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId });
table.ForeignKey(
name: "FK_LivreAuteurs_Auteurs_AuteurId",
column: x => x.AuteurId,
principalTable: "Auteurs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LivreAuteurs_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Livres_TitreNormalise",
table: "Livres",
column: "TitreNormalise");
migrationBuilder.CreateIndex(
name: "IX_Auteurs_CleRegroupement",
table: "Auteurs",
column: "CleRegroupement",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Auteurs_NomNormalise",
table: "Auteurs",
column: "NomNormalise");
migrationBuilder.CreateIndex(
name: "IX_LivreAuteurs_AuteurId",
table: "LivreAuteurs",
column: "AuteurId");
migrationBuilder.CreateIndex(
name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId",
table: "RapprochementsRefuses",
columns: new[] { "AuteurAId", "AuteurBId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_LivreId_Utilisateur",
table: "StatutsLecture",
columns: new[] { "LivreId", "Utilisateur" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_Utilisateur",
table: "StatutsLecture",
column: "Utilisateur");
// ── Reprise des données depuis la table de transit ──────────────────────────────
// Le titre normalisé n'est qu'approché ici (lower() de SQLite ne touche pas aux
// accents) ; le passage C# du démarrage le recalculera correctement.
migrationBuilder.Sql("UPDATE Livres SET TitreNormalise = lower(trim(Titre));");
// Une fiche auteur par valeur distincte, à la casse près. Le nom d'affichage retenu
// est le premier dans l'ordre alphabétique : arbitraire, mais déterministe, et
// l'utilisateur peut le corriger depuis n'importe quel livre.
migrationBuilder.Sql(
"""
INSERT INTO Auteurs (Nom, NomNormalise, CleRegroupement)
SELECT min(trim(Auteur)), lower(trim(Auteur)), lower(trim(Auteur))
FROM _RepriseLivres
WHERE Auteur IS NOT NULL AND trim(Auteur) <> ''
GROUP BY lower(trim(Auteur));
""");
migrationBuilder.Sql(
"""
INSERT INTO LivreAuteurs (LivreId, AuteurId, Position)
SELECT r.Id, a.Id, 0
FROM _RepriseLivres r
JOIN Auteurs a ON a.CleRegroupement = lower(trim(r.Auteur))
JOIN Livres l ON l.Id = r.Id
WHERE r.Auteur IS NOT NULL AND trim(r.Auteur) <> '';
""");
// Le statut commun devient celui de la personne qui a saisi le livre. Sans AjoutePar,
// il n'y a personne à qui l'attribuer : la ligne n'est pas créée.
migrationBuilder.Sql(
"""
INSERT INTO StatutsLecture (LivreId, Utilisateur, Statut, DateMaj)
SELECT l.Id, trim(l.AjoutePar), r.Statut, l.DateAjout
FROM Livres l
JOIN _RepriseLivres r ON r.Id = l.Id
WHERE l.AjoutePar IS NOT NULL AND trim(l.AjoutePar) <> '';
""");
migrationBuilder.Sql("DROP TABLE _RepriseLivres;");
}
/// <inheritdoc />
/// <summary>
/// Retour en arrière. <b>Une partie de l'information est perdue par nature</b> : l'ancien
/// modèle n'a qu'une case pour un auteur et une pour un statut. On y remet le premier
/// auteur et le statut de <c>AjoutePar</c> ; les co-auteurs et les statuts des autres
/// membres du foyer disparaissent.
/// </summary>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Auteur",
table: "Livres",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Statut",
table: "Livres",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.Sql(
"""
UPDATE Livres SET Auteur = (
SELECT a.Nom FROM LivreAuteurs la
JOIN Auteurs a ON a.Id = la.AuteurId
WHERE la.LivreId = Livres.Id
ORDER BY la.Position LIMIT 1);
""");
migrationBuilder.Sql(
"""
UPDATE Livres SET Statut = coalesce((
SELECT s.Statut FROM StatutsLecture s
WHERE s.LivreId = Livres.Id AND s.Utilisateur = trim(Livres.AjoutePar)
LIMIT 1), 0);
""");
migrationBuilder.DropTable(
name: "LivreAuteurs");
migrationBuilder.DropTable(
name: "RapprochementsRefuses");
migrationBuilder.DropTable(
name: "StatutsLecture");
migrationBuilder.DropTable(
name: "Auteurs");
migrationBuilder.DropIndex(
name: "IX_Livres_TitreNormalise",
table: "Livres");
migrationBuilder.DropColumn(
name: "TitreNormalise",
table: "Livres");
}
}
}
@@ -0,0 +1,246 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260818003150_IndexPretEnCours")]
partial class IndexPretEnCours
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class IndexPretEnCours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Prets_LivreId_EnCours",
table: "Prets",
column: "LivreId",
unique: true,
filter: "\"DateRetour\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Prets_LivreId_EnCours",
table: "Prets");
}
}
}
@@ -0,0 +1,301 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260818052834_ListeDEnvies")]
partial class ListeDEnvies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ListeDEnvies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LivresSouhaites",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Auteur = table.Column<string>(type: "TEXT", nullable: true),
AuteurNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
Annee = table.Column<string>(type: "TEXT", nullable: true),
Isbn = table.Column<string>(type: "TEXT", nullable: true),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivresSouhaites", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_LivresSouhaites_Utilisateur",
table: "LivresSouhaites",
column: "Utilisateur");
migrationBuilder.CreateIndex(
name: "IX_LivresSouhaites_Utilisateur_Oeuvre",
table: "LivresSouhaites",
columns: new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LivresSouhaites");
}
}
}
@@ -0,0 +1,304 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260818213512_RangDesEnvies")]
partial class RangDesEnvies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <summary>
/// Ajoute le rang manuel de la liste d'envies (du plus désiré au moins désiré).
/// </summary>
/// <remarks>
/// ⚠️ La colonne seule ne suffit pas : à <c>0</c> partout, l'ordre retomberait sur l'Id,
/// c'est-à-dire l'ordre d'ajout, et les listes existantes se réordonneraient sous les yeux
/// de leur propriétaire sans qu'il ait rien demandé. Le remplissage ci-dessous <b>reconduit
/// exactement l'ordre affiché jusqu'ici</b> — auteurs renseignés d'abord, puis par auteur,
/// puis par titre — de sorte que la mise à jour ne se voie pas.
/// <para>
/// Le rang est <b>personnel</b>, comme toute la table : la numérotation repart de zéro pour
/// chaque <c>Utilisateur</c>, d'où la corrélation sur cette colonne dans la sous-requête.
/// </para>
/// </remarks>
public partial class RangDesEnvies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Rang",
table: "LivresSouhaites",
type: "INTEGER",
nullable: false,
defaultValue: 0);
// Row values (a, b) < (c, d) : SQLite les gère depuis la 3.15, et c'est ce qui
// permet d'exprimer « combien de lignes se classaient avant celle-ci » en une
// seule comparaison lexicographique. L'Id ferme le tri : sans lui, deux envies
// de même auteur et même titre recevraient le même rang.
migrationBuilder.Sql("""
UPDATE LivresSouhaites SET Rang = (
SELECT COUNT(*)
FROM LivresSouhaites AS autre
WHERE autre.Utilisateur = LivresSouhaites.Utilisateur
AND (
CASE WHEN autre.AuteurNormalise = '' THEN 1 ELSE 0 END,
autre.AuteurNormalise,
autre.TitreNormalise,
autre.Id
) < (
CASE WHEN LivresSouhaites.AuteurNormalise = '' THEN 1 ELSE 0 END,
LivresSouhaites.AuteurNormalise,
LivresSouhaites.TitreNormalise,
LivresSouhaites.Id
)
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Rang",
table: "LivresSouhaites");
}
}
}
@@ -1,693 +0,0 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260822104741_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
b.ToTable("RevuesSouhaitees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,556 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Auteurs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nom = table.Column<string>(type: "TEXT", nullable: false),
NomNormalise = table.Column<string>(type: "TEXT", nullable: false),
CleRegroupement = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Auteurs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BibliographiesMasquees",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BibliographiesMasquees", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Livres",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Isbn = table.Column<string>(type: "TEXT", nullable: true),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
Format = table.Column<int>(type: "INTEGER", nullable: false),
TypeDocument = table.Column<int>(type: "INTEGER", nullable: false),
NombrePages = table.Column<int>(type: "INTEGER", nullable: true),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
UrlNotice = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Livres", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LivresSouhaites",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Auteur = table.Column<string>(type: "TEXT", nullable: true),
AuteurNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
Annee = table.Column<string>(type: "TEXT", nullable: true),
Isbn = table.Column<string>(type: "TEXT", nullable: true),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
Rang = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivresSouhaites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RapprochementsRefuses",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AuteurAId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurBId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Revues",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Issn = table.Column<string>(type: "TEXT", nullable: true),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Revues", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RevuesSouhaitees",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Numero = table.Column<string>(type: "TEXT", nullable: true),
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
Issn = table.Column<string>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
Rang = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RevuesSouhaitees", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Series",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
SerieParenteId = table.Column<int>(type: "INTEGER", nullable: true),
Position = table.Column<int>(type: "INTEGER", nullable: false),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Series", x => x.Id);
table.ForeignKey(
name: "FK_Series_Series_SerieParenteId",
column: x => x.SerieParenteId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Themes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nom = table.Column<string>(type: "TEXT", nullable: false),
NomNormalise = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Themes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LivreAuteurs",
columns: table => new
{
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false),
Role = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId });
table.ForeignKey(
name: "FK_LivreAuteurs_Auteurs_AuteurId",
column: x => x.AuteurId,
principalTable: "Auteurs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LivreAuteurs_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Prets",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
Emprunteur = table.Column<string>(type: "TEXT", nullable: false),
DatePret = table.Column<DateTime>(type: "TEXT", nullable: false),
DateRetour = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Prets", x => x.Id);
table.ForeignKey(
name: "FK_Prets_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "StatutsLecture",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Statut = table.Column<int>(type: "INTEGER", nullable: false),
DateMaj = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StatutsLecture", x => x.Id);
table.ForeignKey(
name: "FK_StatutsLecture_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "NumerosRevue",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RevueId = table.Column<int>(type: "INTEGER", nullable: false),
Numero = table.Column<string>(type: "TEXT", nullable: false),
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false),
DateParution = table.Column<DateTime>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NumerosRevue", x => x.Id);
table.ForeignKey(
name: "FK_NumerosRevue_Revues_RevueId",
column: x => x.RevueId,
principalTable: "Revues",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ElementsSerie",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SerieId = table.Column<int>(type: "INTEGER", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false),
LivreId = table.Column<int>(type: "INTEGER", nullable: true),
Titre = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ElementsSerie", x => x.Id);
table.ForeignKey(
name: "FK_ElementsSerie_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ElementsSerie_Series_SerieId",
column: x => x.SerieId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LivreThemes",
columns: table => new
{
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
ThemeId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivreThemes", x => new { x.LivreId, x.ThemeId });
table.ForeignKey(
name: "FK_LivreThemes_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LivreThemes_Themes_ThemeId",
column: x => x.ThemeId,
principalTable: "Themes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ArticlesUne",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
NumeroRevueId = table.Column<int>(type: "INTEGER", nullable: false),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArticlesUne", x => x.Id);
table.ForeignKey(
name: "FK_ArticlesUne_NumerosRevue_NumeroRevueId",
column: x => x.NumeroRevueId,
principalTable: "NumerosRevue",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ArticlesUne_NumeroRevueId_TitreNormalise",
table: "ArticlesUne",
columns: new[] { "NumeroRevueId", "TitreNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Auteurs_CleRegroupement",
table: "Auteurs",
column: "CleRegroupement",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Auteurs_NomNormalise",
table: "Auteurs",
column: "NomNormalise");
migrationBuilder.CreateIndex(
name: "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre",
table: "BibliographiesMasquees",
columns: new[] { "AuteurId", "Utilisateur", "TitreNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BibliographiesMasquees_Utilisateur_AuteurId",
table: "BibliographiesMasquees",
columns: new[] { "Utilisateur", "AuteurId" });
migrationBuilder.CreateIndex(
name: "IX_ElementsSerie_LivreId",
table: "ElementsSerie",
column: "LivreId");
migrationBuilder.CreateIndex(
name: "IX_ElementsSerie_SerieId",
table: "ElementsSerie",
column: "SerieId");
migrationBuilder.CreateIndex(
name: "IX_ElementsSerie_SerieId_LivreId",
table: "ElementsSerie",
columns: new[] { "SerieId", "LivreId" },
unique: true,
filter: "\"LivreId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_LivreAuteurs_AuteurId",
table: "LivreAuteurs",
column: "AuteurId");
migrationBuilder.CreateIndex(
name: "IX_Livres_Isbn",
table: "Livres",
column: "Isbn");
migrationBuilder.CreateIndex(
name: "IX_Livres_TitreNormalise",
table: "Livres",
column: "TitreNormalise");
migrationBuilder.CreateIndex(
name: "IX_LivresSouhaites_Utilisateur",
table: "LivresSouhaites",
column: "Utilisateur");
migrationBuilder.CreateIndex(
name: "IX_LivresSouhaites_Utilisateur_Oeuvre",
table: "LivresSouhaites",
columns: new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LivreThemes_ThemeId",
table: "LivreThemes",
column: "ThemeId");
migrationBuilder.CreateIndex(
name: "IX_NumerosRevue_RevueId_NumeroNormalise",
table: "NumerosRevue",
columns: new[] { "RevueId", "NumeroNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Prets_LivreId",
table: "Prets",
column: "LivreId");
migrationBuilder.CreateIndex(
name: "IX_Prets_LivreId_EnCours",
table: "Prets",
column: "LivreId",
unique: true,
filter: "\"DateRetour\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId",
table: "RapprochementsRefuses",
columns: new[] { "AuteurAId", "AuteurBId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Revues_Issn",
table: "Revues",
column: "Issn",
unique: true,
filter: "\"Issn\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Revues_TitreNormalise",
table: "Revues",
column: "TitreNormalise",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RevuesSouhaitees_Utilisateur",
table: "RevuesSouhaitees",
column: "Utilisateur");
migrationBuilder.CreateIndex(
name: "IX_RevuesSouhaitees_Utilisateur_Revue",
table: "RevuesSouhaitees",
columns: new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Series_SerieParenteId",
table: "Series",
column: "SerieParenteId");
migrationBuilder.CreateIndex(
name: "IX_Series_TitreNormalise",
table: "Series",
column: "TitreNormalise",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_LivreId_Utilisateur",
table: "StatutsLecture",
columns: new[] { "LivreId", "Utilisateur" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_Utilisateur",
table: "StatutsLecture",
column: "Utilisateur");
migrationBuilder.CreateIndex(
name: "IX_Themes_NomNormalise",
table: "Themes",
column: "NomNormalise",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArticlesUne");
migrationBuilder.DropTable(
name: "BibliographiesMasquees");
migrationBuilder.DropTable(
name: "ElementsSerie");
migrationBuilder.DropTable(
name: "LivreAuteurs");
migrationBuilder.DropTable(
name: "LivresSouhaites");
migrationBuilder.DropTable(
name: "LivreThemes");
migrationBuilder.DropTable(
name: "Prets");
migrationBuilder.DropTable(
name: "RapprochementsRefuses");
migrationBuilder.DropTable(
name: "RevuesSouhaitees");
migrationBuilder.DropTable(
name: "StatutsLecture");
migrationBuilder.DropTable(
name: "NumerosRevue");
migrationBuilder.DropTable(
name: "Series");
migrationBuilder.DropTable(
name: "Auteurs");
migrationBuilder.DropTable(
name: "Themes");
migrationBuilder.DropTable(
name: "Livres");
migrationBuilder.DropTable(
name: "Revues");
}
}
}
@@ -1,696 +0,0 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260907234923_NumeroDeTome")]
partial class NumeroDeTome
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
b.ToTable("RevuesSouhaitees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class NumeroDeTome : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Numero",
table: "ElementsSerie",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Numero",
table: "ElementsSerie");
}
}
}
@@ -1,699 +0,0 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260910204753_AjouterEtatSerie")]
partial class AjouterEtatSerie
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
b.ToTable("RevuesSouhaitees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Etat")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AjouterEtatSerie : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Etat",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Etat",
table: "Series");
}
}
}
@@ -1,699 +0,0 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260910214613_AjouterTypesPratiquetConnaissance")]
partial class AjouterTypesPratiquetConnaissance
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
b.ToTable("RevuesSouhaitees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Etat")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AjouterTypesPratiquetConnaissance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -17,34 +17,6 @@ namespace MaBibli.Api.Data.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
@@ -73,68 +45,6 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
@@ -159,9 +69,6 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
@@ -170,12 +77,6 @@ namespace MaBibli.Api.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
@@ -196,9 +97,6 @@ namespace MaBibli.Api.Data.Migrations
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
@@ -264,58 +162,6 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
@@ -366,132 +212,6 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
.IsUnique();
b.ToTable("RevuesSouhaitees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Etat")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
@@ -521,57 +241,6 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
@@ -591,36 +260,6 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
@@ -632,16 +271,6 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
@@ -665,30 +294,6 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
+3 -217
View File
@@ -1,5 +1,4 @@
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -26,228 +25,15 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
{
var titres = await RenormaliserTitresAsync(ct);
var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct);
var autres = await RenormaliserLesAutresTablesAsync(ct);
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
if (titres + auteursCorriges + auteursFusionnes > 0)
{
logger.LogInformation(
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
titres, auteursCorriges, auteursFusionnes, autres);
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).",
titres, auteursCorriges, auteursFusionnes);
}
}
/// <summary>
/// Remet à jour les colonnes normalisées des tables apparues après ce service.
/// </summary>
/// <remarks>
/// Envies, séries, revues et numéros calculent leurs formes <b>à l'écriture</b> : rien ne
/// les rattraperait si la règle de normalisation changeait — et elle a changé le 2026-08-19,
/// avec le développement des ligatures <c>œ</c> et <c>æ</c>. Sans ce passage, une envie
/// enregistrée avant serait restée introuvable par sa propre clé.
/// <para>
/// ⚠️ Trois de ces colonnes portent une <b>unicité</b>. Une règle plus large peut donc
/// rendre identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), et
/// l'écriture échouerait <b>au démarrage</b> — l'application ne se lancerait plus. La
/// collision est donc détectée avant d'écrire : la ligne la plus ancienne prend la nouvelle
/// clé, les suivantes <b>gardent l'ancienne</b> et sont journalisées. Mal normalisée est
/// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas.
/// </para>
/// </remarks>
private async Task<int> RenormaliserLesAutresTablesAsync(CancellationToken ct)
{
var corriges = 0;
var souhaits = await db.LivresSouhaites.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
souhaits,
s => Cle(s.Utilisateur, s.TitreNormalise, s.AuteurNormalise),
s => Cle(s.Utilisateur, CleOeuvre.Cle(s.Titre), RapprochementAuteurs.Cle(s.Auteur)),
s => s.RecalculerFormes(),
s => $"l'envie « {s.Titre} »");
// ⚠️ La table sœur des envies suit exactement la même règle. L'oublier ne se verrait
// pas tout de suite : une revue souhaitée resterait introuvable par sa propre clé le
// jour où la normalisation changerait — c'est ce qui était arrivé aux envies avec les
// ligatures.
var revuesSouhaitees = await db.RevuesSouhaitees.OrderBy(e => e.Id).ToListAsync(ct);
corriges += Renormaliser(
revuesSouhaitees,
e => Cle(e.Utilisateur, e.TitreNormalise, e.NumeroNormalise),
e => Cle(
e.Utilisateur,
NormalisationTexte.Normaliser(e.Titre),
e.Numero is null ? string.Empty : NormalisationTexte.Normaliser(e.Numero)),
e => e.RecalculerFormes(),
e => $"l'envie de revue « {e.Titre} »");
// ⚠️ Renormaliser() n'applique rien quand la CLÉ ne bouge pas : un ISSN mal formé sur
// une ligne au titre inchangé y échapperait. Il se canonise donc à part — et sans le
// moindre jeu de clés, cette colonne ne portant aucune unicité, contrairement à celle
// de Revue.
foreach (var envie in revuesSouhaitees)
{
var voulu = FormatageIssn.Canonique(envie.Issn);
if (voulu != envie.Issn)
{
envie.Issn = voulu;
corriges++;
}
}
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
series,
s => s.TitreNormalise,
s => NormalisationTexte.Normaliser(s.Titre),
s => s.RecalculerFormes(),
s => $"la série « {s.Titre} »");
var revues = await db.Revues.OrderBy(r => r.Id).ToListAsync(ct);
corriges += Renormaliser(
revues,
r => r.TitreNormalise,
r => NormalisationTexte.Normaliser(r.Titre),
r => r.RecalculerFormes(),
r => $"la revue « {r.Titre} »");
var numeros = await db.NumerosRevue.OrderBy(n => n.Id).ToListAsync(ct);
corriges += Renormaliser(
numeros,
n => Cle(n.RevueId.ToString(), n.NumeroNormalise),
n => Cle(n.RevueId.ToString(), NormalisationTexte.Normaliser(n.Numero)),
n => n.RecalculerFormes(),
n => $"le numéro {n.Numero}");
// ⚠️ L'ISSN ne peut PAS passer par Renormaliser : sa clé est nullable — la plupart des
// revues n'en ont pas — et un jeu de clés confondrait tous ces NULL en une seule.
// Son unicité est d'ailleurs un index PARTIEL, précisément pour cette raison.
corriges += CanoniserLesIssn(revues);
var articles = await db.ArticlesUne.OrderBy(a => a.Id).ToListAsync(ct);
corriges += Renormaliser(
articles,
a => Cle(a.NumeroRevueId.ToString(), a.TitreNormalise),
a => Cle(a.NumeroRevueId.ToString(), NormalisationTexte.Normaliser(a.Titre)),
a => a.RecalculerFormes(),
a => $"l'article « {a.Titre} »");
var themes = await db.Themes.OrderBy(t => t.Id).ToListAsync(ct);
corriges += Renormaliser(
themes,
t => t.NomNormalise,
t => NormalisationTexte.Normaliser(t.Nom),
t => t.RecalculerFormes(),
t => $"le thème « {t.Nom} »");
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
}
return corriges;
}
/// <summary>
/// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité.
/// </summary>
/// <remarks>
/// Le jeu des clés occupées part de l'état <b>actuel</b> de la base, et chaque ligne libère
/// la sienne avant de réserver la nouvelle. Sans cette libération, une ligne inchangée
/// bloquerait sa propre mise à jour ; sans le jeu initial, une ligne traitée tôt prendrait
/// la clé d'une ligne non encore traitée, et l'écriture échouerait.
/// </remarks>
private int Renormaliser<T>(
IReadOnlyList<T> lignes,
Func<T, string> cleActuelle,
Func<T, string> cleVoulue,
Action<T> appliquer,
Func<T, string> nommer)
{
var occupees = new HashSet<string>(lignes.Select(cleActuelle), StringComparer.Ordinal);
var corriges = 0;
foreach (var ligne in lignes)
{
var actuelle = cleActuelle(ligne);
var voulue = cleVoulue(ligne);
if (actuelle == voulue)
{
continue;
}
occupees.Remove(actuelle);
if (!occupees.Add(voulue))
{
// Deux lignes que la nouvelle règle rend identiques. On garde les deux, en
// laissant la seconde sur son ancienne clé : c'est visible, réparable à la main,
// et surtout ça ne coûte pas le démarrage du serveur.
occupees.Add(actuelle);
logger.LogWarning(
"Renormalisation : {Ligne} garde son ancienne forme, la nouvelle est déjà prise.",
nommer(ligne));
continue;
}
appliquer(ligne);
corriges++;
}
return corriges;
}
/// <summary>
/// Range les ISSN existants sous leur forme à tiret, sans jamais créer de collision.
/// </summary>
/// <remarks>
/// Rattrape les revues saisies à la main avant que la canonisation n'existe : « 24666718 »
/// devient « 2466-6718 », donc rapprochable du code-barres et de <c>bib.issn</c>.
/// <para>
/// ⚠️ Une revue dont l'ISSN canonique est déjà celui d'une autre <b>garde le sien</b> : la
/// fusion de deux fiches n'est pas une opération de démarrage, et l'unicité partielle
/// ferait échouer le lancement du serveur.
/// </para>
/// </remarks>
private int CanoniserLesIssn(IReadOnlyList<Revue> revues)
{
var porteuses = revues.Where(r => r.Issn is not null).ToList();
var occupes = new HashSet<string>(porteuses.Select(r => r.Issn!), StringComparer.Ordinal);
var corriges = 0;
foreach (var revue in porteuses)
{
var actuel = revue.Issn!;
var voulu = FormatageIssn.Canonique(actuel);
if (voulu is null || voulu == actuel)
{
continue;
}
occupes.Remove(actuel);
if (!occupes.Add(voulu))
{
occupes.Add(actuel);
logger.LogWarning(
"Renormalisation : la revue « {Titre} » garde l'ISSN {Issn}, sa forme à tiret est déjà prise.",
revue.Titre,
actuel);
continue;
}
revue.Issn = voulu;
corriges++;
}
return corriges;
}
/// <summary>Clé composite comparable, les composants ne pouvant pas contenir de NUL.</summary>
private static string Cle(params string?[] composants) => string.Join('\0', composants);
private async Task<int> RenormaliserTitresAsync(CancellationToken ct)
{
var livres = await db.Livres.ToListAsync(ct);
@@ -1,40 +0,0 @@
using MaBibli.Api.Services.Couvertures;
namespace MaBibli.Api.Endpoints;
public static class CouverturesEndpoints
{
/// <summary>
/// Relais d'images de couverture, pour la mise en cache hors-ligne.
/// </summary>
/// <remarks>
/// ⚠️ <b>Tout refus répond 404</b>, qu'il s'agisse d'une URL inconnue de la base, d'un schéma
/// interdit, d'une adresse non routable ou d'un hôte injoignable. Distinguer ces cas ferait du
/// point d'entrée un oracle : on y lirait quelles URL l'application connaît, et quelles
/// adresses répondent depuis le serveur. Voir <see cref="ServiceCouvertures"/>.
/// </remarks>
public static IEndpointRouteBuilder MapCouverturesEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapGet("/api/couvertures", async (
string? url,
IServiceCouvertures service,
CancellationToken ct) =>
{
var resultat = await service.ObtenirAsync(url, ct);
return resultat.Trouvee
? Results.File(resultat.Octets!, resultat.TypeContenu!)
: Results.NotFound();
})
.WithName("RelayerCouverture")
.WithTags("Couvertures")
.WithSummary("Relaie une couverture déjà enregistrée, en même origine.")
// ⚠️ Pas de `contentType` ici : il varie avec l'image (image/jpeg, image/png…), et
// l'annotation OpenAPI REFUSE les jokers — « image/* » fait échouer le démarrage du
// serveur, pas la compilation. Constaté en exécution.
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
}
+4 -50
View File
@@ -28,11 +28,8 @@ public static class LivresEndpoints
groupe.MapGet("/", async (
string? recherche,
Format? format,
TypeDocument? typeDocument,
Statut? statut,
int? auteurId,
bool? prete,
bool? sansCouverture,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
@@ -41,18 +38,15 @@ public static class LivresEndpoints
{
Recherche = recherche,
Format = format,
TypeDocument = typeDocument,
Statut = statut,
AuteurId = auteurId,
Prete = prete,
SansCouverture = sansCouverture,
};
var utilisateur = utilisateurs.Obtenir().Identifiant;
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
})
.WithName("ListerLivres")
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; cherchable sur titre et auteurs.")
.WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.")
.WithDescription(
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. "
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. "
@@ -75,36 +69,21 @@ public static class LivresEndpoints
groupe.MapPost("/", async (
EnregistrementLivre saisie,
bool? confirmerDoublon,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
// AjoutePar vient du serveur, pas de la charge utile.
var utilisateur = utilisateurs.Obtenir();
var resultat = await service.CreerAsync(
saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct);
var resultat = await service.CreerAsync(saisie, utilisateur.Identifiant, ct);
if (resultat.Erreur is not null)
{
return Results.BadRequest(new { erreur = resultat.Erreur });
}
// 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà
// quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira.
return resultat.Doublons is { } doublons
? Results.Conflict(doublons)
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
})
.WithName("CreerLivre")
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
.WithDescription(
"Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même "
+ "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : "
+ "posséder deux exemplaires est légitime, et « confirmerDoublon=true » "
+ "enregistre la même saisie sans plus rien demander.")
.Produces<LivreDto>(StatusCodes.Status201Created)
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
@@ -190,31 +169,6 @@ public static class LivresEndpoints
.Produces<AuteurDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}", async (
int id,
RenommerAuteur demande,
bool? confirmerFusion,
IServiceAuteurs service,
CancellationToken ct) =>
{
var resultat = await service.RenommerAsync(
id, demande.Nom, confirmerFusion ?? false, ct);
if (resultat.Auteur is null && resultat.Collision is null)
{
return Results.NotFound();
}
return resultat.Collision is not null
? Results.Conflict(resultat)
: Results.Ok(resultat);
})
.WithName("RenommerAuteur")
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
.Produces<ResultatRenommageAuteur>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.SuggestionsAsync(ct)))
.WithName("ListerRapprochements")
-14
View File
@@ -53,20 +53,6 @@ public static class PretsEndpoints
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
prets.MapDelete("/{id:int}", async (int id, IServicePrets service, CancellationToken ct) =>
{
var supprime = await service.SupprimerAsync(id, ct);
return supprime ? Results.NoContent() : Results.NotFound();
})
.WithName("SupprimerPret")
.WithSummary("Efface un prêt saisi par erreur — ce n'est pas « rendre ».")
.WithDescription(
"Rendre pose une date de retour et conserve la ligne : c'est l'histoire du livre. "
+ "Effacer défait une saisie fautive (mauvais emprunteur, mauvaise date, prêt noté "
+ "deux fois). Un historique juste vaut mieux qu'un historique complet mais faux.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
var parLivre = routes.MapGroup("/api/livres/{livreId:int}/prets").WithTags("Prêts");
parLivre.MapGet("/", async (int livreId, IServicePrets service, CancellationToken ct) =>
-108
View File
@@ -1,108 +0,0 @@
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Revues;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class RevuesEndpoints
{
/// <summary>
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
/// </summary>
/// <remarks>
/// <b>Communes au foyer</b>, comme le catalogue : aucun point d'entrée ne prend d'identité,
/// et « ajoutePar » n'est qu'une trace. La lecture est unique — <c>GET /api/revues</c> rend
/// les revues avec leurs numéros, ce qui donne un seul instantané hors-ligne.
/// </remarks>
public static IEndpointRouteBuilder MapRevuesEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/revues").WithTags("Revues");
groupe.MapGet("/", async (IServiceRevues service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerRevues")
.WithSummary("Toutes les revues, avec les numéros possédés, du plus récent au plus ancien.")
.Produces<IReadOnlyList<RevueDto>>();
groupe.MapPost("/", async (
EnregistrementRevue saisie,
IServiceRevues service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.CreerOuRetrouverAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Revue);
})
.WithName("CreerRevue")
.WithSummary("Crée la revue, ou rend celle qui existe déjà (même ISSN, ou même titre).")
.WithDescription(
"« Créer ou retrouver » et non « créer » : scanner le code-barres d'un second "
+ "numéro retombe forcément sur la même revue, et le geste de l'utilisateur est "
+ "toujours « voici un numéro de plus ».")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id, EnregistrementRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.ModifierAsync(id, saisie, ct)))
.WithName("ModifierRevue")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (int id, IServiceRevues service, CancellationToken ct) =>
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithName("SupprimerRevue")
.WithSummary("Supprime la revue et les numéros qu'elle porte : ils n'existent que par elle.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/{id:int}/numeros", async (
int id, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.AjouterNumeroAsync(id, saisie, ct)))
.WithName("AjouterNumeroRevue")
.WithSummary("Ajoute un numéro possédé. Le numéro est obligatoire et unique dans la revue.")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/numeros/{numeroId:int}", async (
int numeroId, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.ModifierNumeroAsync(numeroId, saisie, ct)))
.WithName("ModifierNumeroRevue")
.WithSummary("Modifie un numéro : numéro, parution, note, couverture et articles à la une.")
.WithDescription(
"La couverture d'un numéro et sa une ne viennent d'aucune source — l'ISSN "
+ "désigne la revue, pas la parution — et se saisissent donc après coup.")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/numeros/{numeroId:int}", async (
int numeroId, IServiceRevues service, CancellationToken ct) =>
await service.RetirerNumeroAsync(numeroId, ct)
? Results.NoContent()
: Results.NotFound())
.WithName("RetirerNumeroRevue")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
private static IResult Repondre(ResultatRevue resultat)
{
if (resultat.EstIntrouvable)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Revue);
}
}
-174
View File
@@ -1,174 +0,0 @@
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Series;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class SeriesEndpoints
{
/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Aucun point d'entrée n'accepte ni ne rend d'identité</b>, à une exception près :
/// la mise en envies. Les séries sont <b>communes au foyer</b>, comme le catalogue et les
/// prêts — l'ordre de lecture d'une saga ne dépend pas de qui regarde. La liste d'envies,
/// elle, reste personnelle, et c'est le serveur qui sait à qui il parle.
/// <para>
/// La lecture est <b>unique</b> : <c>GET /api/series</c> rend tout l'arbre à plat. Le détail
/// d'une série s'y lit sans second appel, ce qui donne un seul instantané hors-ligne à
/// tenir à jour plutôt qu'un par série consultée.
/// </para>
/// </remarks>
public static IEndpointRouteBuilder MapSeriesEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/series").WithTags("Séries");
groupe.MapGet("/", async (IServiceSeries service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerSeries")
.WithSummary("Toutes les séries, à plat, chacune avec ses tomes dans l'ordre de lecture.")
.WithDescription(
"Le client rebâtit l'arbre à partir de « serieParenteId ». Un tome sans "
+ "« livreId » est un tome qu'on ne possède pas : c'est ce que l'écran montre.")
.Produces<IReadOnlyList<SerieDto>>();
groupe.MapPost("/", async (
EnregistrementSerie saisie,
IServiceSeries service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.CreerAsync(saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/series/{resultat.Serie!.Id}", resultat.Serie);
})
.WithName("CreerSerie")
.WithSummary("Crée une série. « AjoutePar » est déterminé par le serveur, et n'est qu'une trace.")
.Produces<SerieDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id, EnregistrementSerie saisie, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ModifierAsync(id, saisie, ct)))
.WithName("ModifierSerie")
.WithSummary("Renomme la série, ou la range dans un cycle.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (int id, IServiceSeries service, CancellationToken ct) =>
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithName("SupprimerSerie")
.WithSummary("Supprime la série. Ses sous-séries remontent à la racine, elles ne sont pas détruites.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/{id:int}/elements", async (
int id, AjoutElementSerie saisie, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.AjouterElementAsync(id, saisie, ct)))
.WithName("AjouterElementSerie")
.WithSummary("Ajoute un tome en fin de série : un livre du catalogue, ou un simple titre.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}/ordre", async (
int id, OrdreElementsSerie ordre, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ReordonnerAsync(id, ordre.Ids, ct)))
.WithName("ReordonnerSerie")
.WithSummary("Fixe l'ordre de lecture — la liste entière des tomes, dans l'ordre voulu.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}/sous-series/ordre", async (
int id, OrdreSeries ordre, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ReordonnerSousSeriesAsync(id, ordre.Ids, ct)))
.WithName("ReordonnerSousSeries")
.WithSummary("Fixe l'ordre de lecture des séries directement contenues dans un cycle.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/elements/{elementId:int}/livre", async (
int elementId,
RattachementLivre rattachement,
IServiceSeries service,
CancellationToken ct) =>
Repondre(await service.RattacherLivreAsync(elementId, rattachement.LivreId, ct)))
.WithName("RattacherLivreSerie")
.WithSummary("Rattache un livre possédé à une place, ou l'en détache (« livreId » nul).")
.WithDescription(
"Détacher laisse un trou NOMMÉ : le titre du tome est conservé, il ne dépend "
+ "pas de l'exemplaire.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/elements/{elementId:int}", async (
int elementId,
AjoutElementSerie saisie,
IServiceSeries service,
CancellationToken ct) =>
Repondre(await service.ModifierElementAsync(elementId, saisie, ct)))
.WithName("ModifierElementSerie")
.WithSummary("Corrige le titre et le numéro d'un tome, sans toucher au livre rattaché.")
.WithDescription(
"Le numéro est celui imprimé sur le livre (« 7 », « Hors-série »), à ne pas "
+ "confondre avec la position, qui est le rang dans l'ordre de lecture.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/elements/{elementId:int}", async (
int elementId, IServiceSeries service, CancellationToken ct) =>
await service.RetirerElementAsync(elementId, ct)
? Results.NoContent()
: Results.NotFound())
.WithName("RetirerElementSerie")
.WithSummary("Retire une place de la série. Le livre, lui, reste au catalogue.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/elements/{elementId:int}/souhait", async (
int elementId,
IServiceSeries service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
// ⚠️ Le seul endroit où une série (commune) touche à une liste d'envies
// (personnelle). L'envie créée est celle de l'appelant, jamais celle du foyer.
var resultat = await service.MettreEnEnviesAsync(
elementId, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Souhait);
})
.WithName("SouhaiterTomeManquant")
.WithSummary("Met un tome manquant dans la liste d'envies de l'appelant.")
.Produces<SouhaitDto>()
.Produces(StatusCodes.Status400BadRequest);
return routes;
}
private static IResult Repondre(ResultatSerie resultat)
{
if (resultat.EstIntrouvable)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Serie);
}
}
/// <summary>Charge utile du rattachement d'un livre à une place. <c>null</c> détache.</summary>
public record RattachementLivre
{
public int? LivreId { get; set; }
}
+6 -182
View File
@@ -74,37 +74,6 @@ public static class SouhaitsEndpoints
.Produces<SouhaitDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id,
EnregistrementSouhait saisie,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.NExistePas)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Souhait);
})
.WithName("ModifierSouhait")
.WithSummary("Corrige une envie de l'appelant.")
.WithDescription(
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
+ "sont volontairement indiscernables. 400 — et non 409 — quand la correction "
+ "ferait doublon avec une autre envie : l'unicité l'interdit, il n'y a rien à "
+ "confirmer, contrairement au doublon du catalogue. Le rang n'est pas touché : "
+ "l'ordre a son propre point d'entrée.")
.Produces<SouhaitDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (
int id,
IServiceSouhaits service,
@@ -124,108 +93,20 @@ public static class SouhaitsEndpoints
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// ─────────────────────────────────────────────────────────────────────
// Envies de revues et de numéros.
//
// Table sœur, donc points d'entrée sœurs : mêmes règles de portée, même 404 sur ce qui
// appartient à un autre. Le préfixe littéral « revues » ne peut pas être confondu avec
// « /{id:int} », qui exige un entier.
// ─────────────────────────────────────────────────────────────────────
groupe.MapGet("/revues", async (
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
.WithName("ListerRevuesSouhaitees")
.WithSummary("Les revues et numéros souhaités par l'appelant.")
.WithDescription(
"Section distincte de la liste d'envies : les rangs des deux tables sont "
+ "numérotés indépendamment, les entrelacer produirait un ordre que personne "
+ "n'a choisi.")
.Produces<IReadOnlyList<RevueSouhaiteeDto>>();
groupe.MapPost("/revues", async (
EnregistrementRevueSouhaitee saisie,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.AjouterAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/souhaits/revues/{resultat.Envie!.Id}", resultat.Envie);
})
.WithName("AjouterRevueSouhaitee")
.WithSummary("Ajoute une revue — ou un numéro précis — à la liste d'envies.")
.WithDescription(
"Le numéro est facultatif : on souhaite parfois un numéro vu en kiosque, "
+ "parfois le titre entier. L'ISSN est canonisé avec son tiret.")
.Produces<RevueSouhaiteeDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/revues/{id:int}", async (
int id,
EnregistrementRevueSouhaitee saisie,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.NExistePas)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Envie);
})
.WithName("ModifierRevueSouhaitee")
.WithSummary("Corrige une envie de revue de l'appelant.")
.Produces<RevueSouhaiteeDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/revues/{id:int}", async (
int id,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var supprime = await service.SupprimerAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return supprime ? Results.NoContent() : Results.NotFound();
})
.WithName("SupprimerRevueSouhaitee")
.WithSummary("Retire une revue de la liste d'envies de l'appelant.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// Deux formats parce que les deux usages décrits dans CLAUDE.md diffèrent : le .txt
// Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt
// s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se
// répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les
// produire, ni pour les lire.
groupe.MapGet("/export.txt", (
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct))
.WithName("ExporterSouhaitsTexte")
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
.ExcludeFromDescription();
groupe.MapGet("/export.csv", (
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, revues, utilisateurs, ct))
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct))
.WithName("ExporterSouhaitsCsv")
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
.ExcludeFromDescription();
@@ -250,20 +131,15 @@ public static class SouhaitsEndpoints
private static async Task<IResult> ExporterAsync(
FormatExportSouhaits format,
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct)
{
var utilisateur = utilisateurs.Obtenir();
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
// ⚠️ Les deux tables partent ensemble, sans quoi les envies de revues n'existeraient
// pas dans le fichier qu'on emporte — c'est-à-dire là où elles servent.
var revuesSouhaitees = await revues.ListerAsync(utilisateur.Identifiant, ct);
var maintenant = DateTime.Now;
var contenu = ExportSouhaits.Produire(
format, souhaits, revuesSouhaitees, utilisateur.Affichage, maintenant);
format, souhaits, utilisateur.Affichage, maintenant);
// Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le
// fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait
@@ -304,58 +180,6 @@ public static class SouhaitsEndpoints
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapGet("/api/auteurs/{id:int}/bibliographie/nouveautes", async (
int id,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var bibliographie = await service.ObtenirNouveautesAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
})
.WithName("NouveautesAuteur")
.WithTags("Auteurs")
.WithSummary("Les œuvres de cet auteur parues depuis le livre le plus récent possédé.")
.WithDescription(
"La date du représentant d'une œuvre est celle de l'édition la plus ancienne ; "
+ "la sélection compare donc la date la plus récente des éditions connues. "
+ "La recherche étendue est bornée à 1000 notices BnF.")
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async (
int id,
MasquageBibliographie saisie,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.MasquerAsync(
id, saisie.Titre, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.BadRequest();
})
.WithName("MasquerOeuvreBibliographie")
.WithSummary("Masque une œuvre de la bibliographie pour l'appelant.")
.Produces(StatusCodes.Status204NoContent);
routes.MapDelete("/api/auteurs/{id:int}/bibliographie/masque", async (
int id,
string titre,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.DemasquerAsync(
id, titre, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.NotFound();
})
.WithName("DemasquerOeuvreBibliographie")
.WithSummary("Réaffiche une œuvre masquée de la bibliographie.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
}
-38
View File
@@ -1,38 +0,0 @@
using System.Reflection;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
/// <summary>
/// Ce que l'application sait d'elle-même : sa version publiée et sa date de build.
/// </summary>
/// <remarks>
/// C'est le <b>serveur</b> qui répond, et non le client Blazor, bien que les deux soient
/// compilés du même coup : lire ses propres attributs depuis le WebAssembly dépendrait de ce que
/// le trimmer veut bien conserver, alors qu'ici la lecture est certaine. La page « À propos »
/// affiche donc la version du binaire réellement déployé — ce qu'on veut savoir en diagnostiquant
/// un appareil.
/// </remarks>
public static class VersionEndpoints
{
// Lue une fois : les attributs d'un assembly ne changent pas en cours d'exécution.
private static readonly VersionApplication Version = Lire(typeof(VersionEndpoints).Assembly);
public static IEndpointRouteBuilder MapVersionEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapGet("/api/version", () => Results.Ok(Version))
.WithName("VersionApplication")
.WithTags("Version")
.WithSummary("Version publiée et date de build, ou « rien » pour un binaire non publié.")
.Produces<VersionApplication>();
return routes;
}
/// <summary>Extrait les deux valeurs de l'assembly, sans rien interpréter lui-même.</summary>
public static VersionApplication Lire(Assembly assembly) =>
VersionApplication.Depuis(
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "MaBibliDateBuild")?.Value);
}
-14
View File
@@ -16,20 +16,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
</ItemGroup>
<!--
Version publiée et horodatage de build, posés par `build/publier-release.sh` (dépôt
`mabibli_ynh`) : `-p:Version=X.Y.Z -p:MaBibliDateBuild=...`.
⚠️ L'horodatage est le TÉMOIN de l'injection, et c'est pour cela qu'il n'est jamais calculé
ici : un `DateTime.UtcNow` d'MSBuild vaudrait pour toute compilation locale, y compris celles
que le SDK numérote 1.0.0 — l'application afficherait alors une version inventée, ce qui est
pire que pas de version du tout (voir VersionApplication). Il ferait de surcroît recompiler
l'assembly à chaque build, l'attribut changeant à chaque fois.
-->
<ItemGroup Condition="'$(MaBibliDateBuild)' != ''">
<AssemblyMetadata Include="MaBibliDateBuild" Value="$(MaBibliDateBuild)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaBibli.Client\MaBibli.Client.csproj" />
<ProjectReference Include="..\MaBibli.Shared\MaBibli.Shared.csproj" />
+2 -69
View File
@@ -1,12 +1,9 @@
using MaBibli.Api.Data;
using MaBibli.Api.Endpoints;
using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Couvertures;
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Isbn;
using MaBibli.Api.Services.Prets;
using MaBibli.Api.Services.Revues;
using MaBibli.Api.Services.Series;
using MaBibli.Api.Services.Souhaits;
using Microsoft.EntityFrameworkCore;
@@ -21,10 +18,7 @@ builder.Services.AddScoped<IServiceAuteurs, ServiceAuteurs>();
builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>();
builder.Services.AddScoped<IServicePrets, ServicePrets>();
builder.Services.AddScoped<IServiceSouhaits, ServiceSouhaits>();
builder.Services.AddScoped<IServiceRevuesSouhaitees, ServiceRevuesSouhaitees>();
builder.Services.AddScoped<IServiceBibliographie, ServiceBibliographie>();
builder.Services.AddScoped<IServiceSeries, ServiceSeries>();
builder.Services.AddScoped<IServiceRevues, ServiceRevues>();
// Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé
// configuré en développement (il n'y a pas de portail YunoHost en local).
@@ -36,19 +30,6 @@ builder.Services.AddScoped<IFournisseurUtilisateur, FournisseurUtilisateurSsowat
// Timeout court : une source lente ne doit pas bloquer la cascade, l'appelant bascule sur la suivante.
var timeoutSources = TimeSpan.FromSeconds(10);
// ⚠️ Le relais de couvertures a son PROPRE délai, et bien plus long. Ce n'est pas une largesse :
// les deux appels n'ont pas le même témoin. Un lookup ISBN fait patienter quelqu'un devant un
// formulaire — d'où les 10 s ci-dessus, et la bascule sur la source suivante. Le relais, lui,
// remplit un cache en tâche de fond : personne n'attend derrière, et abandonner ne fait pas
// gagner du temps, cela perd la couverture.
//
// Mesuré le 2026-08-21 : les couvertures OpenLibrary aboutissent sur `archive.org`, qui les
// EXTRAIT D'UNE ARCHIVE ZIP à la volée et met 5 à 15 s. À 10 s, le relais répondait 404 une fois
// sur trois — indistinguable, pour l'appelant, d'une URL inconnue. L'image n'était alors jamais
// mise en cache, et le client rejouait CORS puis relais à CHAQUE affichage : exactement le
// défaut que ce relais existe pour corriger.
var timeoutRelaisCouvertures = TimeSpan.FromSeconds(30);
builder.Services.AddHttpClient<IBnfClient, BnfClient>(http =>
{
http.BaseAddress = new Uri(BnfClient.UrlBase);
@@ -65,31 +46,6 @@ builder.Services.AddHttpClient<IOpenLibraryClient, OpenLibraryClient>(http =>
builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>();
// Relais de couvertures : rend une image de MÊME ORIGINE, seule façon de la mettre en cache
// hors-ligne quand son hébergeur n'envoie pas d'en-tête CORS (le formulaire livre accepte
// n'importe quelle URL de couverture).
//
// ⚠️ Les deux réglages du handler ne sont PAS des détails de confort :
// - ConnectCallback n'ouvre la socket que vers une adresse publiquement routable, ce qui
// ferme le SSRF y compris par DNS rebinding (voir GardeAdresses) ;
// - les redirections sont SUIVIES, mais bornées à 3. Elles avaient d'abord été coupées, à
// tort : `covers.openlibrary.org` répond 302 (mesuré), et beaucoup de CDN font de même —
// les couper refusait donc les couvertures les plus courantes. Les suivre reste sûr parce
// que le garde agit à la CONNEXION : il s'applique à chaque saut, cible de redirection
// comprise. C'est la position du garde, et non l'interdiction des redirections, qui ferme
// le SSRF.
builder.Services.AddHttpClient<IServiceCouvertures, ServiceCouvertures>(http =>
{
http.Timeout = timeoutRelaisCouvertures;
http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 3,
ConnectCallback = GardeAdresses.ConnecterAsync,
});
var app = builder.Build();
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost.
@@ -113,40 +69,17 @@ if (app.Environment.IsDevelopment())
// Le client Blazor WebAssembly est servi comme fichiers statiques par cette API :
// un seul processus tourne sur le serveur.
//
// ⚠️ Les empreintes WASM sont désactivées (voir CLAUDE.md) : tous ces fichiers portent des noms
// stables. Sans en-tête Cache-Control, un navigateur peut garder une version périmée en cache
// heuristique (par exemple MaBibli.Client.styles.css) même si le service worker, lui, revalide
// déjà à l'installation — mais seulement quand il parvient à s'activer. `no-cache` force une
// revalidation conditionnelle (ETag/Last-Modified, déjà posés par défaut) à chaque requête :
// pas de re-téléchargement si le fichier n'a pas changé, mais jamais de contenu périmé servi
// sans repasser par le serveur.
var optionsFichiersStatiques = new StaticFileOptions
{
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers.CacheControl = "no-cache, must-revalidate",
};
app.UseBlazorFrameworkFiles();
app.UseStaticFiles(optionsFichiersStatiques);
app.UseStaticFiles();
app.MapIsbnEndpoints();
app.MapLivresEndpoints();
app.MapAuteursEndpoints();
app.MapPretsEndpoints();
app.MapSouhaitsEndpoints();
app.MapSeriesEndpoints();
app.MapRevuesEndpoints();
app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints();
app.MapCouverturesEndpoints();
app.MapVersionEndpoints();
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées
// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
// la seule URL qu'ouvre l'application installée — repartait sans aucun Cache-Control, donc en
// cache heuristique du navigateur. Mesuré : /index.html portait bien l'en-tête, « / » non.
// C'est la coquille HTML qui décide de tout le reste : la servir périmée suffit à mélanger deux
// versions sur l'appareil.
app.MapFallbackToFile("index.html", optionsFichiersStatiques);
app.MapFallbackToFile("index.html");
app.Run();
@@ -18,10 +18,6 @@ public interface IServiceAuteurs
Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);
/// <summary>Renomme une fiche, ou signale la fiche cible à fusionner.</summary>
Task<ResultatRenommageAuteur> RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default);
/// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
@@ -143,62 +139,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
})
.FirstOrDefaultAsync(ct);
public async Task<ResultatRenommageAuteur> RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
{
var auteur = await db.Auteurs.FirstOrDefaultAsync(a => a.Id == id, ct);
if (auteur is null)
{
return new ResultatRenommageAuteur();
}
nom = nom.Trim();
var cle = RapprochementAuteurs.Cle(nom);
if (cle.Length == 0)
{
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto { Id = id, Nom = auteur.Nom },
};
}
var collision = await db.Auteurs
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id != id && a.CleRegroupement == cle, ct);
if (collision is not null)
{
var dto = new AuteurDto
{
Id = collision.Id,
Nom = collision.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == collision.Id, ct),
};
if (!confirmerFusion)
{
return new ResultatRenommageAuteur { Collision = dto };
}
await FusionnerAsync(id, collision.Id, ct);
return new ResultatRenommageAuteur { Auteur = dto };
}
auteur.Nom = nom;
auteur.RecalculerFormes();
await db.SaveChangesAsync(ct);
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto
{
Id = auteur.Id,
Nom = auteur.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == auteur.Id, ct),
},
};
}
public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
CancellationToken ct = default)
{
@@ -277,15 +217,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
// la clé primaire du lien est (LivreId, AuteurId).
if (!dejaLies.Add(lien.LivreId))
{
var existant = cible.Livres.FirstOrDefault(l => l.LivreId == lien.LivreId);
if (existant is not null)
{
existant.Position = Math.Min(existant.Position, lien.Position);
if (existant.Role == RoleAuteur.NonPrecise)
{
existant.Role = lien.Role;
}
}
db.LivreAuteurs.Remove(lien);
continue;
}
@@ -296,7 +227,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
LivreId = lien.LivreId,
AuteurId = cible.Id,
Position = lien.Position,
Role = lien.Role,
});
}
@@ -1,8 +1,6 @@
using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -15,13 +13,6 @@ public interface IServiceBibliographie
/// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas.
/// </summary>
Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default);
Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
Task<bool> DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
@@ -82,10 +73,9 @@ public sealed class ServiceBibliographie(
}
var souhaits = await SouhaitsAsync(utilisateur, ct);
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.Select(o => Confronter(o, parTitre, parIsbn, souhaits))
.OrderByDescending(o => AnneeTriable(o.Annee))
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
@@ -105,122 +95,6 @@ public sealed class ServiceBibliographie(
};
}
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default)
{
var auteur = await auteurs.ObtenirAsync(auteurId, ct);
if (auteur is null)
{
return null;
}
var recherche = bnf is IBnfClientNouveautes etendu
? await etendu.RechercherParAuteurNouveautesAsync(auteur.Nom, ct)
: await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
var resultat = recherche.Resultat;
var possedes = await db.LivreAuteurs
.AsNoTracking()
.Where(la => la.AuteurId == auteurId)
.Select(la => new { la.LivreId, la.Livre!.Titre, la.Livre.Isbn })
.ToListAsync(ct);
var parTitre = new Dictionary<string, int>();
var parIsbn = new Dictionary<string, int>();
foreach (var livre in possedes)
{
var cle = CleOeuvre.Cle(livre.Titre);
if (cle.Length > 0)
{
parTitre.TryAdd(cle, livre.LivreId);
}
if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
parIsbn.TryAdd(livre.Isbn, livre.LivreId);
}
}
var souhaits = await SouhaitsAsync(utilisateur, ct);
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.ToList();
var seuil = AnneeSeuilNouveautes(
possedes.Select(l => (l.Titre, l.Isbn)), resultat.Notices);
// Une notice d'édition originale ne date pas la dernière édition de l'œuvre. Le
// regroupement conserve donc AnneeDerniereEdition, qui est la seule date pertinente
// pour répondre à « paru depuis ».
var nouveautes = FiltrerNouveautes(oeuvres, seuil);
return new BibliographieDto
{
Auteur = auteur,
Oeuvres = nouveautes,
NoticesAnnoncees = resultat.NombreAnnonce,
NoticesLues = resultat.NombreLu,
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
Avertissement = recherche.Avertissement,
Etat = resultat.Etat,
EstNouveautes = true,
AnneeSeuilNouveautes = seuil,
SeuilNouveautesInconnu = seuil is null && possedes.Count > 0,
};
}
public async Task<bool> MasquerAsync(
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var cle = CleOeuvre.Cle(titre);
if (cle.Length == 0)
{
return false;
}
var existe = await db.BibliographiesMasquees.AnyAsync(
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
if (existe)
{
return true;
}
db.BibliographiesMasquees.Add(new BibliographieMasquee
{
AuteurId = auteurId,
Utilisateur = utilisateur,
TitreNormalise = cle,
});
await db.SaveChangesAsync(ct);
return true;
}
public async Task<bool> DemasquerAsync(
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var cle = CleOeuvre.Cle(titre);
var ligne = await db.BibliographiesMasquees.FirstOrDefaultAsync(
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
if (ligne is null)
{
return false;
}
db.BibliographiesMasquees.Remove(ligne);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary>
private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct)
{
@@ -244,22 +118,6 @@ public sealed class ServiceBibliographie(
return index;
}
private async Task<HashSet<string>> MasqueesAsync(
int auteurId, string? utilisateur, CancellationToken ct)
{
if (utilisateur is null)
{
return [];
}
var cles = await db.BibliographiesMasquees
.AsNoTracking()
.Where(m => m.AuteurId == auteurId && m.Utilisateur == utilisateur)
.Select(m => m.TitreNormalise)
.ToListAsync(ct);
return cles.ToHashSet(StringComparer.Ordinal);
}
/// <summary>
/// Réunit les rééditions sous une seule œuvre.
/// </summary>
@@ -290,12 +148,6 @@ public sealed class ServiceBibliographie(
{
Titre = reference.Titre,
Annee = reference.Annee,
AnneeDerniereEdition = ordonnees
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value)
.DefaultIfEmpty()
.Max() is var annee && annee > 0 ? annee : null,
Editeur = reference.Editeur,
Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
NombreEditions = groupe.Count(),
@@ -306,8 +158,7 @@ public sealed class ServiceBibliographie(
OeuvreBibliographie oeuvre,
IReadOnlyDictionary<string, int> parTitre,
IReadOnlyDictionary<string, int> parIsbn,
IReadOnlyDictionary<string, int> souhaits,
IReadOnlySet<string> masquees)
IReadOnlyDictionary<string, int> souhaits)
{
var cle = CleOeuvre.Cle(oeuvre.Titre);
@@ -328,65 +179,9 @@ public sealed class ServiceBibliographie(
LivreId = livreId,
Souhaite = souhaitId is not null,
SouhaitId = souhaitId,
Masquee = masquees.Contains(cle),
};
}
public static int? AnneeSeuilNouveautes(
IEnumerable<(string Titre, string? Isbn)> possedes,
IReadOnlyList<NoticeAuteur> notices)
{
var annees = new List<int>();
foreach (var livre in possedes)
{
var correspondantes = !string.IsNullOrWhiteSpace(livre.Isbn)
? notices.Where(n => MemesIsbn(n.Isbn, livre.Isbn))
: notices.Where(n => n.CleTitre == CleOeuvre.Cle(livre.Titre));
annees.AddRange(correspondantes
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value));
}
return annees.Count == 0 ? null : annees.Max();
}
private static bool MemesIsbn(string? gauche, string? droite)
{
var a = IsbnUtils.Normaliser(gauche);
var b = IsbnUtils.Normaliser(droite);
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
return false;
}
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return IsbnUtils.TryConvertirEnIsbn10(a, out var a10)
&& string.Equals(a10, b, StringComparison.OrdinalIgnoreCase)
|| IsbnUtils.TryConvertirEnIsbn10(b, out var b10)
&& string.Equals(b10, a, StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyList<OeuvreBibliographie> FiltrerNouveautes(
IEnumerable<OeuvreBibliographie> oeuvres, int? seuil)
{
if (seuil is null)
{
return [];
}
return oeuvres
.Where(o => o.AnneeDerniereEdition > seuil && o.ADecouvrir)
.OrderByDescending(o => o.AnneeDerniereEdition)
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
/// <summary>
/// Année exploitable pour le tri, extraite d'une date BnF.
/// </summary>
@@ -3,34 +3,21 @@ using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
/// <summary>
/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
/// </summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
{
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null);
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);
public static ResultatEcriture Invalide(string message) => new(null, message, null);
public static ResultatEcriture Invalide(string message) => new(null, message);
/// <summary>
/// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
/// </summary>
/// <remarks>
/// Ce n'est <b>pas</b> une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
/// D'où un troisième état plutôt qu'un message dans <c>Erreur</c> : l'écran doit proposer
/// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
/// </remarks>
public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatEcriture Introuvable = new(null, null);
/// <summary>Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.</summary>
public static readonly ResultatEcriture Introuvable = new(null, null, null);
public bool EstIntrouvable => Livre is null && Erreur is null && Doublons is null;
public bool EstIntrouvable => Livre is null && Erreur is null;
}
public interface IServiceCatalogue
@@ -41,10 +28,7 @@ public interface IServiceCatalogue
Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
CancellationToken ct = default);
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
@@ -72,9 +56,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
// la bibliothèque est commune à tout le foyer (CLAUDE.md).
var requete = FiltreLivres.Appliquer(
db.Livres.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme),
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
criteres,
utilisateur);
@@ -94,7 +76,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
var livre = await db.Livres
.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
@@ -108,35 +89,20 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
}
public async Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
CancellationToken ct = default)
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{
if (Valider(saisie, out var isbn, out var erreur) is false)
{
return ResultatEcriture.Invalide(erreur!);
}
if (!confirmerDoublon)
{
var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct);
if (deja is not null)
{
return ResultatEcriture.Doublon(deja);
}
}
var livre = new Livre
{
Isbn = isbn,
Titre = saisie.Titre,
Editeur = Vide(saisie.Editeur),
Format = saisie.Format,
TypeDocument = saisie.TypeDocument,
NombrePages = saisie.NombrePages,
CoverUrl = Vide(saisie.CoverUrl),
UrlNotice = Vide(saisie.UrlNotice),
DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
@@ -147,7 +113,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
db.Livres.Add(livre);
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await RattacherThemesAsync(livre, saisie.Themes, ct);
await db.SaveChangesAsync(ct);
// Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
@@ -160,10 +125,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
public async Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{
var livre = await db.Livres
.Include(l => l.Auteurs)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
.FirstOrDefaultAsync(l => l.Id == id, ct);
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatEcriture.Introuvable;
@@ -178,17 +140,13 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
livre.Titre = saisie.Titre;
livre.Editeur = Vide(saisie.Editeur);
livre.Format = saisie.Format;
livre.TypeDocument = saisie.TypeDocument;
livre.NombrePages = saisie.NombrePages;
livre.CoverUrl = Vide(saisie.CoverUrl);
livre.UrlNotice = Vide(saisie.UrlNotice);
livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
// d'origine, pas des champs éditables.
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await RattacherThemesAsync(livre, saisie.Themes, ct);
await db.SaveChangesAsync(ct);
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
@@ -226,102 +184,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
return true;
}
/// <summary>
/// Cherche ce que le catalogue contient déjà de semblable, ou <c>null</c> s'il n'y a rien.
/// </summary>
/// <remarks>
/// <b>Deux critères, et aucun n'est une clé.</b> L'ISBN identique attrape le cas visé — le
/// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est
/// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le
/// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le
/// résultat est un <b>avertissement</b> et non un refus : un index unique interdirait le
/// second exemplaire, qui est un cas parfaitement normal.
/// <para>
/// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
/// <see cref="RapprochementAuteurs"/> — sinon deux « Nouvelles » sans rapport se
/// signaleraient l'une l'autre.
/// </para>
/// </remarks>
private async Task<DoublonsLivre?> DoublonsAsync(
string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct)
{
// Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit
// apparaître qu'une fois.
var candidats = new Dictionary<int, Livre>();
if (isbn is not null)
{
foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct))
{
candidats[livre.Id] = livre;
}
}
var cle = CleOeuvre.Cle(saisie.Titre);
if (cle.Length > 0)
{
// 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. SQLite dégrossit
// donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire
// — sans quoi il faudrait charger tout le catalogue à chaque ajout.
var parTitre = await AvecAuteurs()
.Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " "))
.ToListAsync(ct);
foreach (var livre in parTitre)
{
if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs))
{
candidats[livre.Id] = livre;
}
}
}
if (candidats.Count == 0)
{
return null;
}
var trouves = candidats.Values.OrderBy(l => l.Id).ToList();
var ids = trouves.Select(l => l.Id).ToList();
var statuts = await StatutsAsync(ids, utilisateur, ct);
var prets = await PretsOuvertsAsync(ids, ct);
return new DoublonsLivre
{
Message = Avertir(trouves),
Livres = trouves
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
.ToList(),
};
}
private IQueryable<Livre> AvecAuteurs() =>
db.Livres.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme);
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
/// <remarks>Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici.</remarks>
private static bool MemeAuteur(Livre livre, IReadOnlyList<AuteurSaisi> saisis) =>
RapprochementAuteurs.PartagentUnAuteur(
livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom));
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
/// <remarks>
/// Elle nomme ce qu'on possède déjà et <b>invite à passer outre</b> : c'est un doute soumis à
/// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le
/// texte n'a pas à les décrire.
/// </remarks>
private static string Avertir(IReadOnlyList<Livre> trouves)
{
var debut = trouves.Count == 1
? $"« {trouves[0].Titre} » est déjà au catalogue."
: $"{trouves.Count} livres du catalogue ressemblent à celui-ci.";
return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition.";
}
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
private async Task<Dictionary<int, Statut?>> StatutsAsync(
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
@@ -415,18 +277,11 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre et leur rôle.
/// </summary>
/// <remarks>
/// ⚠️ Le <b>rôle vit sur le lien</b>, pas sur la fiche de l'auteur : le même dessinateur
/// peut avoir scénarisé un autre album, et il ne doit pas exister en deux fiches pour
/// autant. C'est pour cela que <c>ResoudreAsync</c> ne reçoit que des noms.
/// </remarks>
/// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
private async Task RattacherAuteursAsync(
Livre livre, IReadOnlyList<AuteurSaisi> saisis, CancellationToken ct)
Livre livre, IEnumerable<string> noms, CancellationToken ct)
{
var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), ct);
var resolus = await auteurs.ResoudreAsync(noms, ct);
// Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
// gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
@@ -436,54 +291,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
livre.Auteurs.Clear();
}
// ResoudreAsync écarte les noms vides et fusionne les variantes : sa liste peut être
// plus courte que la saisie. Les rôles se retrouvent donc par NOM RÉSOLU, jamais par
// position — sans quoi un nom vide au milieu décalerait tous les rôles suivants.
var roles = new Dictionary<string, RoleAuteur>(StringComparer.Ordinal);
foreach (var saisi in saisis)
{
roles.TryAdd(RapprochementAuteurs.Cle(saisi.Nom), saisi.Role);
}
for (var i = 0; i < resolus.Count; i++)
{
livre.Auteurs.Add(new LivreAuteur
{
Auteur = resolus[i],
Position = i,
Role = roles.GetValueOrDefault(resolus[i].CleRegroupement, RoleAuteur.NonPrecise),
});
}
}
private async Task RattacherThemesAsync(
Livre livre, IReadOnlyList<string> saisis, CancellationToken ct)
{
var themes = saisis
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())
.GroupBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.Select(g => g.First())
.ToList();
if (livre.Themes.Count > 0)
{
db.LivreThemes.RemoveRange(livre.Themes);
livre.Themes.Clear();
}
foreach (var nom in themes)
{
var normalise = NormalisationTexte.Normaliser(nom);
var theme = await db.Themes.FirstOrDefaultAsync(t => t.NomNormalise == normalise, ct);
if (theme is null)
{
theme = new Theme { Nom = nom };
theme.RecalculerFormes();
db.Themes.Add(theme);
}
livre.Themes.Add(new LivreTheme { Livre = livre, Theme = theme });
livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i });
}
}
@@ -518,15 +328,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
isbn = normalise;
}
// ⚠️ On refuse « 0 page » plutôt que de l'enregistrer : la colonne est nullable
// précisément pour que l'absence ait sa propre valeur, et un zéro stocké se lirait
// comme une donnée. Laisser le champ vide est la façon de dire « je ne sais pas ».
if (saisie.NombrePages is { } pages && pages <= 0)
{
erreur = "Le nombre de pages doit être positif. Laissez le champ vide si vous ne l'avez pas.";
return false;
}
return true;
}
@@ -541,20 +342,12 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
Auteurs = livre.Auteurs
.OrderBy(la => la.Position)
.Where(la => la.Auteur is not null)
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom, Role = la.Role })
.ToList(),
Themes = livre.Themes
.Where(lt => lt.Theme is not null)
.Select(lt => lt.Theme!.Nom)
.OrderBy(nom => NormalisationTexte.Normaliser(nom), StringComparer.Ordinal)
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom })
.ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
NombrePages = livre.NombrePages,
Statut = statut,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar,
PreteA = pretOuvert?.Emprunteur,
@@ -1,115 +0,0 @@
using System.Net;
using System.Net.Sockets;
namespace MaBibli.Api.Services.Couvertures;
/// <summary>
/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>Ce garde est la raison pour laquelle le proxy de couvertures est acceptable.</b> Le
/// service .NET écoute sur <c>127.0.0.1</c> et vit sur un serveur YunoHost qui héberge d'autres
/// applications, souvent sur des ports locaux. Un proxy qui irait chercher n'importe quelle URL
/// permettrait de faire lire au serveur ce que le client ne peut pas atteindre lui-même —
/// c'est-à-dire exactement ce que la contrainte « écouter sur 127.0.0.1 uniquement » cherche à
/// empêcher.
/// </para>
/// <para>
/// ⚠️ <b>La vérification est faite au moment de CONNECTER, pas avant la requête.</b> Résoudre le
/// nom d'abord puis laisser <c>HttpClient</c> résoudre à nouveau laisserait passer un
/// <i>DNS rebinding</i> : un nom qui répond une adresse publique à la vérification et
/// <c>127.0.0.1</c> à la connexion. Ici, la socket se connecte <b>aux adresses déjà validées</b>,
/// et à aucune autre.
/// </para>
/// </remarks>
public static class GardeAdresses
{
/// <summary>
/// Rappel de connexion pour <see cref="SocketsHttpHandler.ConnectCallback"/>.
/// </summary>
public static async ValueTask<Stream> ConnecterAsync(
SocketsHttpConnectionContext contexte, CancellationToken ct)
{
var adresses = await Dns.GetHostAddressesAsync(contexte.DnsEndPoint.Host, ct);
var permises = adresses.Where(EstPubliquementRoutable).ToArray();
if (permises.Length == 0)
{
// Message volontairement muet sur ce qui a été résolu : l'appelant n'a pas à
// apprendre la topologie du réseau du serveur.
throw new HttpRequestException("Adresse non autorisée.");
}
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(permises, contexte.DnsEndPoint.Port, ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
/// <summary>
/// Vrai si l'adresse est publiquement routable, c'est-à-dire hors de tout ce qui pourrait
/// désigner le serveur lui-même ou son réseau local.
/// </summary>
/// <remarks>
/// Liste <b>négative</b>, contrairement à la plupart des filtres du projet : ici, laisser
/// passer une plage oubliée est une faille, alors qu'en écarter une de trop ne coûte qu'une
/// couverture non mise en cache.
/// </remarks>
public static bool EstPubliquementRoutable(IPAddress adresse)
{
// Une adresse IPv4 encapsulée en IPv6 (::ffff:127.0.0.1) doit être jugée sur son IPv4,
// sans quoi le bouclage passerait déguisé.
if (adresse.IsIPv4MappedToIPv6)
{
adresse = adresse.MapToIPv4();
}
if (IPAddress.IsLoopback(adresse))
{
return false;
}
if (adresse.AddressFamily == AddressFamily.InterNetwork)
{
var o = adresse.GetAddressBytes();
return o[0] switch
{
0 => false, // 0.0.0.0/8 « ce réseau »
10 => false, // 10.0.0.0/8 privé
127 => false, // bouclage (déjà couvert, par sûreté)
169 when o[1] == 254 => false, // 169.254.0.0/16 lien-local (métadonnées cloud)
172 when o[1] >= 16 && o[1] <= 31 => false, // 172.16.0.0/12 privé
192 when o[1] == 168 => false, // 192.168.0.0/16 privé
192 when o[1] == 0 && o[2] == 0 => false, // 192.0.0.0/24 usages protocolaires
100 when o[1] >= 64 && o[1] <= 127 => false, // 100.64.0.0/10 CGNAT
>= 224 => false, // multicast et réservé
_ => true,
};
}
if (adresse.AddressFamily == AddressFamily.InterNetworkV6)
{
if (adresse.IsIPv6LinkLocal || adresse.IsIPv6SiteLocal || adresse.IsIPv6Multicast
|| adresse.Equals(IPAddress.IPv6Any))
{
return false;
}
// fc00::/7 : adresses locales uniques (l'équivalent IPv6 du privé).
return (adresse.GetAddressBytes()[0] & 0xFE) != 0xFC;
}
// Ni IPv4 ni IPv6 : on ne sait pas juger, donc on refuse.
return false;
}
}
@@ -1,157 +0,0 @@
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Couvertures;
/// <summary>Résultat d'une demande de couverture : les octets, ou rien.</summary>
public sealed record ResultatCouverture(byte[]? Octets, string? TypeContenu)
{
public static readonly ResultatCouverture Refusee = new(null, null);
public bool Trouvee => Octets is not null && TypeContenu is not null;
}
public interface IServiceCouvertures
{
Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct);
}
/// <summary>
/// Relaie une image de couverture <b>en même origine</b>, pour qu'elle puisse être mise en cache
/// hors-ligne quel que soit son hébergeur.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pourquoi ce service existe.</b> Le cache hors-ligne des couvertures (lot A5) lit les octets
/// par <c>fetch()</c>, ce qui exige un en-tête <c>Access-Control-Allow-Origin</c>. OpenLibrary
/// l'envoie, mais le formulaire livre accepte <b>n'importe quelle URL</b> de couverture : ces
/// images s'affichaient bien (une balise <c>&lt;img&gt;</c> n'a que faire du CORS) sans jamais
/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même
/// origine, et la question du CORS disparaît.
/// </para>
/// <para>
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
/// </para>
/// <list type="number">
/// <item>
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre, d'une envie ou d'un
/// numéro de revue. Le proxy
/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
/// l'application détient déjà.
/// </item>
/// <item>
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
/// (<see cref="GardeAdresses"/>). C'est ce second verrou qui tient si quelqu'un enregistre
/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait
/// refusée quand même.
/// </item>
/// </list>
/// <para>
/// ⚠️ <b>Tout refus est indistinct</b> côté appelant (voir l'endpoint) : distinguer « pas en
/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur.
/// </para>
/// </remarks>
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
{
/// <summary>
/// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce
/// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros
/// fichier ne puisse pas remplir la mémoire du serveur.
/// </summary>
public const int TailleMaximale = 5 * 1024 * 1024;
public async Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(url)
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| uri.Scheme != Uri.UriSchemeHttps)
{
// https uniquement : l'application est servie en https par YunoHost, une image en
// clair y serait de toute façon bloquée par le navigateur (contenu mixte).
return ResultatCouverture.Refusee;
}
if (!await EstConnueAsync(url, ct))
{
return ResultatCouverture.Refusee;
}
try
{
using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
// Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond
// 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à
// la connexion — c'est lui qui borne, pas l'interdiction de rediriger.
if (!reponse.IsSuccessStatusCode)
{
return ResultatCouverture.Refusee;
}
var type = reponse.Content.Headers.ContentType?.MediaType;
if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
return ResultatCouverture.Refusee;
}
if (reponse.Content.Headers.ContentLength > TailleMaximale)
{
return ResultatCouverture.Refusee;
}
var octets = await LireBorneAsync(reponse.Content, ct);
return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type);
}
catch (Exception)
{
// Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non
// relayée reste un agrément perdu, jamais une erreur à propager.
return ResultatCouverture.Refusee;
}
}
/// <summary>
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
/// valeur stockée qui autorise, pas une ressemblance.
/// </summary>
/// <remarks>
/// ⚠️ <b>Les numéros de revue en font partie</b> depuis qu'ils portent une couverture. Les
/// oublier ici aurait produit exactement le défaut que ce service existe pour corriger :
/// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant
/// refusé par le relais.
/// </remarks>
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct)
|| await db.NumerosRevue.AnyAsync(n => n.CoverUrl == url, ct);
/// <summary>
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
/// </summary>
/// <remarks>
/// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
/// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
/// </remarks>
private static async Task<byte[]?> LireBorneAsync(HttpContent contenu, CancellationToken ct)
{
await using var flux = await contenu.ReadAsStreamAsync(ct);
using var tampon = new MemoryStream();
var morceau = new byte[81920];
int lus;
while ((lus = await flux.ReadAsync(morceau, ct)) > 0)
{
if (tampon.Length + lus > TailleMaximale)
{
return null;
}
tampon.Write(morceau, 0, lus);
}
return tampon.ToArray();
}
}
+1 -62
View File
@@ -45,19 +45,10 @@ public interface IBnfClient
string titre, string? auteur, CancellationToken ct = default);
}
/// <summary>
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
/// </summary>
public interface IBnfClientNouveautes
{
Task<(ResultatBibliographie Resultat, string? Avertissement)>
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default);
}
/// <summary>
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
/// </summary>
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
{
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
public const string NomHttpClient = "bnf";
@@ -220,12 +211,6 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
public const int NoticesParPage = 100;
public const int PagesMaximum = 2;
public const int PagesMaximumNouveautes = 10;
public static int NombrePagesNouveautes(int nombreAnnonce) =>
Math.Min(
PagesMaximumNouveautes,
Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage));
public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default)
@@ -272,52 +257,6 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
}
}
public async Task<(ResultatBibliographie Resultat, string? Avertissement)>
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(auteur))
{
return (new ResultatBibliographie(), null);
}
try
{
var premiere = await LirePageAsync(auteur, 1, ct);
if (premiere is null)
{
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce);
var pages = new List<ResultatBibliographie> { premiere };
if (nombrePages > 1)
{
var suivantes = await Task.WhenAll(
Enumerable.Range(1, nombrePages - 1)
.Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));
pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!));
}
return (Fusionner(pages), null);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur);
return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
}
}
/// <summary>
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
/// </summary>
+1 -16
View File
@@ -62,8 +62,6 @@ public static class BnfSruParser
continue;
}
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
candidats.Add(new CandidatLivre
{
Titre = titre,
@@ -71,29 +69,16 @@ public static class BnfSruParser
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
Annee = Premier(data, "date"),
Langue = Premier(data, "language"),
// ⚠️ Plusieurs dc:format cohabitent sur une notice (« 1 vol. (349 p.) », puis
// « 30 cm ») : on prend le PREMIER qui donne une pagination, et non le premier
// tout court, qui pourrait n'être qu'une dimension.
NombrePages = Tous(data, "format")
.Select(NettoyageIsbd.NombreDePages)
.FirstOrDefault(pages => pages is not null),
CoverUrl = urlCouverture,
Source = SourceMetadonnees.Bnf,
IsbnInterroge = isbnInterroge,
IdentifiantSource = identifiant,
UrlNotice = UrlNotice(identifiant),
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
});
}
return candidats;
}
private static string? UrlNotice(string? identifiant) =>
string.IsNullOrWhiteSpace(identifiant)
? null
: $"https://catalogue.bnf.fr/{identifiant}";
/// <summary>
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
/// </summary>
@@ -23,13 +23,6 @@ public static partial class NettoyageIsbd
[GeneratedRegex(@"\s*\([^)]*\)\s*$")]
private static partial Regex ParenthesesFinales();
/// <summary>
/// Un nombre collé à une mention de pages. Le nombre doit <b>précéder</b> « p. » : dans
/// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur.
/// </summary>
[GeneratedRegex(@"(?<![\d\]])(\d{1,6})\s*(?:p\.|pages?\b)", RegexOptions.IgnoreCase)]
private static partial Regex Pagination();
/// <summary>
/// Titre : couper à la première mention de responsabilité (« / »).
/// </summary>
@@ -173,51 +166,6 @@ public static partial class NettoyageIsbd
return s.Length == 0 ? null : s;
}
/// <summary>
/// Nombre de pages lu dans la description matérielle (<c>dc:format</c>), ou <c>null</c>.
/// </summary>
/// <remarks>
/// <b>Ce champ n'est pas un nombre, c'est une phrase</b> : la BnF y décrit le support
/// (« 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm »,
/// mesuré sur les notices enregistrées sous <c>Fixtures/</c>). Les règles restent
/// volontairement <b>étroites</b> : on ne reconnaît qu'un nombre suivi de « p. » ou de
/// « page(s) », et <b>tout le reste rend <c>null</c></b> — le catalogue décrit aussi des
/// disques compacts et des dimensions en centimètres.
/// <para>
/// ⚠️ Le sens de l'erreur n'est pas symétrique : un champ vide se remplit à la main en trois
/// secondes, un chiffre faux s'enregistre sans que personne ne le remarque. C'est pourquoi
/// « 1 vol. » ne rend pas 1, « 30 cm » ne rend pas 30, et « (p. 45-90) » — une pagination de
/// contribution, pas une épaisseur — ne rend rien du tout.
/// </para>
/// <para>
/// La valeur reste de toute façon <b>proposée</b> dans un champ modifiable, jamais imposée.
/// </para>
/// </remarks>
public static int? NombreDePages(string? brut)
{
if (string.IsNullOrWhiteSpace(brut))
{
return null;
}
var trouve = Pagination().Match(brut);
if (!trouve.Success)
{
return null;
}
if (!int.TryParse(
trouve.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var pages))
{
// Un nombre à rallonge ne tient pas dans un int : ce n'est pas une pagination.
return null;
}
// Bornes de bon sens. Zéro page n'est pas une donnée, et au-delà de dix mille on a lu
// autre chose que l'épaisseur d'un livre.
return pages is > 0 and <= 10_000 ? pages : null;
}
/// <summary>
/// Titre d'un <b>périodique</b> : couper la mention de responsabilité puis retirer le
/// qualificatif entre parenthèses.
@@ -83,15 +83,9 @@ public static class OpenLibraryMapper
Source = SourceMetadonnees.OpenLibrary,
IsbnInterroge = isbnInterroge,
IdentifiantSource = edition.Key,
UrlNotice = UrlNotice(edition.Key),
};
}
private static string? UrlNotice(string? cle) =>
string.IsNullOrWhiteSpace(cle)
? null
: $"https://openlibrary.org/{cle.TrimStart('/')}";
private static IReadOnlyList<string> Liste(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];
@@ -29,17 +29,6 @@ public interface IServicePrets
Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);
Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
/// <summary>
/// Efface un prêt, en cours ou clos. <c>false</c> s'il n'existe pas.
/// </summary>
/// <remarks>
/// ⚠️ <b>Ce n'est pas « rendre »</b>, et les deux ne doivent jamais être confondus. Rendre
/// pose une date et conserve la ligne : c'est l'histoire du livre, et c'est la raison d'être
/// de la table. Effacer <b>défait une saisie</b> — mauvais emprunteur, mauvaise date, prêt
/// noté deux fois. Un historique juste vaut mieux qu'un historique complet mais faux.
/// </remarks>
Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default);
}
/// <summary>
@@ -186,22 +175,6 @@ public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
}
public async Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default)
{
var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct);
if (pret is null)
{
return false;
}
// Aucune identité n'entre ici, comme partout dans ce service : le prêt est commun au
// foyer, et celui qui constate l'erreur n'est pas forcément celui qui l'a faite.
db.Prets.Remove(pret);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
{
var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
@@ -1,362 +0,0 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Revues;
/// <summary>Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatRevue(RevueDto? Revue, string? Erreur)
{
public static ResultatRevue Ok(RevueDto revue) => new(revue, null);
public static ResultatRevue Invalide(string message) => new(null, message);
public static readonly ResultatRevue Introuvable = new(null, null);
public bool EstIntrouvable => Revue is null && Erreur is null;
}
public interface IServiceRevues
{
Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default);
/// <summary>
/// Crée la revue, ou <b>rend celle qui existe déjà</b> quand l'ISSN ou le titre la désigne.
/// </summary>
Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
/// <summary>
/// Modifie un numéro déjà recensé : son numéro, sa parution, sa note, sa couverture et ses
/// articles à la une.
/// </summary>
/// <remarks>
/// Sans ce point d'entrée, la couverture et les articles ne se seraient saisis qu'à la
/// création — c'est-à-dire jamais, puisqu'on ajoute un numéro le jour où on l'achète et
/// qu'on en recopie la une plus tard.
/// </remarks>
Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default);
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
}
/// <summary>
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
/// </summary>
/// <remarks>
/// ⚠️ <b>Communes au foyer</b>, comme le catalogue : aucune méthode ne filtre sur un
/// utilisateur, <see cref="Revue.AjoutePar"/> n'étant qu'une trace.
/// <para>
/// Un numéro est <b>recensé, rien de plus</b> — ni prêt, ni statut de lecture (décidé le
/// 2026-08-19). C'est ce qui garde cette table à l'écart de deux mécanismes attachés à
/// <c>Livre</c> par clé étrangère.
/// </para>
/// </remarks>
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
{
public async Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default)
{
var revues = await db.Revues
.AsNoTracking()
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.OrderBy(r => r.TitreNormalise)
.ToListAsync(ct);
return revues.Select(Projeter).ToList();
}
public async Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
// ⚠️ Canonisé AVANT la recherche, pas seulement à l'écriture : « 24666718 » et
// « 2466-6718 » désignent la même revue, et sans cela le scan suivant en créerait une
// seconde à côté de celle saisie à la main.
var issn = FormatageIssn.Canonique(saisie.Issn);
var normalise = NormalisationTexte.Normaliser(titre);
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
// forcément la même revue à chaque numéro. Refuser le doublon obligerait l'utilisateur
// à comprendre qu'il doit aller chercher la fiche lui-même — alors que le geste est
// toujours le même : « voici un numéro de plus ».
var existante = await db.Revues
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstOrDefaultAsync(
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
if (existante is not null)
{
// Un ISSN appris après coup complète la fiche créée à la main : c'est ce qui
// permettra de la retrouver au prochain scan.
if (existante.Issn is null && issn is not null)
{
existante.Issn = issn;
await db.SaveChangesAsync(ct);
}
return ResultatRevue.Ok(Projeter(existante));
}
var revue = new Revue
{
Titre = titre,
Issn = issn,
Editeur = Vide(saisie.Editeur),
DateAjout = DateTime.UtcNow,
AjoutePar = utilisateur,
};
revue.RecalculerFormes();
db.Revues.Add(revue);
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Revues.AnyAsync(r => r.Id != id && r.TitreNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
}
var issn = FormatageIssn.Canonique(saisie.Issn);
if (issn is not null && await db.Revues.AnyAsync(r => r.Id != id && r.Issn == issn, ct))
{
return ResultatRevue.Invalide($"L'ISSN {issn} est déjà celui d'une autre revue.");
}
revue.Titre = titre;
revue.Issn = issn;
revue.Editeur = Vide(saisie.Editeur);
revue.RecalculerFormes();
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var revue = await db.Revues.FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return false;
}
// Les numéros partent en cascade : ils n'existent que par leur revue, contrairement aux
// livres d'une série, qui vivent leur vie au catalogue.
db.Revues.Remove(revue);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == revueId, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (revue.Numeros.Any(n => n.NumeroNormalise == normalise))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
// Comme pour les prêts : une date future est une faute de frappe, pas une intention.
// Un jour de marge, les revues datées du mois suivant étant courantes en kiosque.
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
var ligne = new NumeroRevue
{
RevueId = revueId,
Numero = numero,
DateParution = saisie.DateParution,
Note = Vide(saisie.Note),
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
};
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
db.NumerosRevue.Add(ligne);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == revueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
public async Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var ligne = await db.NumerosRevue
.Include(n => n.Articles)
.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (ligne is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (await db.NumerosRevue.AnyAsync(
n => n.Id != numeroId && n.RevueId == ligne.RevueId && n.NumeroNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
ligne.Numero = numero;
ligne.DateParution = saisie.DateParution;
ligne.Note = Vide(saisie.Note);
ligne.CoverUrl = Vide(saisie.CoverUrl);
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == ligne.RevueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
/// <summary>
/// Remplace les articles à la une du numéro par ceux de la saisie.
/// </summary>
/// <remarks>
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
/// du champ doit disparaître, comme pour les thèmes d'un livre.
/// <para>
/// Le dédoublonnage a déjà eu lieu à la saisie (<c>ListeSaisie</c>), mais il est refait ici
/// sur la forme normalisée : c'est l'unicité <c>(NumeroRevueId, TitreNormalise)</c> qui est
/// en jeu, et un appel direct à l'API n'est pas passé par le champ texte.
/// </para>
/// </remarks>
private void RemplacerArticles(NumeroRevue numero, IReadOnlyList<string>? titres)
{
if (numero.Articles.Count > 0)
{
db.ArticlesUne.RemoveRange(numero.Articles);
numero.Articles.Clear();
}
var vus = new HashSet<string>(StringComparer.Ordinal);
var position = 0;
foreach (var titre in titres ?? [])
{
var article = new ArticleUne { NumeroRevue = numero, Titre = titre ?? string.Empty };
article.RecalculerFormes();
if (article.TitreNormalise.Length == 0 || !vus.Add(article.TitreNormalise))
{
continue;
}
article.Position = position++;
numero.Articles.Add(article);
}
}
public async Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
{
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (numero is null)
{
return false;
}
db.NumerosRevue.Remove(numero);
await db.SaveChangesAsync(ct);
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static RevueDto Projeter(Revue revue) => new()
{
Id = revue.Id,
Titre = revue.Titre,
Issn = revue.Issn,
Editeur = revue.Editeur,
AjoutePar = revue.AjoutePar,
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche,
// à l'inverse d'une série dont on suit l'ordre de lecture. Les numéros sans date de
// parution ferment la liste plutôt que de passer pour les plus vieux.
Numeros = revue.Numeros
.OrderByDescending(n => n.DateParution.HasValue)
.ThenByDescending(n => n.DateParution)
.ThenByDescending(n => n.NumeroNormalise, StringComparer.Ordinal)
.Select(n => new NumeroRevueDto
{
Id = n.Id,
Numero = n.Numero,
DateParution = n.DateParution,
Note = n.Note,
CoverUrl = n.CoverUrl,
Articles = n.Articles
.OrderBy(a => a.Position)
.ThenBy(a => a.Id)
.Select(a => a.Titre)
.ToList(),
})
.ToList(),
};
}
@@ -1,523 +0,0 @@
using MaBibli.Api.Data;
using MaBibli.Api.Services.Souhaits;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Series;
/// <summary>Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur)
{
public static ResultatSerie Ok(SerieDto serie) => new(serie, null);
public static ResultatSerie Invalide(string message) => new(null, message);
/// <summary>Ni série ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatSerie Introuvable = new(null, null);
public bool EstIntrouvable => Serie is null && Erreur is null;
}
public interface IServiceSeries
{
Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default);
Task<ResultatSerie> CreerAsync(
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatSerie> ModifierAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatSerie> AjouterElementAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default);
Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);
/// <summary>Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.</summary>
Task<ResultatSerie> ModifierElementAsync(
int elementId, AjoutElementSerie saisie, CancellationToken ct = default);
Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
/// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Portée COMMUNE au foyer</b>, comme le catalogue et les prêts. Aucune méthode ne prend
/// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas
/// selon qui regarde. La seule exception est <see cref="MettreEnEnviesAsync"/>, qui écrit dans
/// une liste d'envies, et qui est donc <b>obligée</b> de savoir de qui elle parle : c'est là que
/// les deux portées se rencontrent, et nulle part ailleurs.
/// </remarks>
public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries
{
public async Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default)
{
// AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue.
var series = await db.Series
.AsNoTracking()
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
.ThenInclude(la => la.Auteur)
.ToListAsync(ct);
var livreIds = series
.SelectMany(s => s.Elements)
.Select(e => e.LivreId)
.OfType<int>()
.ToList();
var prets = await PretsOuvertsAsync(livreIds, ct);
return series
.OrderBy(s => s.Position)
.ThenBy(s => s.TitreNormalise, StringComparer.Ordinal)
.Select(s => Projeter(s, prets))
.ToList();
}
public async Task<ResultatSerie> CreerAsync(
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct))
{
// Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien
// dire pour l'utilisateur. L'index reste là comme garantie de dernier recours.
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
}
if (saisie.SerieParenteId is { } parenteId
&& !await db.Series.AnyAsync(s => s.Id == parenteId, ct))
{
return ResultatSerie.Invalide("La série parente n'existe pas.");
}
var serie = new Serie
{
Titre = titre,
SerieParenteId = saisie.SerieParenteId,
DateAjout = DateTime.UtcNow,
// Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar.
AjoutePar = utilisateur,
};
serie.RecalculerFormes();
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
db.Series.Add(serie);
await db.SaveChangesAsync(ct);
return await RelireAsync(serie.Id, ct);
}
public async Task<ResultatSerie> ModifierAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default)
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct))
{
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
}
if (saisie.SerieParenteId is { } parenteId)
{
if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct))
{
return ResultatSerie.Invalide("La série parente n'existe pas.");
}
if (await EstDescendanteAsync(parenteId, id, ct))
{
// Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se
// refermerait sur lui-même et l'affichage boucherait à l'infini.
return ResultatSerie.Invalide(
"Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes.");
}
}
if (serie.SerieParenteId != saisie.SerieParenteId)
{
serie.SerieParenteId = saisie.SerieParenteId;
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
}
serie.Titre = titre;
serie.RecalculerFormes();
await db.SaveChangesAsync(ct);
return await RelireAsync(id, ct);
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
if (serie is null)
{
return false;
}
// Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles,
// remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres.
db.Series.Remove(serie);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatSerie> AjouterElementAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default)
{
var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (saisie.LivreId is { } livreId)
{
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
if (livre is null)
{
return ResultatSerie.Invalide("Ce livre n'existe plus.");
}
if (serie.Elements.Any(e => e.LivreId == livreId))
{
return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série.");
}
// Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de
// quoi la saga se retrouverait avec une place muette.
titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre;
}
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
}
var element = new ElementSerie
{
SerieId = serieId,
LivreId = saisie.LivreId,
Titre = titre,
Numero = saisie.Numero,
Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1,
};
element.RecalculerFormes();
db.ElementsSerie.Add(element);
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSerie> RattacherLivreAsync(
int elementId, int? livreId, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSerie.Introuvable;
}
if (livreId is { } id)
{
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatSerie.Invalide("Ce livre n'existe plus.");
}
var deja = await db.ElementsSerie.AnyAsync(
e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct);
if (deja)
{
return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série.");
}
}
// Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher
// un livre laisse donc un trou nommé, et non une ligne vide.
element.LivreId = livreId;
await db.SaveChangesAsync(ct);
return await RelireAsync(element.SerieId, ct);
}
/// <remarks>
/// ⚠️ Ce point d'entrée existe pour que le <b>numéro de tome</b> puisse se saisir après coup.
/// Sans lui, il ne serait renseignable qu'à la création — c'est-à-dire jamais en pratique,
/// puisqu'on découvre souvent le numéro en ayant le livre en main. Même défaut que celui
/// rencontré sur les numéros de revue, et corrigé de la même façon.
/// <para>
/// Le livre rattaché n'est <b>pas</b> touché : c'est l'affaire de
/// <see cref="RattacherLivreAsync"/>. Un point d'entrée, un geste.
/// </para>
/// </remarks>
public async Task<ResultatSerie> ModifierElementAsync(
int elementId, AjoutElementSerie saisie, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSerie.Introuvable;
}
var titre = (saisie.Titre ?? string.Empty).Trim();
if (titre.Length == 0)
{
// Le titre survit à la suppression du livre : une place sans titre serait un trou
// anonyme, que plus rien ne permettrait d'identifier.
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
}
element.Titre = titre;
element.Numero = saisie.Numero;
element.RecalculerFormes();
await db.SaveChangesAsync(ct);
return await RelireAsync(element.SerieId, ct);
}
public async Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return false;
}
db.ElementsSerie.Remove(element);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
{
var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct);
if (elements.Count == 0)
{
return await db.Series.AnyAsync(s => s.Id == serieId, ct)
? await RelireAsync(serieId, ct)
: ResultatSerie.Introuvable;
}
// Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on
// ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un
// tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être
// périmée, et mal classé vaut infiniment mieux que disparu.
var parId = elements.ToDictionary(e => e.Id);
var position = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var element))
{
element.Position = position++;
}
}
foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id))
{
oublie.Position = position++;
}
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
{
if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
{
return ResultatSerie.Introuvable;
}
var enfants = await db.Series
.Where(s => s.SerieParenteId == serieId)
.ToListAsync(ct);
var parId = enfants.ToDictionary(s => s.Id);
var position = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var enfant))
{
enfant.Position = position++;
}
}
foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
{
oublie.Position = position++;
}
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default)
{
var element = await db.ElementsSerie
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSouhait.Invalide("Ce tome n'existe plus.");
}
if (element.LivreId is not null)
{
// Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait
// aussitôt comme « déjà au catalogue ».
return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque.");
}
// ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
// (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
// membres peuvent vouloir le même tome manquant, chacun dans sa liste.
return await souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
}
/// <summary>Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs.</summary>
private async Task<Dictionary<int, string>> PretsOuvertsAsync(
IReadOnlyList<int> livreIds, CancellationToken ct)
{
if (livreIds.Count == 0)
{
return [];
}
return await db.Prets
.AsNoTracking()
.Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId))
.ToDictionaryAsync(p => p.LivreId, p => p.Emprunteur, ct);
}
private async Task<int> ProchainePositionAsync(int? parenteId, CancellationToken ct)
{
var derniere = await db.Series
.Where(s => s.SerieParenteId == parenteId)
.Select(s => (int?)s.Position)
.MaxAsync(ct);
return (derniere ?? -1) + 1;
}
/// <summary><paramref name="candidate"/> est-elle <paramref name="serieId"/> ou l'une de ses filles ?</summary>
private async Task<bool> EstDescendanteAsync(int candidate, int serieId, CancellationToken ct)
{
var courante = (int?)candidate;
// Remontée bornée par le nombre de séries : même si une boucle existait déjà en base,
// cette fonction s'arrête au lieu de tourner indéfiniment.
var garde = await db.Series.CountAsync(ct) + 1;
while (courante is { } id && garde-- > 0)
{
if (id == serieId)
{
return true;
}
courante = await db.Series
.Where(s => s.Id == id)
.Select(s => s.SerieParenteId)
.FirstOrDefaultAsync(ct);
}
return false;
}
private async Task<ResultatSerie> RelireAsync(int serieId, CancellationToken ct)
{
var serie = await db.Series
.AsNoTracking()
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
.ThenInclude(la => la.Auteur)
.FirstOrDefaultAsync(s => s.Id == serieId, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var prets = await PretsOuvertsAsync(
serie.Elements.Select(e => e.LivreId).OfType<int>().ToList(), ct);
return ResultatSerie.Ok(Projeter(serie, prets));
}
private static SerieDto Projeter(Serie serie, IReadOnlyDictionary<int, string> prets) => new()
{
Id = serie.Id,
Titre = serie.Titre,
SerieParenteId = serie.SerieParenteId,
Position = serie.Position,
AjoutePar = serie.AjoutePar,
Elements = serie.Elements
.OrderBy(e => e.Position)
.ThenBy(e => e.Id)
.Select(e => new ElementSerieDto
{
Id = e.Id,
Position = e.Position,
// Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé
// depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression.
Titre = e.Livre?.Titre ?? e.Titre,
Numero = e.Numero,
LivreId = e.LivreId,
Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0
? null
: string.Join(", ", e.Livre.Auteurs
.OrderBy(la => la.Position)
.Select(la => la.Auteur?.Nom)
.Where(nom => !string.IsNullOrWhiteSpace(nom))),
PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null,
})
.ToList(),
};
}
+14 -138
View File
@@ -13,7 +13,7 @@ namespace MaBibli.Api.Services.Souhaits;
/// C'est ce qui permet de vérifier le contenu exact du fichier en test plutôt que de se fier à
/// une inspection visuelle.
/// <para>
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> CLAUDE.md demandait un format
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> IDEES.md demandait un format
/// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte.
/// </para>
/// </remarks>
@@ -48,29 +48,12 @@ public static class ExportSouhaits
public static string TypeMime(FormatExportSouhaits format) =>
format == FormatExportSouhaits.Csv ? "text/csv; charset=utf-8" : "text/plain; charset=utf-8";
/// <summary>
/// Produit le fichier, <b>les deux tables réunies</b>.
/// </summary>
/// <remarks>
/// ⚠️ Les revues souhaitées viennent d'une table sœur et doivent traverser l'export : c'est
/// le fichier qu'on emporte, donc précisément l'endroit où les oublier ne se verrait pas.
/// <para>
/// Elles forment une <b>section à part</b> plutôt que d'être entrelacées : les deux tables
/// numérotent leur rang indépendamment, et mélanger deux suites sans rapport produirait un
/// ordre que personne n'a choisi. Une revue n'a d'ailleurs pas d'auteur, donc rien à faire
/// dans un fichier groupé par auteur.
/// </para>
/// </remarks>
public static string Produire(
FormatExportSouhaits format,
IReadOnlyList<SouhaitDto> souhaits,
IReadOnlyList<RevueSouhaiteeDto> revues,
string? proprietaire,
DateTime maintenant) =>
FormatExportSouhaits format, IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant) =>
format switch
{
FormatExportSouhaits.Csv => Csv(souhaits, revues),
_ => Texte(souhaits, revues, proprietaire, maintenant),
FormatExportSouhaits.Csv => Csv(souhaits),
_ => Texte(souhaits, proprietaire, maintenant),
};
/// <summary>
@@ -80,11 +63,7 @@ public static class ExportSouhaits
/// Le groupement par auteur n'est pas cosmétique : c'est ainsi qu'une librairie est rangée.
/// Une liste à plat obligerait à parcourir tout le fichier pour chaque rayon.
/// </remarks>
public static string Texte(
IReadOnlyList<SouhaitDto> souhaits,
IReadOnlyList<RevueSouhaiteeDto> revues,
string? proprietaire,
DateTime maintenant)
public static string Texte(IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant)
{
var sortie = new StringBuilder();
@@ -97,32 +76,13 @@ public static class ExportSouhaits
sortie.Append(" — ").Append(maintenant.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)).Append('\n');
sortie.Append(new string('=', 40)).Append('\n');
if (souhaits.Count == 0 && revues.Count == 0)
if (souhaits.Count == 0)
{
sortie.Append("\n(aucune envie dans la liste)\n");
sortie.Append("\n(aucun livre dans la liste)\n");
return sortie.ToString();
}
if (souhaits.Count > 0)
{
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
Livres(sortie, souhaits);
}
Revues(sortie, revues);
return sortie.ToString();
}
/// <summary>Les livres souhaités, groupés par auteur.</summary>
private static void Livres(StringBuilder sortie, IReadOnlyList<SouhaitDto> souhaits)
{
// ⚠️ Le groupement par auteur DÉTRUIT l'ordre d'envie : c'est le prix, assumé, d'une
// liste rangée comme une librairie. Le rang est donc réinscrit devant chaque titre,
// faute de quoi le fichier emporté ne dit plus par quoi commencer — précisément
// l'information que l'écran de réordonnancement sert à établir.
var rangs = Rangs(souhaits);
sortie.Append("(le chiffre entre crochets est votre ordre d'envie, 1 = le plus désiré)\n");
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
// « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins
// exploitables en rayon, elles ne doivent pas ouvrir le fichier.
@@ -137,7 +97,7 @@ public static class ExportSouhaits
foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase))
{
sortie.Append(" - [").Append(rangs[souhait.Id]).Append("] ").Append(souhait.Titre);
sortie.Append(" - ").Append(souhait.Titre);
// Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on
// épelle à un libraire, et ça reste lisible sur un écran de téléphone.
@@ -158,74 +118,21 @@ public static class ExportSouhaits
}
}
}
return sortie.ToString();
}
/// <summary>
/// Les revues souhaitées, en <b>section à part</b>, à la fin.
/// </summary>
/// <remarks>
/// ⚠️ Elles ne peuvent pas rejoindre les groupes ci-dessus : une revue n'a pas d'auteur, et
/// « Auteur non précisé » réunirait des magazines et des livres dont l'auteur est simplement
/// inconnu. Au kiosque comme en librairie, ce ne sont pas les mêmes rayons.
/// </remarks>
private static void Revues(StringBuilder sortie, IReadOnlyList<RevueSouhaiteeDto> revues)
{
if (revues.Count == 0)
{
return;
}
sortie.Append('\n').Append(new string('-', 40)).Append('\n');
sortie.Append(revues.Count).Append(revues.Count > 1 ? " revues" : " revue").Append('\n');
// La numérotation repart de 1 : le rang d'une revue est celui de sa propre section, pas
// une place dans un classement commun qui n'existe pas.
var rang = 0;
foreach (var revue in revues)
{
sortie.Append(" - [").Append(++rang).Append("] ").Append(revue.TitreComplet);
if (!string.IsNullOrWhiteSpace(revue.Issn))
{
sortie.Append(" (ISSN ").Append(revue.Issn).Append(')');
}
sortie.Append('\n');
if (!string.IsNullOrWhiteSpace(revue.Note))
{
sortie.Append(" ").Append(revue.Note.Trim()).Append('\n');
}
}
}
/// <summary>
/// Tableau ouvrable dans un tableur, une envie par ligne, <b>les deux tables réunies</b>.
/// </summary>
/// <remarks>
/// ⚠️ Une colonne « Type » ouvre le tableau à côté du rang : sans elle, une revue et un
/// livre seraient indiscernables une fois le fichier trié par titre, et la colonne
/// « ISBN / ISSN » deviendrait illisible — deux codes de nature différente y cohabitent.
/// Les revues suivent les livres, chaque table gardant sa propre numérotation.
/// </remarks>
public static string Csv(IReadOnlyList<SouhaitDto> souhaits, IReadOnlyList<RevueSouhaiteeDto> revues)
/// <summary>Tableau ouvrable dans un tableur, une envie par ligne.</summary>
public static string Csv(IReadOnlyList<SouhaitDto> souhaits)
{
var sortie = new StringBuilder();
// Le rang ouvre le tableau : c'est la colonne sur laquelle on trie dans un tableur, et
// celle qui répond à « par quoi commence-t-on ? » quand on se répartit des achats.
// Le CSV suit déjà cet ordre, mais un ordre implicite se perd au premier tri par titre.
Ligne(sortie, "Rang", "Type", "Titre", "Auteur", "Éditeur", "Année", "ISBN / ISSN", "Note", "Ajouté le");
var rangs = Rangs(souhaits);
Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le");
foreach (var souhait in souhaits)
{
Ligne(
sortie,
rangs[souhait.Id].ToString(CultureInfo.InvariantCulture),
"Livre",
souhait.Titre,
souhait.Auteur,
souhait.Editeur,
@@ -235,40 +142,9 @@ public static class ExportSouhaits
souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
var rangRevue = 0;
foreach (var revue in revues)
{
// Ni auteur, ni éditeur, ni année : une revue n'en a pas au sens de cette liste, et
// inventer des colonnes remplies au hasard serait pire que des cases vides.
Ligne(
sortie,
(++rangRevue).ToString(CultureInfo.InvariantCulture),
"Revue",
revue.TitreComplet,
null,
null,
null,
revue.Issn,
revue.Note,
revue.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
return sortie.ToString();
}
/// <summary>
/// Rang affiché de chaque envie, à partir de 1.
/// </summary>
/// <remarks>
/// Déduit de la <b>position dans la liste reçue</b>, et non lu sur une propriété du DTO :
/// <c>ListerAsync</c> trie déjà par <c>Rang</c>, et la colonne en base peut comporter des
/// trous (une suppression ne renumérote pas). Ce qu'on veut imprimer est « troisième de ma
/// liste », pas la valeur brute stockée.
/// </remarks>
private static Dictionary<int, int> Rangs(IReadOnlyList<SouhaitDto> souhaits) =>
souhaits.Select((s, i) => (s.Id, Rang: i + 1)).ToDictionary(x => x.Id, x => x.Rang);
/// <summary>
/// ISBN découpé pour la ligne texte : c'est un numéro qu'on <b>épelle à un libraire</b>,
/// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres.
@@ -1,208 +0,0 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Souhaits;
/// <summary>Issue d'une écriture d'envie de revue : la ligne, un message, ou rien du tout.</summary>
public readonly record struct ResultatRevueSouhaitee(
RevueSouhaiteeDto? Envie, string? Erreur, bool NExistePas = false)
{
public static readonly ResultatRevueSouhaitee Introuvable = new(null, null, true);
public static ResultatRevueSouhaitee Ok(RevueSouhaiteeDto envie) => new(envie, null);
public static ResultatRevueSouhaitee Invalide(string message) => new(null, message);
}
public interface IServiceRevuesSouhaitees
{
Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
string? utilisateur, CancellationToken ct = default);
Task<ResultatRevueSouhaitee> AjouterAsync(
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatRevueSouhaitee> ModifierAsync(
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Les envies de revues et de numéros, <b>personnelles</b>.
/// </summary>
/// <remarks>
/// Mêmes règles que <see cref="ServiceSouhaits"/>, sans exception : toute méthode filtre sur
/// l'appelant, aucun point d'entrée n'accepte de nom d'utilisateur, et l'envie d'un autre est
/// simplement introuvable.
/// <para>
/// ⚠️ Il n'y a <b>pas</b> de rapprochement avec les revues possédées, contrairement aux envies
/// de livres. Ce n'est pas un oubli : « je possède la revue mais pas ce numéro » et « je possède
/// ce numéro » demandent deux réponses différentes, et un signalement approximatif sur une liste
/// d'envies coûte plus qu'il ne rapporte.
/// </para>
/// </remarks>
public sealed class ServiceRevuesSouhaitees(MaBibliDbContext db) : IServiceRevuesSouhaitees
{
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return [];
}
var envies = await db.RevuesSouhaitees
.AsNoTracking()
.Where(e => e.Utilisateur == utilisateur)
.OrderBy(e => e.Rang)
.ThenBy(e => e.Id)
.ToListAsync(ct);
return envies.Select(Projeter).ToList();
}
public async Task<ResultatRevueSouhaitee> AjouterAsync(
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatRevueSouhaitee.Invalide(
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
}
var envie = new RevueSouhaitee
{
Utilisateur = utilisateur,
Titre = saisie.Titre,
Numero = saisie.Numero,
Issn = saisie.Issn,
Note = Vide(saisie.Note),
DateAjout = DateTime.UtcNow,
};
// ⚠️ Canonise l'ISSN au passage : « 24666718 » devient « 2466-6718 », la seule forme
// rangée en base et la seule que la BnF interroge.
envie.RecalculerFormes();
if (await ExisteDejaAsync(envie, null, ct))
{
return ResultatRevueSouhaitee.Invalide(Deja(envie));
}
// En fin de section, comme une envie de livre : on vient de la noter, on n'a pas dit
// qu'on la voulait plus que les autres.
var dernierRang = await db.RevuesSouhaitees
.Where(e => e.Utilisateur == utilisateur)
.Select(e => (int?)e.Rang)
.MaxAsync(ct);
envie.Rang = (dernierRang ?? -1) + 1;
db.RevuesSouhaitees.Add(envie);
await db.SaveChangesAsync(ct);
return ResultatRevueSouhaitee.Ok(Projeter(envie));
}
public async Task<ResultatRevueSouhaitee> ModifierAsync(
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatRevueSouhaitee.Introuvable;
}
// Le filtre fait partie de la clé de recherche : l'envie d'un autre n'est pas refusée,
// elle n'est pas trouvée.
var envie = await db.RevuesSouhaitees
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
if (envie is null)
{
return ResultatRevueSouhaitee.Introuvable;
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
}
envie.Titre = saisie.Titre;
envie.Numero = saisie.Numero;
envie.Issn = saisie.Issn;
envie.Note = Vide(saisie.Note);
envie.RecalculerFormes();
if (await ExisteDejaAsync(envie, envie.Id, ct))
{
return ResultatRevueSouhaitee.Invalide(Deja(envie));
}
// Rang inchangé : corriger un numéro ne dit rien de l'ordre voulu.
await db.SaveChangesAsync(ct);
return ResultatRevueSouhaitee.Ok(Projeter(envie));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var envie = await db.RevuesSouhaitees
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
if (envie is null)
{
return false;
}
db.RevuesSouhaitees.Remove(envie);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
/// Le doublon est refusé <b>avec un message</b>, jamais laissé à l'index unique.
/// </summary>
/// <remarks>
/// ⚠️ L'entité est déjà rattachée au contexte lors d'une modification : d'où l'exclusion
/// explicite de sa propre ligne, sans quoi toute édition se dirait doublon d'elle-même.
/// </remarks>
private Task<bool> ExisteDejaAsync(RevueSouhaitee envie, int? saufId, CancellationToken ct) =>
db.RevuesSouhaitees.AnyAsync(
e => e.Utilisateur == envie.Utilisateur
&& e.TitreNormalise == envie.TitreNormalise
&& e.NumeroNormalise == envie.NumeroNormalise
&& (saufId == null || e.Id != saufId),
ct);
private static string Deja(RevueSouhaitee envie) =>
envie.Numero is null
? $"« {envie.Titre} » est déjà dans votre liste d'envies."
: $"« {envie.Titre} n° {envie.Numero} » est déjà dans votre liste d'envies.";
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
/// <summary>Projection vers le DTO, <b>sans</b> le champ <c>Utilisateur</c>.</summary>
private static RevueSouhaiteeDto Projeter(RevueSouhaitee envie) => new()
{
Id = envie.Id,
Titre = envie.Titre,
Numero = envie.Numero,
Issn = envie.Issn,
Note = envie.Note,
DateAjout = envie.DateAjout,
};
}
@@ -7,16 +7,9 @@ using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Souhaits;
/// <summary>Issue d'une écriture d'envie : la ligne, un message, ou rien du tout.</summary>
/// <remarks>
/// ⚠️ « Introuvable » est un <b>troisième état</b>, et pas un message d'erreur de plus : c'est
/// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par
/// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables.
/// </remarks>
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false)
/// <summary>Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur.</summary>
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur)
{
public static readonly ResultatSouhait Introuvable = new(null, null, true);
public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);
public static ResultatSouhait Invalide(string message) => new(null, message);
@@ -29,16 +22,6 @@ public interface IServiceSouhaits
Task<ResultatSouhait> AjouterAsync(
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
/// <summary>
/// Corrige une envie existante de l'appelant.
/// </summary>
/// <remarks>
/// ⚠️ <c>Rang</c> n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un
/// titre n'est pas dire qu'on le veut davantage.
/// </remarks>
Task<ResultatSouhait> ModifierAsync(
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
/// <summary>
@@ -89,9 +72,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
.ThenBy(s => s.Id)
.ToListAsync(ct);
var possedes = await CorrespondancesAsync(souhaits, ct);
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
return souhaits.Select(Projeter).ToList();
}
public async Task<ResultatSouhait> AjouterAsync(
@@ -165,98 +146,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
db.LivresSouhaites.Add(souhait);
await db.SaveChangesAsync(ct);
var possede = await CorrespondancesAsync([souhait], ct);
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
}
public async Task<ResultatSouhait> ModifierAsync(
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatSouhait.Introuvable;
}
// Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne
// vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un
// autre. C'est ce qui rend le 404 exact plutôt que poli.
var souhait = await db.LivresSouhaites
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
if (souhait is null)
{
return ResultatSouhait.Introuvable;
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatSouhait.Invalide("Le titre est obligatoire.");
}
string? isbn = null;
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
{
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
if (!IsbnUtils.EstValide(normalise))
{
return ResultatSouhait.Invalide(
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
}
isbn = normalise;
}
souhait.Titre = saisie.Titre;
souhait.Auteur = saisie.Auteur;
souhait.Editeur = Vide(saisie.Editeur);
souhait.Annee = Vide(saisie.Annee);
souhait.Isbn = isbn;
souhait.Note = Vide(saisie.Note);
// ⚠️ Écrite SEULEMENT quand le geste de correction a été utilisé (CoverUrlModifiee) :
// « Modifier une envie » enregistre aussi le titre, l'ISBN, etc. sans qu'on ait touché à
// la couverture, et cette valeur affichée peut être une DÉDUCTION à la lecture (formule
// OpenLibrary à ISBN connu, voir Projeter) plutôt que ce qu'il y a réellement en base.
// La matérialiser à chaque enregistrement figerait cette déduction, et une correction
// d'ISBN qui suit ne la referait plus jouer. Le drapeau distingue « je n'y ai pas
// touché » de « je viens de vider le champ », ce qu'un simple null ne pourrait pas dire.
if (saisie.CoverUrlModifiee)
{
souhait.CoverUrl = Vide(saisie.CoverUrl);
}
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
// se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise.
souhait.RecalculerFormes();
// ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise),
// contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en
// une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message
// plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire.
//
// 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux
// exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a
// rien à confirmer. C'est une saisie à corriger, pas un choix à trancher.
var deja = await db.LivresSouhaites.AnyAsync(
s => s.Id != souhait.Id
&& s.Utilisateur == utilisateur
&& s.TitreNormalise == souhait.TitreNormalise
&& s.AuteurNormalise == souhait.AuteurNormalise,
ct);
if (deja)
{
return ResultatSouhait.Invalide(
$"« {souhait.Titre} » est déjà dans votre liste d'envies.");
}
await db.SaveChangesAsync(ct);
var possede = await CorrespondancesAsync([souhait], ct);
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
return ResultatSouhait.Ok(Projeter(souhait));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
@@ -330,97 +220,6 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
return true;
}
/// <summary>
/// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
/// </summary>
/// <remarks>
/// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
/// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
/// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
/// d'acheter à nouveau.
/// <para>
/// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
/// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
/// commun. Le titre seul confondrait deux recueils homonymes.
/// </para>
/// <para>
/// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est 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 ici
/// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
/// </para>
/// </remarks>
private async Task<Dictionary<int, int>> CorrespondancesAsync(
IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
{
if (souhaits.Count == 0)
{
return [];
}
var livres = await db.Livres
.AsNoTracking()
.Select(l => new
{
l.Id,
l.Titre,
l.Isbn,
Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
})
.ToListAsync(ct);
var parCle = livres
.Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
.Where(x => x.Cle.Length > 0)
.GroupBy(x => x.Cle)
.ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());
var parIsbn = new Dictionary<string, int>();
foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
{
parIsbn.TryAdd(livre.Isbn!, livre.Id);
}
var correspondances = new Dictionary<int, int>();
foreach (var souhait in souhaits)
{
if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
{
correspondances[souhait.Id] = idIsbn;
continue;
}
// TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
// recalculer de ce côté-là.
if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
{
continue;
}
var trouve = homonymes.FirstOrDefault(
l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));
if (trouve is not null)
{
correspondances[souhait.Id] = trouve.Id;
}
}
return correspondances;
}
/// <summary>
/// Identifiant du livre correspondant, ou <c>null</c>.
/// </summary>
/// <remarks>
/// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
/// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
/// été pris par les tests, pas par le compilateur.
/// </remarks>
private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
@@ -428,7 +227,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
/// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
/// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
/// </summary>
internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
internal static SouhaitDto Projeter(LivreSouhaite souhait) => new()
{
Id = souhait.Id,
Titre = souhait.Titre,
@@ -436,24 +235,8 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
// ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie.
//
// Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le
// composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie »
// remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie
// d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais
// aucune image.
//
// Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour
// les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN,
// pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on
// applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.
CoverUrl = souhait.CoverUrl
?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)),
CoverUrl = souhait.CoverUrl,
Note = souhait.Note,
DateAjout = souhait.DateAjout,
Possede = livreId is not null,
LivreId = livreId,
};
}
-133
View File
@@ -1,133 +0,0 @@
@using MaBibli.Shared.Dtos
@*
L'arbre des séries : rien d'autre que la descendance.
⚠️ Il dépliait auparavant la liste complète des tomes de chaque série, carte de livre
comprise. Un cycle de quatre séries occupait alors plusieurs écrans, et l'on ne voyait
justement plus ce qu'un arbre sert à voir — qui contient quoi. Les tomes ont leur écran,
celui de la série ; ici on ne montre que le titre, l'avancement, et le lien qui y mène.
Corollaire : tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. L'inverse
(tout replié) obligeait à ouvrir chaque nœud pour découvrir s'il contenait quelque chose,
c'est-à-dire à faire à la main le travail que l'arbre est censé faire.
*@
<li class="arbre-noeud" @key="Serie.Id">
<div class="arbre-ligne">
@if (Enfants.Count > 0)
{
<button type="button" class="arbre-plier" aria-expanded="@Aria.Etat(Deplie)"
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Serie.Titre} »")"
@onclick="Basculer">
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
</button>
}
else
{
@* Une puce à la place de la bascule : sans elle, les titres sans descendance ne
s'aligneraient pas sur ceux qui en ont. *@
<span class="arbre-puce" aria-hidden="true">•</span>
}
<a class="arbre-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
<span class="arbre-compte">@Avancement()</span>
@if (ActionsActives)
{
<span class="arbre-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_index <= 0)" title="@MotifBlocage"
aria-label="@($"Monter « {Serie.Titre} »")"
@onclick="Monter">▲</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
aria-label="@($"Descendre « {Serie.Titre} »")"
@onclick="Descendre">▼</button>
</span>
}
</div>
@* Le chemin déjà rendu coupe la récursion : une boucle résiduelle en base ferait sinon
boucler le rendu Razor à l'infini. Le garde-fou serveur, lui, reste borné par le
nombre de séries. *@
@if (Deplie && Enfants.Count > 0)
{
<ul class="arbre-enfants">
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
{
<ArbreSerie Serie="enfant" Toutes="Toutes"
Totaux="Totaux" Chemin="@CheminAvecSerie"
ActionsActives="ActionsActives"
MotifBlocage="MotifBlocage"
Replies="Replies"
OnDeplacer="OnDeplacer" />
}
</ul>
}
</li>
@code {
[Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
[Parameter, EditorRequired] public IReadOnlyList<SerieDto> Toutes { get; set; } = [];
[Parameter, EditorRequired] public IReadOnlyDictionary<int, (int Possedes, int Total)> Totaux { get; set; } = new Dictionary<int, (int, int)>();
[Parameter] public IReadOnlySet<int> Chemin { get; set; } = new HashSet<int>();
/// <summary>
/// Nœuds REPLIÉS, et non dépliés : le défaut est de tout montrer.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture,
/// pas une propriété de la série.
/// </remarks>
[Parameter] public ISet<int> Replies { get; set; } = new HashSet<int>();
[Parameter] public bool ActionsActives { get; set; }
[Parameter] public string? MotifBlocage { get; set; }
[Parameter] public EventCallback<DeplacementSerie> OnDeplacer { get; set; }
private List<SerieDto> Soeurs => Toutes
.Where(s => s.SerieParenteId == Serie.SerieParenteId)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
private List<SerieDto> Enfants => Toutes
.Where(s => s.SerieParenteId == Serie.Id)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
private bool Deplie => !Replies.Contains(Serie.Id);
private IReadOnlySet<int> CheminAvecSerie => new HashSet<int>(Chemin) { Serie.Id };
/// <summary>
/// « 3 sur 7 tomes », en comptant les descendants — c'est ce qu'on vient chercher.
/// </summary>
private string Avancement()
{
var total = Totaux.TryGetValue(Serie.Id, out var valeur)
? valeur
: (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
return total.Total == 0
? "aucun tome recensé"
: $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
}
private void Basculer()
{
if (!Replies.Add(Serie.Id))
{
Replies.Remove(Serie.Id);
}
}
private Task Monter() => Deplacer(-1);
private Task Descendre() => Deplacer(1);
private Task Deplacer(int delta) =>
Serie.SerieParenteId is { } parent
? OnDeplacer.InvokeAsync(new DeplacementSerie(Serie.Id, parent, delta))
: Task.CompletedTask;
}
@@ -1,74 +0,0 @@
@*
Le catalogue contient déjà quelque chose de semblable.
⚠️ C'est un AVERTISSEMENT, jamais un refus : posséder deux exemplaires est légitime — on
garde le sien et on prête l'autre — et le rapprochement se trompe dans les deux sens (il
confond le poche et le grand format, il rate une réédition retitrée). L'écran montre donc
les fiches en cause plutôt que de les décrire, et laisse la décision à l'utilisateur.
Les fiches sont des LIENS : la question qui se pose vraiment est « est-ce bien le même
livre ? », et on n'y répond qu'en regardant celui qui est déjà là.
*@
<div class="message-avertissement" role="alert">@Doublons.Message</div>
<ul class="liste-livres">
@foreach (var livre in Doublons.Livres)
{
<li class="carte-livre" @key="livre.Id">
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
@if (livre.Auteurs.Count > 0)
{
<p class="carte-auteur">@string.Join(", ", livre.Auteurs.Select(a => a.Nom))</p>
}
<p class="carte-details">
@if (livre.Format == Format.Numerique)
{
<span class="etiquette">@Libelles.Format(livre.Format)</span>
}
@* Un exemplaire dehors est justement une raison d'en vouloir un second. *@
@if (livre.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
{
<span>@livre.Editeur</span>
}
@if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
<span class="code-isbn">@FormatageIsbn.Afficher(livre.Isbn)</span>
}
</p>
</div>
</li>
}
</ul>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" disabled="@EnCours" @onclick="OnConfirmer">
@(EnCours ? "Ajout…" : "Ajouter quand même")
</button>
<button type="button" class="bouton bouton-discret" disabled="@EnCours" @onclick="OnRenoncer">
Ne pas ajouter
</button>
</div>
@code {
[Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!;
/// <summary>Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté.</summary>
[Parameter] public EventCallback OnConfirmer { get; set; }
/// <summary>Retour en arrière : le livre était bien déjà là.</summary>
[Parameter] public EventCallback OnRenoncer { get; set; }
[Parameter] public bool EnCours { get; set; }
}
@@ -1,30 +0,0 @@
@*
Les deux autres façons de regarder le même auteur, posées à côté du filtre.
Elles étaient auparavant dans une barre flottante en bas d'écran. Sur une bibliographie
de cinquante lignes, cela revenait à les cacher : « Nouveautés » est justement ce qu'on
veut au moment où l'on ouvre l'écran, pas après l'avoir parcouru.
Composant plutôt que balisage recopié : la bibliographie les affiche à deux endroits
(liste garnie, et liste vide où le filtre n'a pas lieu d'être), et deux copies auraient
divergé.
*@
@if (Biblio.EstNouveautes)
{
<a class="bouton bouton-discret" href="@($"auteurs/{Biblio.Auteur.Id}/bibliographie")">
Toute la bibliographie
</a>
}
else
{
<a class="bouton bouton-discret"
href="@($"auteurs/{Biblio.Auteur.Id}/bibliographie?nouveautes=true")">
Nouveautés
</a>
}
<a class="bouton bouton-discret" href="@($"?auteur={Biblio.Auteur.Id}")">Ses livres chez vous</a>
@code {
[Parameter, EditorRequired] public BibliographieDto Biblio { get; set; } = default!;
}
@@ -1,61 +0,0 @@
<div class="carte-livre">
<Couverture Url="@Livre.CoverUrl" Titre="@Livre.Titre"
Classe="couverture-petite" Agrandissable="true" />
<div class="carte-corps">
<a class="carte-titre" href="@($"livres/{Livre.Id}")">@Livre.Titre</a>
@if (Livre.Auteurs.Count > 0)
{
<p class="carte-auteur">
@foreach (var (auteur, index) in Livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
@if (auteur.Role != RoleAuteur.NonPrecise)
{
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</p>
}
<p class="carte-details">
@if (Livre.Format == Format.Numerique)
{
<span class="etiquette">@Libelles.Format(Livre.Format)</span>
}
@if (Livre.TypeDocument != TypeDocument.NonPrecise)
{
<span class="etiquette etiquette-type @Libelles.ClasseTypeDocument(Livre.TypeDocument)">
@Libelles.TypeDocument(Livre.TypeDocument)
</span>
}
@foreach (var theme in Livre.Themes)
{
<span class="etiquette">@theme</span>
}
@if (Livre.Statut is { } statut)
{
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
@Libelles.Statut(statut)
</span>
}
@if (Livre.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
@if (!string.IsNullOrWhiteSpace(Livre.Editeur))
{
<span>@Livre.Editeur</span>
}
</p>
@Actions
</div>
</div>
@code {
[Parameter, EditorRequired] public LivreDto Livre { get; set; } = default!;
[Parameter] public RenderFragment? Actions { get; set; }
}
@@ -1,137 +0,0 @@
@*
Les champs d'un numéro de revue, partagés par l'ajout et la modification.
Un seul exemplaire, et pas deux blocs jumeaux : le formulaire d'ajout et celui de
modification doivent proposer exactement les mêmes champs, sinon une couverture ou une une
saisies à la création ne seraient plus modifiables ensuite — ce qui est précisément le cas
d'usage (on ajoute un numéro le jour où on l'achète, on en recopie la une plus tard).
⚠️ Aucun champ ne se lie DIRECTEMENT à « Saisie » : chacun passe par une propriété qui
prévient le parent (OnChangement). Blazor ne redessine que le composant qui a traité
l'événement — une frappe ici ne re-rendait donc pas l'écran qui porte le bouton
« Ajouter ce numéro », et celui-ci restait grisé jusqu'à ce qu'autre chose redessine le
parent (enregistrer la revue, par exemple). Voir CLAUDE.md.
*@
<div class="champs-numero">
<label class="champ">
<span class="champ-libelle">Numéro</span>
<input class="champ-saisie" type="text" placeholder="43" @bind="Numero"
@bind:event="oninput" @onkeydown="SurTouche" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Parution <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="date" @bind="Parution" />
</label>
<label class="champ">
<span class="champ-libelle">Note <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" @bind="Note" @bind:event="oninput"
placeholder="dossier sur l'eau" autocomplete="off" />
</label>
</div>
@*
⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules :
voir SaisieListe, qui porte le pourquoi. Une virgule dans un titre (« Ukraine, deux ans
après ») n'a plus rien à couper, puisque plus rien ne découpe.
*@
<SaisieListe Valeurs="Saisie.Articles" Libelle="À la une"
Aide=" (un titre à la fois : tapez-le, puis Entrée)"
Placeholder="Ukraine, deux ans après"
MessageDoublon="Ce titre est déjà à la une de ce numéro."
OnChangement="Notifier" />
@*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
la revue, pas la parution. Et le projet ne stocke aucun fichier — « ebooks : fiches
uniquement » vaut ici aussi.
*@
<label class="champ">
<span class="champ-libelle">
Couverture <span class="champ-aide">(adresse d'une image, facultatif)</span>
</span>
<input class="champ-saisie" type="url" @bind="CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
@code {
[Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!;
/// <summary>
/// Signale au parent que la saisie a changé, pour qu'il se redessine.
/// </summary>
/// <remarks>
/// ⚠️ <b>Sans cela, un bouton du parent qui dépend de la saisie reste figé.</b> Blazor ne
/// redessine que le composant ayant traité l'événement : « Ajouter ce numéro » vit dans
/// l'écran de la revue, le champ « Numéro » vit ici, et le bouton restait donc désactivé
/// tant que rien d'autre ne re-rendait le parent — constaté en usage, où il fallait passer
/// par « Modifier la revue » puis « Enregistrer » pour le débloquer.
/// <para>
/// Tous les champs préviennent, pas seulement le numéro : aucun autre bouton n'en dépend
/// aujourd'hui, mais faire dépendre la correction de « lequel commande quoi » la referait
/// oublier au premier champ ajouté.
/// </para>
/// </remarks>
[Parameter] public EventCallback OnChangement { get; set; }
/// <summary>
/// Les champs du numéro, servis par des propriétés plutôt que liés à <see cref="Saisie"/> :
/// c'est le seul endroit d'où prévenir le parent à coup sûr.
/// </summary>
private string Numero
{
get => Saisie.Numero;
set { Saisie.Numero = value; Notifier(); }
}
private string? Note
{
get => Saisie.Note;
set { Saisie.Note = value; Notifier(); }
}
private string? CoverUrl
{
get => Saisie.CoverUrl;
set { Saisie.CoverUrl = value; Notifier(); }
}
/// <remarks>
/// Le parent est prévenu sans être attendu : <c>InvokeAsync</c> ne fait que demander un
/// nouveau rendu, et un setter de propriété ne peut de toute façon rien attendre.
/// </remarks>
private void Notifier() => _ = OnChangement.InvokeAsync();
/// <summary>
/// Date affichée par le champ, en <b>local</b>.
/// </summary>
/// <remarks>
/// ⚠️ Le <c>&lt;input type="date"&gt;</c> produit une date sans fuseau. Elle est déclarée
/// locale ici et convertie en UTC, comme toutes les dates du projet : sans cela la parution
/// se décale d'un jour pour la moitié du globe. La conversion se fait dans le composant
/// pour que les deux écrans qui s'en servent ne puissent pas l'oublier chacun de leur côté.
/// </remarks>
private DateTime? Parution
{
get => Saisie.DateParution?.ToLocalTime().Date;
set
{
Saisie.DateParution = value is { } date
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
: null;
Notifier();
}
}
[Parameter] public EventCallback<KeyboardEventArgs> OnEntree { get; set; }
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await OnEntree.InvokeAsync(e);
}
}
}
-23
View File
@@ -1,23 +0,0 @@
namespace MaBibli.Client.Composants;
/// <summary>
/// Ce qu'un coup de scanner a produit : le code du livre ou de la revue, et son add-on éventuel.
/// </summary>
/// <param name="Code">EAN-13 normalisé, seul code qui identifie l'ouvrage.</param>
/// <param name="Parution">
/// Contenu de l'add-on <b>EAN-2</b>, imprimé à côté du code principal sur les périodiques.
/// </param>
/// <remarks>
/// ⚠️ <b>L'EAN-2 est une indication, jamais une donnée acquise.</b> Il porte le numéro de
/// parution d'une revue — c'est sa raison d'être normative — mais rien n'a pu être vérifié sur
/// des magazines réels : la vérification demande un exemplaire devant la caméra. Il est donc
/// <b>proposé</b> à l'utilisateur dans un champ qu'il corrige, jamais enregistré tel quel.
/// <para>
/// L'add-on <b>EAN-5</b> n'est pas remonté : il porte un <i>prix</i>, et le confondre avec un
/// numéro créerait des numéros « 00595 » que personne ne comprendrait.
/// </para>
/// </remarks>
public readonly record struct CodeScanne(string Code, string? Parution);
/// <summary>Forme brute renvoyée par <c>scanner-camera.js</c>, avant validation de l'ISBN.</summary>
internal sealed record LectureCodeBarres(string? Code, string? Parution);
+11 -142
View File
@@ -1,7 +1,3 @@
@using MaBibli.Client.Services
@inject CacheHorsLigne Cache
@inject EtatReseau Reseau
@implements IAsyncDisposable
@*
Couverture d'un livre, avec substitut.
@@ -13,93 +9,39 @@
@key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que
le onerror a marqué comme cassé.
Agrandissement (CLAUDE.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
à agrandir - le substitut à initiale reste un simple décor, jamais un bouton.
⚠️ Image cassée après coup (404/502 intermittent d'OpenLibrary) : le onerror désactive aussi
le bouton déclencheur (this.closest('.couverture-declencheur').disabled = true), pas
seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image
cassée (A4, CLAUDE.md).
Cache des couvertures : le cache IndexedDB est consulté D'ABORD, en ligne comme hors ligne.
Une image déjà rangée sur l'appareil s'affiche sans aucune requête réseau — c'est ce qui rend
un rechargement instantané sur téléphone, là où covers.openlibrary.org impose deux
redirections avant d'aboutir sur archive.org. Le réseau n'est sollicité QUE si rien n'est en
cache, et la mise en cache démarre alors en tâche de fond.
⚠️ Ceci REMPLACE la règle d'origine (A5) « en ligne, rien ne doit ralentir la voie rapide »,
qui réservait le cache au hors-ligne. La lecture IndexedDB précède désormais l'affichage :
elle est locale et sans réseau, donc largement plus rapide que ce qu'elle remplace.
⚠️ Corollaire : l'URL d'objet doit être LIBÉRÉE quand le composant disparaît ou change
d'image (Dispose / changement d'URL). Un blob: retient son Blob en mémoire jusque-là, et il y
a maintenant une vignette par carte sur tous les écrans.
⚠️ L'allègement (redimensionner, convertir en WebP) n'est PAS fait ici : à mesurer avant
de s'y engager, comme pour l'AOT — voir CLAUDE.md.
Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
à agrandir le substitut à initiale reste un simple décor, jamais un bouton.
*@
<div class="couverture @Classe">
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
@if (ADesPixels)
{
@* ⚠️ L'image cassée est désormais un état C# (`_cassee`), posé par @onerror, et non plus
une classe ajoutée par du JavaScript en ligne. C'est ce qui permet d'en faire quelque
chose : proposer de corriger l'URL, là où l'on constate qu'elle est morte.
`onerror` ne remonte pas dans le DOM, mais Blazor le connaît comme événement
non propagatif et pose son écouteur sur l'élément même : @onerror fonctionne bien
sur une <img>. *@
@if (Corrigeable)
{
<button type="button" class="couverture-declencheur declencheur-correction"
aria-label="@($"Corriger l'adresse de la couverture de {Titre}")"
title="Cette image ne se charge pas — cliquez pour corriger son adresse"
@onclick="OnCorriger">
<img @key="UrlAffichee" class="couverture-image image-cassee"
src="@UrlAffichee" alt="Couverture de @Titre" loading="lazy" />
</button>
}
else if (Agrandissable && !_cassee)
@if (Agrandissable)
{
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
et activation à l'Entrée sans une ligne de code. *@
<button type="button" class="couverture-declencheur"
aria-label="Agrandir la couverture de @Titre"
@onclick="Ouvrir">
<img @key="UrlAffichee"
<img @key="Url"
class="couverture-image"
src="@UrlAffichee"
src="@Url"
alt="Couverture de @Titre"
loading="lazy"
@onerror="() => _cassee = true" />
onerror="this.classList.add('image-cassee')" />
</button>
}
else
{
<img @key="UrlAffichee"
class="couverture-image @(_cassee ? "image-cassee" : null)"
src="@UrlAffichee"
<img @key="Url"
class="couverture-image"
src="@Url"
alt="Couverture de @Titre"
loading="lazy"
@onerror="() => _cassee = true" />
onerror="this.classList.add('image-cassee')" />
}
}
else if (OnCorriger.HasDelegate)
{
@*
⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle
« le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic
servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et
c'est précisément le livre qui n'en a pas qui en a besoin.
Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte :
le paramètre n'est pas passé, et le substitut y reste un décor inerte.
*@
<button type="button" class="couverture-declencheur declencheur-correction"
aria-label="@($"Ajouter une couverture à {Titre}")"
title="Aucune couverture — cliquez pour en indiquer l'adresse"
@onclick="OnCorriger"></button>
}
</div>
@if (_agrandie)
@@ -110,7 +52,7 @@
aria-label="Couverture de @Titre"
tabindex="-1" @ref="_calque"
@onclick="Fermer" @onkeydown="SurToucheAsync">
<img class="calque-image" src="@UrlAffichee" alt="Couverture de @Titre"
<img class="calque-image" src="@Url" alt="Couverture de @Titre"
@onclick:stopPropagation="true"
onerror="this.classList.add('image-cassee')" />
@@ -128,37 +70,12 @@
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
[Parameter] public bool Agrandissable { get; set; }
/// <summary>
/// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse.
/// </summary>
/// <remarks>
/// ⚠️ Renseigné, il <b>remplace</b> l'agrandissement sur une image cassée, et rend cliquable
/// le substitut à initiale d'un livre <b>sans</b> couverture. Agrandir une image qui ne
/// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque
/// est exactement ce qu'on veut faire à cet instant.
/// </remarks>
[Parameter] public EventCallback OnCorriger { get; set; }
/// <summary>L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager.</summary>
private bool _cassee;
private bool Corrigeable => _cassee && OnCorriger.HasDelegate;
private bool _agrandie;
private bool _aFocaliser;
private ElementReference _calque;
private string? _urlCachee;
private (string? Url, bool EnLigne)? _urlResoluePour;
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
/// <summary>
/// URL réellement posée en <c>src</c> : le blob mis en cache dès qu'il existe — en ligne
/// comme hors ligne —, sinon l'URL réseau telle quelle (qui échoue simplement hors-ligne,
/// exactement comme avant cette fonctionnalité).
/// </summary>
private string? UrlAffichee => _urlCachee ?? Url;
private string Initiale =>
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
@@ -178,53 +95,6 @@
}
}
protected override async Task OnParametersSetAsync()
{
// ⚠️ Le jeton compare aussi l'état réseau, pas seulement l'URL : sans quoi, passer
// en ligne → hors ligne sans que l'URL change (le cas le plus courant) ne relirait
// jamais le cache, et la couverture resterait invisible hors-ligne.
var jeton = (Url, Reseau.EnLigne);
if (!ADesPixels || _urlResoluePour == jeton)
{
return;
}
_urlResoluePour = jeton;
// Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci.
_cassee = false;
await LibererAsync();
// Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche
// sans requête, sans redirection, et sans réveiller la radio du téléphone.
_urlCachee = await Cache.LireCouvertureCacheeAsync(Url!);
if (_urlCachee is null && Reseau.EnLigne)
{
// Rien en cache : le réseau sert l'image (UrlAffichee retombe sur Url), et la mise
// en cache démarre en tâche de fond pour que le prochain chargement soit local.
// ⚠️ Jamais attendue : elle ne doit pas retarder un affichage qui va avoir lieu.
_ = Cache.MettreEnCacheCouvertureAsync(Url!);
}
StateHasChanged();
}
/// <summary>Révoque l'URL d'objet en cours, s'il y en a une.</summary>
private async Task LibererAsync()
{
if (_urlCachee is null)
{
return;
}
var aLiberer = _urlCachee;
_urlCachee = null;
await Cache.LibererCouvertureAsync(aLiberer);
}
public async ValueTask DisposeAsync() => await LibererAsync();
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_aFocaliser)
@@ -234,4 +104,3 @@
}
}
}
+20 -179
View File
@@ -1,5 +1,3 @@
@inject ServiceLivresApi Api
@*
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
@@ -21,81 +19,29 @@
</label>
@*
⚠️ L'ORDRE DES CHAMPS EST UNE DÉCISION (lot Q, 2026-08-21), pas une mise en page.
Type de document, puis thèmes, puis auteurs (chacun avec son rôle) : c'est l'ordre dans
lequel on regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de
la saisie — les rôles ne se posent que là. Relégué en bas, il obligeait à descendre tout le formulaire
pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend.
⚠️ Remonter le champ le rend visible ; le PRÉREMPLIR écrirait quelque chose de faux. Le
défaut reste « non précisé », qui ne prétend rien (décision actée) — c'est aussi ce qui
rend l'étiquette honnête dans les listes : elle ne s'affiche que si un type a été choisi.
Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà
prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle.
Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu.
*@
<fieldset class="champ">
<legend class="champ-libelle">
Type<span class="champ-aide"> (facultatif)</span>
</legend>
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
</fieldset>
<label class="champ">
<span class="champ-libelle">
Auteur<span class="champ-aide"> (plusieurs : séparez par « ; »)</span>
</span>
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
</label>
@*
⚠️ Thèmes et auteurs se saisissent UN PAR UN, et non plus sur une ligne à séparateurs.
Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis
recomposait la liste : le point-virgule tapé y était élagué (entrée vide) et l'espace
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
Voir CLAUDE.md.
*@
@*
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
filtre par thème n'y verrait que deux étiquettes sans rapport.
*@
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
</label>
@*
Un auteur par ligne, et son RÔLE avec lui.
⚠️ Le rôle vit sur le LIEN, pas sur la personne : le même auteur scénarise un album et
dessine le suivant. C'est aussi pourquoi renommer une ligne conserve son rôle
(NomsAuteurs) — on corrige un nom, on ne désigne pas quelqu'un d'autre.
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
*@
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà sur ce livre."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
placeholder="978…" autocomplete="off" />
</label>
@*
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc
@@ -120,32 +66,6 @@
</div>
</fieldset>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
</label>
@*
⚠️ Le champ reste VIDE quand on ne sait pas : « 0 » se lirait comme une donnée. C'est
aussi pourquoi la valeur proposée par la BnF arrive ici et non en base directement —
une pagination lue dans une phrase (« 1 vol. (349 p.) ») se corrige d'un geste.
*@
<label class="champ">
<span class="champ-libelle">
Nombre de pages <span class="champ-aide">(facultatif)</span>
</span>
<input class="champ-saisie" type="number" inputmode="numeric" min="1" step="1"
@bind="Saisie.NombrePages" @bind:event="oninput"
placeholder="349" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
placeholder="978…" autocomplete="off" />
</label>
<fieldset class="champ">
<legend class="champ-libelle">
Statut de lecture<span class="champ-aide"> (le vôtre)</span>
@@ -192,85 +112,6 @@
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
/// <summary>
/// Pose le rôle d'un auteur.
/// </summary>
/// <remarks>
/// <see cref="AuteurSaisi"/> est un record : on remplace l'entrée plutôt que de la muter,
/// et la liste reste celle que la propriété <c>Auteur</c> saura relire pour préserver les
/// rôles à la frappe suivante.
/// </remarks>
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>
/// Les noms des auteurs, vus comme une liste de chaînes par <c>SaisieListe</c>.
/// </summary>
/// <remarks>
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
/// par <c>OnParametersSet</c> et par elle seule.
/// <para>
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
/// </para>
/// </remarks>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private EnregistrementLivre? _saisiePrecedente;
/// <summary>
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
/// </summary>
/// <remarks>
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
/// pris de la détection de doublons.
/// <para>
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
/// </para>
/// </remarks>
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
@@ -1,226 +0,0 @@
@inject ServiceLivresApi Api
@*
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
*@
<div class="formulaire">
<p class="message-avertissement" role="status">
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
sélectionnés — pour les auteurs, une liste vide les retire tous.
</p>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierType" />
<span>Type</span>
</label>
</legend>
@if (Saisie.ModifierType)
{
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierThemes" />
<span>Thèmes</span>
</label>
@if (Saisie.ModifierThemes)
{
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
}
</div>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
<span>Auteurs</span>
</label>
@if (Saisie.ModifierAuteurs)
{
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà dans la liste."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierFormat" />
<span>Format</span>
</label>
</legend>
@if (Saisie.ModifierFormat)
{
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
<span>Éditeur</span>
</label>
@if (Saisie.ModifierEditeur)
{
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierStatut" />
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
</label>
</legend>
@if (Saisie.ModifierStatut)
{
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
}
</fieldset>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private SaisieLot? _saisiePrecedente;
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
[Parameter] public int NombreLivres { get; set; }
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}
@@ -1,135 +0,0 @@
@using MaBibli.Client.Services
@*
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
série de séries : chaque niveau porte sa propre bascule.
⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
alors que l'écran des séries EST la liste des séries, où les masquer toutes viderait l'écran.
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
du catalogue paraîtrait mentir.
⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce
qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le
filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a
qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ».
*@
<li class="groupe-serie" @key="Entree.Serie!.Id">
<div class="groupe-serie-entete">
@* ⚠️ Aria.Etat, et non le booléen nu : voir la mise en garde qu'il porte. *@
<button type="button" class="groupe-serie-plier"
aria-expanded="@Aria.Etat(Deplie)"
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Entree.Serie!.Titre} »")"
@onclick="Basculer">
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
</button>
@* Le nom mène à la SÉRIE : c'est le seul écran qui montre aussi les tomes manquants,
ce que le catalogue ne peut pas faire. *@
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
<span class="groupe-serie-compte">
@Libelles.CompteBloc(Entree.NombreLivres, Possedes, Total)
</span>
</a>
</div>
@if (Deplie)
{
@if (Entree.SousEntrees.Count > 0)
{
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux"
LotActif="LotActif" SelectionLot="SelectionLot" OnSelectionner="OnSelectionner" />
}
</ul>
}
@if (Entree.Livres.Count > 0)
{
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
@if (LotActif)
{
<li class="carte-oeuvre" @key="livre.Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@(SelectionLot?.Contains(livre.Id) ?? false)"
@onchange="e => OnSelectionner.InvokeAsync((livre.Id, e.Value is true))" />
<span class="lecture-ecran">Sélectionner « @livre.Titre »</span>
</label>
<CarteLivre Livre="livre" />
</li>
}
else
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
}
</ul>
}
}
</li>
@code {
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs OUVERTS, par identifiant de série — et non repliés : le défaut est de tout replier.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture, pas
/// une propriété de la série. Il survit en revanche aux rechargements du catalogue (frappe
/// dans la recherche, filtre), le jeu appartenant à l'écran et non au bloc.
/// </remarks>
[Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
/// <summary>
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
/// </summary>
/// <remarks>
/// ⚠️ Calculés une fois par l'écran (<c>TotauxSeries</c>), et non ici : le catalogue se relit
/// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre.
/// C'est aussi ce qui garantit le <b>même</b> compte que la fiche de la série.
/// </remarks>
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
/// <summary>Modification en lot du catalogue : voir la remarque équivalente sur Catalogue.razor.</summary>
[Parameter] public bool LotActif { get; set; }
[Parameter] public ISet<int>? SelectionLot { get; set; }
/// <summary>
/// ⚠️ EventCallback, et non un Action brut : coché depuis un tome niché dans ce bloc, l'écran
/// du catalogue (le VRAI propriétaire de la sélection) ne serait sinon jamais prévenu du
/// changement, et sa barre de sélection resterait figée sur l'état d'avant — le second piège
/// Blazor de CLAUDE.md.
/// </summary>
[Parameter] public EventCallback<(int LivreId, bool Coche)> OnSelectionner { get; set; }
private (int Possedes, int Total) Compte =>
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
? connu
: (Entree.NombreLivres, Entree.NombreLivres);
private int Possedes => Compte.Possedes;
private int Total => Compte.Total;
private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Ouverts.Add(Entree.Serie!.Id))
{
Ouverts.Remove(Entree.Serie!.Id);
}
}
}
-77
View File
@@ -12,34 +12,6 @@ public static class Libelles
_ => format.ToString(),
};
/// <summary>
/// Libellé du type de document. <b>« Non précisé » ne s'affiche pas sur une fiche</b> :
/// c'est l'absence de choix, et l'interface ne montre que ce qui a été choisi. Le libellé
/// existe pour les boutons de saisie et de filtre, où il faut bien nommer l'option.
/// </summary>
public static string TypeDocument(TypeDocument type) => type switch
{
MaBibli.Shared.Entites.TypeDocument.NonPrecise => "Non précisé",
MaBibli.Shared.Entites.TypeDocument.Roman => "Roman",
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "BD",
MaBibli.Shared.Entites.TypeDocument.Pratique => "Pratique",
MaBibli.Shared.Entites.TypeDocument.Connaissance => "Connaissance",
_ => type.ToString(),
};
/// <summary>
/// Libellé d'un rôle d'auteur. « Non précisé » ne s'affiche jamais sur une fiche : c'est
/// l'absence de choix, pas une information.
/// </summary>
public static string RoleAuteur(RoleAuteur role) => role switch
{
MaBibli.Shared.Entites.RoleAuteur.NonPrecise => "Non précisé",
MaBibli.Shared.Entites.RoleAuteur.Scenario => "Scénario",
MaBibli.Shared.Entites.RoleAuteur.Dessin => "Dessin",
MaBibli.Shared.Entites.RoleAuteur.Couleurs => "Couleurs",
_ => role.ToString(),
};
public static string Statut(Statut statut) => statut switch
{
MaBibli.Shared.Entites.Statut.ALire => "À lire",
@@ -56,53 +28,4 @@ public static class Libelles
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
_ => string.Empty,
};
/// <summary>
/// Classe CSS associée au type de document, pour la pastille de couleur (A2, CLAUDE.md) :
/// le filtre doit reprendre le même vocabulaire visuel que la carte, pas des segments neutres.
/// « Non précisé » n'a pas de couleur : ce n'est pas une valeur choisie.
/// </summary>
public static string ClasseTypeDocument(TypeDocument type) => type switch
{
MaBibli.Shared.Entites.TypeDocument.Roman => "type-roman",
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
MaBibli.Shared.Entites.TypeDocument.Pratique => "type-pratique",
MaBibli.Shared.Entites.TypeDocument.Connaissance => "type-connaissance",
_ => string.Empty,
};
/// <summary>
/// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et
/// ce que la série compte de tomes.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le compte d'un bloc changeait avec le filtre</b> : « 3 tomes » devenait « 1 tome »
/// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un
/// tome ». Le nombre affiché ne disait pas de quoi il parlait.
/// <para>
/// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc.
/// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant.
/// </para>
/// </remarks>
/// <param name="affiches">Tomes de ce bloc actuellement à l'écran, filtre appliqué.</param>
/// <param name="possedes">Tomes de cette série présents au catalogue, descendance comprise.</param>
/// <param name="total">Tomes recensés dans la série, ceux qui manquent compris.</param>
public static string CompteBloc(int affiches, int possedes, int total)
{
var tomes = total > 1 ? "tomes" : "tome";
if (affiches == possedes && possedes == total)
{
return $"{total} {tomes}";
}
if (affiches == possedes)
{
return $"{possedes} sur {total} {tomes}";
}
return possedes == total
? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}"
: $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}";
}
}
-80
View File
@@ -1,80 +0,0 @@
using System.Collections;
using MaBibli.Shared.Dtos;
namespace MaBibli.Client.Composants;
/// <summary>
/// Vue « liste de noms » des auteurs d'une saisie, pour que <c>SaisieListe</c> les remplisse
/// comme il remplit les thèmes.
/// </summary>
/// <remarks>
/// ⚠️ <b>Un auteur n'est pas un nom : il porte aussi son rôle sur ce livre</b> (scénario,
/// dessin…), qui vit sur le lien et non sur la personne. Renommer par l'indexeur <b>conserve
/// donc le rôle</b> — c'est ce qui remplace la reprise des rôles que faisait
/// <c>EnregistrementLivre.Decouper</c> quand le champ des noms se reconstruisait à chaque
/// frappe : ici la ligne n'est pas reconstruite, elle est corrigée.
/// <para>
/// La liste sous-jacente est celle de la saisie, modifiée en place : cette vue n'en garde
/// aucune copie qui pourrait diverger.
/// </para>
/// </remarks>
public sealed class NomsAuteurs(IList<AuteurSaisi> auteurs) : IList<string>
{
public int Count => auteurs.Count;
public bool IsReadOnly => false;
public string this[int index]
{
get => auteurs[index].Nom;
set => auteurs[index] = auteurs[index] with { Nom = value };
}
public void Add(string nom) => auteurs.Add(new AuteurSaisi(nom));
public void Insert(int index, string nom) => auteurs.Insert(index, new AuteurSaisi(nom));
public void RemoveAt(int index) => auteurs.RemoveAt(index);
public void Clear() => auteurs.Clear();
public bool Contains(string nom) => IndexOf(nom) >= 0;
public int IndexOf(string nom)
{
for (var i = 0; i < auteurs.Count; i++)
{
if (auteurs[i].Nom == nom)
{
return i;
}
}
return -1;
}
public bool Remove(string nom)
{
var index = IndexOf(nom);
if (index < 0)
{
return false;
}
auteurs.RemoveAt(index);
return true;
}
public void CopyTo(string[] tableau, int index)
{
foreach (var auteur in auteurs)
{
tableau[index++] = auteur.Nom;
}
}
public IEnumerator<string> GetEnumerator() => auteurs.Select(a => a.Nom).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
-25
View File
@@ -1,25 +0,0 @@
@*
Indicateur d'attente pour les appels lents (lot L1).
⚠️ Le besoin n'est pas décoratif. Une recherche BnF coûte environ une seconde par page, et
jusqu'à dix pages pour les nouveautés : sans rien à l'écran, l'application se lit comme
plantée, et l'utilisateur reclique — donc relance l'appel. C'est exactement ce que le lot H
avait constaté sur la bibliographie.
⚠️ Un indicateur NE REMPLACE PAS un message d'échec. Une source muette garde ses états
EtatSourceBibliographie et son motif : ici on dit « ça travaille », jamais « ça a marché ».
role="status" et non role="alert" : l'attente s'annonce sans interrompre le lecteur d'écran.
L'animation est neutralisée sous prefers-reduced-motion (voir app.css).
*@
<p class="attente @Classe" role="status">
<span class="attente-rondelle" aria-hidden="true"></span>
<span>@Message</span>
</p>
@code {
/// <summary>Ce qu'on attend, dit à l'utilisateur. Jamais un libellé générique si l'on sait mieux.</summary>
[Parameter, EditorRequired] public string Message { get; set; } = default!;
[Parameter] public string? Classe { get; set; }
}
+5 -82
View File
@@ -69,12 +69,8 @@
à <strong>@pret.Emprunteur</strong>
depuis le @Jour(pret.DatePret)
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
@Croix(pret)
</p>
@Confirmation(pret)
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
@onclick="RendreAsync">
@@ -131,21 +127,11 @@
@foreach (var passe in Historique)
{
<li @key="passe.Id">
@* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
reléguée dessous elle se lisait comme une action de la liste entière. *@
<div class="historique-ligne">
<span class="historique-qui">
<strong>@passe.Emprunteur</strong>
<span class="historique-dates">
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
— @Duree(passe.JoursEcoules)
</span>
</span>
@Croix(passe)
</div>
@Confirmation(passe)
<strong>@passe.Emprunteur</strong>
<span class="historique-dates">
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
— @Duree(passe.JoursEcoules)
</span>
</li>
}
</ul>
@@ -157,44 +143,7 @@
}
</section>
@*
⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
historique juste vaut mieux qu'un historique complet mais faux.
⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
aucun.
*@
@code {
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
private RenderFragment Croix(PretDto pret) =>
@<button type="button" class="bouton-croix" disabled="@_occupe"
title="Effacer ce prêt (saisie erronée)"
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
@onclick="() => _aEffacer = pret.Id">✕</button>;
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
private RenderFragment Confirmation(PretDto pret) => @<text>
@if (_aEffacer == pret.Id)
{
<p class="message-avertissement" role="status">
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
@onclick="() => EffacerAsync(pret)">
@(_occupe ? "Suppression…" : "Oui, effacer")
</button>
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
@onclick="() => _aEffacer = null">
Annuler
</button>
</div>
}
</text>;
[Parameter, EditorRequired] public int LivreId { get; set; }
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
@@ -220,9 +169,6 @@
private string? _erreur;
private int _livreCharge;
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
private int? _aEffacer;
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
private IReadOnlyList<PretDto> Historique =>
@@ -333,29 +279,6 @@
await OnChangement.InvokeAsync();
}
private async Task EffacerAsync(PretDto pret)
{
_occupe = true;
_erreur = null;
var erreur = await Api.SupprimerPretAsync(pret.Id);
_occupe = false;
_aEffacer = null;
if (erreur is not null)
{
_erreur = erreur;
return;
}
await ChargerAsync();
// Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle
// doit donc se relire, comme après un retour.
await OnChangement.InvokeAsync();
}
private static string Jour(DateTime instant) =>
instant.ToLocalTime().ToString("dd/MM/yyyy");
-280
View File
@@ -1,280 +0,0 @@
@*
Un champ qui remplit une LISTE : on écrit une valeur, Entrée la range, et chaque ligne se
modifie ou se retire ensuite.
⚠️ Il remplace partout le « champ unique à séparateur » (thèmes, articles à la une), qui
était intapable : relié à chaque frappe sur une propriété qui découpait puis recomposait la
ligne, il élaguait le point-virgule tapé (entrée vide) et rognait l'espace qui le suit. Le
seul caractère qu'on ne pouvait pas y taper était son propre séparateur. Voir CLAUDE.md.
⚠️ Le composant écrit dans une liste PRÊTÉE par son parent : il doit donc le prévenir
(OnChangement), sinon tout ce que le parent calcule à partir d'elle reste figé.
*@
<div class="champ">
<label class="champ-libelle" for="@_idChamp">
@Libelle
@if (!string.IsNullOrWhiteSpace(Aide))
{
<span class="champ-aide">@Aide</span>
}
</label>
<div class="ligne-creation">
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
@bind:event="oninput" @onkeydown="SurTouche"
@onfocus="() => _suggestionsOuvertes = true"
placeholder="@Placeholder" autocomplete="off" />
<button type="button" class="bouton bouton-compact"
disabled="@(string.IsNullOrWhiteSpace(_saisie))" @onclick="Valider">
@(_indexModifie is null ? "Ajouter" : "Enregistrer")
</button>
@if (_indexModifie is not null)
{
<button type="button" class="bouton bouton-compact bouton-discret"
@onclick="Annuler">
Annuler
</button>
}
</div>
@if (_message is not null)
{
<p class="message-discret" role="status">@_message</p>
}
@*
Ce qui existe déjà ailleurs, proposé dès le focus — un thème est un VOCABULAIRE, il ne
vaut que réutilisé : « fantasy » et « Fantaisie » saisis séparément ne regroupent rien.
⚠️ La liste ne se referme PAS au blur, et c'est délibéré : le blur précède le clic sur
une proposition, et la faire disparaître à ce moment-là rend les propositions
incliquables — le piège classique de l'autocomplétion. Elle se referme quand on a choisi.
*@
@if (Proposees.Count > 0)
{
<div class="suggestions">
@foreach (var suggestion in Proposees)
{
<button type="button" class="segment" @key="suggestion"
@onclick="() => Choisir(suggestion)">
@suggestion
</button>
}
</div>
}
@if (Valeurs.Count > 0)
{
<ul class="liste-saisie">
@for (var rang = 0; rang < Valeurs.Count; rang++)
{
@* La copie locale est indispensable : sans elle, les fermetures des boutons
captureraient la variable de boucle, donc son état final. *@
var index = rang;
var valeur = Valeurs[rang];
<li class="ligne-saisie" @key="index">
<span class="ligne-saisie-valeur">@valeur</span>
@* Ce que le parent veut poser sur la ligne — les rôles d'un auteur, par
exemple. Rien par défaut. *@
@Complement?.Invoke(index)
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
aria-label="@($"Modifier « {valeur} »")"
@onclick="() => Modifier(index)">
Modifier
</button>
<button type="button" class="bouton bouton-compact bouton-danger"
aria-label="@($"Supprimer « {valeur} »")"
@onclick="() => Supprimer(index)">
Supprimer
</button>
</div>
</li>
}
</ul>
}
</div>
@code {
/// <summary>La liste remplie, modifiée <b>en place</b> : c'est celle du parent.</summary>
[Parameter, EditorRequired] public IList<string> Valeurs { get; set; } = default!;
[Parameter, EditorRequired] public string Libelle { get; set; } = default!;
[Parameter] public string? Aide { get; set; }
[Parameter] public string? Placeholder { get; set; }
/// <summary>Message affiché quand la valeur tapée est déjà dans la liste.</summary>
[Parameter] public string MessageDoublon { get; set; } = "C'est déjà dans la liste.";
/// <summary>
/// Ce que le parent ajoute à chaque ligne — les rôles d'un auteur, par exemple.
/// </summary>
[Parameter] public RenderFragment<int>? Complement { get; set; }
/// <summary>
/// Valeurs déjà employées ailleurs, proposées sous le champ. <c>null</c> = aucune proposition.
/// </summary>
/// <remarks>
/// ⚠️ N'a de sens que pour un <b>vocabulaire</b> — les thèmes, qui ne valent que réutilisés.
/// Elle n'en a aucun pour des valeurs uniques à leur fiche (titres d'articles à la une, noms
/// d'auteurs d'un livre donné), où elle ne ferait que du bruit.
/// </remarks>
[Parameter] public IReadOnlyList<string>? Suggestions { get; set; }
/// <summary>
/// Prévient le parent que la liste a changé.
/// </summary>
/// <remarks>
/// ⚠️ Sans cela, un bouton du parent qui dépend de la liste reste figé : Blazor ne redessine
/// que le composant qui a traité l'événement.
/// </remarks>
[Parameter] public EventCallback OnChangement { get; set; }
private readonly string _idChamp = $"liste-{Guid.NewGuid():N}";
/// <summary>Valeur en cours de frappe. <b>Elle n'est reliée à rien d'autre</b>.</summary>
private string? _saisie;
/// <summary>Rang de la valeur qu'on modifie — <c>null</c> = on en ajoute une nouvelle.</summary>
private int? _indexModifie;
private string? _message;
/// <summary>Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse.</summary>
private bool _suggestionsOuvertes;
/// <summary>Nombre de propositions affichées : au-delà, la liste cache le formulaire.</summary>
private const int MaximumPropose = 8;
/// <summary>
/// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à
/// ce qu'on tape.
/// </summary>
private IReadOnlyList<string> Proposees
{
get
{
if (!_suggestionsOuvertes || Suggestions is null)
{
return [];
}
var deja = Valeurs.Select(NormalisationTexte.Normaliser).ToHashSet(StringComparer.Ordinal);
var terme = NormalisationTexte.Normaliser(_saisie);
return
[
.. Suggestions
.Where(s => !deja.Contains(NormalisationTexte.Normaliser(s)))
.Where(s => terme.Length == 0
|| NormalisationTexte.Normaliser(s).Contains(terme, StringComparison.Ordinal))
.Take(MaximumPropose),
];
}
}
private void Choisir(string suggestion)
{
_saisie = suggestion;
Valider();
}
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
private IList<string>? _precedente;
/// <summary>
/// Le parent a changé de liste (autre fiche, formulaire vidé après enregistrement) : ce qui
/// restait dans le champ appartenait à la précédente.
/// </summary>
protected override void OnParametersSet()
{
if (!ReferenceEquals(_precedente, Valeurs))
{
_precedente = Valeurs;
RemettreAZero();
}
}
private void RemettreAZero()
{
_saisie = null;
_indexModifie = null;
_message = null;
_suggestionsOuvertes = false;
}
/// <remarks>
/// ⚠️ Cette touche ne remonte pas au parent : celui-ci enregistre la fiche entière, et l'on
/// serait bien en peine de saisir deux valeurs.
/// </remarks>
private void SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
Valider();
}
}
private void Valider()
{
var valeur = _saisie?.Trim();
if (string.IsNullOrEmpty(valeur))
{
return;
}
// Doublon au sens de la normalisation, comme partout ailleurs : le libellé déjà rangé
// est celui qui reste.
var cle = NormalisationTexte.Normaliser(valeur);
var deja = Valeurs.ToList().FindIndex(v => NormalisationTexte.Normaliser(v) == cle);
if (deja >= 0 && deja != _indexModifie)
{
_message = MessageDoublon;
return;
}
if (_indexModifie is { } index)
{
Valeurs[index] = valeur;
}
else
{
Valeurs.Add(valeur);
}
RemettreAZero();
Notifier();
}
/// <summary>Reprend une valeur dans le champ : on la corrige là où on l'a écrite.</summary>
private void Modifier(int index)
{
_message = null;
_indexModifie = index;
_saisie = Valeurs[index];
}
private void Annuler() => RemettreAZero();
private void Supprimer(int index)
{
Valeurs.RemoveAt(index);
// La valeur qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont
// glissé : reprendre la modification écrirait sur la mauvaise.
RemettreAZero();
Notifier();
}
private void Notifier() => _ = OnChangement.InvokeAsync();
}
-49
View File
@@ -1,49 +0,0 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Composants;
/// <summary>
/// Saisie du panneau « Modifier en lot » (fiche série).
/// </summary>
/// <remarks>
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
/// </remarks>
public sealed class SaisieLot
{
public bool ModifierType { get; set; }
public TypeDocument TypeDocument { get; set; }
public bool ModifierThemes { get; set; }
public List<string> Themes { get; set; } = [];
public bool ModifierAuteurs { get; set; }
public List<AuteurSaisi> Auteurs { get; set; } = [];
public bool ModifierFormat { get; set; }
public Format Format { get; set; }
public bool ModifierEditeur { get; set; }
public string? Editeur { get; set; }
/// <summary>
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
/// </summary>
public bool ModifierStatut { get; set; }
public Statut? Statut { get; set; }
public bool AucunChamp =>
!ModifierType && !ModifierThemes && !ModifierAuteurs
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
}
@@ -1,16 +1,15 @@
@*
Scan du code-barres ISBN par la caméra.
Le décodage se fait dans wwwroot/js/scanner-camera.js, par zbar compilé en WebAssembly.
⚠️ Il était en C# (ZXing.Net) jusqu'au 2026-08-19 : voir CLAUDE.md, ZXing.Net ne lisait pas
des codes-barres que zbar lit sur le même livre. Ce composant ne voit plus passer un seul
pixel — seulement la valeur décodée.
Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage
est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md.
On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée
(voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la
main, et cela ménage la batterie du téléphone.
*@
@using System.Diagnostics
@using MaBibli.Client.Scan
@using MaBibli.Shared.Isbn
@implements IAsyncDisposable
@inject IJSRuntime JS
@@ -45,9 +44,9 @@
@if (_framesDecodees > 0)
{
@* Mesure sur l'appareil de l'utilisateur. ⚠️ Elle porte maintenant sur la capture,
le décodage zbar ET l'aller-retour d'interopérabilité : elle n'est pas comparable
aux chiffres ZXing.Net de CLAUDE.md, qui ne mesuraient que le décodage C#. *@
@* Mesure réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation
(~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté
sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@
<p class="message-discret scanner-mesure">
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
</p>
@@ -61,7 +60,7 @@
@code {
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
[Parameter] public EventCallback<CodeScanne> OnCodeDetecte { get; set; }
[Parameter] public EventCallback<string> OnCodeDetecte { get; set; }
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
[Parameter] public EventCallback OnAnnuler { get; set; }
@@ -77,13 +76,12 @@
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
private const int IntervalleMs = 80;
// L'image est réduite avant décodage. Elle ne traverse plus le pont JS→C# — c'est zbar,
// côté JS, qui la lit — mais 960 px de large restent un bon compromis entre le détail
// nécessaire à un EAN-13 dense et le temps de décodage.
//
// ⚠️ Plus large qu'avec ZXing.Net (640 px), et surtout l'image ENTIÈRE est analysée : le
// recadrage sur la bande centrale n'existait que pour alléger le transfert de pixels.
private const int LargeurMax = 960;
// La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser
// le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13.
private const int LargeurMax = 640;
// Seule la bande centrale est décodée : c'est là que l'utilisateur vise.
private const double PartHauteur = 0.45;
private ElementReference _video;
private IJSObjectReference? _module;
@@ -167,9 +165,16 @@
return;
}
var dimensions = await _module.InvokeAsync<int[]?>("capturer", LargeurMax, PartHauteur);
if (dimensions is null)
{
continue; // vidéo pas encore prête
}
var pixels = await _module.InvokeAsync<byte[]>("pixels");
var depart = Stopwatch.GetTimestamp();
var lecture = await _module.InvokeAsync<LectureCodeBarres?>("decoder", LargeurMax);
var code = lecture?.Code;
var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]);
_totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds;
_framesDecodees++;
@@ -193,7 +198,7 @@
}
await ArreterCameraAsync();
await OnCodeDetecte.InvokeAsync(new CodeScanne(isbn, lecture?.Parution));
await OnCodeDetecte.InvokeAsync(isbn);
return;
}
}
+23 -194
View File
@@ -2,50 +2,11 @@
@implements IDisposable
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@inject CacheHorsLigne Cache
@*
Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
Le PC hérite de la même colonne, simplement centrée et limitée en largeur.
⚠️ La navigation est passée EN HAUT le 2026-08-20, à la demande de l'utilisateur, après
que la barre d'onglets du bas se soit affichée « toujours très mal » sur son téléphone.
Cela renverse la décision du 2026-08-18 (« en bas, le pouce atteint le bas de l'écran ») :
le raisonnement sur le pouce reste juste, mais il ne vaut rien face à une barre qui ne
s'affiche pas correctement. Le repli retenu est celui du gabarit Blazor par défaut — un
bouton bascule et une liste qui se déploie — parce qu'il ne dépend d'AUCUNE mise en page
exotique : sans la moindre feuille de style, il reste une suite de liens lisibles les uns
sous les autres, au lieu d'une rangée écrasée.
*@
@*
⚠️ Bandeau et menu sont dans un MÊME conteneur collant, et ce n'est pas cosmétique : sur PC
la rangée du menu doit rester atteignable au défilement (lot R), et deux éléments collants
superposés obligeraient à écrire en dur la hauteur du bandeau dans le `top` du second.
*@
<div class="entete">
@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
<header class="bandeau">
@* Le déplacement entre écrans était jugé « foireux » : d'un écran profond (bibliographie,
fiche de tome, ajout d'envie) il fallait deviner quel onglet ramenait en arrière. Un
retour explicite, au même endroit sur tous les écrans, répond à la question sans
obliger à connaître l'arborescence. *@
<button type="button" class="bandeau-icone" @onclick="Retour"
title="Revenir à l'écran précédent" aria-label="Revenir à l'écran précédent">
<span aria-hidden="true">&#8592;</span>
</button>
<a class="marque" href="">
@* Pictogramme dessiné en ligne plutôt que chargé depuis /logo-bandeau.svg : un fichier
séparé se sert (ou non) indépendamment de l'application, et c'est précisément ce qui
produisait l'icône cassée en haut à gauche — un appareil dont le cache datait d'avant
l'ajout du fichier recevait un 404. Un SVG en ligne ne peut pas manquer. *@
<svg class="marque-logo" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<polygon points="32,15 10,20 10,47 32,44" fill="currentColor" />
<polygon points="32,15 54,20 54,47 32,44" fill="currentColor" />
<line x1="32" y1="15" x2="32" y2="44" stroke="#1b3a5c" stroke-width="1.5" />
</svg>
<span class="marque-nom">MaBibli</span>
</a>
<a class="marque" href="">MaBibli</a>
@if (!Reseau.EnLigne)
{
@@ -60,72 +21,8 @@
@_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "")
</span>
}
<button type="button" class="bandeau-icone bandeau-bascule" @onclick="BasculerMenu"
aria-expanded="@Aria.Etat(_menuOuvert)" aria-controls="menu-principal"
aria-label="@(_menuOuvert ? "Fermer le menu" : "Ouvrir le menu")">
<span aria-hidden="true">@(_menuOuvert ? "\u2715" : "\u2630")</span>
</button>
</header>
@*
Les six destinations de l'application. Sur PC elles tiennent en une rangée sous le
bandeau ; sur téléphone elles occupent l'écran ENTIER au clic sur la bascule (lot R) —
déployé sous le bandeau, le menu partageait l'écran avec la liste qu'on quittait, et l'on
choisissait sa destination au milieu d'autre chose.
⚠️ Un calque plein écran sans porte de sortie est un piège : trois en sont offertes, en plus
de la fermeture déjà en place sur LocationChanged — la croix, la touche Échap (le calque est
focalisé à l'ouverture, sans quoi aucun keydown ne lui parviendrait, comme celui
d'agrandissement des couvertures) et le clic hors des liens.
Les entrées restent actives hors-ligne : les six écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.
*@
<nav id="menu-principal" class="menu @(_menuOuvert ? "menu-ouvert" : null)" aria-label="Navigation principale"
tabindex="-1" @ref="_menu" @onclick="FermerMenu" @onkeydown="SurToucheMenu">
@if (_menuOuvert)
{
<div class="menu-entete">
<span class="menu-titre">Aller à</span>
<button type="button" class="menu-fermer" @onclick="FermerMenu" aria-label="Fermer le menu">
<span aria-hidden="true">&#10005;</span>
</button>
</div>
}
@*
Une rafale non vidée reste consultable ailleurs sur le site — sans quoi y revenir
depuis un autre écran demande de refaire tout le chemin d'« Ajouter ». L'entrée
disparaît d'elle-même une fois la file effacée (bouton « Nouvelle rafale »).
*@
@if (_rafale is { Total: > 0 })
{
<NavLink class="menu-lien menu-lien-rafale" href="ajout/rafale">@LibelleRafale</NavLink>
}
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
<NavLink class="menu-lien" href="series">Séries</NavLink>
<NavLink class="menu-lien" href="revues">Revues</NavLink>
<NavLink class="menu-lien" href="prets">Prêts</NavLink>
<NavLink class="menu-lien" href="souhaits">Envies</NavLink>
@*
« À propos » n'est PAS une septième destination : c'est une annexe, et elle est
visuellement détachée pour cela. Les six entrées ci-dessus sont la bibliothèque ; celle-ci
porte le manuel, le contact, la version et la licence — on y va une fois, pas tous les
jours. Mise sur le même rang, elle diluerait une navigation qu'on venait justement de
reprendre. Elle reste dans le menu plutôt qu'en pied de page : sur téléphone, un pied de
page vit sous une liste de trois cents livres.
⚠️ Elle reste active hors-ligne, comme les six autres : la version qu'on vient y lire est
justement ce qu'on cherche quand quelque chose ne va pas.
*@
<NavLink class="menu-lien menu-lien-annexe" href="a-propos">À propos</NavLink>
</nav>
</div>
@if (!Reseau.EnLigne)
{
@*
@@ -146,37 +43,36 @@
@Body
</main>
@*
Navigation permanente, en bas de l'écran : quatre onglets, toujours au même endroit.
Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq
boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne
figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS.
⚠️ Le pouce atteint le bas de l'écran, pas le haut : c'est ce qui justifie le bas plutôt
qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus
(variable --mb-onglets-hauteur, partagée avec .actions-flottantes).
Les onglets restent actifs hors-ligne : les quatre écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, pas la navigation.
*@
<nav class="onglets" aria-label="Navigation principale">
<NavLink class="onglet" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="onglet" href="auteurs">Auteurs</NavLink>
<NavLink class="onglet" href="prets">Prêts</NavLink>
<NavLink class="onglet" href="souhaits">Envies</NavLink>
</nav>
@code {
private UtilisateurCourant? _utilisateur;
private bool _etaitEnLigne = true;
private bool _synchroEnCours;
/// <summary>Menu déployé (téléphone). Sur PC la rangée est visible en permanence.</summary>
private bool _menuOuvert;
/// <summary>Le calque du menu doit prendre le focus au prochain rendu — sinon pas d'Échap.</summary>
private bool _menuAFocaliser;
private ElementReference _menu;
/// <summary>Rafale non vidée, s'il y en a une. Relue à chaque navigation (lot AB).</summary>
private Rafale? _rafale;
private string LibelleRafale =>
_rafale is { Crees: > 0 } r ? $"Compte rendu ({r.Crees})" : "Rafale à reprendre";
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
Reseau.SynchroChange += SurSynchro;
// ⚠️ Sans cela, le menu resterait déployé par-dessus l'écran qu'on vient d'atteindre :
// NavLink ne referme rien de lui-même, et un clic sur « Auteurs » laisserait les six
// entrées empilées au-dessus de la liste des auteurs.
Navigation.LocationChanged += SurNavigation;
_rafale = await Cache.LireRafaleAsync();
// Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit
// aller directement au cache, sans attendre l'échec d'une requête.
await Reseau.DemarrerAsync();
@@ -243,76 +139,9 @@
/// <summary>La date affichée vient de changer : rien à recharger, juste à redessiner.</summary>
private void SurSynchro() => _ = InvokeAsync(StateHasChanged);
private void SurNavigation(object? _, LocationChangedEventArgs __)
{
if (_menuOuvert)
{
_menuOuvert = false;
}
// ⚠️ Relu à CHAQUE navigation, pas seulement au quitter de l'écran de rafale : c'est ce
// qui fait apparaître l'entrée du menu dès le premier code scanné, et disparaître dès
// la file effacée, où qu'on soit rendu entre-temps.
_ = InvokeAsync(async () =>
{
_rafale = await Cache.LireRafaleAsync();
StateHasChanged();
});
}
/// <summary>
/// Remonte d'un cran dans l'arborescence des écrans, en repliant le menu au passage.
/// </summary>
/// <remarks>
/// ⚠️ <b>Renverse la décision du 2026-08-20</b> (« le retour passe par l'historique du
/// navigateur, jamais par une destination calculée »). L'historique remonte aussi les
/// allers-retours — filtre, ordre, édition — et l'on cliquait cinq fois sans quitter le même
/// écran. La parenté est désormais explicite et testable : voir
/// <see cref="RemonteeRoutes"/>, qui porte aussi le garde-fou « ne jamais sortir de
/// l'application » que <c>history.length</c> tenait mal dans une PWA <c>standalone</c>.
/// </remarks>
private void Retour()
{
FermerMenu();
Navigation.NavigateTo(RemonteeRoutes.Parent(Navigation.ToBaseRelativePath(Navigation.Uri)));
}
private void BasculerMenu()
{
_menuOuvert = !_menuOuvert;
// Le focus n'est demandé qu'à l'ouverture : c'est lui qui rend Échap opérant sur un
// calque, et il n'y a rien à focaliser une fois le menu refermé.
_menuAFocaliser = _menuOuvert;
}
/// <summary>
/// Referme le menu. Posé sur le calque lui-même : un clic hors des liens ferme, et un clic
/// SUR un lien ferme aussi — la navigation qui suit s'en chargerait de toute façon.
/// </summary>
private void FermerMenu() => _menuOuvert = false;
private void SurToucheMenu(KeyboardEventArgs e)
{
if (e.Key is "Escape" or "Esc")
{
FermerMenu();
}
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_menuAFocaliser)
{
_menuAFocaliser = false;
await _menu.FocusAsync();
}
}
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Reseau.SynchroChange -= SurSynchro;
Navigation.LocationChanged -= SurNavigation;
}
}
+50 -78
View File
@@ -1,10 +1,12 @@
/* Ce n'est plus le bandeau qui colle en haut, mais le conteneur `.entete` qui le réunit au
menu (feuille globale, lot R) : les deux doivent rester solidaires au défilement. */
.bandeau {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1b3a5c;
color: #fff;
}
@@ -14,62 +16,15 @@
font-size: 1.15rem;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
/* La marque prend la place restante : c'est elle qui repousse la bascule à droite,
sans dépendre d'un justify-content que le nombre d'éléments du bandeau ferait varier
(la pastille hors-ligne et le nom d'utilisateur vont et viennent). */
flex: 1 1 auto;
min-width: 0;
}
.marque-nom {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.marque-logo {
width: 1.3rem;
height: 1.3rem;
flex: 0 0 auto;
color: #fff;
}
/* Boutons du bandeau (retour, bascule du menu). Cibles tactiles de 2.25rem : on les touche
au pouce, souvent en marchant. */
.bandeau-icone {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
font-size: 1.15rem;
line-height: 1;
color: #fff;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 6px;
cursor: pointer;
}
.bandeau-icone:hover,
.bandeau-icone:focus-visible {
background: rgba(255, 255, 255, 0.15);
}
.utilisateur {
flex: 0 1 auto;
font-size: 0.8rem;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40%;
max-width: 55%;
}
/* L'état du réseau change ce que l'application permet : il se voit dans le bandeau, en
@@ -103,34 +58,51 @@
width: 100%;
max-width: 46rem;
margin: 0 auto;
/* Marge basse généreuse : la barre d'actions flottantes ne doit pas masquer le dernier
livre de la liste. */
padding: 1rem 1rem 6rem;
/* Marge basse généreuse : ni les onglets ni les actions flottantes ne doivent masquer
le dernier livre de la liste. */
padding: 1rem 1rem 9rem;
}
/*
Les styles du MENU ne sont PAS ici, mais dans `wwwroot/css/app.css`.
/* --- Onglets de navigation --- */
Deux raisons, la première étant un vrai bug corrigé le 2026-08-20 :
.onglets {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: flex;
background: #fff;
border-top: 1px solid var(--mb-bord);
padding-bottom: env(safe-area-inset-bottom);
1. `NavLink` est un COMPOSANT. L'isolation CSS ne pose son attribut de portée (`b-xxxx`)
que sur les éléments écrits dans le balisage du composant courant jamais sur ce que
rend un composant enfant. `.menu-lien` déclaré ici ne pouvait donc atteindre AUCUN des
six liens : le `<nav>` recevait bien son fond sombre, et les liens restaient en bleu
souligné par défaut. C'est exactement ce que l'utilisateur voyait (« toujours les liens
trop moches et pas ressemblant à un menu »). Le gabarit Blazor par défaut contourne cela
avec `::deep`.
2. Une feuille globale ne dépend pas du bundle `MaBibli.Client.styles.css`, qui est
précisément le fichier qu'un cache a déjà servi périmé ou pas du tout (voir « lot A1 »
et « L'icône cassée du bandeau » dans CLAUDE.md). La navigation est ce qui doit le moins
pouvoir tomber.
*/
@media (min-width: 40rem) {
/* La rangée du menu est visible en permanence à partir de cette largeur : la bascule
n'a plus rien à déployer. Le point de rupture est répété dans `app.css` les deux
doivent rester identiques. */
.bandeau-bascule {
display: none;
}
/* Hauteur figée et partagée : .actions-flottantes s'en sert pour se poser juste au-dessus. */
height: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom));
}
.onglet {
flex: 1 1 0;
padding: 0.4rem 0.25rem;
line-height: 1.4;
text-align: center;
text-decoration: none;
font-size: 0.85rem;
color: var(--mb-texte-doux);
/* Le trait actif est posé en haut de l'onglet, du côté du contenu qu'il désigne. */
border-top: 3px solid transparent;
/* Le libellé ne doit jamais passer sur deux lignes : quatre onglets tiennent sur 320 px. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* NavLink pose « active » tout seul ; « Catalogue » utilise NavLinkMatch.All, sans quoi
il resterait allumé sur toutes les pages son href est la racine. */
.onglet.active {
color: var(--mb-accent);
font-weight: 600;
border-top-color: var(--mb-accent);
background: var(--mb-fond-doux);
}
+2 -14
View File
@@ -18,20 +18,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all" />
<!--
Décodage des codes-barres : moteur zbar (LGPL-2.1) compilé en WebAssembly.
⚠️ Le paquet est référencé pour ses ASSETS STATIQUES (_content/ZBar.Blazor/zbar.js et
zbar.wasm), pas pour ses composants Blazor. Son composant ZBarCamera ouvre lui-même la
caméra et AVALE les erreurs (« catch { console.log(error) } ») : on perdrait les messages
qui distinguent permission refusée, absence de caméra, caméra occupée et contexte non
sécurisé — précisément ce que CLAUDE.md documente comme durement acquis. On garde donc
notre scanner-camera.js et on n'emprunte que le décodeur.
Remplace ZXing.Net, qui ne lisait pas certains codes-barres pourtant lus par zbar sur le
même livre et le même appareil (voir CLAUDE.md).
-->
<PackageReference Include="ZBar.Blazor" Version="1.1.0" />
<!-- Décodage EAN-13 en C# (Apache 2.0). Le JS ne fournit que les pixels de la caméra. -->
<PackageReference Include="ZXing.Net" Version="0.16.11" />
</ItemGroup>
<ItemGroup>
-223
View File
@@ -1,223 +0,0 @@
@page "/a-propos"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject IJSRuntime JS
@implements IDisposable
@*
Ce que l'application dit d'elle-même : à qui écrire, où trouver de l'aide, quelle version
tourne réellement, et sous quelle licence elle est distribuée.
⚠️ Ce n'est pas une page d'ornement. Elle porte la version, c'est-à-dire la première chose
qu'on demande à quelqu'un dont l'application se comporte bizarrement — le projet a déjà connu
un appareil au cache dépareillé, où seule une version affichée aurait tranché. Et elle porte
le lien vers la source, que l'AGPL v3 attend qu'un service en réseau offre à ses utilisateurs.
*@
<PageTitle>MaBibli — à propos</PageTitle>
<h1 class="titre-page">À propos</h1>
<section class="bloc-apropos">
<h2 class="titre-section">Manuel</h2>
@*
Le manuel vit hors de l'application, sur le site de l'auteur : il se corrige et s'illustre
sans qu'il faille republier une version. Même règle que les autres liens sortants — un
<a> ne se désactivant pas, hors-ligne il devient un bouton inerte qui porte sa raison,
plutôt qu'une navigation qui abandonnerait l'utilisateur sur la page d'erreur du navigateur.
*@
@if (Reseau.EnLigne)
{
<p>
<a href="@Manuel" target="_blank" rel="noopener">Comment utiliser MaBibli</a>
</p>
}
else
{
<p>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Comment utiliser MaBibli</button>
</p>
}
<p class="message-discret">
Chaque écran porte par ailleurs ses explications à l'endroit où elles servent.
</p>
</section>
<section class="bloc-apropos">
<h2 class="titre-section">Contact</h2>
@*
⚠️ Le « mailto: » reste un lien même hors-ligne, et c'est délibéré : il ne charge aucune
page, il passe la main au client de messagerie de l'appareil, lequel sait mettre un
message en attente d'envoi. Le désactiver empêcherait précisément d'écrire « ça ne marche
pas, je n'ai plus de réseau ».
*@
<p><a href="mailto:info@limonier.be">info@limonier.be</a></p>
@*
Le site, lui, est une vraie navigation vers l'extérieur : hors-ligne elle finirait sur la
page d'erreur du navigateur, hors de l'application. Un <a> ne se désactivant pas, il
bascule en bouton inerte portant sa raison — même règle que les liens d'export des envies.
*@
@if (Reseau.EnLigne)
{
<p>
<a href="@Site" target="_blank" rel="noopener">www.limonier.be</a>
</p>
}
else
{
<p>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">www.limonier.be</button>
</p>
}
</section>
<section class="bloc-apropos">
<h2 class="titre-section">Version</h2>
@if (_version is null)
{
<p class="message-discret">Chargement…</p>
}
else if (_version.Publiee)
{
<p>
<strong>@_version.Numero</strong>
<span class="message-discret"> — compilée @Quand(_version.DateBuild!.Value)</span>
</p>
@if (!Reseau.EnLigne)
{
<p class="message-discret">
Hors ligne : c'est la dernière version annoncée par le serveur, pas forcément
celle qui y tourne en ce moment.
</p>
}
@*
⚠️ Ce numéro vient du SERVEUR (voir plus haut) : il ne prouve pas que le navigateur a
lui-même récupéré la nouvelle version. Le service worker n'active un nouveau cache
qu'une fois TOUS les onglets fermés — un appareil qui reste ouvert en arrière-plan
(téléphone surtout) peut rester bloqué sur l'ancienne version indéfiniment. Ce bouton
court-circuite cette attente : désinscrire le worker et vider son cache, recharger.
Hors-ligne il est désactivé — le déclencher sans réseau couperait l'accès hors-ligne
en cours, sans rien pouvoir retélécharger à la place.
*@
<p>
@if (Reseau.EnLigne)
{
<button type="button" class="bouton bouton-discret" disabled="@_forcageEnCours"
@onclick="ForcerMiseAJourAsync">
@(_forcageEnCours ? "Mise à jour…" : "Forcer la mise à jour")
</button>
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Forcer la mise à jour</button>
}
</p>
<p class="message-discret">
Vide le cache de l'application et la recharge entièrement — à utiliser si une nouvelle
version tarde à apparaître malgré la fermeture de tous les onglets.
</p>
}
else
{
@*
⚠️ Ne JAMAIS afficher ici le « 1.0.0 » que le SDK .NET pose par défaut : il se lirait
comme une vraie version. Mieux vaut ne rien annoncer que d'annoncer un faux numéro,
surtout sur la valeur qui sert à diagnostiquer.
*@
<p class="message-discret">
Version de développement : ce binaire n'a pas été produit par la chaîne de
publication, il n'a donc pas de numéro de version à annoncer.
</p>
}
</section>
<section class="bloc-apropos">
<h2 class="titre-section">Licence</h2>
<p>
MaBibli est distribuée sous licence <strong>GNU AGPL v3</strong>.
</p>
@*
Le lien vers le dépôt n'est pas un ornement : l'AGPL demande que les utilisateurs d'un
service accessible par le réseau puissent en obtenir le code source.
*@
@if (Reseau.EnLigne)
{
<p>
Son code source est disponible :
<a href="@Depot" target="_blank" rel="noopener">@Depot</a>
</p>
}
else
{
<p>
Son code source est disponible à l'adresse <code>@Depot</code>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Ouvrir le dépôt</button>
</p>
}
</section>
@code {
/// <summary>Site de l'auteur, ouvert dans une autre fenêtre.</summary>
private const string Site = "https://www.limonier.be";
/// <summary>Manuel d'utilisation, hébergé avec les autres tutoriels du site.</summary>
private const string Manuel = "https://limonier.be/tuto/utiliser_mabibli/";
/// <summary>Dépôt du code — ce que l'AGPL attend qu'on rende accessible.</summary>
private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
private VersionApplication? _version;
private bool _forcageEnCours;
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
_version = await Api.ObtenirVersionAsync();
}
/// <summary>
/// Au retour du réseau, la version se redemande : hors-ligne on affichait la dernière connue.
/// </summary>
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
_version = await Api.ObtenirVersionAsync();
StateHasChanged();
});
/// <summary>
/// Désinscrit le service worker et vide son cache avant de recharger — voir js/forcer-maj.js.
/// Le bouton reste désactivé pendant l'opération : un double clic déclencherait deux
/// désinscriptions concurrentes pour un seul rechargement.
/// </summary>
private async Task ForcerMiseAJourAsync()
{
_forcageEnCours = true;
try
{
await using var module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/forcer-maj.js");
await module.InvokeVoidAsync("forcerMiseAJour");
}
catch
{
// Le rechargement forcé qui suit normalement l'appel JS ne survient pas ici : on
// rend la main pour que l'utilisateur puisse réessayer plutôt que de rester sur un
// bouton grisé pour rien.
_forcageEnCours = false;
}
}
private static string Quand(DateTimeOffset instant)
{
var local = instant.ToLocalTime();
return $"le {local:dd/MM/yyyy} à {local:HH:mm}";
}
public void Dispose() => Reseau.Change -= SurChangementReseau;
}
+36 -208
View File
@@ -1,28 +1,18 @@
@page "/ajout"
@page "/ajout/isbn"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — ajouter un ouvrage</PageTitle>
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
@*
Entrée UNIQUE d'ajout, atteinte depuis le catalogue comme depuis les revues.
Le geste réel est « j'ai un truc avec un code-barres », pas « je vais cataloguer un livre » :
obliger à savoir d'avance ce qu'on tient était un détour, d'autant que le décodage sait
déjà distinguer un 978/979 (livre) d'un 977 (publication en série). Le modèle, lui, ne
bouge pas : une revue reste une Revue, jamais un Livre — voir CLAUDE.md.
*@
<h1 class="titre-page">Ajouter un ouvrage</h1>
<h1 class="titre-page">Ajouter par ISBN</h1>
@if (_etape == Etape.Saisie)
{
<p class="message-discret">
Scannez le code-barres, ou saisissez le code imprimé sur l'ouvrage — livre ou
magazine, l'application reconnaît lequel. Une douchette USB fonctionne telle quelle :
le champ est déjà actif.
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
Une douchette USB fonctionne telle quelle : le champ est déjà actif.
</p>
@*
@@ -67,15 +57,7 @@
@onclick="OuvrirScanner">
Scanner
</button>
@* Deux saisies manuelles, parce qu'il faut bien trancher soi-même quand aucun code
n'est là pour le faire : c'est le seul endroit où l'entrée unique doit poser la
question. *@
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
@* Une pile de livres et une douchette : on collecte d'abord, on traite ensuite.
Reste ici, et non dans le menu : c'est une façon d'ajouter, pas une destination. *@
<a class="bouton bouton-discret" href="ajout/rafale">Cataloguer en rafale</a>
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
</div>
}
@@ -86,10 +68,9 @@
*@
@if (_etape == Etape.Recherche)
{
@* L1 : l'étape existait déjà pour que l'enchaînement du scan SE VOIE ; la rondelle dit en
plus que ça travaille encore, ce qu'un texte figé ne distingue pas d'un écran bloqué. *@
<Patience Classe="attente-recherche"
Message="@($"Recherche de {FormatageIsbn.Afficher(_isbn)} à la BnF, puis chez OpenLibrary…")" />
<p class="message-discret" role="status">
Recherche de <span class="code-isbn">@FormatageIsbn.Afficher(_isbn)</span> à la BnF, puis chez OpenLibrary…
</p>
}
@if (_etape == Etape.Scan)
@@ -105,20 +86,6 @@
@if (_messagePeriodique is not null)
{
<p class="message-avertissement" role="status">@_messagePeriodique</p>
@* Une revue a désormais sa place : sa fiche, où l'on ajoute le numéro qu'on vient de
scanner. Avant, ce code retombait sur la saisie manuelle d'un LIVRE — ce qu'un magazine
n'est pas. *@
@if (_periodique is not null)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirLaRevueAsync">
@(_enregistrement ? "Ouverture…" : "Ouvrir la fiche de cette revue")
</button>
</div>
}
}
@foreach (var avertissement in _avertissements)
@@ -178,30 +145,17 @@
@if (_etape == Etape.Formulaire)
{
@* Le doublon prend toute la place tant qu'il n'est pas tranché : la fiche revient intacte
si l'on renonce. C'est ici que le cas se présente le plus — scanner deux fois le même
livre est précisément ce qui crée les fiches en double. *@
@if (_doublons is { } doublons)
{
<AvertissementDoublon Doublons="doublons"
EnCours="_enregistrement"
OnConfirmer="ConfirmerAsync"
OnRenoncer="() => _doublons = null" />
}
else
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
MessageBlocage="@MotifBlocage"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
}
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
MessageBlocage="@MotifBlocage"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
}
@code {
@@ -216,10 +170,6 @@
private bool _focusAFaire = true;
private string? _messagePeriodique;
private PeriodiqueDetecte? _periodique;
/// <summary>Add-on EAN-2 du dernier scan, quand il y en avait un. Voir <see cref="CodeScanne"/>.</summary>
private string? _parutionLue;
private bool _chargement;
private bool _enregistrement;
private string? _erreur;
@@ -227,31 +177,8 @@
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private EnregistrementLivre _saisie = new();
private DoublonsLivre? _doublons;
/// <summary>
/// Code transmis par un autre écran — un résidu de rafale à trancher.
/// </summary>
/// <remarks>
/// ⚠️ La recherche s'enchaîne d'elle-même, comme après un scan : arriver ici depuis un
/// compte rendu de rafale signifie qu'on a déjà décidé de reprendre ce code. Demander un
/// clic de plus sur « Chercher » serait le détour que cet écran documente déjà comme un
/// défaut (voir l'étape « Recherche »).
/// </remarks>
[SupplyParameterFromQuery(Name = "code")]
public string? CodePropose { get; set; }
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
if (!string.IsNullOrWhiteSpace(CodePropose))
{
_isbn = CodePropose.Trim();
}
}
private bool _repriseFaite;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
@@ -263,29 +190,6 @@
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
// ⚠️ AVANT le garde de focus ci-dessous, qui rend la main dès que le focus est posé :
// greffée après, la reprise ne serait jouée qu'une fois sur deux, selon l'étape.
//
// ⚠️ Et surtout PAS conditionnée à `premierRendu` : au premier rendu, l'état réseau
// n'est pas encore établi (il s'initialise de façon asynchrone), donc `EnLigne` est
// faux et la reprise ne partait jamais. Constaté à l'écran — le champ se préremplissait
// bien, la recherche ne s'enchaînait pas. C'est `_repriseFaite` qui garantit l'unicité,
// pas le premier rendu ; l'événement réseau provoque le rendu où la condition passe.
if (!_repriseFaite && !string.IsNullOrWhiteSpace(CodePropose) && Reseau.EnLigne)
{
_repriseFaite = true;
await ChercherAsync();
// ⚠️ `StateHasChanged` est INDISPENSABLE ici, et son absence ne se voit pas :
// `OnAfterRenderAsync` n'est pas un gestionnaire d'événement, donc Blazor ne
// redessine rien de lui-même. La recherche partait bien — sonde à l'appui — et
// remplissait `_candidats`, mais l'écran restait sur l'étape de saisie.
// Même famille que « un composant qui écrit dans un objet prêté doit prévenir son
// parent » : le symptôme trompe, on cherche un appel qui n'a pas eu lieu là où il
// n'y a qu'un rendu manquant.
StateHasChanged();
}
if (_etape != Etape.Saisie || !_focusAFaire)
{
return;
@@ -314,8 +218,6 @@
{
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_parutionLue = null;
_avertissements = [];
_etape = Etape.Scan;
}
@@ -323,15 +225,9 @@
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
// ne retape jamais ce qui vient d'être scanné, et n'a aucun bouton à confirmer.
// Le code lu reste dans le champ : si le décodage était mauvais, il se corrige et se relance.
private async Task SurCodeDetecteAsync(CodeScanne lecture)
private async Task SurCodeDetecteAsync(string isbn)
{
_isbn = lecture.Code;
// Gardé de côté pour la seule chose qui sache quoi en faire : la fiche d'une revue.
// Sur un livre, l'add-on EAN-5 (le prix) n'est même pas remonté, et l'EAN-2 n'a pas
// de sens.
_parutionLue = lecture.Parution;
_isbn = isbn;
await ChercherAsync();
}
@@ -339,9 +235,6 @@
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
{
// Saisi à la main ou à la douchette : aucun add-on, et surtout pas celui d'un
// scan précédent, qui se retrouverait proposé pour un autre magazine.
_parutionLue = null;
await ChercherAsync();
}
}
@@ -351,7 +244,6 @@
_chargement = true;
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_avertissements = [];
_etape = Etape.Recherche;
@@ -408,75 +300,33 @@
}
/// <summary>
/// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
/// </summary>
/// <remarks>
/// ⚠️ <b>Ne remplit surtout pas le formulaire d'un livre</b>, ce qu'il faisait tant que les
/// revues n'avaient pas de modèle : un magazine n'est pas un livre, et ranger un code
/// <c>977</c> dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur la fiche.
/// <para>
/// Le <b>numéro</b> se saisit ensuite à la main : les deux chiffres de parution du
/// code-barres ne sont pas un numéro fiable, et l'add-on EAN-2 qui le porterait vraiment
/// n'a pas pu être vérifié sur un magazine réel.
/// </para>
/// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est
/// pré-rempli, mais <b>pas l'ISBN</b> — le code-barres d'une revue n'en est pas un, et le
/// ranger dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche.
/// </remarks>
private void DecrireLeMagazine(PeriodiqueDetecte revue)
{
var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
_periodique = revue;
_messagePeriodique =
$"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : "
+ "il commence par 977, réservé aux publications en série. "
+ "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner.";
+ "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, "
+ "sans ISBN.";
_erreur = null;
RevenirALaSaisie();
}
/// <summary>
/// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore.
/// </summary>
/// <remarks>
/// Le serveur fait « créer ou retrouver » : scanner le numéro suivant du même magazine
/// retombe sur la même fiche, ce qui est exactement le geste attendu.
/// <para>
/// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que
/// l'utilisateur corrigera sur la fiche.
/// </para>
/// </remarks>
private async Task OuvrirLaRevueAsync()
{
if (_periodique is not { } revue)
_saisie = new EnregistrementLivre
{
return;
}
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
{
Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}",
Issn = revue.Issn,
Titre = revue.Titre ?? string.Empty,
Editeur = revue.Editeur,
});
Format = Format.Physique,
Statut = Statut.ALire,
};
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
// Le numéro lu sur l'add-on EAN-2 suit la navigation : la fiche le proposera dans un
// champ modifiable. On ne l'enregistre pas soi-même — voir CodeScanne.
var numero = string.IsNullOrWhiteSpace(_parutionLue)
? string.Empty
: $"?numero={Uri.EscapeDataString(_parutionLue)}";
Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}{numero}");
_erreur = null;
_etape = Etape.Formulaire;
}
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
@@ -494,12 +344,7 @@
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
// Proposé, jamais imposé : la notice donne une phrase (« 1 vol. (349 p.) »), le
// champ reste modifiable et se vide d'un geste si elle a mal été lue.
NombrePages = candidat.NombrePages,
CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
Format = Format.Physique,
Statut = Statut.ALire,
};
@@ -512,37 +357,20 @@
{
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_erreurFormulaire = null;
_doublons = null;
_candidats = [];
RevenirALaSaisie();
}
private Task EnregistrerAsync() => AjouterAsync(false);
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
private Task ConfirmerAsync() => AjouterAsync(true);
private async Task AjouterAsync(bool confirmerDoublon)
private async Task EnregistrerAsync()
{
_enregistrement = true;
_erreurFormulaire = null;
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
var resultat = await Api.CreerAsync(_saisie);
_enregistrement = false;
// Rien n'a été écrit : le catalogue contient déjà quelque chose de semblable, et c'est
// à l'utilisateur de dire s'il s'agit du même livre. Voir AvertissementDoublon.
if (resultat.Doublons is { } doublons)
{
_doublons = doublons;
return;
}
_doublons = null;
if (!resultat.EstOk)
{
_erreurFormulaire = resultat.Erreur;
+10 -60
View File
@@ -12,52 +12,18 @@
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
</p>
@*
Le formulaire s'efface pendant qu'on tranche le doublon : la saisie n'est pas perdue pour
autant, elle attend dans « _saisie » et revient telle quelle si l'on renonce. Laisser les
deux à l'écran donnerait deux boutons d'ajout, dont un seul confirme.
*@
@if (_doublons is { } doublons)
{
<AvertissementDoublon Doublons="doublons"
EnCours="_enregistrement"
OnConfirmer="ConfirmerAsync"
OnRenoncer="() => _doublons = null" />
}
else
{
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
}
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
@code {
private readonly EnregistrementLivre _saisie = new();
/// <summary>
/// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît.
/// </summary>
/// <remarks>
/// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire
/// <b>vide</b> : il fallait retaper le code qu'on venait de scanner, c'est-à-dire refaire
/// exactement ce que la rafale existe pour éviter. Le lien compilait et ne servait à rien.
/// </remarks>
[SupplyParameterFromQuery(Name = "isbn")]
public string? IsbnPropose { get; set; }
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
if (!string.IsNullOrWhiteSpace(IsbnPropose))
{
_saisie.Isbn = IsbnPropose.Trim();
}
}
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
@@ -65,32 +31,16 @@ else
private bool _enregistrement;
private string? _erreur;
private DoublonsLivre? _doublons;
private Task EnregistrerAsync() => AjouterAsync(false);
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
private Task ConfirmerAsync() => AjouterAsync(true);
private async Task AjouterAsync(bool confirmerDoublon)
private async Task EnregistrerAsync()
{
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
var resultat = await Api.CreerAsync(_saisie);
_enregistrement = false;
// Un doublon signalé n'est pas un échec : rien n'a été écrit, et la même saisie
// confirmée s'enregistrera telle quelle.
if (resultat.Doublons is { } doublons)
{
_doublons = doublons;
return;
}
_doublons = null;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
-741
View File
@@ -1,741 +0,0 @@
@page "/ajout/rafale"
@inject ServiceLivresApi Api
@inject CacheHorsLigne Cache
@inject EtatReseau Reseau
@inject NavigationManager Nav
@implements IDisposable
<PageTitle>MaBibli — cataloguer en rafale</PageTitle>
<h1>Cataloguer en rafale</h1>
@*
Lancée depuis une saga, la rafale rattache chaque livre créé à la série. ⚠️ La cible vit
dans la FILE (Rafale.SerieId), pas dans cet écran : une rafale se reprend plus tard, et la
reprise doit rattacher au même endroit.
*@
@if (SerieVisee is { } visee)
{
<p class="aide">
Les livres créés seront ajoutés à la série
<a href="@($"series/{visee.Id}")"><strong>@visee.Titre</strong></a>.
</p>
}
@*
Le geste visé : une pile de livres et une douchette. Celle-ci se présente au système comme
un CLAVIER — elle tape les chiffres puis « Entrée ». Or dans une zone de texte multiligne,
« Entrée » fait un saut de ligne : la rafale se collecte donc toute seule, sans mécanique
dédiée. C'est ce qui rend cet écran bien moins coûteux qu'une file interactive.
*@
@if (_erreur is { } erreur)
{
<p class="message-erreur" role="alert">@erreur</p>
}
@if (_repriseProposee)
{
@*
⚠️ Sans cette proposition, la file existerait en base et personne ne la retrouverait :
on rescannerait tout. C'est la contrepartie directe de « reprendre plus tard ».
*@
<div class="encart-reprise">
@*
⚠️ La file est proposée MÊME TERMINÉE (2026-09-08). Elle ne l'était que s'il restait
à traiter ou des résidus : une rafale entièrement réussie devenait donc inatteignable
alors qu'elle dormait dans IndexedDB, et la liste des livres créés — avec ses liens
vers chaque fiche — était perdue au premier changement d'écran.
*@
@if (_rafale.ResteATraiter || _rafale.Residus.Count > 0)
{
<p>
Une rafale de <strong>@_rafale.Total code@(_rafale.Total > 1 ? "s" : "")</strong>
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
</p>
}
else
{
<p>
Votre dernière rafale est terminée :
<strong>@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</strong>.
Son compte rendu, et les liens vers chaque fiche, sont encore là.
</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" @onclick="Reprendre">
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
</button>
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0
? "Abandonner cette rafale"
: "Commencer une nouvelle rafale")
</button>
</div>
</div>
}
else if (_phase == Phase.Collecte)
{
<p class="aide">
Scannez les codes-barres à la suite : chaque lecture ajoute une ligne. Vous pouvez aussi
les taper ou les coller, un par ligne. Rien n'est envoyé avant que vous ne lanciez le
traitement.
</p>
@*
⚠️ La zone est focalisée à l'ouverture, comme le champ ISBN de l'écran d'ajout : une
douchette « tape » dès qu'on scanne, et sans le focus le premier code partait dans le
vide. C'est une étape de moins, et c'est celle qu'on oublie en ayant les mains prises.
⚠️ Le focus ne se reprend pas à chaque rendu (`_focusAFaire`) : il serait arraché à
chaque frappe, et la zone se réécrit à chaque caractère.
*@
<div class="champ">
<label for="codes">Codes scannés</label>
<textarea id="codes" class="champ-saisie zone-codes" rows="10"
@ref="_zoneCodes"
placeholder="9782070612758&#10;9782253004226&#10;…"
@bind="_saisie" @bind:event="oninput"></textarea>
</div>
<p class="aide" aria-live="polite">
@{
var codes = SaisieRafale.Decouper(_saisie).Count;
}
@if (codes == 0)
{
<span>Aucun code pour l'instant.</span>
}
else
{
<span><strong>@codes</strong> code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.</span>
}
</p>
@*
Cochée par défaut : cataloguer cinquante livres ne doit pas être interrompu cinquante
fois. ⚠️ Mais le compte rendu DIT combien ont été passés — posséder deux exemplaires est
légitime, et passer en silence contredirait cet esprit.
*@
<label class="case-a-cocher">
<input type="checkbox" @bind="_passerLesDoublons" />
Passer les livres déjà au catalogue
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(!Reseau.EnLigne || SaisieRafale.Decouper(_saisie).Count == 0)"
title="@MotifBlocage"
@onclick="LancerAsync">
Traiter @(SaisieRafale.Decouper(_saisie).Count > 0 ? $"({SaisieRafale.Decouper(_saisie).Count})" : "")
</button>
</div>
@if (!Reseau.EnLigne)
{
@*
La COLLECTE marche hors-ligne, pas le traitement : c'est le seul endroit du projet
où l'on accumule quelque chose sans réseau. ⚠️ Ce ne sont pas des écritures en
attente, ce sont des codes à interroger — rien n'est promis à la base.
*@
<p class="aide">
Vous pouvez scanner sans réseau : la liste est conservée sur cet appareil. Le
traitement, lui, interroge la BnF et attendra le retour du réseau.
</p>
}
}
else if (_phase == Phase.Traitement)
{
<h2 class="titre-section">Traitement en cours</h2>
<div class="avancement" role="progressbar"
aria-valuenow="@_rafale.Pourcentage" aria-valuemin="0" aria-valuemax="100"
aria-label="Avancement du traitement">
<div class="avancement-barre" style="width: @(_rafale.Pourcentage)%"></div>
</div>
<p aria-live="polite">
<strong>@_rafale.Pourcentage %</strong> — @_rafale.Traites sur @_rafale.Total
@if (_codeEnCours is { } encours)
{
<span class="aide"> · @FormatageIsbn.Afficher(encours)</span>
}
</p>
<div class="actions-formulaire">
<button type="button" class="bouton" @onclick="() => _interrompu = true">
Interrompre
</button>
</div>
<p class="aide">
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
</p>
}
else
{
<h2 class="titre-section">Compte rendu</h2>
<ul class="bilan">
<li><strong>@_rafale.Crees</strong> livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</li>
@if (_rafale.Passes > 0)
{
@*
⚠️ Se COMPTE, ne se tait pas : sans cette ligne on croirait avoir ajouté
cinquante livres alors qu'on en a ajouté quarante-deux.
*@
<li><strong>@_rafale.Passes</strong> déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")</li>
}
@if (_rafale.Residus.Count > 0)
{
<li><strong>@_rafale.Residus.Count</strong> à regarder ci-dessous</li>
}
</ul>
@*
⚠️ La liste des livres ajoutés est la moitié utile du compte rendu : sans elle, on sait
qu'on a créé vingt fiches et l'on n'a aucun moyen de les retrouver autrement qu'en
fouillant le catalogue à la main. Or c'est juste après la rafale qu'on veut les
compléter — un scan ne donne ni type de document, ni thèmes, ni série.
Chaque ligne mène donc à sa fiche, et un bouton mène droit à son écran d'édition.
*@
@if (Ajoutes.Count > 0)
{
<h2 class="titre-section">Livres ajoutés</h2>
<ul class="liste-residus">
@foreach (var ligne in Ajoutes)
{
<li class="carte-residu" @key="ligne.Code">
<div class="residu-tete">
<a class="carte-titre" href="@($"livres/{ligne.LivreId}")">
@(string.IsNullOrWhiteSpace(ligne.Titre) ? "Fiche du livre" : ligne.Titre)
</a>
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
</div>
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
{
<p class="aide">@ligne.Motif</p>
}
<div class="actions-tome">
<a class="bouton bouton-compact" href="@($"livres/{ligne.LivreId}/edition")">
Compléter la fiche
</a>
</div>
</li>
}
</ul>
}
@if (_rafale.ResteATraiter)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
@onclick="TraiterAsync">
Continuer (@(_rafale.Total - _rafale.Traites) restant@(_rafale.Total - _rafale.Traites > 1 ? "s" : ""))
</button>
</div>
}
@if (_rafale.Residus.Count > 0)
{
<h2 class="titre-section">À reprendre</h2>
<ul class="liste-residus">
@foreach (var ligne in _rafale.Residus)
{
<li class="carte-residu" @key="ligne.Code">
<div class="residu-tete">
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
<span class="etiquette @ClasseEtat(ligne.Etat)">@Libelle(ligne.Etat)</span>
</div>
@if (!string.IsNullOrWhiteSpace(ligne.Titre))
{
<p class="carte-titre">@ligne.Titre</p>
}
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
{
<p class="aide">@ligne.Motif</p>
}
<div class="actions-tome">
@*
Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que
de réimplémenter ici le choix d'édition ou la fiche revue.
*@
<a class="bouton bouton-compact" href="@LienDeReprise(ligne)">
@(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre")
</a>
<button type="button" class="bouton bouton-compact"
@onclick="() => RetirerAsync(ligne)">
Retirer de la liste
</button>
</div>
</li>
}
</ul>
}
<p class="aide">
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
vidée.
</p>
<div class="actions-formulaire">
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
@if (SerieVisee is { } retour)
{
<a class="bouton bouton-principal" href="@($"series/{retour.Id}")">
Revenir à @retour.Titre
</a>
}
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
Terminer et vider la liste
</button>
</div>
}
@code {
private enum Phase { Collecte, Traitement, Bilan }
/// <summary>
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
/// </summary>
/// <remarks>
/// ⚠️ Ce paramètre ne sert qu'à <b>amorcer</b> la file : une fois lancée, c'est
/// <c>Rafale.SerieId</c> qui commande, sans quoi une reprise ouverte depuis une autre
/// adresse rattacherait ailleurs.
/// </remarks>
[SupplyParameterFromQuery(Name = "serie")]
public int? SerieId { get; set; }
private SerieDto? _serie;
private Phase _phase = Phase.Collecte;
private Rafale _rafale = new();
private string _saisie = string.Empty;
private bool _passerLesDoublons = true;
private bool _repriseProposee;
private bool _interrompu;
private string? _codeEnCours;
private string? _erreur;
/// <summary>
/// ISBN déjà au catalogue, sous leurs DEUX formes.
/// </summary>
/// <remarks>
/// ⚠️ Lu une seule fois par traitement, et volontairement : le catalogue ne bouge que par
/// nos propres créations, qu'on y ajoute au fil de l'eau.
/// </remarks>
private HashSet<string> _isbnDuCatalogue = new(StringComparer.Ordinal);
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.</summary>
private SerieDto? SerieVisee => _serie;
private int? CibleSerie => _rafale.SerieId ?? SerieId;
/// <summary>
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
/// </summary>
/// <remarks>
/// Lu depuis l'instantané des séries, donc sans coût ni dépendance au réseau. Une lecture
/// ratée ne retire rien : le rattachement, lui, ne dépend pas de ce nom.
/// </remarks>
private async Task ChargerSerieAsync()
{
if (CibleSerie is not { } id)
{
_serie = null;
return;
}
try
{
_serie = (await Api.ListerSeriesAsync()).FirstOrDefault(s => s.Id == id);
}
catch (Exception)
{
_serie = null;
}
}
/// <summary>Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.</summary>
private ElementReference _zoneCodes;
private bool _focusAFaire = true;
/// <summary>Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.</summary>
private IReadOnlyList<CodeRafale> Ajoutes =>
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
/// <remarks>
/// ⚠️ Une seule fois par entrée dans l'étape de collecte : reprendre le focus à chaque rendu
/// arracherait le curseur en pleine frappe, et la zone se réécrit à chaque caractère.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusAFaire && _phase == Phase.Collecte && !_repriseProposee)
{
_focusAFaire = false;
await _zoneCodes.FocusAsync();
}
}
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurReseau;
// ⚠️ Une rafale inachevée doit se proposer d'elle-même : sans cela, elle existe dans
// IndexedDB et personne ne la retrouve.
// ⚠️ Toute file non vidée est proposée, terminée ou non : c'est le seul chemin de retour
// vers la liste des livres qu'une rafale vient de créer.
var reprise = await Cache.LireRafaleAsync();
if (reprise is { Total: > 0 })
{
_rafale = reprise;
_passerLesDoublons = reprise.PasserLesDoublons;
_repriseProposee = true;
}
await ChargerSerieAsync();
}
public void Dispose() => Reseau.Change -= SurReseau;
private void SurReseau() => InvokeAsync(StateHasChanged);
private void Reprendre()
{
_repriseProposee = false;
_phase = Phase.Bilan;
}
private async Task AbandonnerAsync()
{
await Cache.EffacerRafaleAsync();
_rafale = new Rafale();
_saisie = string.Empty;
_repriseProposee = false;
_phase = Phase.Collecte;
// On revient à une zone vide : c'est une nouvelle rafale, elle mérite le même focus
// que la première.
_focusAFaire = true;
// ⚠️ La file effacée emportait sa série : sans cette relecture, le bandeau continuait
// d'annoncer « les livres seront ajoutés à la série X » pour une rafale neuve qui,
// ouverte sans paramètre d'adresse, ne rattachera nulle part.
await ChargerSerieAsync();
}
private async Task LancerAsync()
{
var codes = SaisieRafale.Decouper(_saisie);
if (codes.Count == 0)
{
return;
}
_rafale = new Rafale
{
Codes = [.. codes.Select(c => new CodeRafale { Code = c })],
PasserLesDoublons = _passerLesDoublons,
SerieId = SerieId,
};
await Cache.EcrireRafaleAsync(_rafale);
await TraiterAsync();
}
/// <summary>
/// Traite la file, un code à la fois.
/// </summary>
/// <remarks>
/// ⚠️ <b>Un par un, délibérément.</b> Cinquante livres font jusqu'à cent requêtes — chaque
/// ISBN est cherché en 13 puis en 10 — et c'est le SERVEUR qui appelle la BnF. Les lancer
/// ensemble lui ferait ouvrir cent connexions vers <c>catalogue.bnf.fr</c>. Séquentiel, la
/// progression se voit et une coupure ne perd que le code en cours.
/// <para>
/// La file est réécrite <b>après chaque code</b> : c'est ce qui rend la reprise exacte.
/// </para>
/// </remarks>
private async Task TraiterAsync()
{
_phase = Phase.Traitement;
_interrompu = false;
_erreur = null;
await ChargerIsbnDuCatalogueAsync();
for (var i = 0; i < _rafale.Codes.Count; i++)
{
if (_interrompu || !Reseau.EnLigne)
{
break;
}
if (_rafale.Codes[i].Etat != EtatCodeRafale.ATraiter)
{
continue; // Déjà traité : une reprise ne refait jamais ce qui est fait.
}
_codeEnCours = _rafale.Codes[i].Code;
StateHasChanged();
_rafale.Codes[i] = await TraiterUnAsync(_rafale.Codes[i]);
await Cache.EcrireRafaleAsync(_rafale);
StateHasChanged();
}
_codeEnCours = null;
_phase = Phase.Bilan;
}
/// <summary>
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
/// </summary>
/// <remarks>
/// ⚠️ <b>Les deux formes sont indispensables</b> : un livre saisi avant 2007 porte un
/// ISBN-10 en base, alors qu'un scanner lit toujours un EAN-13. Ne comparer qu'une forme
/// ferait rescanner tout le fonds ancien comme s'il était neuf — c'est le même piège que
/// celui de la recherche BnF, à l'autre bout de la chaîne.
/// </remarks>
private async Task ChargerIsbnDuCatalogueAsync()
{
try
{
var livres = await Api.ListerAsync(new CritereLivres());
_isbnDuCatalogue = new HashSet<string>(StringComparer.Ordinal);
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType<string>())
{
_isbnDuCatalogue.Add(isbn);
if (IsbnUtils.TryConvertirEnIsbn10(isbn, out var court) && court is not null)
{
_isbnDuCatalogue.Add(court);
}
}
}
catch (Exception)
{
// Sans cette liste, on retombe simplement sur la détection du serveur à la
// création : plus lente, mais jamais fausse.
_isbnDuCatalogue = [];
}
}
private bool DejaAuCatalogue(string code)
{
if (_isbnDuCatalogue.Contains(code))
{
return true;
}
return IsbnUtils.TryConvertirEnIsbn10(code, out var court)
&& court is not null
&& _isbnDuCatalogue.Contains(court);
}
private async Task<CodeRafale> TraiterUnAsync(CodeRafale code)
{
// ⚠️ AVANT le lookup, et avant le choix d'édition. Constaté à l'écran : sans ce test,
// un livre déjà possédé dont la BnF rend trois notices ressortait « à choisir » — on
// demandait de trancher l'édition d'un livre qu'on allait de toute façon passer.
// Épargne au passage une requête BnF par livre déjà catalogué.
if (_rafale.PasserLesDoublons && DejaAuCatalogue(code.Code))
{
return code with
{
Etat = EtatCodeRafale.Passe,
Motif = "Déjà au catalogue.",
};
}
ResultatLookupIsbn? lookup;
try
{
lookup = await Api.ChercherIsbnAsync(code.Code);
}
catch (Exception)
{
return code with
{
Etat = EtatCodeRafale.Echec,
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
};
}
if (lookup is null)
{
return code with
{
Etat = EtatCodeRafale.Echec,
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
};
}
// Un préfixe 977 est un périodique : il porte un ISSN, donc un titre de revue, et son
// numéro se saisit à la main. Il ne peut pas suivre le chemin des livres.
if (lookup.Periodique is { } revue)
{
return code with
{
Etat = EtatCodeRafale.Revue,
Titre = revue.Titre,
Motif = "Magazine : le numéro de parution se saisit à la main.",
};
}
if (lookup.Candidats.Count == 0)
{
return code with
{
Etat = EtatCodeRafale.Introuvable,
Motif = "Aucune source ne connaît ce code. À saisir à la main.",
};
}
// ⚠️ Plusieurs notices : on NE choisit PAS à la place de l'utilisateur. C'est la règle
// actée « notices multiples : demander systématiquement », et elle vaut ici aussi —
// sauf qu'on demande à la fin, pas au milieu de la rafale.
if (lookup.Candidats.Count > 1)
{
return code with
{
Etat = EtatCodeRafale.AChoisir,
Titre = lookup.Candidats[0].Titre,
Motif = $"{lookup.Candidats.Count} éditions possibles : à choisir.",
};
}
var candidat = lookup.Candidats[0];
// Même construction que l'écran d'ajout unitaire : `Format.Physique` parce qu'un livre
// scanné est un objet qu'on tient, et `Statut.ALire` parce qu'on vient de l'acquérir.
var saisie = new EnregistrementLivre
{
Isbn = code.Code,
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
NombrePages = candidat.NombrePages,
CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
Format = Format.Physique,
Statut = Statut.ALire,
};
var resultat = await Api.CreerAsync(saisie);
if (resultat.Doublons is { } doublons)
{
// Passer est le défaut, mais cela se compte : voir le compte rendu.
return _rafale.PasserLesDoublons
? code with
{
Etat = EtatCodeRafale.Passe,
Titre = candidat.Titre,
Motif = doublons.Message,
}
: code with
{
Etat = EtatCodeRafale.AChoisir,
Titre = candidat.Titre,
Motif = doublons.Message,
};
}
if (resultat.Livre is { } livre)
{
// Un code scanné deux fois dans la MÊME rafale est déjà écarté par la découpe ;
// ceci couvre le cas de deux codes différents qui désignent le même livre.
_isbnDuCatalogue.Add(code.Code);
return code with
{
Etat = EtatCodeRafale.Cree,
Titre = livre.Titre,
LivreId = livre.Id,
Motif = await RattacherAsync(livre),
};
}
return code with
{
Etat = EtatCodeRafale.Echec,
Titre = candidat.Titre,
Motif = resultat.Erreur ?? "L'enregistrement n'a pas abouti.",
};
}
/// <summary>
/// Range le livre créé dans la série visée, s'il y en a une.
/// </summary>
/// <remarks>
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : <b>le livre existe</b>, et le
/// redire « à traiter » le recréerait à la reprise. On le dit dans le compte rendu, où la
/// ligne mène déjà à sa fiche — c'est là qu'on répare, en un geste.
/// </remarks>
private async Task<string?> RattacherAsync(LivreDto livre)
{
if (_rafale.SerieId is not { } serieId)
{
return null;
}
var resultat = await Api.AjouterElementSerieAsync(
serieId, new AjoutElementSerie { LivreId = livre.Id });
return resultat.EstOk
? null
: "Livre enregistré, mais il n'a pas pu être ajouté à la série.";
}
private async Task RetirerAsync(CodeRafale code)
{
_rafale.Codes.RemoveAll(c => c.Code == code.Code);
await Cache.EcrireRafaleAsync(_rafale);
}
/// <summary>
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
/// </summary>
/// <remarks>
/// ⚠️ On ne réimplémente ici ni le choix d'édition, ni la fiche revue, ni la saisie
/// manuelle : trois copies d'un écran existant divergeraient.
/// </remarks>
private static string LienDeReprise(CodeRafale code) => code.Etat switch
{
EtatCodeRafale.Introuvable => $"/ajout/manuel?isbn={code.Code}",
_ => $"/ajout/isbn?code={code.Code}",
};
private static string Libelle(EtatCodeRafale etat) => etat switch
{
EtatCodeRafale.AChoisir => "Plusieurs éditions",
EtatCodeRafale.Introuvable => "Introuvable",
EtatCodeRafale.Revue => "Magazine",
EtatCodeRafale.Echec => "Échec",
_ => string.Empty,
};
private static string ClasseEtat(EtatCodeRafale etat) => etat switch
{
EtatCodeRafale.Revue => "etiquette-type",
EtatCodeRafale.Echec => "etiquette-manquant",
_ => "etiquette-prete",
};
}
-109
View File
@@ -1,109 +0,0 @@
@page "/revues/ajout"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
<PageTitle>MaBibli — saisir une revue</PageTitle>
<h1 class="titre-page">Saisir une revue</h1>
@*
Le pendant de « Saisir un livre à la main » : ce qu'on fait quand le code-barres est absent,
abîmé, ou que la BnF ne connaît pas le titre. L'entrée normale reste « Ajouter un ouvrage »,
qui scanne et reconnaît un code 977 tout seul.
*@
<p class="message-discret">
L'entrée habituelle est <a href="ajout">Ajouter un ouvrage</a> : le code-barres d'un
magazine commence par <span class="code-isbn">977</span> et l'application le reconnaît
seule. Ce formulaire sert quand il n'y a pas de code à lire.
</p>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">
Hors ligne : l'enregistrement passe par le serveur. Il redeviendra possible au retour
du réseau.
</p>
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
<label class="champ">
<span class="champ-libelle">Titre<span class="obligatoire"> *</span></span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput"
placeholder="Médor" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">
ISSN<span class="champ-aide"> (facultatif — huit chiffres, imprimé près du code-barres ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur<span class="champ-aide"> (facultatif)</span></span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput"
autocomplete="off" />
</label>
<p class="champ-aide">
Les numéros s'ajoutent ensuite sur la fiche de la revue : une fiche par revue, les numéros
à l'intérieur.
</p>
<nav class="actions-flottantes">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="EnregistrerAsync">
@(_enCours ? "Enregistrement…" : "Créer la revue")
</button>
</nav>
@code {
private string _titre = string.Empty;
private string _issn = string.Empty;
private string _editeur = string.Empty;
private bool _enCours;
private string? _erreur;
/// <summary>
/// Crée la revue — ou retrouve celle qui existe déjà — puis ouvre sa fiche.
/// </summary>
/// <remarks>
/// Le serveur fait « créer ou retrouver » sur l'ISSN <b>ou</b> le titre : saisir deux fois
/// la même revue mène à la même fiche au lieu d'un refus. C'est aussi ce qui permet à une
/// revue saisie ici d'apprendre son ISSN au premier scan.
/// <para>
/// On enchaîne sur la fiche plutôt que de revenir à la liste : le geste n'est jamais
/// « créer une revue », c'est toujours « ranger un numéro ».
/// </para>
/// </remarks>
private async Task EnregistrerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
{
Titre = _titre,
Issn = string.IsNullOrWhiteSpace(_issn) ? null : _issn,
Editeur = string.IsNullOrWhiteSpace(_editeur) ? null : _editeur,
});
_enCours = false;
if (!resultat.EstOk || resultat.Valeur is not { } revue)
{
_erreur = resultat.Erreur ?? "La revue n'a pas pu être créée.";
return;
}
Navigation.NavigateTo($"revues/{revue.Id}");
}
}
+4 -191
View File
@@ -27,14 +27,12 @@
}
<div class="onglets-secondaires" role="group" aria-label="Manière de chercher">
<button type="button" class="segment @(_mode == Mode.Isbn ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
<button type="button" class="segment @(_mode == Mode.Titre ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Titre)">Par titre</button>
<button type="button" class="segment @(_mode == Mode.Isbn ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
<button type="button" class="segment @(_mode == Mode.Manuel ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Manuel)">À la main</button>
<button type="button" class="segment @(_mode == Mode.Revue ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Revue)">Une revue</button>
</div>
@if (_mode == Mode.Titre)
@@ -78,32 +76,9 @@
title="@MotifBlocage" @onclick="ChercherParIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
@*
J2 : le cas d'usage est direct — le livre est en main, en librairie. C'est le même
composant de scan que l'écran d'ajout au catalogue, sans création de Livre au bout :
un souhait n'est pas une possession.
*@
<button type="button" class="bouton bouton-discret"
disabled="@(_scan || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirScanner">
Scanner
</button>
</div>
}
@if (_scan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="() => _scan = false" />
}
@* L1 : la BnF met environ une seconde à répondre. Sans indicateur, l'écran ne dit rien et
l'on reclique — donc on relance l'appel. *@
@if (_recherche)
{
<Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
@@ -123,16 +98,6 @@
Choisissez celle qui correspond, ou complétez à la main.
</p>
<div class="champ champ-tri">
<label class="champ-libelle" for="tri-resultats">Trier par</label>
<select id="tri-resultats" class="champ-saisie" value="@_tri"
@onchange="e => ChangerTri(e.Value?.ToString())">
<option value="@TriResultats.Date">Date (récent en premier)</option>
<option value="@TriResultats.Titre">Titre</option>
<option value="@TriResultats.Editeur">Éditeur</option>
</select>
</div>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
@@ -200,59 +165,14 @@
</div>
}
@if (_mode == Mode.Revue)
{
@*
⚠️ Aucune recherche ici, et ce n'est pas un manque : l'ISSN désigne un TITRE de
périodique, jamais une parution. Interroger la BnF ne dirait donc rien du numéro, qui est
justement ce qu'on souhaite. On note ce qu'on a vu en kiosque.
*@
<p class="message-discret">
Une revue entière, ou un numéro précis. Le numéro peut rester vide : « je voudrais
m'abonner à Médor » est une envie comme une autre.
</p>
<div class="champ">
<label class="champ-libelle" for="titre-revue">Titre de la revue <span class="obligatoire">*</span></label>
<input id="titre-revue" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_saisieRevue.Titre" @bind:event="oninput" placeholder="Médor" />
</div>
<div class="champ">
<label class="champ-libelle" for="numero-revue">Numéro</label>
<input id="numero-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Numero" @bind:event="oninput" placeholder="43, hors-série 7…" />
</div>
<div class="champ">
<label class="champ-libelle" for="issn-revue">ISSN</label>
<input id="issn-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Issn" @bind:event="oninput" placeholder="2466-6718" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-revue">Note</label>
<input id="note-revue" class="champ-saisie" type="text"
@bind="_saisieRevue.Note" @bind:event="oninput" placeholder="« vu chez le dentiste »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieRevue.Titre))"
title="@MotifBlocage" @onclick="AjouterRevueAsync">
Ajouter à ma liste
</button>
</div>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="souhaits">Retour à ma liste</a>
</nav>
@code {
private enum Mode { Titre, Isbn, Manuel, Revue }
private enum Mode { Titre, Isbn, Manuel }
private Mode _mode = Mode.Isbn;
private Mode _mode = Mode.Titre;
private ElementReference _premierChamp;
private bool _focusAFaire = true;
@@ -261,15 +181,10 @@
private string _isbn = string.Empty;
private EnregistrementSouhait _saisie = new();
private EnregistrementRevueSouhaitee _saisieRevue = new();
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private TriResultats _tri = TriResultats.Date;
private bool _recherche;
private bool _ajout;
/// <summary>Le scanner est ouvert. Il ne l'est que depuis la voie « par ISBN ».</summary>
private bool _scan;
private string? _erreur;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
@@ -280,13 +195,6 @@
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private enum TriResultats
{
Date,
Titre,
Editeur,
}
/// <summary>
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
/// </summary>
@@ -313,43 +221,13 @@
}
}
private void OuvrirScanner()
{
_erreur = null;
_candidats = [];
_avertissements = [];
_scan = true;
}
/// <summary>
/// Enchaîne la recherche sur le code lu, sans bouton à confirmer.
/// </summary>
/// <remarks>
/// Le code reste dans le champ : un décodage erroné se corrige et se relance. C'est la
/// décision déjà actée pour l'écran d'ajout au catalogue, et la seule cohérente avec une
/// douchette, qui envoie <c>Entrée</c> d'elle-même.
/// <para>
/// ⚠️ L'add-on EAN-2 (numéro de parution) est ignoré ici, et ce n'est pas un oubli : il ne
/// concerne que les revues, qui ne se souhaitent pas — cet écran alimente une liste
/// d'<i>œuvres</i>. Un code de périodique est d'ailleurs intercepté plus bas et nommé.
/// </para>
/// </remarks>
private async Task SurCodeDetecteAsync(CodeScanne lecture)
{
_scan = false;
_isbn = lecture.Code;
await ChercherParIsbnAsync();
}
private void Basculer(Mode mode)
{
_scan = false;
_mode = mode;
_focusAFaire = true;
_erreur = null;
_candidats = [];
_avertissements = [];
_tri = TriResultats.Date;
}
private async Task SurTouche(KeyboardEventArgs e)
@@ -406,7 +284,6 @@
}
_candidats = resultat.Candidats;
_candidats = Trier(_candidats);
if (_candidats.Count == 0)
{
@@ -419,52 +296,6 @@
}
}
private void ChangerTri(string? valeur)
{
if (!Enum.TryParse<TriResultats>(valeur, out var tri))
{
return;
}
_tri = tri;
_candidats = Trier(_candidats);
}
private IReadOnlyList<CandidatLivre> Trier(IEnumerable<CandidatLivre> candidats) =>
_tri switch
{
TriResultats.Titre => candidats
.OrderBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Editeur, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
TriResultats.Editeur => candidats
.OrderBy(c => c.Editeur ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
_ => candidats
.OrderByDescending(c => AnneeTriable(c.Annee))
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(),
};
private static int AnneeTriable(string? date)
{
if (string.IsNullOrWhiteSpace(date))
{
return 0;
}
for (var i = 0; i + 4 <= date.Length; i++)
{
if (int.TryParse(date.AsSpan(i, 4), out var annee) && annee is >= 1000 and <= 2999)
{
return annee;
}
}
return 0;
}
/// <summary>
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
/// </summary>
@@ -502,22 +333,4 @@
// Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
Navigation.NavigateTo("souhaits");
}
private async Task AjouterRevueAsync()
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterRevueSouhaiteeAsync(_saisieRevue);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("souhaits");
}
}
+5 -204
View File
@@ -67,136 +67,27 @@ else if (_auteurs.Count == 0)
}
else
{
@*
Recherche et tri d'une seule pièce : la question qu'on se pose devant une liste
d'auteurs est soit « où est untel ? » (recherche), soit « qui ai-je le plus ? » (tri).
⚠️ Tout se fait en mémoire, sans nouvel appel : la liste des auteurs est déjà
entièrement chargée — c'est elle qui sert l'instantané hors-ligne — et la filtrer
côté serveur aurait rendu la page inutilisable sans réseau. La normalisation passe
par NormalisationTexte, comme partout : « emile » retrouve « Émile ».
*@
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Nom d'auteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un auteur" />
</div>
<div class="segments" role="group" aria-label="Tri">
<button type="button" class="segment @(_tri == TriAuteurs.Nom ? "segment-actif" : null)"
@onclick="() => _tri = TriAuteurs.Nom">Par nom</button>
<button type="button" class="segment @(_tri == TriAuteurs.NombreDeLivres ? "segment-actif" : null)"
@onclick="() => _tri = TriAuteurs.NombreDeLivres">Les plus présents</button>
</div>
@* Le compteur dit ce qu'on regarde : sur une liste filtrée, afficher le total ferait
croire que la recherche n'a rien fait. *@
<p class="compteur">
@Affiches.Count auteur@(Affiches.Count > 1 ? "s" : "")
@if (Affiches.Count != _auteurs.Count)
{
<text> sur @_auteurs.Count</text>
}
</p>
@if (Affiches.Count == 0)
{
<p class="message-discret">Aucun auteur ne correspond à « @_recherche ».</p>
}
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p>
<ul class="liste-auteurs">
@foreach (var auteur in Affiches)
@foreach (var auteur in _auteurs)
{
@*
Trois colonnes, dont une seule est élastique.
Le nombre de livres passe à GAUCHE du nom, sur une largeur figée de trois
chiffres : les compteurs s'alignent alors les uns sous les autres et se
comparent d'un coup d'œil. Trois chiffres suffisent — personne ne possède
mille livres d'un même auteur. Les actions se rangent tout à droite, elles
aussi sur une largeur constante, pour que seul le nom absorbe la place
restante au lieu de décaler les boutons ligne après ligne.
*@
<li class="carte-auteur-ligne" @key="auteur.Id">
<span class="auteur-compte" title="@Livres(auteur.NombreDeLivres)">
@auteur.NombreDeLivres
<span class="lecture-ecran">@Livres(auteur.NombreDeLivres)</span>
</span>
<a class="lien-auteur auteur-nom" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<span class="auteur-actions">
@* Le declencheur decrit dans CLAUDE.md : depuis un auteur qu'on a deja, voir
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<span class="etiquette">@Livres(auteur.NombreDeLivres)</span>
@* Le declencheur decrit dans IDEES.md : depuis un auteur qu'on a deja, voir
tout ce qu'il a ecrit et marquer ce qui manque. *@
<a class="bouton bouton-compact bouton-discret"
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
@if (_auteurRenomme == auteur.Id)
{
<input class="champ-saisie champ-auteur-renomme" @bind="_nomRenomme" />
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_renommageEnCours" @onclick="RenommerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
Annuler
</button>
}
else
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@MotifBlocage"
@onclick="() => CommencerRenommage(auteur)">
Renommer
</button>
}
</span>
</li>
}
</ul>
}
@if (_collisionRenommage is not null)
{
<section class="message-avertissement" role="alert">
<p>
Un auteur existe déjà sous le nom « @_collisionRenommage.Nom ».
Les livres seront regroupés sous cette fiche.
Confirmer la fusion ?
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@_renommageEnCours" @onclick="ConfirmerFusionAsync">
Oui, fusionner
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
Annuler
</button>
</div>
</section>
}
@code {
/// <summary>Les deux façons de trier une liste d'auteurs, et il n'y en a pas de troisième.</summary>
private enum TriAuteurs
{
/// <summary>Ordre alphabétique : « où est untel ? ».</summary>
Nom = 0,
/// <summary>Du plus présent au moins présent : « qui ai-je le plus ? ».</summary>
NombreDeLivres = 1,
}
private IReadOnlyList<AuteurDto>? _auteurs;
private string _recherche = string.Empty;
private TriAuteurs _tri = TriAuteurs.Nom;
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
private bool _enCours;
private bool _renommageEnCours;
private int? _auteurRenomme;
private string _nomRenomme = string.Empty;
private AuteurDto? _collisionRenommage;
private string? _erreur;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
@@ -216,47 +107,6 @@ else
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
/// <summary>
/// La liste telle qu'elle s'affiche : filtrée par la recherche, puis triée.
/// </summary>
/// <remarks>
/// La recherche est une <b>sous-chaîne du nom normalisé</b>, comme celle du catalogue :
/// « zola » retrouve « Émile Zola », et « emile » aussi. Elle ne cherche pas mot à mot,
/// contrairement au rapprochement d'auteurs, qui répond à une autre question — « ces deux
/// noms désignent-ils la même personne ? » — et n'a rien à faire ici.
/// <para>
/// Le tri par nom est <b>ordinal sur la forme normalisée</b>, pour ne pas dépendre de la
/// culture du navigateur : c'est la même règle que <c>FiltreLivresLocal</c>, et elle évite
/// qu'un appareil classe « Éluard » avant « Duras » et un autre après.
/// </para>
/// </remarks>
private IReadOnlyList<AuteurDto> Affiches
{
get
{
if (_auteurs is null)
{
return [];
}
var terme = NormalisationTexte.Normaliser(_recherche);
var retenus = string.IsNullOrEmpty(terme)
? _auteurs
: _auteurs.Where(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme)).ToList();
return _tri == TriAuteurs.NombreDeLivres
? retenus
.OrderByDescending(a => a.NombreDeLivres)
.ThenBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
.ToList()
: retenus
.OrderBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
.ToList();
}
}
private async Task ChargerAsync()
{
_erreur = null;
@@ -278,55 +128,6 @@ else
private Task RefuserAsync(SuggestionRapprochement suggestion) =>
DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
private void CommencerRenommage(AuteurDto auteur)
{
_auteurRenomme = auteur.Id;
_nomRenomme = auteur.Nom;
_collisionRenommage = null;
_erreur = null;
}
private void AnnulerRenommage()
{
_auteurRenomme = null;
_collisionRenommage = null;
_nomRenomme = string.Empty;
}
private Task RenommerAsync() => EnvoyerRenommageAsync(false);
private Task ConfirmerFusionAsync() => EnvoyerRenommageAsync(true);
private async Task EnvoyerRenommageAsync(bool confirmerFusion)
{
if (_auteurRenomme is null || string.IsNullOrWhiteSpace(_nomRenomme))
{
_erreur = "Le nom de l'auteur est obligatoire.";
return;
}
_renommageEnCours = true;
_erreur = null;
var resultat = await Api.RenommerAuteurAsync(
_auteurRenomme.Value, _nomRenomme, confirmerFusion);
_renommageEnCours = false;
if (resultat is null)
{
_erreur = "Le renommage n'a pas pu être enregistré.";
return;
}
if (resultat.Collision is not null)
{
_collisionRenommage = resultat.Collision;
return;
}
AnnulerRenommage();
await ChargerAsync();
}
private async Task DeciderAsync(Func<Task<string?>> action)
{
_enCours = true;
+87 -465
View File
@@ -7,9 +7,7 @@
@if (_bibliographie is null && _erreur is null)
{
<h1 class="titre-page">Bibliographie</h1>
@* L1 : une bibliographie coûte ~1 s par page, et jusqu'à dix pages pour les nouveautés.
Sans indicateur, l'écran se lit comme planté — et l'on reclique, donc on relance. *@
<Patience Message="@(Nouveautes ? "Recherche des nouveautés à la BnF…" : "Interrogation de la BnF…")" />
<p class="message-discret">Interrogation de la BnF…</p>
}
else if (_erreur is not null)
{
@@ -32,16 +30,7 @@ else if (_erreur is not null)
}
else if (_bibliographie is { } biblio)
{
<h1 class="titre-page">
@if (biblio.EstNouveautes)
{
<text>Nouveautés de @biblio.Auteur.Nom</text>
}
else
{
@biblio.Auteur.Nom
}
</h1>
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
@*
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
@@ -76,29 +65,13 @@ else if (_bibliographie is { } biblio)
}
else if (biblio.Oeuvres.Count == 0)
{
@if (biblio.EstNouveautes && biblio.SeuilNouveautesInconnu)
{
<p class="message-discret">
Impossible de dater le livre le plus récent que vous possédez dans les notices
reçues. La BnF a répondu, mais aucune nouveauté fiable ne peut être déduite.
</p>
}
else if (biblio.EstNouveautes)
{
<p class="message-discret">Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.</p>
}
else
{
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
l'expliquer. *@
<p class="message-discret">
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
auteur traduit n'y a souvent pas de notice à son nom.
</p>
}
<BasculeBibliographie Biblio="biblio" />
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
l'expliquer. Le cas est fréquent et n'a rien d'anormal. *@
<p class="message-discret">
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
auteur traduit n'y a souvent pas de notice à son nom.
</p>
}
else if (biblio.Avertissement is not null)
{
@@ -113,19 +86,8 @@ else if (_bibliographie is { } biblio)
<p class="compteur">
@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "")
— @_possedees déjà chez vous
@if (_masquees > 0)
{
<text> — @_masquees masquée@(_masquees > 1 ? "s" : "")</text>
}
</p>
@if (biblio.EstNouveautes)
{
<p class="message-discret">
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
</p>
}
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
conditionnels dans une même phrase produit une ponctuation bancale dès qu'un des
fragments manque (« exister. .34 notices ont été écartées »). *@
@@ -162,231 +124,67 @@ else if (_bibliographie is { } biblio)
<p class="message-erreur" role="alert">@_message</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-discret"
@onclick="() => _filtresOuverts = !_filtresOuverts"
aria-expanded="@Aria.Etat(_filtresOuverts)">
Filtrer (@NombreFiltres)
</button>
@* Les deux bascules d'écran vivent ICI, à côté du filtre, et non plus dans une barre
flottante en bas : « toute la bibliographie / les nouveautés » et « ses livres chez
vous » sont trois façons de restreindre la même liste. Les chercher en bas d'un
écran qui défile sur cinquante lignes n'avait pas de sens. *@
<BasculeBibliographie Biblio="biblio" />
@*
I1 : « Tout cocher » vit ICI et non dans la barre de sélection, qui n'apparaît
qu'une fois une case cochée — c'est-à-dire trop tard pour rendre service.
⚠️ « Tout » signifie ce qui est ACTUELLEMENT VISIBLE, donc après filtre et selon
que les masquées sont affichées ou non. Cocher les 200 notices remontées, dont
l'écran n'en montre que trente, serait exactement ce que les compteurs par
bouton cherchent à éviter.
*@
@if (ACocher(biblio).Count is var aCocher and > 0)
{
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => CocherTout(biblio)">
Tout cocher (@aCocher)
</button>
}
@* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne
sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@
<div class="filtres">
<div class="segments" role="group" aria-label="Filtrer la bibliographie">
<button type="button" class="segment @(_seulementManquants ? null : "segment-actif")"
@onclick="() => _seulementManquants = false">Tout</button>
<button type="button" class="segment @(_seulementManquants ? "segment-actif" : null)"
@onclick="() => _seulementManquants = true">Ce qui me manque</button>
</div>
</div>
@if (_filtresOuverts)
{
<div class="filtres">
<div class="segments" role="group" aria-label="Possession">
<button type="button" class="segment @(_modePossession == 0 ? "segment-actif" : null)"
@onclick="() => _modePossession = 0">Tout</button>
<button type="button" class="segment @(_modePossession == 1 ? "segment-actif" : null)"
@onclick="() => _modePossession = 1">Ceux que j'ai déjà</button>
<button type="button" class="segment @(_modePossession == 2 ? "segment-actif" : null)"
@onclick="() => _modePossession = 2">Ce qui me manque</button>
</div>
@if (_masquees > 0)
{
<div class="segments">
<button type="button" class="segment @(_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = true">
Afficher les masquées (@_masquees)
</button>
<button type="button" class="segment @(!_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = false">Masquer les masquées</button>
</div>
}
</div>
}
@*
La barre de sélection est EN HAUT, au-dessus de la liste, et non plus flottante en
bas : on coche en descendant, et il fallait auparavant redescendre jusqu'au bas de
l'écran pour valider — ou pire, la barre recouvrait les dernières lignes qu'on
venait justement de vouloir cocher.
*@
@if (_selection.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">@_selection.Count sélectionnée@(_selection.Count > 1 ? "s" : "")</span>
@if (SelectionADecouvrir(biblio).Count is var aSouhaiter and > 0)
{
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="AjouterSelectionAsync">
Ajouter aux envies (@aSouhaiter)
</button>
}
@if (SelectionParMasquage(biblio, false).Count is var aMasquer and > 0)
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="() => MasquerSelectionAsync(true)">
Masquer (@aMasquer)
</button>
}
@if (SelectionParMasquage(biblio, true).Count is var aReafficher and > 0)
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="() => MasquerSelectionAsync(false)">
Réafficher (@aReafficher)
</button>
}
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => _selection.Clear()">
Tout décocher
</button>
</div>
}
<ul class="liste-bibliographie">
@foreach (var oeuvre in Affichees(biblio))
{
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
<li class="carte-oeuvre @(oeuvre.Possede ? "oeuvre-possedee" : null)" @key="oeuvre.Titre">
@*
La case est à GAUCHE du titre, et la ligne est centrée verticalement :
c'est la disposition d'une liste qu'on parcourt en cochant. Elle est
offerte sur TOUTE œuvre visible, pas seulement sur celles à découvrir —
masquer en lot suppose de pouvoir cocher ce qu'on possède déjà.
*@
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selection.Contains(oeuvre.Titre)"
@onchange="e => Selectionner(oeuvre.Titre, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @oeuvre.Titre »</span>
</label>
@if (oeuvre is { Possede: true, LivreId: { } livreId }
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
else
{
<div class="carte-corps">
<p class="carte-titre">
@if (oeuvre is { Possede: true, LivreId: { } livreIdPossede })
{
<a class="lien-auteur" href="@($"livres/{livreIdPossede}")">@oeuvre.Titre</a>
}
else
{
<button type="button" class="carte-titre oeuvre-titre-bouton"
@onclick="() => BasculerOeuvre(oeuvre.Titre)"
aria-expanded="@Aria.Etat(_oeuvresOuvertes.Contains(oeuvre.Titre))">
@oeuvre.Titre
</button>
}
</p>
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(oeuvre.Annee))
{
<span>@oeuvre.Annee</span>
}
@if (!string.IsNullOrWhiteSpace(oeuvre.Editeur))
{
<span>@oeuvre.Editeur</span>
}
@if (oeuvre.NombreEditions > 1)
{
<span class="etiquette">@oeuvre.NombreEditions éditions</span>
}
@if (oeuvre.Possede)
{
<span class="etiquette etiquette-possede">Déjà chez vous</span>
}
else if (oeuvre.Souhaite)
{
<span class="etiquette etiquette-souhaite">Dans vos envies</span>
}
</p>
@if (_oeuvresOuvertes.Contains(oeuvre.Titre))
<div class="carte-corps">
<p class="carte-titre">
@if (oeuvre is { Possede: true, LivreId: { } livreId })
{
@*
I2 : la vignette n'est rendue qu'ICI, au dépliage. Une
bibliographie compte des dizaines de lignes : les charger
toutes d'avance ferait payer des images que personne ne
regarde. Les œuvres possédées, elles, ont déjà la leur par
CarteLivre.
⚠️ « Sans ISBN, pas de couverture, et on n'en invente pas » :
la formule OpenLibrary s'applique à l'ISBN repris du
dc:identifier de la notice, et le substitut à initiale sert
de repli quand la notice n'en porte aucun.
*@
<div class="detail-oeuvre">
<Couverture Url="@CouvertureDe(oeuvre)" Titre="@oeuvre.Titre"
Classe="couverture-petite" Agrandissable="true" />
<p class="message-discret">
@if (!string.IsNullOrWhiteSpace(oeuvre.Isbn))
{
<span>ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn)</span>
}
@if (oeuvre.NombreEditions > 1)
{
<span>@oeuvre.NombreEditions éditions recensées</span>
}
</p>
</div>
<a class="lien-auteur" href="@($"livres/{livreId}")">@oeuvre.Titre</a>
}
</div>
}
else
{
@oeuvre.Titre
}
</p>
@* Les boutons sont groupés à droite, centrés sur la même ligne que le titre :
une colonne d'actions alignée se balaie du regard, des boutons empilés au fil
du texte non. *@
<div class="oeuvre-actions">
@if (oeuvre.ADecouvrir)
{
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="() => SouhaiterAsync(oeuvre)"
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
Je le veux
</button>
}
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(oeuvre.Annee))
{
<span>@oeuvre.Annee</span>
}
@if (!string.IsNullOrWhiteSpace(oeuvre.Editeur))
{
<span>@oeuvre.Editeur</span>
}
@if (oeuvre.NombreEditions > 1)
{
<span class="etiquette">@oeuvre.NombreEditions éditions</span>
}
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="() => ModifierMasquageAsync(oeuvre, !oeuvre.Masquee)">
@(oeuvre.Masquee ? "Réafficher" : "Masquer")
</button>
@if (oeuvre.Possede)
{
<span class="etiquette etiquette-possede">Déjà chez vous</span>
}
else if (oeuvre.Souhaite)
{
<span class="etiquette etiquette-souhaite">Dans vos envies</span>
}
</p>
</div>
@if (oeuvre.ADecouvrir)
{
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
Je le veux
</button>
}
</li>
}
</ul>
@@ -402,17 +200,21 @@ else if (_bibliographie is { } biblio)
}
}
@* « Retour aux auteurs » est parti : l'onglet Auteurs fait exactement cela, en permanence.
Ne reste que ce qu'aucun onglet ne sait faire — le catalogue restreint à cet auteur. *@
@if (_bibliographie is { } b)
{
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
</nav>
}
@code {
[Parameter]
public int AuteurId { get; set; }
[SupplyParameterFromQuery(Name = "nouveautes")]
public bool Nouveautes { get; set; }
private BibliographieDto? _bibliographie;
private int _modePossession;
private bool _filtresOuverts;
private bool _afficherMasquees;
private bool _seulementManquants;
private bool _enCours;
private bool _chargement;
@@ -423,63 +225,21 @@ else if (_bibliographie is { } biblio)
private string? _message;
private int _possedees;
private int _masquees;
private IReadOnlyList<LivreDto> _livresCatalogue = [];
private readonly HashSet<string> _oeuvresOuvertes = [];
private readonly HashSet<string> _selection = [];
protected override async Task OnParametersSetAsync() => await ChargerAsync();
private IEnumerable<OeuvreBibliographie> Affichees(BibliographieDto biblio) =>
biblio.Oeuvres.Where(o =>
(_afficherMasquees || !o.Masquee)
&& (_modePossession == 0
|| (_modePossession == 1 && o.Possede)
|| (_modePossession == 2 && o.ADecouvrir)));
private int NombreFiltres =>
(_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0);
/// <summary>
/// URL de couverture d'une œuvre non possédée, ou <c>null</c> quand la notice n'a pas
/// d'ISBN — auquel cas la vignette retombe sur son substitut à initiale.
/// </summary>
private static string? CouvertureDe(OeuvreBibliographie oeuvre) =>
string.IsNullOrWhiteSpace(oeuvre.Isbn) ? null : IsbnUtils.UrlCouverture(oeuvre.Isbn);
/// <summary>Les œuvres visibles qui ne sont pas encore cochées.</summary>
private List<OeuvreBibliographie> ACocher(BibliographieDto biblio) =>
Affichees(biblio).Where(o => !_selection.Contains(o.Titre)).ToList();
/// <summary>Coche tout ce qui est visible — jamais ce que le filtre écarte.</summary>
private void CocherTout(BibliographieDto biblio)
{
foreach (var oeuvre in ACocher(biblio))
{
_selection.Add(oeuvre.Titre);
}
}
/// <summary>Les œuvres cochées qui peuvent effectivement rejoindre la liste d'envies.</summary>
private List<OeuvreBibliographie> SelectionADecouvrir(BibliographieDto biblio) =>
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir).ToList();
private List<OeuvreBibliographie> SelectionParMasquage(BibliographieDto biblio, bool masquees) =>
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.Masquee == masquees).ToList();
_seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres;
private async Task ChargerAsync()
{
_erreur = null;
_reessayable = false;
_chargement = true;
_selection.Clear();
try
{
_bibliographie = Nouveautes
? await Api.ObtenirNouveautesAsync(AuteurId)
: await Api.ObtenirBibliographieAsync(AuteurId);
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
if (_bibliographie is null)
{
@@ -489,7 +249,7 @@ else if (_bibliographie is { } biblio)
return;
}
RecompterAsync();
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
}
catch (Exception)
{
@@ -512,164 +272,18 @@ else if (_bibliographie is { } biblio)
}
}
private void RecompterAsync()
{
_possedees = _bibliographie?.Oeuvres.Count(o => o.Possede) ?? 0;
_masquees = _bibliographie?.Oeuvres.Count(o => o.Masquee) ?? 0;
}
/// <summary>
/// Applique en mémoire ce que l'écriture vient de changer, au lieu de tout recharger.
/// </summary>
/// <remarks>
/// ⚠️ C'est LA correction de lenteur signalée en usage : chaque « Je le veux » et chaque
/// « Masquer » relançaient un <c>ChargerAsync</c> complet, donc une interrogation de la BnF
/// (une seconde par page, jusqu'à dix pages pour les nouveautés, qui ne sont pas mises en
/// cache) suivie d'une relecture de tout le catalogue. L'écran attendait une source
/// distante pour apprendre un drapeau <b>personnel</b> qu'il connaissait déjà.
/// <para>
/// Le cache partagé de <see cref="ServiceLivresApi"/> est corrigé de la même façon de son
/// côté ; ici on corrige la copie que cet écran tient en main, celle des nouveautés n'étant
/// justement pas dans ce cache.
/// </para>
/// </remarks>
private void MettreAJourOeuvre(string titre, Func<OeuvreBibliographie, OeuvreBibliographie> correction)
{
if (_bibliographie is null)
{
return;
}
_bibliographie = _bibliographie with
{
Oeuvres = _bibliographie.Oeuvres
.Select(o => o.Titre == titre ? correction(o) : o)
.ToList(),
};
RecompterAsync();
}
private void Selectionner(string titre, bool selectionnee)
{
if (selectionnee)
{
_selection.Add(titre);
}
else
{
_selection.Remove(titre);
}
}
private async Task AjouterSelectionAsync()
{
if (_bibliographie is null)
{
return;
}
var oeuvres = SelectionADecouvrir(_bibliographie);
var saisies = oeuvres.Select(Saisie).ToList();
_enCours = true;
_message = null;
var resultats = await Api.AjouterSouhaitsAsync(saisies);
_enCours = false;
// Chaque envie réussie bascule sa ligne ; les échecs gardent la leur intacte et sont
// rapportés. Rien n'est rechargé — voir MettreAJourOeuvre.
for (var i = 0; i < oeuvres.Count; i++)
{
if (resultats[i].EstOk)
{
var id = resultats[i].Valeur?.Id;
MettreAJourOeuvre(oeuvres[i].Titre, o => o with { Souhaite = true, SouhaitId = id });
}
}
var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList();
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs);
_selection.Clear();
}
/// <summary>Masque (ou réaffiche) toutes les œuvres cochées qui sont dans l'autre état.</summary>
private async Task MasquerSelectionAsync(bool masquer)
{
if (_bibliographie is null)
{
return;
}
var oeuvres = SelectionParMasquage(_bibliographie, !masquer);
_enCours = true;
_message = null;
var erreurs = new List<string>();
foreach (var oeuvre in oeuvres)
{
var motif = masquer
? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
: await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
if (motif is null)
{
MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
}
else
{
erreurs.Add(motif);
}
}
_enCours = false;
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs.Distinct());
_selection.Clear();
}
private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool masquer)
{
_enCours = true;
_message = masquer
? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
: await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
_enCours = false;
if (_message is null)
{
MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
}
}
private void BasculerOeuvre(string titre)
{
if (!_oeuvresOuvertes.Add(titre))
{
_oeuvresOuvertes.Remove(titre);
}
}
/// <summary>
/// Ce que la BnF sait de l'œuvre, mis en forme pour la liste d'envies.
/// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
/// </summary>
/// <remarks>
/// L'auteur enregistré est celui de l'écran, pas celui de la notice : c'est lui que
/// l'utilisateur a en tête, et c'est sous ce nom qu'il retrouvera l'envie dans l'export.
/// <para>
/// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
/// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
/// Après l'ajout, la bibliographie est <b>relue</b> plutôt que corrigée en mémoire : c'est
/// le serveur qui sait rapprocher l'envie de l'œuvre, et refaire ce calcul ici le
/// dupliquerait — avec le risque qu'il diverge.
/// </para>
/// </remarks>
private EnregistrementSouhait Saisie(OeuvreBibliographie oeuvre) => new()
{
Titre = oeuvre.Titre,
Auteur = _bibliographie!.Auteur.Nom,
Editeur = oeuvre.Editeur,
Annee = oeuvre.Annee,
Isbn = oeuvre.Isbn,
};
private async Task SouhaiterAsync(OeuvreBibliographie oeuvre)
{
if (_bibliographie is null)
@@ -680,7 +294,17 @@ else if (_bibliographie is { } biblio)
_enCours = true;
_message = null;
var resultat = await Api.AjouterSouhaitAsync(Saisie(oeuvre));
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
{
Titre = oeuvre.Titre,
Auteur = _bibliographie.Auteur.Nom,
Editeur = oeuvre.Editeur,
Annee = oeuvre.Annee,
// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
Isbn = oeuvre.Isbn,
});
_enCours = false;
@@ -690,8 +314,6 @@ else if (_bibliographie is { } biblio)
return;
}
MettreAJourOeuvre(
oeuvre.Titre,
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
await ChargerAsync();
}
}
+88 -565
View File
@@ -1,7 +1,6 @@
@page "/"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — catalogue</PageTitle>
@@ -12,91 +11,30 @@
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
et replié serait invisible, et l'on conclurait que des livres ont disparu.
*@
@*
⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre
flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons
du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même
écran seraient du bruit, et le projet désencombre par le regroupement, jamais par
l'effacement.
⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une
fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau.
*@
<div class="entete-page">
<h1 class="titre-page">Catalogue</h1>
<div class="entete-page-actions">
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
@if (_grouper && Entrees.Any(e => e.EstGroupe))
{
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
</button>
}
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX livres — même seuil que sur la fiche
série. Reste possible hors-ligne : l'écriture, elle, est bloquée par livre comme
partout ailleurs (⚠️ MotifBlocage).
*@
@if (Reseau.EnLigne && _livres is { Count: > 1 })
{
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-catalogue"
@onclick="BasculerLot">
Modifier en lot
</button>
}
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal bouton-compact" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</div>
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@_filtresOuverts" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
</div>
@if (_auteur is not null)
{
@* Le « cliquer sur un auteur » d'CLAUDE.md : le catalogue est ici restreint à ses livres. *@
@* Le « cliquer sur un auteur » d'IDEES.md : le catalogue est ici restreint à ses livres. *@
<p class="message-discret">
Livres de <strong>@_auteur.Nom</strong>.
<a href="">Revenir à toute la bibliothèque</a>
</p>
}
@*
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
*@
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
<button type="button" class="bouton bouton-discret"
@onclick="() => _scanOuvert = !_scanOuvert">
@(_scanOuvert ? "Fermer" : "Scanner")
</button>
</div>
@if (_scanOuvert)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
}
@if (_filtresOuverts)
{
<div class="filtres" id="filtres-catalogue">
@@ -116,24 +54,6 @@
</div>
}
@* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne
n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée.
⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai
pas encore rangé ? » est justement la question qui se pose après coup. *@
@if (PlusieursTypes)
{
<div class="segments" role="group" aria-label="Filtrer par type de document">
<button type="button" class="segment @(_type is null ? "segment-actif" : null)"
@onclick="() => FiltrerType(null)">Tous</button>
@foreach (var type in Enum.GetValues<TypeDocument>())
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(_type == type ? "segment-actif" : null)"
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</button>
}
</div>
}
@* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut
pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran
l'assume au lieu de laisser croire à une vue commune. *@
@@ -142,81 +62,14 @@
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button"
class="segment @Libelles.ClasseStatut(statut) @(_statut == statut ? "segment-actif" : null)"
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
}
</div>
@*
Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran
« Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre
moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle
qu'on se pose avant de promettre un livre à quelqu'un.
⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien
n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se
déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des
livres à la maison escamoterait le filtre sans qu'on puisse l'élargir.
*@
@if (QuelqueChoseEstPrete)
{
<div class="segments" role="group" aria-label="Filtrer par prêt en cours">
<button type="button" class="segment @(_prete is null ? "segment-actif" : null)"
@onclick="() => FiltrerPret(null)">Tous</button>
<button type="button" class="segment @(_prete == false ? "segment-actif" : null)"
@onclick="() => FiltrerPret(false)">À la maison</button>
<button type="button"
class="segment segment-prete @(_prete == true ? "segment-actif" : null)"
@onclick="() => FiltrerPret(true)">Prêtés</button>
</div>
}
@*
« Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce
filtre répond, et la raison pour laquelle il existe.
⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne
se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que
CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre
entrerait et sortirait du filtre d'un rafraîchissement à l'autre.
⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien.
Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre
n'a de couverture, la liste complète EST déjà celle des livres à illustrer.
Et comme partout, cela ne se déduit QUE d'un chargement sans critère.
*@
@if (CouverturesInegales)
{
<div class="segments" role="group" aria-label="Filtrer par couverture">
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(null)">Tous</button>
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
</div>
}
@*
⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série.
Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat.
*@
@if (_series is { Count: > 0 })
{
<label class="bascule">
<input type="checkbox" checked="@_grouper"
@onchange="e => _grouper = (bool?)e.Value ?? true" />
<span>Grouper les livres d'une même série</span>
</label>
}
<p class="champ-aide">
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
lecture, pas celle du foyer. Le prêt, lui, est commun.
lecture, pas celle du foyer.
</p>
</div>
}
@@ -228,7 +81,7 @@
@if (_chargement && _livres is null)
{
<Patience Message="Chargement du catalogue…" />
<p class="message-discret">Chargement…</p>
}
else if (_livres is { Count: 0 })
{
@@ -240,195 +93,102 @@ else if (_livres is not null)
{
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
@*
Modification en lot : même patron que la fiche série (barre collante, formulaire affiché
sous elle) — sauf que la sélection porte ici sur le catalogue ENTIER, groupé ou non, d'où
le bouton « Tout sélectionner » qui coche d'un coup tous les livres actuellement chargés
(donc déjà filtrés : cocher les 400 livres du fonds derrière un filtre à trois résultats
n'aurait aucun sens).
*@
@if (_lotOuvert)
{
<div id="lot-catalogue">
@if (_selectionLot.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
</span>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="ToutSelectionnerLot">
Tout sélectionner (@_livres!.Count)
</button>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreurLot" EnCours="_enCoursLot" MessageBlocage="@MotifBlocageLot"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">
Cochez les livres à modifier ensemble, ou
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="ToutSelectionnerLot">tout sélectionner (@_livres!.Count)</button>.
</p>
}
</div>
}
@*
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on
ne possède pas — ce que le catalogue, lui, ne peut pas montrer.
Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
toujours ce qu'il contient.
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
*@
<ul class="liste-livres">
@foreach (var entree in Entrees)
@foreach (var livre in _livres)
{
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries"
LotActif="_lotOuvert" SelectionLot="_selectionLot"
OnSelectionner="args => SelectionnerLot(args.LivreId, args.Coche)" />
}
else if (_lotOuvert)
{
<li class="carte-oeuvre" @key="entree.Livres[0].Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(entree.Livres[0].Id)"
@onchange="e => SelectionnerLot(entree.Livres[0].Id, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @entree.Livres[0].Titre »</span>
</label>
<CarteLivre Livre="entree.Livres[0]" />
</li>
}
else
{
<li @key="entree.Livres[0].Id">
<CarteLivre Livre="entree.Livres[0]" />
</li>
}
<li class="carte-livre" @key="livre.Id">
@* La vignette s'agrandit au clic ; le titre, lui, ouvre la fiche. *@
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre"
Classe="couverture-petite" Agrandissable="true" />
<div class="carte-corps">
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
@if (livre.Auteurs.Count > 0)
{
<p class="carte-auteur">
@foreach (var (auteur, index) in livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
}
</p>
}
@* Une seule ligne d'étiquettes, en lecture seule (IDEES.md) : la liste se
parcourt, elle ne se manipule pas. Tout se modifie depuis la fiche. *@
<p class="carte-details">
@* Un livre est physique par défaut : ne l'afficher que pour les ebooks,
sinon l'étiquette est sur toutes les lignes et n'apprend rien. *@
@if (livre.Format == Format.Numerique)
{
<span class="etiquette">@Libelles.Format(livre.Format)</span>
}
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
Pas de pastille = non commencé, l'absence de ligne en base. *@
@if (livre.Statut is { } statut)
{
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
@Libelles.Statut(statut)
</span>
}
@* Un livre dehors l'est pour tout le foyer, contrairement au statut. *@
@if (livre.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
{
<span>@livre.Editeur</span>
}
</p>
</div>
</li>
}
</ul>
}
@*
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
les onglets, en bas.
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter un livre</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un livre</button>
}
</nav>
@code {
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
[SupplyParameterFromQuery(Name = "auteur")]
public int? AuteurId { get; set; }
private IReadOnlyList<LivreDto>? _livres;
/// <summary>
/// Les séries du foyer, pour regrouper leurs tomes. <c>null</c> = pas encore lues.
/// </summary>
/// <remarks>
/// ⚠️ Elles ne sont lues <b>qu'une fois</b> par visite (et à chaque bascule du réseau), pas à
/// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et
/// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas
/// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît
/// groupé qu'au prochain passage ici.
/// </remarks>
private IReadOnlyList<SerieDto>? _series;
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
private bool _grouper = true;
/// <summary>
/// Blocs ouverts à la main, par identifiant de série. Tout est replié par défaut.
/// </summary>
/// <remarks>
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
/// lecture, pas une propriété de la série.
/// </remarks>
private readonly HashSet<int> _ouverts = [];
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
private void ToutReplier() => _ouverts.Clear();
private void ToutDeplier()
{
foreach (var id in IdentifiantsSeries(Entrees))
{
_ouverts.Add(id);
}
}
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
{
foreach (var entree in entrees)
{
if (!entree.EstGroupe)
{
continue;
}
yield return entree.Serie!.Id;
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
{
yield return id;
}
}
}
/// <summary>
/// Ce que chaque série possède et recense, descendance comprise.
/// </summary>
/// <remarks>
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
/// </remarks>
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
new Dictionary<int, (int, int)>();
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
private IReadOnlyList<EntreeCatalogue> Entrees =>
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
private AuteurDto? _auteur;
private int? _auteurCharge;
private string _recherche = string.Empty;
private Format? _format;
private TypeDocument? _type;
private Statut? _statut;
private bool? _prete;
private bool? _sansCouverture;
private bool _chargement;
private bool _filtresOuverts;
private string? _erreur;
/// <summary>Le panneau de modification en lot est déplié. Même patron que la fiche série.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des livres cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private bool _enCoursLot;
private string? _erreurLot;
private string? MotifBlocageLot => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
/// </summary>
@@ -443,32 +203,6 @@ else if (_livres is not null)
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
/// <summary>Types réellement présents dans le fonds. Mêmes précautions que les formats.</summary>
private IReadOnlySet<TypeDocument>? _typesDuFonds;
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1;
/// <summary>
/// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas.
/// </summary>
/// <remarks>Mêmes précautions que les formats : dans le doute, la ligne reste affichée.</remarks>
private bool? _fondsAvecPret;
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
/// <summary>
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
/// sait pas encore.
/// </summary>
/// <remarks>
/// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours :
/// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste
/// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée.
/// </remarks>
private bool? _fondsCouverturesInegales;
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
/// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
@@ -477,16 +211,13 @@ else if (_livres is not null)
/// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
/// qui serait invisible une fois le panneau refermé.
/// </remarks>
private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1);
private int FiltresActifs => (_format is null ? 0 : 1) + (_statut is null ? 0 : 1);
// Une frappe au clavier annule la recherche précédente encore en vol.
private CancellationTokenSource _cts = new();
private bool EstFiltre =>
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|| _statut is not null || _prete is not null || _sansCouverture is not null
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null
|| AuteurId is not null;
/// <summary>
@@ -495,42 +226,14 @@ else if (_livres is not null)
/// </summary>
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Lit les séries, et se contente de ne pas grouper si elles manquent.
/// </summary>
/// <remarks>
/// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à
/// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même
/// sur l'instantané « series ».
/// </remarks>
private async Task ChargerSeriesAsync()
{
try
{
_series = await Api.ListerSeriesAsync();
_totauxSeries = TotauxSeries.Calculer(_series);
}
catch (Exception)
{
_series = null;
_totauxSeries = new Dictionary<int, (int, int)>();
}
}
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerSeriesAsync();
await ChargerAsync();
StateHasChanged();
});
protected override async Task OnParametersSetAsync()
{
if (_series is null)
{
await ChargerSeriesAsync();
}
if (_auteurCharge != AuteurId)
{
_auteurCharge = AuteurId;
@@ -546,86 +249,23 @@ else if (_livres is not null)
await ChargerAsync();
}
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
private bool _scanOuvert;
/// <summary>
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
/// </summary>
/// <remarks>
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
/// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne
/// sont pas au catalogue.
/// </remarks>
private async Task SurCodeScanneAsync(CodeScanne lecture)
{
_scanOuvert = false;
_recherche = lecture.Code;
await ChargerAsync();
}
/// <summary>
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
/// </summary>
/// <remarks>
/// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un
/// élément à cliquer.
/// <para>
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
/// est un hasard, et l'écran sauterait sous les doigts.
/// </para>
/// </remarks>
private bool OuvrirSiUnique()
{
if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche))
{
return false;
}
Navigation.NavigateTo($"livres/{_livres[0].Id}");
return true;
}
private Task FiltrerFormat(Format? format)
{
_format = format;
return ChargerAsync();
}
private Task FiltrerType(TypeDocument? type)
{
_type = type;
return ChargerAsync();
}
private Task FiltrerStatut(Statut? statut)
{
_statut = statut;
return ChargerAsync();
}
private Task FiltrerPret(bool? prete)
{
_prete = prete;
return ChargerAsync();
}
private Task FiltrerCouverture(bool? sansCouverture)
{
_sansCouverture = sansCouverture;
return ChargerAsync();
}
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
private static bool EstSansCritere(CritereLivres criteres) =>
string.IsNullOrWhiteSpace(criteres.Recherche)
&& criteres.Format is null
&& criteres.TypeDocument is null
&& criteres.Statut is null
&& criteres.Prete is null
&& criteres.SansCouverture is null
&& criteres.AuteurId is null;
private async Task ChargerAsync()
@@ -644,28 +284,15 @@ else if (_livres is not null)
{
Recherche = _recherche,
Format = _format,
TypeDocument = _type,
Statut = _statut,
Prete = _prete,
SansCouverture = _sansCouverture,
AuteurId = AuteurId,
};
_livres = await Api.ListerAsync(criteres, jeton);
if (OuvrirSiUnique())
{
return;
}
if (EstSansCritere(criteres))
{
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
_fondsCouverturesInegales =
_livres.Any(l => string.IsNullOrEmpty(l.CoverUrl))
&& _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl));
}
}
catch (OperationCanceledException)
@@ -687,110 +314,6 @@ else if (_livres is not null)
}
}
/// <summary>
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
/// la saisie en cours — même règle que sur la fiche série : un panneau qui se rouvrirait sur
/// l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreurLot = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
/// <summary>
/// Coche d'un coup tous les livres actuellement CHARGÉS — donc déjà filtrés. Un groupe replié
/// n'y échappe pas : la sélection porte sur <c>_livres</c>, pas sur ce que l'écran affiche.
/// </summary>
private void ToutSelectionnerLot()
{
foreach (var livre in _livres ?? [])
{
_selectionLot.Add(livre.Id);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreurLot = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// la fiche série : pas de point d'entrée dédié, la boucle réutilise
/// <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs cochés et en
/// reprenant tels quels ceux qui ne le sont pas.
/// </summary>
private async Task EnregistrerLotAsync()
{
_enCoursLot = true;
_erreurLot = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = (_livres ?? []).FirstOrDefault(l => l.Id == livreId);
if (livre is null)
{
continue;
}
var saisie = new EnregistrementLivre
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = _saisieLot.ModifierAuteurs
? _saisieLot.Auteurs
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
NombrePages = livre.NombrePages,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
var resultat = await Api.ModifierAsync(livre.Id, saisie);
if (!resultat.EstOk)
{
erreurs.Add(resultat.Erreur!);
}
}
_enCoursLot = false;
if (erreurs.Count > 0)
{
_erreurLot = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
await ChargerAsync();
}
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
-209
View File
@@ -1,209 +0,0 @@
@page "/souhaits/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
@*
Écran dédié plutôt qu'un formulaire déplié dans la liste : convention du projet depuis la
fiche livre — le mode est dans l'ADRESSE, jamais dans un booléen interne. C'est ce qui rend
le bouton « retour » du navigateur une sortie d'édition naturelle, et ce qui empêche de
modifier une envie en faisant défiler sa liste.
⚠️ Cette page ne partage aucun paramètre avec une autre route : contrairement à la fiche
livre ou à la fiche série, elle n'a pas besoin de s'abonner à LocationChanged. L'abonnement
ci-dessous ne sert qu'à suivre l'état du réseau.
*@
<PageTitle>MaBibli — modifier une envie</PageTitle>
<h1 class="titre-page">Modifier une envie</h1>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_introuvable)
{
@* Hors-ligne, « supprimée » et « pas encore enregistrée ici » sont indiscernables : on ne
tranche pas à la place de l'utilisateur. Même règle que pour une fiche livre absente. *@
<p class="message-discret">
@(Reseau.EnLigne
? "Cette envie n'existe plus dans votre liste."
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else if (_saisie is null)
{
<Patience Message="Ouverture de l'envie…" />
}
else
{
<div class="formulaire">
@* Même ligne que dans le formulaire d'un ouvrage (FormulaireLivre) : un aperçu quand il y
a quelque chose à montrer, puis un champ d'adresse ordinaire, au même titre que les
autres. *@
@if (!string.IsNullOrEmpty(_saisie.CoverUrl) || !string.IsNullOrWhiteSpace(_saisie.Titre))
{
<div class="formulaire-apercu">
<Couverture Url="@_saisie.CoverUrl" Titre="@_saisie.Titre" Classe="couverture-moyenne" />
</div>
}
<label class="champ">
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Auteur</span>
<input class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput"
autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Année</span>
<input class="champ-saisie" type="text" @bind="_saisie.Annee" @bind:event="oninput"
placeholder="2012" autocomplete="off" />
</label>
@* Le champ de saisie garde la valeur nue : découper à la frappe se battrait avec le
curseur. Les tirets sont une affaire d'affichage, pas de saisie. *@
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric"
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">URL de couverture</span>
<input class="champ-saisie" type="url" @bind="_saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Note</span>
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" autocomplete="off" />
</label>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
title="@MotifBlocage" @onclick="EnregistrerAsync">
@(_enCours ? "Enregistrement…" : "Enregistrer")
</button>
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
</div>
</div>
}
@code {
[Parameter] public int Id { get; set; }
private EnregistrementSouhait? _saisie;
private bool _introuvable;
private bool _enCours;
private string? _erreur;
/// <summary>
/// La couverture telle que chargée — éventuellement déduite de l'ISBN (voir <c>Projeter</c>
/// côté serveur), pas forcément ce qu'il y a réellement en base. Sert uniquement à détecter,
/// à l'enregistrement, si l'utilisateur a touché au champ.
/// </summary>
private string? _couvertureChargee;
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
await ChargerAsync();
}
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>
/// Reprend l'envie depuis la <b>liste</b>, et non depuis un point d'entrée dédié.
/// </summary>
/// <remarks>
/// L'API n'expose pas <c>GET /api/souhaits/{id}</c>, et il n'y a pas lieu de l'ajouter : la
/// liste entière est déjà chargée, déjà mise en instantané, et c'est ce qui rend cet écran
/// consultable hors-ligne — même si l'enregistrement, lui, y est refusé.
/// </remarks>
private async Task ChargerAsync()
{
var souhaits = await Api.ListerSouhaitsAsync();
var souhait = souhaits.FirstOrDefault(s => s.Id == Id);
if (souhait is null)
{
_introuvable = true;
return;
}
_couvertureChargee = souhait.CoverUrl;
_saisie = new EnregistrementSouhait
{
Titre = souhait.Titre,
Auteur = souhait.Auteur,
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
Note = souhait.Note,
};
}
private async Task EnregistrerAsync()
{
if (_saisie is null)
{
return;
}
// ⚠️ Envoyée seulement si elle a changé depuis le chargement, et non à chaque
// enregistrement : la valeur affichée peut être une DÉDUCTION à la lecture (formule
// OpenLibrary à ISBN connu, voir `Projeter`) plutôt que ce qu'il y a réellement en base.
// La renvoyer telle quelle à chaque correction de titre la figerait en base, et une
// correction d'ISBN qui suit ne la referait plus jouer.
_saisie.CoverUrlModifiee = !string.Equals(
_saisie.CoverUrl?.Trim() ?? string.Empty,
_couvertureChargee?.Trim() ?? string.Empty,
StringComparison.Ordinal);
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierSouhaitAsync(Id, _saisie);
_enCours = false;
if (!resultat.EstOk)
{
// Le message vient de l'API : doublon d'une autre envie, ISBN invalide, ou hors
// ligne. Aucun code HTTP brut n'arrive jusqu'ici.
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("souhaits");
}
}
@@ -1,144 +0,0 @@
@page "/souhaits/revues/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
@*
Le même geste que pour une envie de livre, sur la table sœur : écran dédié, mode dans
l'adresse. Les deux formulaires ne se fondent pas en un seul — une revue n'a ni auteur, ni
éditeur, ni ISBN, et un formulaire à moitié grisé se lit plus mal que deux formulaires courts.
*@
<PageTitle>MaBibli — modifier une envie de revue</PageTitle>
<h1 class="titre-page">Modifier une envie de revue</h1>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_introuvable)
{
<p class="message-discret">
@(Reseau.EnLigne
? "Cette envie n'existe plus dans votre liste."
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else if (_saisie is null)
{
<Patience Message="Ouverture de l'envie…" />
}
else
{
<div class="formulaire">
<label class="champ">
<span class="champ-libelle">Titre de la revue <span class="obligatoire" aria-hidden="true">*</span></span>
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
placeholder="Médor" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">
Numéro <span class="champ-aide">(vide = la revue entière)</span>
</span>
<input class="champ-saisie" type="text" @bind="_saisie.Numero" @bind:event="oninput"
placeholder="43, hors-série 7…" autocomplete="off" />
</label>
@* La saisie garde ce qui est tapé : le tiret est posé à l'enregistrement, jamais à la
frappe — sinon il se battrait avec le curseur. *@
<label class="champ">
<span class="champ-libelle">ISSN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" @bind="_saisie.Issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Note</span>
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« vu chez le dentiste »…" autocomplete="off" />
</label>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
title="@MotifBlocage" @onclick="EnregistrerAsync">
@(_enCours ? "Enregistrement…" : "Enregistrer")
</button>
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
</div>
</div>
}
@code {
[Parameter] public int Id { get; set; }
private EnregistrementRevueSouhaitee? _saisie;
private bool _introuvable;
private bool _enCours;
private string? _erreur;
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
await ChargerAsync();
}
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private async Task ChargerAsync()
{
var envies = await Api.ListerRevuesSouhaiteesAsync();
var envie = envies.FirstOrDefault(e => e.Id == Id);
if (envie is null)
{
_introuvable = true;
return;
}
_saisie = new EnregistrementRevueSouhaitee
{
Titre = envie.Titre,
Numero = envie.Numero,
Issn = envie.Issn,
Note = envie.Note,
};
}
private async Task EnregistrerAsync()
{
if (_saisie is null)
{
return;
}
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierRevueSouhaiteeAsync(Id, _saisie);
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo("souhaits");
}
}
+4 -200
View File
@@ -6,7 +6,7 @@
@implements IDisposable
@*
Une seule page, deux modes (CLAUDE.md) :
Une seule page, deux modes (IDEES.md) :
— consultation par défaut. Ouvrir une fiche depuis le catalogue ne doit rien pouvoir casser,
surtout au doigt sur un téléphone.
@@ -91,17 +91,8 @@ else
}
<div class="fiche-entete">
@*
⚠️ Une couverture CASSÉE devient un point de correction : cliquer dessus ouvre son
adresse, focalisée, et « Entrée » enregistre. C'est là qu'on constate le défaut, et
c'est donc là qu'il faut pouvoir le réparer — passer par « Éditer » puis descendre
au dernier champ du formulaire pour corriger un lien mort était un détour.
Une couverture qui s'affiche, elle, s'agrandit comme avant.
*@
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
Classe="couverture-grande" Agrandissable="true"
OnCorriger="OuvrirCorrectionCouverture" />
Classe="couverture-grande" Agrandissable="true" />
<div class="fiche-infos">
<h2 class="fiche-titre">@_livre.Titre</h2>
@@ -113,27 +104,11 @@ else
@foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>@if (auteur.Role != RoleAuteur.NonPrecise)
{
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
}
</p>
}
@*
Les séries auxquelles ce livre appartient. Il peut y en avoir plusieurs — une
trilogie, une intégrale, un cycle — d'où une liste et non un champ.
⚠️ Lu depuis l'instantané des séries, donc disponible hors-ligne comme le reste.
*@
@foreach (var appartenance in _appartenances)
{
<p class="fiche-serie">
Tome @appartenance.Rang de
<a href="@($"series/{appartenance.Serie.Id}")">@appartenance.Serie.Titre</a>
</p>
}
<dl class="fiche-champs">
@if (!string.IsNullOrWhiteSpace(_livre.Editeur))
{
@@ -152,70 +127,10 @@ else
{
<div><dt>Format</dt><dd>@Libelles.Format(_livre.Format)</dd></div>
}
@* Idem : « non précisé » est l'absence de réponse, pas une réponse. *@
@if (_livre.TypeDocument != TypeDocument.NonPrecise)
{
<div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div>
}
@if (_livre.Themes.Count > 0)
{
<div>
<dt>Thèmes</dt>
<dd class="etiquettes">
@foreach (var theme in _livre.Themes)
{
<span class="etiquette">@theme</span>
}
</dd>
</div>
}
@*
Même règle que le format et le type de document : la ligne ne s'affiche que
lorsqu'elle apprend quelque chose. « Nombre de pages : inconnu » sur presque
chaque fiche n'apprendrait rien et allongerait toutes les autres.
*@
@if (_livre.NombrePages is { } pages)
{
<div><dt>Pages</dt><dd>@pages</dd></div>
}
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
{
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></dd></div>
}
</dl>
</div>
</div>
@if (_correctionCouverture)
{
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
@bind="_urlCouverture" @bind:event="oninput"
@onkeydown="SurToucheCouverture"
placeholder="https://…" aria-label="Adresse de la couverture"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="EnregistrerCouvertureAsync">
@(_enregistrement ? "Enregistrement…" : "Enregistrer")
</button>
<button type="button" class="bouton bouton-discret" disabled="@_enregistrement"
@onclick="() => _correctionCouverture = false">
Annuler
</button>
</div>
<p class="message-discret">
Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture.
</p>
}
@* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
<section class="bloc-statut">
@@ -259,35 +174,6 @@ else
[Parameter] public int Id { get; set; }
private LivreDto? _livre;
/// <summary>Places de ce livre dans des séries : la série, et son rang de lecture.</summary>
private IReadOnlyList<AppartenanceSerie> _appartenances = [];
private sealed record AppartenanceSerie(SerieDto Serie, int Rang);
/// <summary>
/// Cherche ce livre dans les séries.
/// </summary>
/// <remarks>
/// Une seule lecture rend tout l'arbre, et elle est déjà en cache : chercher côté client
/// évite un point d'entrée « séries d'un livre » qui n'aurait servi qu'ici, et qui aurait
/// eu son propre instantané à tenir.
/// <para>
/// Le rang affiché est la <b>position dans l'ordre de lecture</b>, à partir de 1 — pas un
/// numéro de tome imprimé sur la couverture, que le modèle ne connaît pas.
/// </para>
/// </remarks>
private async Task ChargerAppartenancesAsync()
{
var series = await Api.ListerSeriesAsync();
_appartenances = series
.SelectMany(serie => serie.Elements
.Select((element, index) => (serie, element, index))
.Where(x => x.element.LivreId == Id)
.Select(x => new AppartenanceSerie(x.serie, x.index + 1)))
.ToList();
}
private EnregistrementLivre? _saisie;
private bool _introuvable;
private bool _enregistrement;
@@ -360,7 +246,6 @@ else
try
{
_livre = await Api.ObtenirAsync(Id);
await ChargerAppartenancesAsync();
}
catch (Exception ex)
{
@@ -386,15 +271,11 @@ else
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
NombrePages = livre.NombrePages,
Themes = livre.Themes.ToList(),
Statut = livre.Statut,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
/// <summary>
@@ -403,83 +284,6 @@ else
/// </summary>
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
/// <summary>Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même.</summary>
private bool _correctionCouverture;
private string _urlCouverture = string.Empty;
private ElementReference _champUrlCouverture;
private bool _focusCouverture;
private void OuvrirCorrectionCouverture()
{
_erreur = null;
_urlCouverture = _livre?.CoverUrl ?? string.Empty;
_correctionCouverture = true;
// ⚠️ Le focus est demandé UNE fois, au prochain rendu : le reprendre à chaque rendu
// l'arracherait pendant qu'on colle ou qu'on tape.
_focusCouverture = true;
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusCouverture)
{
_focusCouverture = false;
await _champUrlCouverture.FocusAsync();
}
}
private async Task SurToucheCouverture(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await EnregistrerCouvertureAsync();
}
else if (e.Key is "Escape" or "Esc")
{
_correctionCouverture = false;
}
}
/// <summary>
/// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition.
/// </summary>
/// <remarks>
/// Même exception que le statut de lecture et les prêts : ce n'est pas « modifier la fiche »,
/// c'est réparer ce qu'on a sous les yeux. La saisie du formulaire d'édition est mise à jour
/// dans la foulée, sinon un passage ultérieur en édition réécrirait l'ancienne adresse.
/// </remarks>
private async Task EnregistrerCouvertureAsync()
{
if (_saisie is null)
{
return;
}
var url = _urlCouverture.Trim();
_saisie.CoverUrl = string.IsNullOrEmpty(url) ? null : url;
_enregistrement = true;
_erreur = null;
var resultat = await Api.ModifierAsync(Id, _saisie);
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_livre = resultat.Valeur;
_saisie = Recopier(_livre!);
_correctionCouverture = false;
}
private async Task ChangerStatutAsync(Statut? statut)
{
_statutEnCours = true;
+1 -34
View File
@@ -1,5 +1,4 @@
@page "/prets"
@using MaBibli.Shared.Textes
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
@@ -39,24 +38,8 @@ else
{
<p class="compteur">@_prets.Count livre@(_prets.Count > 1 ? "s" : "") dehors</p>
@* Filtre en mémoire : la liste des prêts en cours est courte, une forme normalisée en
base serait disproportionnée pour ce seul besoin (voir CLAUDE.md, lot G). La recherche
reste insensible aux accents et à la casse, comme partout ailleurs dans l'application. *@
@if (_prets.Count > 1)
{
<div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Filtrer par emprunteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Filtrer par emprunteur" />
</div>
}
@if (PretsAffiches().Count == 0)
{
<p class="message-discret">Aucun prêt ne correspond à « @_recherche ».</p>
}
<ul class="liste-livres">
@foreach (var pret in PretsAffiches())
@foreach (var pret in _prets)
{
<li class="carte-livre" @key="pret.Id">
<Couverture Url="@pret.LivreCoverUrl" Titre="@pret.LivreTitre"
@@ -94,22 +77,6 @@ else
private IReadOnlyList<PretDto>? _prets;
private string? _erreur;
private int? _enCours;
private string _recherche = string.Empty;
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
private IReadOnlyList<PretDto> PretsAffiches()
{
if (_prets is null)
{
return [];
}
var terme = NormalisationTexte.Normaliser(_recherche);
return terme.Length == 0
? _prets
: _prets.Where(p => NormalisationTexte.Normaliser(p.Emprunteur).Contains(terme)).ToList();
}
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
-673
View File
@@ -1,673 +0,0 @@
@page "/revues/{Id:int}"
@page "/revues/{Id:int}/edition"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — @(_revue?.Titre ?? "revue")</PageTitle>
@if (_revue is null)
{
<p class="message-discret" role="status">
@(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else
{
@*
Titre, identité de la revue et actions sur la MÊME ligne (lot W) : disposition
symétrique de celle de la fiche d'une série, et déjà tenue par la bibliographie et la
liste des auteurs. Elle rend les deux actions visibles sans descendre au bas d'une
collection de trente numéros.
*@
<div class="entete-fiche">
<div class="entete-fiche-texte">
<h1 class="titre-page">@_revue.Titre</h1>
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
{
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches
d'un ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
<span>@_revue.Editeur</span>
}
</p>
</div>
@if (!ModeEdition)
{
<div class="entete-fiche-actions">
@* Ajouter un numéro reste en CONSULTATION, et ce n'est pas une entorse à
« consulter d'abord, modifier ensuite » : le geste ne touche pas à la fiche de
la revue, il range un objet de plus — comme noter un prêt depuis la fiche d'un
livre. Ce sont la modification et le retrait d'un numéro qui vivent, elles,
uniquement dans l'écran d'édition. *@
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="BasculerAjout">
@(_ajout ? "Fermer" : "Ajouter un numéro")
</button>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-compact bouton-principal"
href="@($"revues/{Id}/edition")">Modifier la revue</a>
}
else
{
<button type="button" class="bouton bouton-compact bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Modifier la revue</button>
}
</div>
}
</div>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (!ModeEdition && _ajout)
{
<h2 class="titre-section">Ajouter un numéro</h2>
@*
⚠️ 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 (CLAUDE.md), et l'add-on EAN-2
qui le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
*@
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
<p class="message-avertissement" role="status">
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
avant d'enregistrer.
</p>
}
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" OnChangement="StateHasChanged" />
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
</button>
</div>
}
<h2 class="titre-section">Numéros possédés</h2>
@if (_revue.Numeros.Count == 0)
{
<p class="message-discret">
Aucun numéro recensé pour l'instant.
</p>
}
else
{
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
son ordre de lecture. L'ordre est celui rendu par l'API — les numéros sans date
ferment la liste, une date inconnue n'étant pas une date ancienne. *@
<ul class="liste-numeros">
@foreach (var numero in _revue.Numeros)
{
<li class="carte-numero" @key="numero.Id">
@if (ModeEdition && _edition == numero.Id)
{
<div class="carte-corps">
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync"
OnChangement="StateHasChanged" />
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieEdition.Numero))"
title="@MotifBlocage"
@onclick="EnregistrerNumeroAsync">
Enregistrer ce numéro
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours" @onclick="FermerEdition">
Annuler
</button>
</div>
</div>
}
else
{
@*
La couverture d'un numéro ne vient d'AUCUNE source — l'ISSN désigne
la revue, pas la parution : elle se colle à la main, ou il n'y en a
pas. C'est donc ici que le clic pour la poser sert le plus.
⚠️ Même principe que la fiche livre : cliquer une couverture absente
ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce
n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les
yeux, et c'est pourquoi ce geste-là existe aussi en consultation,
contrairement à « Modifier » et « Retirer ».
*@
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
Classe="couverture-petite" Agrandissable="true"
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
<div class="carte-corps">
<p class="carte-titre">N° @numero.Numero</p>
@if (_correctionCouverture == numero.Id)
{
<div class="barre-recherche ligne-creation">
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
@bind="_urlCouverture" @bind:event="oninput"
@onkeydown="@(e => SurToucheCouverture(e, numero))"
placeholder="https://…"
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => EnregistrerCouvertureAsync(numero)">
@(_enCours ? "Enregistrement…" : "Enregistrer")
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => _correctionCouverture = null">
Annuler
</button>
</div>
<p class="message-discret">
Collez l'adresse d'une image, puis Entrée. Vider le champ
retire la couverture.
</p>
}
<p class="carte-details">
@if (numero.DateParution is { } parution)
{
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
}
else
{
<span>Date de parution inconnue</span>
}
</p>
@* Les articles à la une décrivent CE numéro, pas la revue : ils
changent à chaque parution. D'où une étiquette par titre, sur la
fiche du numéro et nulle part ailleurs. *@
@if (numero.Articles.Count > 0)
{
<p class="carte-details">
@foreach (var article in numero.Articles)
{
<span class="etiquette etiquette-une">@article</span>
}
</p>
}
@if (!string.IsNullOrWhiteSpace(numero.Note))
{
<p class="souhait-note">@numero.Note</p>
}
</div>
@*
⚠️ Modifier et retirer un numéro n'existent QUE dans l'écran
d'édition : en consultation, ces deux boutons se déclenchaient sous
le pouce en faisant défiler la collection — même raison que les
flèches d'ordre d'une série, reléguées sur leur propre écran.
*@
@if (ModeEdition)
{
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Modifier le numéro {numero.Numero}")"
@onclick="() => OuvrirEdition(numero)">
Modifier
</button>
@* Un retrait est destructeur et ne se défait pas : il se
confirme, comme la suppression d'un livre ou d'une revue. *@
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Retirer le numéro {numero.Numero}")"
@onclick="() => RetirerAsync(numero)">
@(_confirmeRetrait == numero.Id ? "Confirmer le retrait" : "Retirer")
</button>
</div>
}
}
</li>
}
</ul>
}
@if (ModeEdition)
{
<h2 class="titre-section">La revue</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils
n'existent que par leur revue.
</p>
}
}
}
@code {
[Parameter] public int Id { get; set; }
/// <summary>
/// Numéro lu sur l'add-on <b>EAN-2</b> du code-barres, transmis par l'écran de scan.
/// </summary>
/// <remarks>
/// ⚠️ <b>Proposé, pas acquis.</b> L'EAN-2 est normativement le numéro de parution, mais
/// rien ne l'a confirmé sur des magazines réels : il atterrit donc dans le champ, où
/// l'utilisateur le compare à la couverture et le corrige. C'est aussi pourquoi l'écran le
/// dit explicitement au lieu de le glisser en douce.
/// </remarks>
[SupplyParameterFromQuery(Name = "numero")]
public string? NumeroScanne { get; set; }
/// <summary>Le numéro scanné n'est proposé qu'une fois : ensuite, le champ appartient à l'utilisateur.</summary>
private bool _numeroPropose;
private RevueDto? _revue;
private bool _chargement = true;
private bool _enCours;
private string? _erreur;
private bool _confirmeSuppression;
private string _titre = string.Empty;
private string? _issn;
private string? _editeur;
/// <summary>Saisie du numéro qu'on ajoute, et repli du formulaire qui la porte.</summary>
private AjoutNumeroRevue _saisie = new();
private bool _ajout;
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
private int? _edition;
private AjoutNumeroRevue _saisieEdition = new();
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
private int? _confirmeRetrait;
/// <summary>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
private int? _correctionCouverture;
private string _urlCouverture = string.Empty;
private ElementReference _champUrlCouverture;
private bool _focusCouverture;
/// <summary>
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
/// numéro se modifie ou se retire.
/// </summary>
/// <remarks>
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : convention du projet depuis
/// la fiche livre. Comme les deux routes partagent le même paramètre <c>Id</c>, le routeur
/// ne déclenche <b>aucun</b> nouveau rendu en passant de l'une à l'autre — d'où l'abonnement
/// à <c>LocationChanged</c>, sans lequel l'URL changerait et l'écran resterait le même.
/// </remarks>
private bool ModeEdition =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
}
/// <summary>
/// Quitter l'édition referme ce qui n'a de sens que là : un formulaire de numéro resté
/// ouvert, et une confirmation de retrait en attente.
/// </summary>
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(() =>
{
if (!ModeEdition)
{
_edition = null;
_confirmeRetrait = null;
_confirmeSuppression = false;
}
StateHasChanged();
});
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
protected override Task OnParametersSetAsync() => ChargerAsync();
private async Task ChargerAsync()
{
_chargement = true;
_revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id);
_chargement = false;
if (_revue is not null)
{
_titre = _revue.Titre;
_issn = _revue.Issn;
_editeur = _revue.Editeur;
}
// Un numéro lu au code-barres ouvre le formulaire de lui-même : on vient de scanner,
// demander un clic de plus pour saisir ce qu'on tient en main serait un détour.
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
_numeroPropose = true;
_saisie.Numero = NumeroScanne.Trim();
_ajout = true;
}
}
private void BasculerAjout()
{
_erreur = null;
_ajout = !_ajout;
}
private void Appliquer(RevueDto revue)
{
_revue = revue;
_titre = revue.Titre;
_issn = revue.Issn;
_editeur = revue.Editeur;
}
private async Task AjouterAsync()
{
if (string.IsNullOrWhiteSpace(_saisie.Numero) || !Reseau.EnLigne || _enCours)
{
return;
}
_enCours = true;
_erreur = null;
// La conversion de la date en UTC vit dans ChampsNumeroRevue : les deux formulaires qui
// s'en servent ne peuvent donc pas l'oublier chacun de leur côté.
var resultat = await Api.AjouterNumeroAsync(Id, _saisie);
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_saisie = new AjoutNumeroRevue();
}
/// <summary>
/// Ouvre la modification d'un numéro, en recopiant ce qu'il porte déjà.
/// </summary>
/// <remarks>
/// La couverture et les articles à la une <b>ne peuvent pas</b> être connus à l'achat : on
/// note un numéro quand on le range, et on en recopie la une plus tard. Sans cet écran, ces
/// deux champs n'auraient existé qu'à la création, c'est-à-dire jamais.
/// </remarks>
private void OuvrirEdition(NumeroRevueDto numero)
{
_erreur = null;
_confirmeRetrait = null;
_edition = numero.Id;
_saisieEdition = new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = numero.CoverUrl,
Articles = [.. numero.Articles],
};
}
private void FermerEdition() => _edition = null;
/// <summary>
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
/// </summary>
/// <remarks>
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
/// </remarks>
private void OuvrirCorrectionCouverture(NumeroRevueDto numero)
{
_erreur = null;
_confirmeRetrait = null;
_correctionCouverture = numero.Id;
_urlCouverture = numero.CoverUrl ?? string.Empty;
_focusCouverture = true;
}
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_focusCouverture)
{
_focusCouverture = false;
await _champUrlCouverture.FocusAsync();
}
}
private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero)
{
if (e.Key == "Enter")
{
await EnregistrerCouvertureAsync(numero);
}
else if (e.Key is "Escape" or "Esc")
{
_correctionCouverture = null;
}
}
/// <summary>
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
/// </summary>
/// <remarks>
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, articles à la une compris : tout ce
/// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une
/// couverture effacerait la une. C'est la même précaution que dans <c>OuvrirEdition</c>, et
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
/// </remarks>
private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero)
{
if (!Reseau.EnLigne || _enCours)
{
return;
}
var url = _urlCouverture.Trim();
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierNumeroAsync(
numero.Id,
new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = string.IsNullOrEmpty(url) ? null : url,
Articles = [.. numero.Articles],
});
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_correctionCouverture = null;
}
private async Task EnregistrerNumeroAsync()
{
if (_edition is not { } numeroId
|| string.IsNullOrWhiteSpace(_saisieEdition.Numero)
|| !Reseau.EnLigne
|| _enCours)
{
return;
}
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierNumeroAsync(numeroId, _saisieEdition);
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_edition = null;
}
private async Task RetirerAsync(NumeroRevueDto numero)
{
// Premier clic : on demande confirmation. Un numéro retiré ne se récupère pas, et la
// liste s'égrène sous le pouce.
if (_confirmeRetrait != numero.Id)
{
_erreur = null;
_confirmeRetrait = numero.Id;
return;
}
_enCours = true;
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
_confirmeRetrait = null;
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
// ligne disparue n'aurait plus rien à enregistrer.
if (_edition == numero.Id)
{
_edition = null;
}
// Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible.
if (_correctionCouverture == numero.Id)
{
_correctionCouverture = null;
}
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task EnregistrerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierRevueAsync(
Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
// La fiche est à jour : on retombe sur la consultation, comme après l'édition d'un
// livre ou d'une série. Rester en édition inviterait à réenregistrer.
Navigation.NavigateTo($"revues/{Id}");
}
private async Task SupprimerAsync()
{
if (!_confirmeSuppression)
{
_confirmeSuppression = true;
return;
}
_enCours = true;
_erreur = await Api.SupprimerRevueAsync(Id);
_enCours = false;
if (_erreur is null)
{
Navigation.NavigateTo("revues");
}
}
}
-118
View File
@@ -1,118 +0,0 @@
@page "/revues"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — revues</PageTitle>
<h1 class="titre-page">Revues</h1>
<p class="message-discret">
Une fiche par revue, les numéros à l'intérieur — douze numéros d'un magazine ne font pas
douze fiches. Les revues ne sont pas au catalogue : ce ne sont pas des livres.
</p>
@if (_revues is null)
{
<p class="message-discret" role="status">Chargement…</p>
}
else if (_revues.Count == 0)
{
<p class="message-discret">
Aucune revue. Scannez le code-barres d'un magazine depuis « Ajouter un ouvrage » :
le code d'une revue commence par 977 et porte un ISSN, pas un ISBN. Sans code à lire,
<a href="revues/ajout">saisissez-la à la main</a>.
</p>
}
else
{
<ul class="liste-series">
@foreach (var revue in _revues)
{
@*
⚠️ Toute la LIGNE ouvre la fiche (lot V), et pas seulement le titre : c'est ce que
le projet tient déjà au catalogue, où la carte entière y mène. Viser un titre de
trois mots au pouce était le seul point d'entrée.
Le lien reste un vrai lien : il porte l'adresse, le clavier et le clic-milieu.
Son clic ne remonte donc PAS jusqu'à la ligne, qui navigerait une seconde fois.
Toute action posée un jour sur cette ligne doit faire de même
(@@onclick:stopPropagation), sans quoi elle ouvrirait la fiche en s'exécutant.
*@
<li class="carte-serie carte-cliquable" @key="revue.Id"
@onclick="() => Ouvrir(revue.Id)">
<a class="carte-titre" href="@($"revues/{revue.Id}")"
@onclick:stopPropagation="true">@revue.Titre</a>
<p class="carte-details">
<span>
@(revue.Numeros.Count == 0
? "Aucun numéro"
: $"{revue.Numeros.Count} numéro{(revue.Numeros.Count > 1 ? "s" : "")}")
</span>
@if (!string.IsNullOrWhiteSpace(revue.Issn))
{
<span class="code-isbn">ISSN @FormatageIssn.Afficher(revue.Issn)</span>
}
@if (!string.IsNullOrWhiteSpace(revue.Editeur))
{
<span>@revue.Editeur</span>
}
</p>
@* Le dernier numéro reçu répond seul à « est-ce que je l'ai déjà ? », qui est la
question qu'on se pose devant un kiosque. *@
@if (revue.Numeros.FirstOrDefault() is { } dernier)
{
<p class="carte-details">Dernier : n° @dernier.Numero @Quand(dernier)</p>
}
</li>
}
</ul>
}
@*
⚠️ Le champ « nom d'une nouvelle revue » a quitté cet écran : l'ajout passe par la MÊME
entrée que celle du catalogue, « Ajouter un ouvrage », qui scanne et reconnaît seule un
code 977. Deux points de départ selon ce qu'on croit tenir obligeaient à trancher avant
de regarder le code-barres.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
</nav>
@code {
private IReadOnlyList<RevueDto>? _revues;
private void Ouvrir(int id) => Navigation.NavigateTo($"revues/{id}");
private static string Quand(NumeroRevueDto numero) =>
numero.DateParution is { } date ? $"({date.ToLocalTime():MM/yyyy})" : string.Empty;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
protected override Task OnInitializedAsync() => ChargerAsync();
private async Task ChargerAsync() => _revues = await Api.ListerRevuesAsync();
}
File diff suppressed because it is too large Load Diff
-121
View File
@@ -1,121 +0,0 @@
@page "/series"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — séries</PageTitle>
<h1 class="titre-page">Séries</h1>
<p class="message-discret">
Sagas, cycles et trilogies, dans leur ordre de <strong>lecture</strong> — qui n'est pas
toujours celui de publication. Les séries sont communes au foyer, comme le catalogue.
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@* Créer une série depuis la liste : c'est le seul endroit d'où l'on part, et un écran de plus
pour un unique champ ne se justifierait pas. *@
@* M1 : le champ et son bouton sur UNE ligne. Le champ prend la place restante, le bouton
garde la sienne et son libellé ne se tronque pas — vérifié jusqu'à 320 px. *@
<div class="ligne-creation">
<input class="champ-saisie" type="text" placeholder="Nom d'une nouvelle série…"
@bind="_nouveau" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="Nom de la nouvelle série" autocomplete="off" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveau))"
title="@MotifBlocage"
@onclick="CreerAsync">
Créer
</button>
</div>
@if (_series is null)
{
<p class="message-discret" role="status">Chargement…</p>
}
else if (_series.Count == 0)
{
<p class="message-discret">
Aucune série pour l'instant. Créez-en une, puis ajoutez-y vos livres et les tomes
qui vous manquent : c'est ce qui permet de voir les trous.
</p>
}
else
{
<ul class="liste-series arbre">
@foreach (var serie in Racines)
{
<ArbreSerie Serie="serie" Toutes="_series" Totaux="_totaux" Replies="_replies" />
}
</ul>
}
@code {
private IReadOnlyList<SerieDto>? _series;
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
new Dictionary<int, (int, int)>();
/// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
private readonly HashSet<int> _replies = [];
private string _nouveau = string.Empty;
private bool _enCours;
private string? _erreur;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>Séries de premier niveau. Celles rangées dans un cycle s'affichent sous lui.</summary>
private IEnumerable<SerieDto> Racines =>
(_series?.Where(s => s.SerieParenteId is null) ?? []).OrderBy(s => s.Titre);
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
protected override Task OnInitializedAsync() => ChargerAsync();
private async Task ChargerAsync()
{
// ⚠️ Le catalogue n'est plus chargé ici : l'arbre ne montre que la descendance des
// séries, plus leurs tomes. C'était une lecture complète du fonds pour rien.
_series = await Api.ListerSeriesAsync();
_totaux = TotauxSeries.Calculer(_series);
}
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveau) && Reseau.EnLigne)
{
await CreerAsync();
}
}
private async Task CreerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.CreerSerieAsync(new EnregistrementSerie { Titre = _nouveau });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_nouveau = string.Empty;
await ChargerAsync();
}
}
+23 -167
View File
@@ -1,8 +1,6 @@
@page "/souhaits"
@page "/souhaits/ordre"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — liste d'envies</PageTitle>
@@ -12,9 +10,9 @@
@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas.
Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@
<p class="message-discret">
Les livres et les revues que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
Les livres que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
les autres membres du foyer ont la leur, et ne voient pas celle-ci.
Rien de ce qui est ici n'est dans le catalogue, ni ne compte dans la bibliothèque.
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
</p>
@if (_erreur is not null)
@@ -28,7 +26,7 @@
}
else
{
@if (_souhaits.Count == 0 && _revues.Count == 0)
@if (_souhaits.Count == 0)
{
@* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
@@ -54,7 +52,7 @@ else
}
else
{
<p class="compteur">@Compteur()</p>
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
part en librairie, elle ne doit pas être au bout d'un défilement. *@
@@ -69,7 +67,6 @@ else
<div class="actions-export">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="souhaits/ajout">Ajouter une envie</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
@@ -79,8 +76,6 @@ else
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en texte</button>
<button type="button" class="bouton bouton-discret" disabled
@@ -97,7 +92,7 @@ else
</p>
}
@if (ModeOrdre && _souhaits.Count > 1)
@if (_souhaits.Count > 1)
{
<p class="champ-aide">
Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
@@ -110,21 +105,18 @@ else
HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
*@
@* Une liste vide n'est pas rendue : on peut n'avoir que des revues en envie. *@
@if (_souhaits.Count > 0)
{
<ul class="liste-souhaits">
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
{
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
@key="souhait.Id"
draggable="@((ModeOrdre && Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => DebuterGlisse(souhait.Id)"
@ondragend="TerminerGlisse"
@ondragover:preventDefault="ModeOrdre"
draggable="@((Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => _glisse = souhait.Id"
@ondragend="() => _glisse = null"
@ondragover:preventDefault="true"
@ondrop="() => DeposerAsync(index)">
@if (ModeOrdre && _souhaits.Count > 1)
@if (_souhaits.Count > 1)
{
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
@@ -155,21 +147,6 @@ else
}
<p class="carte-details">
@*
⚠️ On SIGNALE, on ne supprime pas : la liste d'envies est
personnelle et le catalogue est commun, donc l'envie n'est
peut-être pas de celui qui a ajouté le livre — et la note
(« demandé à Noël ») disparaîtrait avec elle. Le rapprochement
est en outre faillible : l'étiquette mène à la fiche pour qu'on
puisse vérifier avant de retirer quoi que ce soit.
*@
@if (souhait is { Possede: true, LivreId: { } livreId })
{
<a class="etiquette etiquette-possede" href="@($"livres/{livreId}")">
Déjà au catalogue
</a>
}
@if (!string.IsNullOrWhiteSpace(souhait.Editeur))
{
<span>@souhait.Editeur</span>
@@ -190,79 +167,12 @@ else
}
</div>
@*
« Éditer » AU-DESSUS de « Retirer » : les deux gestes se ressemblent, et
celui qui répare doit se présenter avant celui qui détruit.
⚠️ Un lien, et non un bouton désactivé hors-ligne : la NAVIGATION reste
toujours possible, c'est l'écriture qui se refuse — et l'écran d'édition
le dit lui-même, avec son motif.
*@
@if (!ModeOrdre)
{
<div class="souhait-actions">
<a class="bouton bouton-compact" href="@($"souhaits/{souhait.Id}/edition")"
aria-label="@($"Éditer « {souhait.Titre} »")">
Éditer
</a>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
</div>
}
</li>
}
</ul>
}
}
@*
⚠️ Une SECTION à part, et non des lignes mêlées aux livres : les deux tables numérotent
leur rang indépendamment (voir RevueSouhaitee.Rang), donc un classement commun afficherait
un ordre que personne n'a choisi. C'est le coût assumé de la table sœur — mais la liste
les porte bien toutes les deux, y compris hors-ligne, ce qui est le seul point qui compte
au kiosque.
*@
@if (_revues.Count > 0 && !ModeOrdre)
{
<h2 class="titre-section">Revues et magazines</h2>
<ul class="liste-souhaits">
@foreach (var revue in _revues)
{
<li class="carte-souhait" @key="revue">
<div class="carte-corps">
<p class="carte-titre">@revue.TitreComplet</p>
@if (!string.IsNullOrWhiteSpace(revue.Issn))
{
<p class="carte-details">
<span class="etiquette code-isbn">ISSN @revue.Issn</span>
</p>
}
@if (!string.IsNullOrWhiteSpace(revue.Note))
{
<p class="souhait-note">@revue.Note</p>
}
</div>
<div class="souhait-actions">
<a class="bouton bouton-compact" href="@($"souhaits/revues/{revue.Id}/edition")"
aria-label="@($"Éditer « {revue.TitreComplet} »")">
Éditer
</a>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SupprimerRevueAsync(revue)"
aria-label="@($"Retirer « {revue.TitreComplet} » de la liste d'envies")">
Retirer
</button>
</div>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
</li>
}
</ul>
@@ -272,35 +182,29 @@ else
@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
fiche livre ne montre plus son formulaire. *@
<nav class="actions-flottantes">
@if (ModeOrdre)
@if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="souhaits">Terminer</a>
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
}
else if (Reseau.EnLigne)
else
{
<a class="bouton bouton-discret" href="souhaits/ordre">Modifier l'ordre</a>
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
}
</nav>
@code {
private IReadOnlyList<SouhaitDto>? _souhaits;
private IReadOnlyList<RevueSouhaiteeDto> _revues = [];
private bool _enCours;
private int? _glisse;
private string? _erreur;
private bool ModeOrdre =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith("/souhaits/ordre", StringComparison.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
await ChargerAsync();
}
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);
/// <summary>
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
/// </summary>
@@ -314,11 +218,7 @@ else
StateHasChanged();
});
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
public void Dispose() => Reseau.Change -= SurChangementReseau;
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
@@ -338,10 +238,6 @@ else
{
_erreur = null;
_souhaits = await Api.ListerSouhaitsAsync();
// Les deux moitiés se chargent ensemble : une liste d'envies amputée de ses revues
// serait fausse sans le dire.
_revues = await Api.ListerRevuesSouhaiteesAsync();
}
catch (Exception)
{
@@ -396,7 +292,7 @@ else
/// <summary>Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.</summary>
private async Task DeposerAsync(int cible)
{
if (!ModeOrdre || _souhaits is null || _glisse is not { } id)
if (_souhaits is null || _glisse is not { } id)
{
return;
}
@@ -410,46 +306,6 @@ else
}
}
private void DebuterGlisse(int id)
{
if (ModeOrdre && Reseau.EnLigne)
{
_glisse = id;
}
}
private void TerminerGlisse() => _glisse = null;
/// <summary>« 3 livres et 1 revue », sans jamais nommer une section vide.</summary>
private string Compteur()
{
var morceaux = new List<string>();
if (_souhaits is { Count: > 0 })
{
morceaux.Add($"{_souhaits.Count} livre{(_souhaits.Count > 1 ? "s" : "")}");
}
if (_revues.Count > 0)
{
morceaux.Add($"{_revues.Count} revue{(_revues.Count > 1 ? "s" : "")}");
}
return string.Join(" et ", morceaux) + " en envie";
}
private async Task SupprimerRevueAsync(RevueSouhaiteeDto revue)
{
_enCours = true;
_erreur = await Api.SupprimerRevueSouhaiteeAsync(revue.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SupprimerAsync(SouhaitDto souhait)
{
_enCours = true;
+40
View File
@@ -0,0 +1,40 @@
using System.Diagnostics;
using Microsoft.JSInterop;
namespace MaBibli.Client.Scan;
/// <summary>
/// Mesure du coût réel du décodage <b>dans le navigateur</b>.
///
/// CLAUDE.md ne dispose que de chiffres JIT x64 (0,53 ms/frame dans le pire cas) et d'une
/// extrapolation non vérifiée pour le WASM interprété (~5-10 ms/frame). Ce banc d'essai
/// donne le chiffre constaté, sur la machine de l'utilisateur, sans caméra ni code-barres.
///
/// On mesure volontairement le <b>pire cas</b> — une frame de bruit sans code-barres —
/// car c'est l'échec de décodage qui coûte le plus cher, et la quasi-totalité des frames
/// d'une caméra sont dans ce cas. C'est donc lui qui gouverne le framerate atteignable.
///
/// Depuis la console du navigateur :
/// <code>await DotNet.invokeMethodAsync('MaBibli.Client', 'MesurerScan', 640, 288, 20)</code>
/// </summary>
public static class BancEssaiScan
{
[JSInvokable("MesurerScan")]
public static double MesurerPireCasMs(int largeur, int hauteur, int iterations)
{
var rgba = new byte[largeur * hauteur * 4];
new Random(1234).NextBytes(rgba);
// Un tour à blanc : la première exécution paie l'initialisation de ZXing,
// qui n'a rien à voir avec le coût par frame en régime établi.
IsbnScanner.TryDecode(rgba, largeur, hauteur);
var depart = Stopwatch.GetTimestamp();
for (var i = 0; i < iterations; i++)
{
IsbnScanner.TryDecode(rgba, largeur, hauteur);
}
return Stopwatch.GetElapsedTime(depart).TotalMilliseconds / iterations;
}
}
+53
View File
@@ -0,0 +1,53 @@
using ZXing;
using ZXing.Common;
namespace MaBibli.Client.Scan;
/// <summary>
/// Décodage d'un code-barres EAN-13 / EAN-8 à partir d'un buffer de pixels RGBA.
///
/// Décision actée dans CLAUDE.md : le décodage reste **entièrement en C#** (ZXing.Net),
/// le JavaScript ne sert qu'à fournir les pixels de la caméra. Cette classe ne dépend
/// donc ni du navigateur, ni de Blazor : elle est réutilisable telle quelle hors WASM
/// (app native, décodage d'une photo côté serveur, scan en masse).
/// </summary>
public static class IsbnScanner
{
// ZXing.Net n'est pas thread-safe sur ce lecteur, mais le WASM est mono-thread
// et la boucle de scan est séquentielle : une instance partagée suffit et évite
// de reconstruire les hints à chaque frame.
private static readonly MultiFormatReader Reader = new()
{
Hints = new Dictionary<DecodeHintType, object>
{
[DecodeHintType.POSSIBLE_FORMATS] = new List<BarcodeFormat>
{
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
},
[DecodeHintType.TRY_HARDER] = true,
},
};
/// <summary>
/// Tente de décoder un code-barres. Renvoie le texte brut du code, ou <c>null</c>
/// si la frame n'en contient aucun de lisible — cas très majoritaire en usage réel.
/// </summary>
/// <param name="rgba">Buffer brut issu de <c>ctx.getImageData(...).data</c> (4 octets par pixel).</param>
public static string? TryDecode(byte[] rgba, int largeur, int hauteur)
{
ArgumentNullException.ThrowIfNull(rgba);
if (largeur <= 0 || hauteur <= 0 || rgba.Length < largeur * hauteur * 4)
{
return null;
}
// RGBLuminanceSource consomme directement le buffer du canvas :
// aucune bibliothèque d'image nécessaire (ni SkiaSharp, ni ImageSharp).
var source = new RGBLuminanceSource(
rgba, largeur, hauteur, RGBLuminanceSource.BitmapFormat.RGBA32);
// La version .NET de ZXing renvoie null sur échec (pas d'exception).
return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text;
}
}
-22
View File
@@ -1,22 +0,0 @@
namespace MaBibli.Client.Services;
/// <summary>
/// Les états ARIA, écrits en toutes lettres.
/// </summary>
/// <remarks>
/// ⚠️ <b><c>aria-expanded="@_ouvert"</c> ne fait pas ce qu'on croit.</b> Blazor traite un booléen
/// comme un attribut de <i>présence</i> — à la manière de <c>disabled</c> ou <c>hidden</c> : vrai
/// rend <c>aria-expanded=""</c>, faux <b>retire l'attribut</b>. Or ARIA attend les chaînes
/// « true » et « false », et un lecteur d'écran ne sait lire ni l'une ni l'autre de ces deux
/// formes : replié, le bouton ne s'annonce plus comme repliable du tout.
/// <para>
/// Le défaut est passé inaperçu cinq fois (2026-08-22) parce qu'il ne se voit pas à l'écran et
/// qu'aucun test ne rend de balisage. D'où cette fonction : elle se remarque à la relecture là
/// où un booléen nu se lit comme correct.
/// </para>
/// </remarks>
public static class Aria
{
/// <summary>« true » ou « false », jamais un attribut de présence.</summary>
public static string Etat(bool valeur) => valeur ? "true" : "false";
}
-158
View File
@@ -1,5 +1,4 @@
using System.Text.Json;
using MaBibli.Shared.Catalogue;
using Microsoft.JSInterop;
namespace MaBibli.Client.Services;
@@ -20,17 +19,6 @@ public static class ClesCache
public const string Utilisateur = "utilisateur";
/// <summary>
/// La version publiée et sa date de build, telles que le serveur les a annoncées.
/// </summary>
/// <remarks>
/// ⚠️ Elle vaut la peine d'être rangée précisément parce qu'on la lit quand quelque chose ne
/// va pas — et un appareil dont on soupçonne le cache est aussi bien celui qui n'a plus de
/// réseau. Ce qui est affiché hors-ligne est la <b>dernière version vue du serveur</b>, et
/// l'écran le dit : c'est un fait daté, pas une supposition.
/// </remarks>
public const string Version = "version";
/// <summary>
/// La liste d'envies de l'utilisateur courant.
/// </summary>
@@ -46,40 +34,6 @@ public static class ClesCache
/// </para>
/// </remarks>
public const string Souhaits = "souhaits";
/// <summary>
/// Les revues et numéros souhaités : la <b>seconde moitié</b> de la liste d'envies.
/// </summary>
/// <remarks>
/// ⚠️ Un instantané à part parce que l'API en fait une vue à part (table sœur), pas parce
/// que l'écran en ferait deux : la liste d'envies est le seul écran qu'on emporte
/// délibérément là où le réseau manque, et une moitié absente en librairie ne servirait à
/// rien. Personnel, comme <see cref="Souhaits"/> — jamais paramétré par utilisateur.
/// </remarks>
public const string SouhaitsRevues = "souhaits-revues";
/// <summary>
/// Toutes les séries, à plat, avec leurs tomes.
/// </summary>
/// <remarks>
/// Un seul instantané sert la liste <b>et</b> le détail de chaque série, l'API rendant
/// l'arbre entier en une lecture. C'est ce qui évite un instantané par série consultée —
/// et surtout ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte avant
/// la coupure, exactement pour la même raison que le catalogue complet.
/// <para>
/// Communes au foyer, contrairement à <see cref="Souhaits"/>.
/// </para>
/// </remarks>
public const string Series = "series";
/// <summary>
/// Les revues, avec les numéros possédés. Communes au foyer, comme les séries.
/// </summary>
/// <remarks>
/// Sa raison d'être est la même que celle de la liste d'envies : savoir <b>si l'on a déjà
/// ce numéro</b> se demande devant le présentoir d'un kiosque, là où le réseau manque.
/// </remarks>
public const string Revues = "revues";
}
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
@@ -146,57 +100,6 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
}
}
// -----------------------------------------------------------------------
// File de catalogage en rafale (lot AB)
// -----------------------------------------------------------------------
// ⚠️ Magasin SÉPARÉ des instantanés, et volontairement : ceux-ci portent ce que le serveur
// a rendu, effaçable sans rien perdre. Celle-ci porte ce que l'utilisateur a scanné, et qui
// n'existe nulle part ailleurs.
/// <summary>Range la rafale en cours. Appelée à chaque scan : perdre la file serait cuisant.</summary>
public async Task EcrireRafaleAsync(Rafale rafale)
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("rafaleEcrire", JsonSerializer.Serialize(rafale, Json));
}
catch (Exception)
{
// ⚠️ On ne marque PAS le cache indisponible ici : ne pas pouvoir ranger la file
// n'empêche pas de la traiter dans la session en cours. C'est la reprise ultérieure
// qui est perdue, et l'écran le dit.
}
}
/// <summary>Relit la rafale interrompue, s'il y en a une.</summary>
public async Task<Rafale?> LireRafaleAsync()
{
try
{
var module = await ModuleAsync();
var brut = await module.InvokeAsync<string?>("rafaleLire");
return brut is null ? null : JsonSerializer.Deserialize<Rafale>(brut, Json);
}
catch (Exception)
{
return null;
}
}
public async Task EffacerRafaleAsync()
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("rafaleEffacer");
}
catch (Exception)
{
// Sans conséquence : une file qu'on n'a pas pu effacer sera écrasée à la suivante.
}
}
/// <summary>État déclaré par le navigateur. Voir la nuance dans <c>cache-hors-ligne.js</c>.</summary>
public async Task<bool> EnLigneAsync()
{
@@ -225,67 +128,6 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
}
}
/// <summary>
/// Range une couverture en cache pour la consultation hors-ligne, en tâche de fond.
/// </summary>
/// <remarks>
/// Fire-and-forget par nature (A5, CLAUDE.md) : appelé après l'affichage réussi d'une
/// couverture, sans jamais bloquer ni faire échouer ce qui s'est déjà bien passé. Une
/// couverture qui ne peut pas être mise en cache (quota, stockage refusé, 502
/// intermittent d'OpenLibrary) reste simplement absente hors-ligne — jamais une erreur
/// visible en ligne.
/// </remarks>
public async Task MettreEnCacheCouvertureAsync(string url)
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("couvertureMettreEnCache", url);
}
catch (Exception)
{
// Voir la remarque ci-dessus : rien à propager.
}
}
/// <summary>
/// Relit une couverture déjà mise en cache, sous forme d'URL d'objet (<c>blob:</c>) utilisable
/// directement comme <c>src</c>. <c>null</c> si elle n'a jamais été mise en cache.
/// </summary>
public async Task<string?> LireCouvertureCacheeAsync(string url)
{
try
{
var module = await ModuleAsync();
return await module.InvokeAsync<string?>("couvertureLire", url);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Libère une URL d'objet obtenue par <see cref="LireCouvertureCacheeAsync"/>.
/// </summary>
/// <remarks>
/// ⚠️ Nécessaire depuis que le cache sert aussi en ligne : une URL <c>blob:</c> retient son
/// Blob en mémoire tant qu'elle n'est pas révoquée. Avec une vignette par carte sur tous les
/// écrans, ne pas libérer ferait enfler la mémoire de l'onglet au fil de la navigation.
/// </remarks>
public async Task LibererCouvertureAsync(string url)
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("couvertureLiberer", url);
}
catch (Exception)
{
// Voir MettreEnCacheCouvertureAsync : rien à propager.
}
}
public async ValueTask DisposeAsync()
{
if (_module is null)
+5 -44
View File
@@ -1,6 +1,5 @@
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
@@ -37,11 +36,6 @@ public static class FiltreLivresLocal
livres = livres.Where(l => l.Format == format);
}
if (criteres.TypeDocument is { } type)
{
livres = livres.Where(l => l.TypeDocument == type);
}
if (criteres.Statut is { } statut)
{
// LivreDto.Statut est déjà celui de l'utilisateur courant : le serveur l'a résolu
@@ -54,31 +48,6 @@ public static class FiltreLivresLocal
livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId));
}
if (criteres.Prete is { } prete)
{
// LivreDto.PreteA porte l'emprunteur du prêt EN COURS, ou null : le serveur a déjà
// écarté les prêts clos. C'est exactement ce que le filtre serveur exprime par
// « une ligne sans date de retour », d'où l'équivalence des deux implémentations.
livres = livres.Where(l => (l.PreteA is not null) == prete);
}
if (criteres.SansCouverture is { } sansCouverture)
{
// Même convention que côté serveur : une URL vide vaut une URL absente.
livres = livres.Where(l => string.IsNullOrEmpty(l.CoverUrl) == sansCouverture);
}
// Même exclusion que côté serveur : un code désigne un objet, des mots désignent une
// œuvre. Les deux branches ne se mélangent pas.
if (RechercheIsbn.Formes(criteres.Recherche) is { } formes)
{
livres = livres.Where(l =>
l.Isbn is not null
&& formes.Any(forme => l.Isbn.Contains(forme, StringComparison.Ordinal)));
return Ordonner(livres);
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
@@ -91,20 +60,12 @@ public static class FiltreLivresLocal
|| RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal)));
}
return Ordonner(livres);
}
/// <summary>
/// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
/// déterministe.
/// </summary>
/// <remarks>
/// La comparaison est <b>ordinale</b> parce que c'est ce que fait SQLite sur une colonne sans
/// collation — un tri « intelligent » ici ferait diverger les deux vues.
/// </remarks>
private static IReadOnlyList<LivreDto> Ordonner(IEnumerable<LivreDto> livres) =>
livres
// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
// déterministe. La comparaison est ordinale parce que c'est ce que fait SQLite sur une
// colonne sans collation — un tri « intelligent » ici ferait diverger les deux vues.
return livres
.OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal)
.ThenBy(l => l.Id)
.ToList();
}
}
@@ -1,218 +0,0 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
/// <summary>
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
/// </summary>
/// <remarks>
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une <b>série de séries</b>
/// (<c>Serie.SerieParenteId</c>), et le catalogue le montre tel quel depuis le 2026-08-22 —
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
/// </remarks>
public sealed record EntreeCatalogue
{
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
public SerieDto? Serie { get; init; }
/// <summary>Les livres rattachés <b>directement</b> à cette série ; un seul hors série.</summary>
public required IReadOnlyList<LivreDto> Livres { get; init; }
/// <summary>Les séries contenues, quand ce bloc est un cycle. Vide sinon.</summary>
public IReadOnlyList<EntreeCatalogue> SousEntrees { get; init; } = [];
public bool EstGroupe => Serie is not null;
/// <summary>
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
/// </summary>
/// <remarks>
/// C'est ce que le bloc annonce, y compris <b>replié</b> : replier ne doit pas faire
/// perdre le compte de ce qu'on vient de cacher.
/// </remarks>
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
}
/// <summary>
/// Regroupe les livres du catalogue par série, en respectant les cycles.
/// </summary>
/// <remarks>
/// <b>Le bloc se place là où son premier tome serait tombé</b> dans l'ordre du catalogue
/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
/// tomes suivent l'<b>ordre de lecture</b>, seul ordre qui ait un sens pour une saga — c'est
/// même la raison d'être de <c>ElementSerie.Position</c>, et les sous-séries d'un cycle
/// suivent le leur (<c>Serie.Position</c>), comme sur l'écran des séries.
/// <para>
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
/// pas.
/// </para>
/// <para>
/// ⚠️ Un livre peut appartenir à <b>plusieurs</b> séries (le modèle l'autorise, sans unicité sur
/// <c>LivreId</c> seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
/// <b>première par ordre alphabétique</b> — un critère explicable, à défaut d'être le bon dans
/// tous les cas ; sa fiche livre, elle, les montre toutes.
/// </para>
/// <para>
/// ⚠️ <b>Seuls les niveaux qui portent quelque chose existent.</b> Un cycle de cinq séries dont
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
/// replierait sur rien.
/// </para>
/// </remarks>
public static class GroupementCatalogue
{
public static IReadOnlyList<EntreeCatalogue> Grouper(
IReadOnlyList<LivreDto> livres, IReadOnlyList<SerieDto>? series)
{
if (series is null || series.Count == 0)
{
return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
}
var place = PlaceDesLivres(series);
var parId = series.ToDictionary(s => s.Id);
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
var brouillons = new List<Noeud>();
var noeuds = new Dictionary<int, Noeud>();
foreach (var livre in livres)
{
if (!place.TryGetValue(livre.Id, out var appartenance))
{
brouillons.Add(new Noeud(null) { Livres = { (0, livre) } });
continue;
}
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
noeud.Livres.Add((appartenance.Position, livre));
}
return [.. brouillons.Select(Materialiser)];
}
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
private sealed class Noeud(SerieDto? serie)
{
public SerieDto? Serie { get; } = serie;
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
public List<Noeud> Enfants { get; } = [];
}
/// <summary>
/// La chaîne de la racine jusqu'à <paramref name="serie"/>, cycle compris.
/// </summary>
/// <remarks>
/// ⚠️ La remontée est <b>bornée par le nombre de séries</b>, comme celle du serveur : une
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
/// </remarks>
private static List<SerieDto> Ascendance(SerieDto serie, Dictionary<int, SerieDto> parId)
{
var chaine = new List<SerieDto> { serie };
var vues = new HashSet<int> { serie.Id };
var courante = serie;
while (courante.SerieParenteId is { } parenteId
&& parId.TryGetValue(parenteId, out var parente)
&& vues.Add(parente.Id)
&& chaine.Count <= parId.Count)
{
chaine.Add(parente);
courante = parente;
}
chaine.Reverse();
return chaine;
}
/// <summary>Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.</summary>
private static Noeud Descendre(
List<SerieDto> chaine, Dictionary<int, Noeud> noeuds, List<Noeud> brouillons)
{
Noeud? parent = null;
foreach (var serie in chaine)
{
if (!noeuds.TryGetValue(serie.Id, out var noeud))
{
noeud = new Noeud(serie);
noeuds[serie.Id] = noeud;
if (parent is null)
{
brouillons.Add(noeud);
}
else
{
parent.Enfants.Add(noeud);
}
}
parent = noeud;
}
return parent!;
}
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
{
Serie = noeud.Serie,
Livres =
[
.. noeud.Livres
.OrderBy(t => t.Position)
.ThenBy(t => t.Livre.Id)
.Select(t => t.Livre),
],
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
SousEntrees =
[
.. noeud.Enfants
.OrderBy(e => e.Serie!.Position)
.ThenBy(e => e.Serie!.Id)
.Select(Materialiser),
],
};
/// <summary>
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
/// </summary>
/// <remarks>
/// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
/// <c>TryAdd</c> garde donc la <b>première</b> : sans cet ordre, la série retenue pour un
/// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
/// rien de compréhensible.
/// </remarks>
private static Dictionary<int, (SerieDto Serie, int Position)> PlaceDesLivres(
IReadOnlyList<SerieDto> series)
{
var place = new Dictionary<int, (SerieDto, int)>();
foreach (var serie in series
.OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
.ThenBy(s => s.Id))
{
foreach (var element in serie.Elements)
{
if (element.LivreId is { } livreId)
{
place.TryAdd(livreId, (serie, element.Position));
}
}
}
return place;
}
}

Some files were not shown because too many files have changed in this diff Show More