@page "/ajout/rafale"
@inject ServiceLivresApi Api
@inject CacheHorsLigne Cache
@inject EtatReseau Reseau
@inject NavigationManager Nav
@implements IDisposable
MaBibli — cataloguer en rafale
Cataloguer en rafale
@*
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)
{
Les livres créés seront ajoutés à la série
@visee.Titre.
}
@*
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)
{
@erreur
}
@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 ».
*@
@*
⚠️ 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)
{
Une rafale de @_rafale.Total code@(_rafale.Total > 1 ? "s" : "")
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
}
else
{
Votre dernière rafale est terminée :
@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "").
Son compte rendu, et les liens vers chaque fiche, sont encore là.
}
}
else if (_phase == Phase.Collecte)
{
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.
@*
⚠️ 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.
*@
@{
var codes = SaisieRafale.Decouper(_saisie).Count;
}
@if (codes == 0)
{
Aucun code pour l'instant.
}
else
{
@codes code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.
}
@*
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.
*@
@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.
*@
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.
}
}
else if (_phase == Phase.Traitement)
{
Traitement en cours
@_rafale.Pourcentage % — @_rafale.Traites sur @_rafale.Total
@if (_codeEnCours is { } encours)
{
· @FormatageIsbn.Afficher(encours)
}
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
@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.
*@
@_rafale.Passes déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")
}
@if (_rafale.Residus.Count > 0)
{
@_rafale.Residus.Count à regarder ci-dessous
}
@*
⚠️ 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)
{
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
vidée.
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
@if (SerieVisee is { } retour)
{
Revenir à @retour.Titre
}
}
@code {
private enum Phase { Collecte, Traitement, Bilan }
///
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
///
///
/// ⚠️ Ce paramètre ne sert qu'à amorcer la file : une fois lancée, c'est
/// Rafale.SerieId qui commande, sans quoi une reprise ouverte depuis une autre
/// adresse rattacherait ailleurs.
///
[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;
///
/// ISBN déjà au catalogue, sous leurs DEUX formes.
///
///
/// ⚠️ 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.
///
private HashSet _isbnDuCatalogue = new(StringComparer.Ordinal);
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.
private SerieDto? SerieVisee => _serie;
private int? CibleSerie => _rafale.SerieId ?? SerieId;
///
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
///
///
/// 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.
///
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;
}
}
/// Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.
private ElementReference _zoneCodes;
private bool _focusAFaire = true;
/// Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.
private IReadOnlyList Ajoutes =>
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
///
/// ⚠️ 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.
///
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();
}
///
/// Traite la file, un code à la fois.
///
///
/// ⚠️ Un par un, délibérément. 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 catalogue.bnf.fr. Séquentiel, la
/// progression se voit et une coupure ne perd que le code en cours.
///
/// La file est réécrite après chaque code : c'est ce qui rend la reprise exacte.
///
///
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;
}
///
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
///
///
/// ⚠️ Les deux formes sont indispensables : 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.
///
private async Task ChargerIsbnDuCatalogueAsync()
{
try
{
var livres = await Api.ListerAsync(new CritereLivres());
_isbnDuCatalogue = new HashSet(StringComparer.Ordinal);
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType())
{
_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 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.",
};
}
///
/// Range le livre créé dans la série visée, s'il y en a une.
///
///
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : le livre existe, 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.
///
private async Task 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);
}
///
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
///
///
/// ⚠️ 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.
///
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",
};
}