Clôt la décision du premier lot, qui écartait les périodiques faute de modèle. Douze numéros d'un magazine ne font plus douze fiches identiques. Table à part, pour la troisième fois et pour le même motif que la liste d'envies et les séries : logée dans Livres, une revue entrerait dans le catalogue, ses compteurs, les doublons et les bibliographies, et il faudrait écrire « et qui n'est pas une revue » à chaque lecture. Un numéro est recensé, rien de plus — ni prêt ni statut, tous deux liés à Livre par clé étrangère. Réversible. « Créer ou retrouver » et non « créer » : scanner le numéro suivant du même magazine retombe forcément sur la même revue. L'unicité de l'ISSN est un index partiel, sans quoi les revues sans ISSN se bloqueraient entre elles. Le flux 977 mène désormais à la fiche de la revue, et ne pré-remplit plus le formulaire d'un livre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
467 lines
16 KiB
Plaintext
467 lines
16 KiB
Plaintext
@page "/ajout/isbn"
|
|
@inject ServiceLivresApi Api
|
|
@inject NavigationManager Navigation
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
|
|
|
|
<h1 class="titre-page">Ajouter par ISBN</h1>
|
|
|
|
@if (_etape == Etape.Saisie)
|
|
{
|
|
<p class="message-discret">
|
|
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
|
|
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>
|
|
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</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)
|
|
{
|
|
<p class="message-discret" role="status">
|
|
Recherche de <span class="code-isbn">@FormatageIsbn.Afficher(_isbn)</span> à la BnF, puis chez OpenLibrary…
|
|
</p>
|
|
}
|
|
|
|
@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;
|
|
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;
|
|
_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;
|
|
_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;
|
|
}
|
|
|
|
Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}");
|
|
}
|
|
|
|
/// <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,
|
|
CoverUrl = candidat.CoverUrl,
|
|
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("/");
|
|
}
|
|
}
|