Files
mabibli/MaBibli.Client/Pages/AjoutIsbn.razor
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

506 lines
18 KiB
Plaintext

@page "/ajout"
@page "/ajout/isbn"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — ajouter un ouvrage</PageTitle>
@*
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.
*@
<h1 class="titre-page">Ajouter un ouvrage</h1>
@if (_etape == Etape.Saisie)
{
<p class="message-discret">
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.
</p>
@*
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)
{
<p class="message-avertissement" role="status">
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.
</p>
}
@*
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.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
@ref="_champIsbn"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="ISBN" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_isbn))"
title="@MotifBlocage"
@onclick="ChercherAsync">
@(_chargement ? "Recherche…" : "Chercher")
</button>
@* 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. *@
<button type="button" class="bouton bouton-discret"
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirScanner">
Scanner
</button>
@* 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. *@
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
</div>
}
@*
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é. *@
<Patience Classe="attente-recherche"
Message="@($"Recherche de {FormatageIsbn.Afficher(_isbn)} à la BnF, puis chez OpenLibrary…")" />
}
@if (_etape == Etape.Scan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_messagePeriodique is not null)
{
<p class="message-avertissement" role="status">@_messagePeriodique</p>
@* 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)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirLaRevueAsync">
@(_enregistrement ? "Ouverture…" : "Ouvrir la fiche de cette revue")
</button>
</div>
}
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
}
@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. *@
<p class="message-discret">
@_candidats.Count notices correspondent à cet ISBN. Elles se distinguent par l'éditeur et l'année.
</p>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
<li class="carte-candidat" @key="index">
<Couverture Url="@candidat.CoverUrl" Titre="@candidat.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
@if (!string.IsNullOrWhiteSpace(candidat.Auteur))
{
<p class="carte-auteur">@candidat.Auteur</p>
}
<p class="candidat-distinction">
<span class="candidat-editeur">@(candidat.Editeur ?? "éditeur inconnu")</span>
<span class="candidat-annee">@(candidat.Annee ?? "année inconnue")</span>
</p>
<p class="carte-details">
<span class="etiquette">@candidat.Source</span>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<span>trouvé via <span class="code-isbn">@FormatageIsbn.Afficher(candidat.IsbnInterroge)</span></span>
}
</p>
<button type="button" class="bouton bouton-principal"
@onclick="() => Choisir(candidat)">
Choisir cette édition
</button>
</div>
</li>
}
</ul>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-discret" @onclick="Recommencer">Aucune : saisir à la main</button>
</div>
}
@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)
{
<AvertissementDoublon Doublons="doublons"
EnCours="_enregistrement"
OnConfirmer="ConfirmerAsync"
OnRenoncer="() => _doublons = null" />
}
else
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
MessageBlocage="@MotifBlocage"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
}
}
@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;
/// <summary>Add-on EAN-2 du dernier scan, quand il y en avait un. Voir <see cref="CodeScanne"/>.</summary>
private string? _parutionLue;
private bool _chargement;
private bool _enregistrement;
private string? _erreur;
private string? _erreurFormulaire;
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private EnregistrementLivre _saisie = new();
private DoublonsLivre? _doublons;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
/// </summary>
/// <remarks>
/// C'est ce qui rend une <b>douchette USB</b> 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.
/// </remarks>
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;
/// <summary>Raison du blocage des actions, ou <c>null</c> quand tout est possible.</summary>
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;
}
}
/// <summary>
/// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
/// </summary>
/// <remarks>
/// ⚠️ <b>Ne remplit surtout pas le formulaire d'un livre</b>, 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
/// <c>977</c> dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur la fiche.
/// <para>
/// Le <b>numéro</b> 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.
/// </para>
/// </remarks>
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();
}
/// <summary>
/// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que
/// l'utilisateur corrigera sur la fiche.
/// </para>
/// </remarks>
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}");
}
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
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);
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
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("/");
}
}