Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
698 lines
29 KiB
Plaintext
698 lines
29 KiB
Plaintext
@page "/auteurs/{AuteurId:int}/bibliographie"
|
|
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
|
|
<PageTitle>MaBibli — bibliographie</PageTitle>
|
|
|
|
@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…")" />
|
|
}
|
|
else if (_erreur is not null)
|
|
{
|
|
<h1 class="titre-page">Bibliographie</h1>
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
|
|
@* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait
|
|
recharger la page entière pour retenter. *@
|
|
@if (_reessayable)
|
|
{
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_chargement || !Reseau.EnLigne)"
|
|
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
|
@onclick="ChargerAsync">
|
|
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
|
</button>
|
|
</div>
|
|
}
|
|
}
|
|
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>
|
|
|
|
@*
|
|
⚠️ 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)
|
|
{
|
|
<p class="message-avertissement" role="alert">
|
|
@biblio.Avertissement
|
|
</p>
|
|
|
|
<p class="message-discret">
|
|
La bibliographie n'a donc <strong>pas pu être établie</strong> : cet écran ne dit
|
|
rien de ce que cet auteur a écrit.
|
|
</p>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_chargement || !Reseau.EnLigne)"
|
|
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
|
@onclick="ChargerAsync">
|
|
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
|
|
</button>
|
|
</div>
|
|
}
|
|
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" />
|
|
}
|
|
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. *@
|
|
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
|
|
}
|
|
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. *@
|
|
<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 »). *@
|
|
<p class="message-discret">
|
|
@if (biblio.Tronquee)
|
|
{
|
|
<text>
|
|
D'après le catalogue de la BnF, à partir de @biblio.NoticesLues notices
|
|
sur les <strong>@biblio.NoticesAnnoncees</strong> qu'elle annonce :
|
|
<strong>cette liste est un extrait</strong>, classé par pertinence.
|
|
Un livre absent d'ici peut très bien exister.
|
|
</text>
|
|
}
|
|
else
|
|
{
|
|
<text>
|
|
D'après le catalogue de la BnF, sur
|
|
@biblio.NoticesLues notice@(biblio.NoticesLues > 1 ? "s" : "").
|
|
</text>
|
|
}
|
|
</p>
|
|
|
|
@if (biblio.NoticesEcartees > 0)
|
|
{
|
|
<p class="message-discret">
|
|
@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.
|
|
</p>
|
|
}
|
|
|
|
@if (_message is not null)
|
|
{
|
|
<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>
|
|
}
|
|
</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))
|
|
{
|
|
@*
|
|
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>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@* 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>
|
|
}
|
|
|
|
<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>
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
|
|
@* 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é. *@
|
|
<p class="message-discret">
|
|
Le rapprochement avec votre bibliothèque se fait <strong>par titre</strong>. 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.
|
|
</p>
|
|
}
|
|
}
|
|
|
|
@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;
|
|
|
|
/// <summary>Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.</summary>
|
|
private bool _reessayable;
|
|
|
|
private string? _erreur;
|
|
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();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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.
|
|
/// </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.
|
|
/// </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)
|
|
{
|
|
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 });
|
|
}
|
|
}
|