Auteurs cherchables et triables, filtre de prêt, rang dans les exports, couvertures d'envies

Auteurs
- Recherche et tri (par nom, ou du plus présent au moins présent), en mémoire : la liste
  est déjà chargée en entier — c'est elle qui sert l'instantané hors-ligne — et la filtrer
  côté serveur aurait rendu la page inutilisable sans réseau.
- Trois colonnes dont une seule est élastique : compteur à gauche sur une largeur figée,
  nom au milieu, actions à droite. Les compteurs et les boutons s'alignent alors d'une
  ligne à l'autre au lieu de suivre la longueur du nom.

Catalogue
- Filtre « À la maison / Prêtés », posé dans les deux implémentations (serveur et
  hors-ligne) et dans le test qui les confronte, avec un cas de prêt CLOS — c'est
  exactement là que les deux auraient pu diverger en silence.
- La ligne disparaît tant que rien n'est prêté, comme celles des formats et des types.

Envies
- ⚠️ Les couvertures manquaient parce que seul l'écran « ajouter une envie » remplissait
  CoverUrl : la bibliographie et les tomes manquants transmettent un ISBN mais aucune
  image. Le repli est posé à la LECTURE, donc il vaut aussi pour les envies déjà
  enregistrées, sans migration.
- Les exports .txt et .csv portent le rang d'envie. Le .txt en a le plus besoin : son
  groupement par auteur détruit précisément cet ordre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 20:42:13 +02:00
