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>
506 lines
18 KiB
Plaintext
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("/");
|
|
}
|
|
}
|