@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 livreSaisir 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)
{
@_erreur
}
@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.
@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 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("/");
}
}