@page "/ajout" @page "/ajout/isbn" @inject ServiceLivresApi Api @inject NavigationManager Navigation @inject EtatReseau Reseau @implements IDisposable MaBibli — ajouter un ouvrage @* Entrée UNIQUE d'ajout, atteinte depuis le catalogue comme depuis les revues. Le geste réel est « j'ai un truc avec un code-barres », pas « je vais cataloguer un livre » : obliger à savoir d'avance ce qu'on tient était un détour, d'autant que le décodage sait déjà distinguer un 978/979 (livre) d'un 977 (publication en série). Le modèle, lui, ne bouge pas : une revue reste une Revue, jamais un Livre — voir CLAUDE.md. *@

Ajouter un ouvrage

@if (_etape == Etape.Saisie) {

Scannez le code-barres, ou saisissez le code imprimé sur l'ouvrage — livre ou magazine, l'application reconnaît lequel. 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. *@ @* Deux saisies manuelles, parce qu'il faut bien trancher soi-même quand aucun code n'est là pour le faire : c'est le seul endroit où l'entrée unique doit poser la question. *@ Saisir un livre Saisir une revue
} @* 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) { @* L1 : l'étape existait déjà pour que l'enchaînement du scan SE VOIE ; la rondelle dit en plus que ça travaille encore, ce qu'un texte figé ne distingue pas d'un écran bloqué. *@ } @if (_etape == Etape.Scan) { } @if (_erreur is not null) { } @if (_messagePeriodique is not null) {

@_messagePeriodique

@* Une revue a désormais sa place : sa fiche, où l'on ajoute le numéro qu'on vient de scanner. Avant, ce code retombait sur la saisie manuelle d'un LIVRE — ce qu'un magazine n'est pas. *@ @if (_periodique is not null) {
} } @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 PeriodiqueDetecte? _periodique; /// Add-on EAN-2 du dernier scan, quand il y en avait un. Voir . private string? _parutionLue; 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; _periodique = null; _parutionLue = 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(CodeScanne lecture) { _isbn = lecture.Code; // Gardé de côté pour la seule chose qui sache quoi en faire : la fiche d'une revue. // Sur un livre, l'add-on EAN-5 (le prix) n'est même pas remonté, et l'EAN-2 n'a pas // de sens. _parutionLue = lecture.Parution; await ChercherAsync(); } private async Task SurTouche(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn)) { // Saisi à la main ou à la douchette : aucun add-on, et surtout pas celui d'un // scan précédent, qui se retrouverait proposé pour un autre magazine. _parutionLue = null; await ChercherAsync(); } } private async Task ChercherAsync() { _chargement = true; _erreur = null; _messagePeriodique = null; _periodique = 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é, et propose la fiche de revue correspondante. /// /// /// ⚠️ Ne remplit surtout pas le formulaire d'un livre, ce qu'il faisait tant que les /// revues n'avaient pas de modèle : un magazine n'est pas un livre, et ranger un code /// 977 dans Livre.Isbn ferait échouer tout lookup ultérieur sur la fiche. /// /// Le numéro se saisit ensuite à la main : les deux chiffres de parution du /// code-barres ne sont pas un numéro fiable, et l'add-on EAN-2 qui le porterait vraiment /// n'a pas pu être vérifié sur un magazine réel. /// /// private void DecrireLeMagazine(PeriodiqueDetecte revue) { var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; _periodique = revue; _messagePeriodique = $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : " + "il commence par 977, réservé aux publications en série. " + "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner."; _erreur = null; RevenirALaSaisie(); } /// /// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore. /// /// /// Le serveur fait « créer ou retrouver » : scanner le numéro suivant du même magazine /// retombe sur la même fiche, ce qui est exactement le geste attendu. /// /// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que /// l'utilisateur corrigera sur la fiche. /// /// private async Task OuvrirLaRevueAsync() { if (_periodique is not { } revue) { return; } _enregistrement = true; _erreur = null; var resultat = await Api.CreerRevueAsync(new EnregistrementRevue { Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}", Issn = revue.Issn, Editeur = revue.Editeur, }); _enregistrement = false; if (!resultat.EstOk) { _erreur = resultat.Erreur; return; } // Le numéro lu sur l'add-on EAN-2 suit la navigation : la fiche le proposera dans un // champ modifiable. On ne l'enregistre pas soi-même — voir CodeScanne. var numero = string.IsNullOrWhiteSpace(_parutionLue) ? string.Empty : $"?numero={Uri.EscapeDataString(_parutionLue)}"; Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}{numero}"); } /// 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, // Proposé, jamais imposé : la notice donne une phrase (« 1 vol. (349 p.) »), le // champ reste modifiable et se vide d'un geste si elle a mal été lue. NombrePages = candidat.NombrePages, CoverUrl = candidat.CoverUrl, UrlNotice = candidat.UrlNotice, Format = Format.Physique, Statut = Statut.ALire, }; _erreur = null; _etape = Etape.Formulaire; } private void Recommencer() { _erreur = null; _messagePeriodique = null; _periodique = 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("/"); } }