@page "/ajout/isbn" @inject ServiceLivresApi Api @inject NavigationManager Navigation @inject EtatReseau Reseau @implements IDisposable MaBibli — ajouter par ISBN

Ajouter par ISBN

@if (_etape == Etape.Saisie) {

Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre. Une douchette USB fonctionne telle quelle : le champ est déjà actif.

@* Le scan comme le lookup interrogent la BnF puis OpenLibrary : ils EXIGENT le réseau, et rien ne peut être ajouté au catalogue hors-ligne de toute façon. Le dire ici évite une caméra ouverte pour rien, puis un délai d'attente incompréhensible. *@ @if (!Reseau.EnLigne) {

Hors ligne : la recherche par ISBN interroge la BnF et OpenLibrary, et l'ajout au catalogue passe par le serveur. Les deux redeviendront possibles au retour du réseau.

} @* Le champ est focalisé à l'ouverture et valide sur Entrée : c'est tout ce qu'exige une douchette USB, qui se présente au système comme un CLAVIER — elle « tape » les chiffres puis Entrée. Aucune permission, aucun HTTPS, aucun décodage : c'est le contournement le plus rentable du scan caméra, qui rate souvent sur la webcam d'un PC. La recherche n'est délibérément PAS déclenchée à chaque frappe : une douchette tape trop vite, et chaque caractère partirait en requête. *@
@* La saisie manuelle reste le recours quand le code-barres est abîmé, absent, ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@ Saisir à la main
} @* Le scan enchaîne la recherche tout seul (décision actée). Encore faut-il que ça SE VOIE : sans cet écran, on retombait sur la saisie avec un bouton « Chercher » intact, et l'on croyait que le scan n'avait rien déclenché. *@ @if (_etape == Etape.Recherche) {

Recherche de @FormatageIsbn.Afficher(_isbn) à la BnF, puis chez OpenLibrary…

} @if (_etape == Etape.Scan) { } @if (_erreur is not null) { } @if (_messagePeriodique is not null) {

@_messagePeriodique

} @foreach (var avertissement in _avertissements) {

@avertissement

} @if (_etape == Etape.Choix) { @* Décision actée dans CLAUDE.md : plusieurs notices pour un même ISBN (rééditions successives partageant l'ISBN), on ne choisit JAMAIS à la place de l'utilisateur. Les candidats partagent titre et auteur : ce sont l'éditeur et l'année qui départagent, donc ils sont mis en avant. *@

@_candidats.Count notices correspondent à cet ISBN. Elles se distinguent par l'éditeur et l'année.

} @if (_etape == Etape.Formulaire) { @* Le doublon prend toute la place tant qu'il n'est pas tranché : la fiche revient intacte si l'on renonce. C'est ici que le cas se présente le plus — scanner deux fois le même livre est précisément ce qui crée les fiches en double. *@ @if (_doublons is { } doublons) { } else {

Vérifiez et complétez la fiche : tout reste modifiable.

} } @code { private enum Etape { Saisie, Scan, Recherche, Choix, Formulaire } private Etape _etape = Etape.Saisie; private string _isbn = string.Empty; private ElementReference _champIsbn; // Le focus ne se redonne qu'une fois par entrée dans l'étape de saisie : le reprendre à // chaque rendu arracherait le curseur à l'utilisateur en pleine frappe. private bool _focusAFaire = true; private string? _messagePeriodique; private bool _chargement; private bool _enregistrement; private string? _erreur; private string? _erreurFormulaire; private IReadOnlyList _candidats = []; private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); private DoublonsLivre? _doublons; protected override void OnInitialized() => Reseau.Change += SurChangementReseau; /// /// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche. /// /// /// C'est ce qui rend une douchette USB utilisable sans rien d'autre : elle tape dans /// le champ actif, quel qu'il soit. Sans focus automatique, l'utilisateur devrait cliquer /// dans le champ avant chaque livre — et le gain sur le scan caméra disparaîtrait. /// protected override async Task OnAfterRenderAsync(bool premierRendu) { if (_etape != Etape.Saisie || !_focusAFaire) { return; } _focusAFaire = false; try { await _champIsbn.FocusAsync(); } catch (InvalidOperationException) { // L'élément a disparu entre le rendu et l'appel (navigation rapide) : sans intérêt. } } private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); public void Dispose() => Reseau.Change -= SurChangementReseau; /// Raison du blocage des actions, ou null quand tout est possible. private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; private void OuvrirScanner() { _erreur = null; _messagePeriodique = null; _avertissements = []; _etape = Etape.Scan; } // Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur // ne retape jamais ce qui vient d'être scanné, et n'a aucun bouton à confirmer. // Le code lu reste dans le champ : si le décodage était mauvais, il se corrige et se relance. private async Task SurCodeDetecteAsync(string isbn) { _isbn = isbn; await ChercherAsync(); } private async Task SurTouche(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn)) { await ChercherAsync(); } } private async Task ChercherAsync() { _chargement = true; _erreur = null; _messagePeriodique = null; _avertissements = []; _etape = Etape.Recherche; try { var resultat = await Api.ChercherIsbnAsync(_isbn.Trim()); if (resultat is null) { _erreur = $"« {_isbn} » n'est pas un ISBN valide."; RevenirALaSaisie(); return; } _avertissements = resultat.Avertissements; _candidats = resultat.Candidats; // Un code de périodique (977) n'a JAMAIS de candidat : ce n'est pas un échec de // recherche, c'est un code qui ne décrit pas un livre. Le dire avant tout le reste. if (resultat.Periodique is { } revue) { DecrireLeMagazine(revue); return; } if (_candidats.Count == 0) { // Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie. _erreur = "Aucune notice trouvée pour cet ISBN. Complétez la fiche à la main."; _saisie = new EnregistrementLivre { Isbn = resultat.IsbnDemande }; _etape = Etape.Formulaire; return; } if (_candidats.Count == 1) { Choisir(_candidats[0]); return; } _etape = Etape.Choix; } catch (Exception) { _erreur = Reseau.EnLigne ? "La recherche a échoué. Réessayez, ou saisissez la fiche à la main." : "La recherche par ISBN interroge la BnF et OpenLibrary : indisponible hors ligne."; RevenirALaSaisie(); } finally { _chargement = false; } } /// /// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle. /// /// /// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est /// pré-rempli, mais pas l'ISBN — le code-barres d'une revue n'en est pas un, et le /// ranger dans Livre.Isbn ferait échouer tout lookup ultérieur sur cette fiche. /// private void DecrireLeMagazine(PeriodiqueDetecte revue) { var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; _messagePeriodique = $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : " + "il commence par 977, réservé aux publications en série. " + "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, " + "sans ISBN."; _saisie = new EnregistrementLivre { Titre = revue.Titre ?? string.Empty, Editeur = revue.Editeur, Format = Format.Physique, Statut = Statut.ALire, }; _erreur = null; _etape = Etape.Formulaire; } /// Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette. private void RevenirALaSaisie() { _etape = Etape.Saisie; _focusAFaire = true; } private void Choisir(CandidatLivre candidat) { _saisie = new EnregistrementLivre { Isbn = _isbn.Trim(), Titre = candidat.Titre, Auteur = candidat.Auteur, Editeur = candidat.Editeur, CoverUrl = candidat.CoverUrl, Format = Format.Physique, Statut = Statut.ALire, }; _erreur = null; _etape = Etape.Formulaire; } private void Recommencer() { _erreur = null; _messagePeriodique = null; _erreurFormulaire = null; _doublons = null; _candidats = []; RevenirALaSaisie(); } private Task EnregistrerAsync() => AjouterAsync(false); /// Ajout maintenu après avoir vu ce que le catalogue contenait déjà. private Task ConfirmerAsync() => AjouterAsync(true); private async Task AjouterAsync(bool confirmerDoublon) { _enregistrement = true; _erreurFormulaire = null; var resultat = await Api.CreerAsync(_saisie, confirmerDoublon); _enregistrement = false; // Rien n'a été écrit : le catalogue contient déjà quelque chose de semblable, et c'est // à l'utilisateur de dire s'il s'agit du même livre. Voir AvertissementDoublon. if (resultat.Doublons is { } doublons) { _doublons = doublons; return; } _doublons = null; if (!resultat.EstOk) { _erreurFormulaire = resultat.Erreur; return; } Navigation.NavigateTo("/"); } }