co-authored by Claude Opus 5
parent 83089d7479
commit 4fd67e4e3b
13 changed files with 344 additions and 24 deletions
+3 -1
View File
@@ -31,6 +31,7 @@ public static class LivresEndpoints
TypeDocument? typeDocument, TypeDocument? typeDocument,
Statut? statut, Statut? statut,
int? auteurId, int? auteurId,
bool? prete,
IServiceCatalogue service, IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs, IFournisseurUtilisateur utilisateurs,
CancellationToken ct) => CancellationToken ct) =>
@@ -42,13 +43,14 @@ public static class LivresEndpoints
TypeDocument = typeDocument, TypeDocument = typeDocument,
Statut = statut, Statut = statut,
AuteurId = auteurId, AuteurId = auteurId,
Prete = prete,
}; };
var utilisateur = utilisateurs.Obtenir().Identifiant; var utilisateur = utilisateurs.Obtenir().Identifiant;
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct)); return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
}) })
.WithName("ListerLivres") .WithName("ListerLivres")
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut et auteur ; cherchable sur titre et auteurs.") .WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; cherchable sur titre et auteurs.")
.WithDescription( .WithDescription(
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " "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. " + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. "
@@ -84,6 +84,13 @@ public static class ExportSouhaits
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
// ⚠️ 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");
// « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins // « 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. // exploitables en rayon, elles ne doivent pas ouvrir le fichier.
var groupes = souhaits var groupes = souhaits
@@ -97,7 +104,7 @@ public static class ExportSouhaits
foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase)) foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase))
{ {
sortie.Append(" - ").Append(souhait.Titre); sortie.Append(" - [").Append(rangs[souhait.Id]).Append("] ").Append(souhait.Titre);
// Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on // É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. // épelle à un libraire, et ça reste lisible sur un écran de téléphone.
@@ -127,12 +134,18 @@ public static class ExportSouhaits
{ {
var sortie = new StringBuilder(); var sortie = new StringBuilder();
Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le"); // 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", "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le");
var rangs = Rangs(souhaits);
foreach (var souhait in souhaits) foreach (var souhait in souhaits)
{ {
Ligne( Ligne(
sortie, sortie,
rangs[souhait.Id].ToString(CultureInfo.InvariantCulture),
souhait.Titre, souhait.Titre,
souhait.Auteur, souhait.Auteur,
souhait.Editeur, souhait.Editeur,
@@ -145,6 +158,18 @@ public static class ExportSouhaits
return sortie.ToString(); 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> /// <summary>
/// ISBN découpé pour la ligne texte : c'est un numéro qu'on <b>épelle à un libraire</b>, /// 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. /// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres.
@@ -330,7 +330,21 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
Editeur = souhait.Editeur, Editeur = souhait.Editeur,
Annee = souhait.Annee, Annee = souhait.Annee,
Isbn = souhait.Isbn, Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
// ⚠️ 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)),
Note = souhait.Note, Note = souhait.Note,
DateAjout = souhait.DateAjout, DateAjout = souhait.DateAjout,
Possede = livreId is not null, Possede = livreId is not null,
+109 -5
View File
@@ -67,21 +67,71 @@ else if (_auteurs.Count == 0)
} }
else else
{ {
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p> @*
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>
}
<ul class="liste-auteurs"> <ul class="liste-auteurs">
@foreach (var auteur in _auteurs) @foreach (var auteur in Affiches)
{ {
@*
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"> <li class="carte-auteur-ligne" @key="auteur.Id">
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a> <span class="auteur-compte" title="@Livres(auteur.NombreDeLivres)">
<span class="etiquette">@Livres(auteur.NombreDeLivres)</span> @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 IDEES.md : depuis un auteur qu'on a deja, voir @* 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. *@ tout ce qu'il a ecrit et marquer ce qui manque. *@
<a class="bouton bouton-compact bouton-discret" <a class="bouton bouton-compact bouton-discret"
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a> href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
@if (_auteurRenomme == auteur.Id) @if (_auteurRenomme == auteur.Id)
{ {
<input class="champ-texte champ-auteur-renomme" @bind="_nomRenomme" /> <input class="champ-saisie champ-auteur-renomme" @bind="_nomRenomme" />
<button type="button" class="bouton bouton-compact bouton-principal" <button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_renommageEnCours" @onclick="RenommerAsync"> disabled="@_renommageEnCours" @onclick="RenommerAsync">
Enregistrer Enregistrer
@@ -100,6 +150,7 @@ else
Renommer Renommer
</button> </button>
} }
</span>
</li> </li>
} }
</ul> </ul>
@@ -127,7 +178,19 @@ else
} }
@code { @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 IReadOnlyList<AuteurDto>? _auteurs;
private string _recherche = string.Empty;
private TriAuteurs _tri = TriAuteurs.Nom;
private IReadOnlyList<SuggestionRapprochement>? _suggestions; private IReadOnlyList<SuggestionRapprochement>? _suggestions;
private bool _enCours; private bool _enCours;
private bool _renommageEnCours; private bool _renommageEnCours;
@@ -153,6 +216,47 @@ else
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}"; 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() private async Task ChargerAsync()
{ {
_erreur = null; _erreur = null;
+46 -3
View File
@@ -86,9 +86,33 @@
} }
</div> </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>
}
<p class="champ-aide"> <p class="champ-aide">
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
lecture, pas celle du foyer. lecture, pas celle du foyer. Le prêt, lui, est commun.
</p> </p>
</div> </div>
} }
@@ -157,6 +181,7 @@ else if (_livres is not null)
private Format? _format; private Format? _format;
private TypeDocument? _type; private TypeDocument? _type;
private Statut? _statut; private Statut? _statut;
private bool? _prete;
private bool _chargement; private bool _chargement;
private bool _filtresOuverts; private bool _filtresOuverts;
private string? _erreur; private string? _erreur;
@@ -180,6 +205,14 @@ else if (_livres is not null)
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1; 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> /// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary> /// </summary>
@@ -189,14 +222,15 @@ else if (_livres is not null)
/// qui serait invisible une fois le panneau refermé. /// qui serait invisible une fois le panneau refermé.
/// </remarks> /// </remarks>
private int FiltresActifs => private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1); (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete is null ? 0 : 1);
// Une frappe au clavier annule la recherche précédente encore en vol. // Une frappe au clavier annule la recherche précédente encore en vol.
private CancellationTokenSource _cts = new(); private CancellationTokenSource _cts = new();
private bool EstFiltre => private bool EstFiltre =>
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|| _statut is not null || AuteurId is not null; || _statut is not null || _prete is not null || AuteurId is not null;
/// <summary> /// <summary>
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
@@ -245,12 +279,19 @@ else if (_livres is not null)
return ChargerAsync(); return ChargerAsync();
} }
private Task FiltrerPret(bool? prete)
{
_prete = prete;
return ChargerAsync();
}
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary> /// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
private static bool EstSansCritere(CritereLivres criteres) => private static bool EstSansCritere(CritereLivres criteres) =>
string.IsNullOrWhiteSpace(criteres.Recherche) string.IsNullOrWhiteSpace(criteres.Recherche)
&& criteres.Format is null && criteres.Format is null
&& criteres.TypeDocument is null && criteres.TypeDocument is null
&& criteres.Statut is null && criteres.Statut is null
&& criteres.Prete is null
&& criteres.AuteurId is null; && criteres.AuteurId is null;
private async Task ChargerAsync() private async Task ChargerAsync()
@@ -271,6 +312,7 @@ else if (_livres is not null)
Format = _format, Format = _format,
TypeDocument = _type, TypeDocument = _type,
Statut = _statut, Statut = _statut,
Prete = _prete,
AuteurId = AuteurId, AuteurId = AuteurId,
}; };
@@ -280,6 +322,7 @@ else if (_livres is not null)
{ {
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet(); _typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -53,6 +53,14 @@ public static class FiltreLivresLocal
livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId)); 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);
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche); var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0) if (terme.Length > 0)
{ {
@@ -95,6 +95,12 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
parametres.Add($"auteurId={auteurId}"); parametres.Add($"auteurId={auteurId}");
} }
if (criteres.Prete is { } prete)
{
// Minuscules : c'est ce que le liage de ASP.NET Core attend d'un bool?.
parametres.Add($"prete={(prete ? "true" : "false")}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct)); var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));
+1
View File
@@ -14,3 +14,4 @@
@using MaBibli.Shared.Dtos @using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites @using MaBibli.Shared.Entites
@using MaBibli.Shared.Isbn @using MaBibli.Shared.Isbn
@using MaBibli.Shared.Textes
+43 -1
View File
@@ -248,6 +248,10 @@ body {
/* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes /* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes
des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */ des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */
/* Le segment « Prêtés » reprend la couleur de l'étiquette de prêt, comme les segments de
statut reprennent celle de leur pastille : le filtre et la carte doivent se répondre. */
.segment.segment-prete.segment-actif { background: #8a5a00; border-color: #8a5a00; }
.segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; } .segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; }
.segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; } .segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; }
@@ -739,12 +743,50 @@ body {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
justify-content: space-between;
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.4rem 0; padding: 0.4rem 0;
border-bottom: 1px solid var(--mb-bord); border-bottom: 1px solid var(--mb-bord);
} }
/*
Trois colonnes, dont une seule est élastique.
⚠️ C'est la largeur FIGÉE des deux colonnes extrêmes qui fait tout l'intérêt : sans elle,
chaque ligne plaçait son compteur et ses boutons à un endroit différent, au gré de la
longueur du nom, et l'œil devait relire chaque ligne entière. Trois chiffres suffisent au
compteur — personne ne possède mille livres d'un même auteur.
*/
.auteur-compte {
flex: 0 0 2.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
font-weight: 600;
color: var(--mb-texte-doux);
}
.auteur-nom {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.auteur-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
}
/* Le champ de renommage s'insère dans la colonne d'actions : il doit pouvoir rétrécir, sinon
il pousse « Enregistrer » hors de l'écran sur un téléphone. */
.champ-auteur-renomme {
min-width: 0;
max-width: 10rem;
}
.rapprochements { .rapprochements {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 0.75rem; padding: 0.75rem;
+16
View File
@@ -46,4 +46,20 @@ public record CritereLivres
/// <summary>Ne garder que les livres d'un auteur — le « cliquer sur un auteur » d'IDEES.md.</summary> /// <summary>Ne garder que les livres d'un auteur — le « cliquer sur un auteur » d'IDEES.md.</summary>
public int? AuteurId { get; init; } public int? AuteurId { get; init; }
/// <summary>
/// <c>true</c> = ce qui est dehors, <c>false</c> = ce qui est à la maison, <c>null</c> = tout.
/// </summary>
/// <remarks>
/// ⚠️ Contrairement à <see cref="Statut"/>, ce critère est <b>commun au foyer</b> : un prêt
/// n'appartient à personne en particulier, et aucune identité n'entre dans son évaluation.
/// C'est la même dissymétrie que partout ailleurs — le livre absent l'est pour tout le monde,
/// sa lecture ne l'est que pour soi.
/// <para>
/// L'écran « Prêts » répond déjà à « qu'est-ce qui est dehors ? ». Ce critère-ci répond à
/// l'autre moitié de la question, celle qu'aucun écran ne servait : « qu'est-ce que j'ai
/// réellement sous la main ? », par exemple avant de promettre un livre à quelqu'un.
/// </para>
/// </remarks>
public bool? Prete { get; init; }
} }
+10
View File
@@ -54,6 +54,16 @@ public static class FiltreLivres
source = source.Where(l => l.Auteurs.Any(la => la.AuteurId == auteurId)); source = source.Where(l => l.Auteurs.Any(la => la.AuteurId == auteurId));
} }
if (criteres.Prete is { } prete)
{
// Un prêt EN COURS est une ligne sans date de retour : l'historique clos ne compte
// pas. Aucune identité n'intervient — le prêt est commun au foyer, contrairement au
// statut de lecture juste au-dessus.
source = prete
? source.Where(l => l.Prets.Any(p => p.DateRetour == null))
: source.Where(l => !l.Prets.Any(p => p.DateRetour == null));
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche); var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0) if (terme.Length > 0)
{ {
+34 -8
View File
@@ -46,8 +46,10 @@ public class ExportSouhaitsTests
Assert.Single(lignes, l => l == "Bernard Werber"); Assert.Single(lignes, l => l == "Bernard Werber");
var debutWerber = Array.IndexOf(lignes, "Bernard Werber"); var debutWerber = Array.IndexOf(lignes, "Bernard Werber");
Assert.Equal(" - La prophétie des abeilles", lignes[debutWerber + 1]); // Le crochet porte le rang d'envie, que le groupement par auteur ferait sinon
Assert.Equal(" - Les fourmis", lignes[debutWerber + 2]); // disparaître : la liste s'emporte pour savoir par quoi commencer.
Assert.Equal(" - [1] La prophétie des abeilles", lignes[debutWerber + 1]);
Assert.Equal(" - [3] Les fourmis", lignes[debutWerber + 2]);
// Werber avant Zola : les auteurs sont classés alphabétiquement. // Werber avant Zola : les auteurs sont classés alphabétiquement.
Assert.True(debutWerber < Array.IndexOf(lignes, "Émile Zola")); Assert.True(debutWerber < Array.IndexOf(lignes, "Émile Zola"));
@@ -75,7 +77,7 @@ public class ExportSouhaitsTests
// ISBN découpé : c'est un numéro qu'on épelle à un libraire. La valeur passée est nue // ISBN découpé : c'est un numéro qu'on épelle à un libraire. La valeur passée est nue
// (« 2253004227 »), les tirets n'existent qu'à la sortie. // (« 2253004227 »), les tirets n'existent qu'à la sortie.
Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte); Assert.Contains(" - [1] Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte);
Assert.Contains(" en poche seulement", texte); Assert.Contains(" en poche seulement", texte);
} }
@@ -109,8 +111,8 @@ public class ExportSouhaitsTests
var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.Equal("Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]); Assert.Equal("Rang;Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]);
Assert.Equal("Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]); Assert.Equal("1;Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]);
} }
/// <summary> /// <summary>
@@ -125,11 +127,35 @@ public class ExportSouhaitsTests
var ligne = csv.Split("\r\n")[1]; var ligne = csv.Split("\r\n")[1];
Assert.StartsWith("\"Titre; piégé\";", ligne); Assert.StartsWith("1;\"Titre; piégé\";", ligne);
Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne); Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne);
// Sept colonnes une fois l'échappement respecté, pas huit. // Huit colonnes une fois l'échappement respecté, pas neuf.
Assert.Equal(7, ColonnesCsv(ligne).Count); Assert.Equal(8, ColonnesCsv(ligne).Count);
}
/// <summary>
/// Le rang imprimé est la <b>position dans la liste reçue</b>, qui vient déjà triée par
/// <c>Rang</c>. Le texte la regroupe par auteur, ce qui casse l'ordre à l'affichage : le
/// crochet est alors la seule trace de « par quoi commencer ».
/// </summary>
[Fact]
public void Le_rang_suit_lordre_recu_et_non_lalphabet()
{
IReadOnlyList<SouhaitDto> liste =
[
Souhait(7, "Zorro", "Émile Zola"),
Souhait(3, "Abécédaire", "Émile Zola"),
];
var texte = ExportSouhaits.Texte(liste, null, Jour);
Assert.Contains(" - [1] Zorro", texte);
Assert.Contains(" - [2] Abécédaire", texte);
// Le CSV, lui, garde l'ordre : le rang y est croissant ligne après ligne.
var lignes = ExportSouhaits.Csv(liste).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("1;Zorro;", lignes[1]);
Assert.StartsWith("2;Abécédaire;", lignes[2]);
} }
[Fact] [Fact]
+26 -3
View File
@@ -32,7 +32,8 @@ public class FiltreLivresLocalTests
private static Livre Livre( private static Livre Livre(
int id, string titre, Auteur? auteur, Format format, Statut? statut, int id, string titre, Auteur? auteur, Format format, Statut? statut,
TypeDocument type = TypeDocument.NonPrecise) TypeDocument type = TypeDocument.NonPrecise,
string? preteA = null, bool pretClos = false)
{ {
var livre = new Livre var livre = new Livre
{ {
@@ -50,6 +51,20 @@ public class FiltreLivresLocalTests
livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = Lecteur, Statut = valeur }); livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = Lecteur, Statut = valeur });
} }
if (preteA is not null)
{
// ⚠️ Un prêt CLOS ne doit pas compter comme « dehors ». C'est le point où les deux
// implémentations pourraient diverger sans qu'on s'en aperçoive : le serveur regarde
// DateRetour, le client un PreteA que le serveur a déjà résolu.
livre.Prets.Add(new Pret
{
LivreId = id,
Emprunteur = preteA,
DatePret = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
DateRetour = pretClos ? new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc) : null,
});
}
return livre; return livre;
} }
@@ -57,8 +72,9 @@ public class FiltreLivresLocalTests
[ [
Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman), Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman),
Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee), Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee),
Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman), Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman,
Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null), preteA: "Marie"),
Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null, preteA: "Paul", pretClos: true),
Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire),
Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), Livre(6, "L'Éducation sentimentale", null, Format.Physique, null),
]; ];
@@ -76,6 +92,9 @@ public class FiltreLivresLocalTests
TypeDocument = l.TypeDocument, TypeDocument = l.TypeDocument,
DateAjout = DateTime.UtcNow, DateAjout = DateTime.UtcNow,
Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut, Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut,
// Ce que l'API expose : l'emprunteur du prêt EN COURS, rien de l'historique clos.
PreteA = l.Prets.FirstOrDefault(p => p.DateRetour is null)?.Emprunteur,
Auteurs = l.Auteurs Auteurs = l.Auteurs
.Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom }) .Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom })
.ToList(), .ToList(),
@@ -104,6 +123,10 @@ public class FiltreLivresLocalTests
new CritereLivres { AuteurId = 1 }, new CritereLivres { AuteurId = 1 },
new CritereLivres { AuteurId = 2 }, new CritereLivres { AuteurId = 2 },
new CritereLivres { AuteurId = 99 }, new CritereLivres { AuteurId = 99 },
new CritereLivres { Prete = true },
new CritereLivres { Prete = false },
new CritereLivres { Prete = false, Format = Format.Physique },
new CritereLivres { Prete = true, Recherche = "horla" },
new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire }, new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire },
new CritereLivres new CritereLivres
{ {