@*
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
Avant correction, une BnF muette produisait une liste vide, que l'écran commentait
d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous
l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas
pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la
question au lieu de la laisser ouverte.
La règle : on n'explique une absence de résultats QUE si la source a répondu.
*@
@if (biblio.SourceMuette)
{
@biblio.Avertissement
La bibliographie n'a donc pas pu être établie : cet écran ne dit
rien de ce que cet auteur a écrit.
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.
}
else if (biblio.EstNouveautes)
{
Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.
}
else
{
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
l'expliquer. *@
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.
}
}
else if (biblio.Avertissement is not null)
{
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@
@biblio.Avertissement
}
else
{
@* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité,
la liste n'est qu'un extrait : le taire ferait croire qu'un livre absent n'existe pas. *@
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
}
@* 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 »). *@
@if (biblio.Tronquee)
{
D'après le catalogue de la BnF, à partir de @biblio.NoticesLues notices
sur les @biblio.NoticesAnnoncees qu'elle annonce :
cette liste est un extrait, classé par pertinence.
Un livre absent d'ici peut très bien exister.
}
else
{
D'après le catalogue de la BnF, sur
@biblio.NoticesLues notice@(biblio.NoticesLues > 1 ? "s" : "").
}
@if (biblio.NoticesEcartees > 0)
{
@biblio.NoticesEcartees notice@(biblio.NoticesEcartees > 1 ? "s ont" : " a")
été écartée@(biblio.NoticesEcartees > 1 ? "s" : "") : autre support (livre audio,
jeu) ou ouvrage signé de quelqu'un d'autre.
}
@if (_message is not null)
{
@_message
}
@* 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. *@
@if (_filtresOuverts)
{
@if (_masquees > 0)
{
}
}
@*
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)
{
@_selection.Count sélectionnée@(_selection.Count > 1 ? "s" : "")
@if (SelectionADecouvrir(biblio).Count is var aSouhaiter and > 0)
{
}
@if (SelectionParMasquage(biblio, false).Count is var aMasquer and > 0)
{
}
@if (SelectionParMasquage(biblio, true).Count is var aReafficher and > 0)
{
}
}
@foreach (var oeuvre in Affichees(biblio))
{
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
@*
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à.
*@
@if (oeuvre is { Possede: true, LivreId: { } livreId }
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
}
else
{
}
@* 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. *@
@if (oeuvre.ADecouvrir)
{
}
}
@* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire
franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il
possède n'est pas grisé. *@
Le rapprochement avec votre bibliothèque se fait par titre. Un livre
que vous possédez sous un titre différent (traduction, intégrale, édition retitrée)
n'apparaîtra donc pas comme possédé. Rien n'est masqué : à vous de trancher.
}
}
@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 _enCours;
private bool _chargement;
/// Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.
private bool _reessayable;
private string? _erreur;
private string? _message;
private int _possedees;
private int _masquees;
private IReadOnlyList _livresCatalogue = [];
private readonly HashSet _oeuvresOuvertes = [];
private readonly HashSet _selection = [];
protected override async Task OnParametersSetAsync() => await ChargerAsync();
private IEnumerable 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);
/// Les œuvres cochées qui peuvent effectivement rejoindre la liste d'envies.
private List SelectionADecouvrir(BibliographieDto biblio) =>
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir).ToList();
private List SelectionParMasquage(BibliographieDto biblio, bool masquees) =>
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.Masquee == masquees).ToList();
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());
if (_bibliographie is null)
{
// Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera
// pas apparaître.
_erreur = "Cet auteur n'existe pas dans votre bibliothèque.";
return;
}
RecompterAsync();
}
catch (Exception)
{
// ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found »
// du service worker que l'utilisateur n'a aucun moyen d'interpréter. La
// bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien
// à réparer, seulement à dire.
//
// Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ;
// une BnF muette, elle, revient en 200 avec un état, et se traite plus haut.
_erreur = Reseau.EnLigne
? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre."
: "La bibliographie vient de la BnF : indisponible hors ligne.";
_reessayable = true;
}
finally
{
_chargement = false;
}
}
private void RecompterAsync()
{
_possedees = _bibliographie?.Oeuvres.Count(o => o.Possede) ?? 0;
_masquees = _bibliographie?.Oeuvres.Count(o => o.Masquee) ?? 0;
}
///
/// Applique en mémoire ce que l'écriture vient de changer, au lieu de tout recharger.
///
///
/// ⚠️ C'est LA correction de lenteur signalée en usage : chaque « Je le veux » et chaque
/// « Masquer » relançaient un ChargerAsync 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 personnel qu'il connaissait déjà.
///
/// Le cache partagé de 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.
///
///
private void MettreAJourOeuvre(string titre, Func 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();
}
/// Masque (ou réaffiche) toutes les œuvres cochées qui sont dans l'autre état.
private async Task MasquerSelectionAsync(bool masquer)
{
if (_bibliographie is null)
{
return;
}
var oeuvres = SelectionParMasquage(_bibliographie, !masquer);
_enCours = true;
_message = null;
var erreurs = new List();
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);
}
}
///
/// Ce que la BnF sait de l'œuvre, mis en forme pour la liste d'envies.
///
///
/// 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.
///
/// 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.
///
///
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)
{
return;
}
_enCours = true;
_message = null;
var resultat = await Api.AjouterSouhaitAsync(Saisie(oeuvre));
_enCours = false;
if (!resultat.EstOk)
{
_message = resultat.Erreur;
return;
}
MettreAJourOeuvre(
oeuvre.Titre,
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
}
}