- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
742 lines
26 KiB
Plaintext
742 lines
26 KiB
Plaintext
@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 9782253004226 …"
|
|
@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",
|
|
};
|
|
}
|