@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)
{
@_erreur
}
@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.
@candidat.Source
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
trouvé via @FormatageIsbn.Afficher(candidat.IsbnInterroge)
}
}
}
@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("/");
}
}