Files
mabibli/MaBibli.Client/Pages/AjoutIsbn.razor
T
mathieuandClaude Opus 5 94f0ef15c0 Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages
`Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait
comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour
`TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre,
d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt
que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ».

Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce
champ n'est pas un nombre mais une phrase décrivant le support, et les notices
déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv.
ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc
étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout
le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à
la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le
voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) »,
qui est une pagination de contribution, ne vaut rien. La valeur reste proposée
dans un champ modifiable, et rien n'est déduit pour un ebook.

Lot X — éditer une envie, et souhaiter une revue
`PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans
ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur
l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur
l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure
— l'envie d'un autre est introuvable (404), jamais refusée (403).

⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un
ajout ne peut pas : renommer une envie en une autre déjà présente répond par un
message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409,
contrairement au doublon du catalogue : là-bas posséder deux exemplaires est
légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à
confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée.

⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran
n'offre de champ « URL de couverture » pour une envie (décision actée), donc un
remplacement inconditionnel l'aurait effacée à la première faute de frappe
corrigée.

`RevueSouhaitee` est une table sœur, et non des colonnes de plus sur
`LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro,
deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans
devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut
vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret,
seul code du projet rangé ainsi.

Le coût de la table sœur est payé partout où il devait l'être : affichage,
`.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment
une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son
rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que
personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas
les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres
dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri
par titre rendrait revues et livres indiscernables, et la colonne des codes
mêlerait ISBN et ISSN en silence.

`ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision
déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien
quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y
échapperait.

`RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de
`GET /api/couvertures`.

Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie
d'un autre en 404, et les deux exports portant bien les deux moitiés.
602 tests au vert (552 au départ), aucun avertissement de compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:02:09 +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("/");
}
}