@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) { } @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.

} else {

Compte rendu

@* ⚠️ 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) {

Livres ajoutés

} @if (_rafale.ResteATraiter) {
} @if (_rafale.Residus.Count > 0) {

À reprendre

}

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", }; }