- Catalogage rapide (douchette, code-barres)

- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
This commit is contained in:
mathieu
2026-09-09 00:05:26 +02:00
parent bb071c3db9
commit 42dec0dbd1
43 changed files with 4154 additions and 189 deletions
+22
View File
@@ -62,4 +62,26 @@ public record CritereLivres
/// </para>
/// </remarks>
public bool? Prete { get; init; }
/// <summary>
/// <c>true</c> = seulement les livres sans couverture, <c>false</c> = seulement ceux qui en
/// ont une, <c>null</c> = tous.
/// </summary>
/// <remarks>
/// Répond à « quels livres me reste-t-il à illustrer ? ». Comme <see cref="Prete"/>, le
/// critère est <b>commun au foyer</b> : une couverture décrit l'ouvrage, pas la relation
/// d'un lecteur à lui.
/// <para>
/// ⚠️ <b>« Sans couverture » veut dire « sans URL enregistrée », et rien d'autre.</b> Ce
/// critère ne dit pas si l'image se charge : le savoir demanderait d'interroger chaque URL,
/// ce que <c>CLAUDE.md</c> interdit — OpenLibrary rend des 502 intermittents, et le même
/// livre entrerait et sortirait du filtre d'un rafraîchissement à l'autre. Une couverture
/// cassée reste donc « avec couverture » ici, et c'est volontaire.
/// </para>
/// <para>
/// Une URL <b>vide</b> compte comme absente : les deux implémentations du filtre doivent
/// s'accorder là-dessus, un test les y confronte.
/// </para>
/// </remarks>
public bool? SansCouverture { get; init; }
}
+24
View File
@@ -1,4 +1,5 @@
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Catalogue;
@@ -64,6 +65,29 @@ public static class FiltreLivres
: source.Where(l => !l.Prets.Any(p => p.DateRetour == null));
}
if (criteres.SansCouverture is { } sansCouverture)
{
// ⚠️ `IsNullOrEmpty` et non `IsNullOrWhiteSpace` : le second ne se traduit PAS en SQL,
// et EF Core basculerait l'évaluation en mémoire — donc sur tout le catalogue chargé.
// Une URL vide compte comme absente, comme côté client.
source = sansCouverture
? source.Where(l => string.IsNullOrEmpty(l.CoverUrl))
: source.Where(l => !string.IsNullOrEmpty(l.CoverUrl));
}
// ⚠️ Un terme qui est un CODE ne se cherche pas dans les mots : il désigne un objet, pas
// une œuvre. Les deux branches s'excluent — chercher aussi dans les titres ne rendrait
// rien de plus, et laisser la branche des mots seule ne rendrait rien du tout.
if (RechercheIsbn.Formes(criteres.Recherche) is { } formes)
{
var longue = formes[0];
var courte = formes.Count > 1 ? formes[1] : null;
return Trier(source.Where(l =>
l.Isbn != null
&& (l.Isbn.Contains(longue) || (courte != null && l.Isbn.Contains(courte)))));
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
+164
View File
@@ -0,0 +1,164 @@
namespace MaBibli.Shared.Catalogue;
/// <summary>
/// Où en est un code scanné dans une rafale.
/// </summary>
/// <remarks>
/// ⚠️ <b>C'est l'existence de ces états qui rend la reprise possible</b>, et la reprise est ce
/// qui distingue une rafale d'une simple liste de codes. Sans <see cref="Cree"/>, reprendre une
/// rafale à moitié traitée recréerait les livres déjà enregistrés — la case « passer si déjà
/// existant » les rattraperait sans doute, mais <b>par accident, pas par construction</b>.
/// </remarks>
public enum EtatCodeRafale
{
/// <summary>Scanné, pas encore interrogé.</summary>
ATraiter = 0,
/// <summary>Livre enregistré. Ne jamais le refaire à la reprise.</summary>
Cree,
/// <summary>Déjà au catalogue, écarté par la case à cocher.</summary>
Passe,
/// <summary>Plusieurs notices : l'utilisateur doit choisir l'édition.</summary>
AChoisir,
/// <summary>Aucune source ne connaît ce code. À saisir à la main.</summary>
Introuvable,
/// <summary>Code de périodique (préfixe 977) : mis de côté, traité à la fin.</summary>
Revue,
/// <summary>La source n'a pas répondu. ⚠️ À distinguer d'<see cref="Introuvable"/>.</summary>
Echec,
}
/// <summary>
/// Un code de la file, et ce qu'on en sait.
/// </summary>
public record CodeRafale
{
public required string Code { get; init; }
public EtatCodeRafale Etat { get; init; } = EtatCodeRafale.ATraiter;
/// <summary>Titre trouvé, ou saisi. Sert à reconnaître la ligne dans le compte rendu.</summary>
public string? Titre { get; init; }
/// <summary>Renseigné quand l'état est <see cref="EtatCodeRafale.Cree"/>.</summary>
public int? LivreId { get; init; }
/// <summary>
/// Ce qui s'est passé, écrit pour un humain.
/// </summary>
/// <remarks>
/// ⚠️ Jamais un message HTTP brut : c'est la règle déjà tenue partout ailleurs, et le compte
/// rendu d'une rafale est précisément un endroit où l'on est tenté de recopier l'erreur telle
/// quelle pour aller vite.
/// </remarks>
public string? Motif { get; init; }
/// <summary>Reste-t-il quelque chose à faire sur cette ligne ?</summary>
public bool Termine => Etat is EtatCodeRafale.Cree or EtatCodeRafale.Passe;
}
/// <summary>
/// Une rafale de catalogage : les codes scannés, et où en est chacun.
/// </summary>
/// <remarks>
/// <b>Persistée dans IndexedDB, magasin <c>rafale</c></b> — séparé des instantanés, qui portent
/// ce que le serveur a rendu. Celle-ci porte ce que l'utilisateur a scanné, et qui n'existe
/// nulle part ailleurs.
/// <para>
/// ⚠️ <b>Ce ne sont PAS des écritures en attente</b>, et la nuance est ce qui la rend acceptable
/// dans un projet qui refuse toute file de synchronisation : ce sont des <b>codes à
/// interroger</b>. Rien n'est promis à la base, rien ne peut entrer en conflit, et l'utilisateur
/// voit sa liste. Ne pas confondre les deux, sous peine de rouvrir la question.
/// </para>
/// </remarks>
public record Rafale
{
public List<CodeRafale> Codes { get; init; } = [];
/// <summary>
/// Passer les livres déjà au catalogue plutôt que de demander à chaque fois.
/// </summary>
/// <remarks>
/// Coché par défaut : quand on catalogue cinquante livres, on ne veut pas être arrêté
/// cinquante fois. ⚠️ Mais le compte rendu doit <b>dire combien ont été passés</b> — posséder
/// deux exemplaires est légitime (c'est pourquoi l'ISBN ne porte pas d'index unique), et
/// passer en silence contredirait cet esprit.
/// </remarks>
public bool PasserLesDoublons { get; init; } = true;
/// <summary>
/// Série à laquelle rattacher chaque livre créé, quand la rafale a été lancée depuis une saga.
/// </summary>
/// <remarks>
/// ⚠️ Elle vit <b>dans la file</b> et non dans l'écran : une rafale se reprend plus tard, et
/// la reprise doit rattacher au même endroit. Elle est reprise du paramètre d'adresse au
/// lancement, une fois pour toutes.
/// </remarks>
public int? SerieId { get; init; }
public DateTimeOffset Debut { get; init; } = DateTimeOffset.UtcNow;
public int Total => Codes.Count;
public int Traites => Codes.Count(c => c.Etat != EtatCodeRafale.ATraiter);
public int Crees => Codes.Count(c => c.Etat == EtatCodeRafale.Cree);
public int Passes => Codes.Count(c => c.Etat == EtatCodeRafale.Passe);
/// <summary>Ce qui attend encore une décision de l'utilisateur.</summary>
public IReadOnlyList<CodeRafale> Residus =>
[.. Codes.Where(c => c.Etat
is EtatCodeRafale.AChoisir or EtatCodeRafale.Introuvable
or EtatCodeRafale.Revue or EtatCodeRafale.Echec)];
public bool ResteATraiter => Codes.Any(c => c.Etat == EtatCodeRafale.ATraiter);
/// <summary>Pourcentage traité, pour l'indicateur d'avancement.</summary>
public int Pourcentage => Total == 0 ? 0 : Traites * 100 / Total;
}
/// <summary>
/// Découpe une saisie en codes, dans l'ordre, sans doublon.
/// </summary>
public static class SaisieRafale
{
/// <summary>
/// Un code par ligne — c'est ce que produit une douchette, qui envoie <c>Entrée</c> après
/// chaque lecture. Les espaces et tirets sont tolérés, la casse aussi.
/// </summary>
/// <remarks>
/// ⚠️ <b>Les doublons de la saisie sont écartés ici</b>, et non au traitement : scanner deux
/// fois le même livre par mégarde est le geste le plus banal d'une rafale, et interroger la
/// BnF deux fois pour le même code ne peut rien apporter. Le doublon avec le <i>catalogue</i>,
/// lui, est une tout autre question, traitée par <c>PasserLesDoublons</c>.
/// </remarks>
public static IReadOnlyList<string> Decouper(string? saisie)
{
if (string.IsNullOrWhiteSpace(saisie))
{
return [];
}
var vus = new HashSet<string>(StringComparer.Ordinal);
var codes = new List<string>();
foreach (var ligne in saisie.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries))
{
var code = new string([.. ligne.Where(c => char.IsAsciiDigit(c) || c is 'X' or 'x')])
.ToUpperInvariant();
if (code.Length > 0 && vus.Add(code))
{
codes.Add(code);
}
}
return codes;
}
}
+6
View File
@@ -47,6 +47,9 @@ public record ElementSerieDto
/// <summary>Titre du tome : celui du livre quand il est rattaché, sinon celui qui a été saisi.</summary>
public required string Titre { get; init; }
/// <summary>Le numéro imprimé sur le livre. <c>null</c> = inconnu.</summary>
public string? Numero { get; init; }
public int? LivreId { get; init; }
public bool Possede => LivreId is not null;
@@ -76,6 +79,9 @@ public record EnregistrementSerie
/// </remarks>
public record AjoutElementSerie
{
/// <summary>Le numéro imprimé sur le livre — facultatif.</summary>
public string? Numero { get; set; }
/// <summary>Livre du catalogue à placer ici. <c>null</c> = un tome qu'on ne possède pas.</summary>
public int? LivreId { get; set; }
+30 -1
View File
@@ -105,5 +105,34 @@ public class ElementSerie
/// </summary>
public string Titre { get; set; } = string.Empty;
public void RecalculerFormes() => Titre = Titre.Trim();
/// <summary>
/// Le numéro imprimé sur le livre — « 7 », « Hors-série », « 3.5 ». <c>null</c> = inconnu.
/// </summary>
/// <remarks>
/// ⚠️ <b>À ne pas confondre avec <see cref="Position"/>.</b> La position est le rang dans
/// l'ordre de <b>lecture</b> ; le numéro est l'étiquette <b>éditoriale</b>. Les deux
/// coïncident tant qu'on possède la série depuis le début, et divergent <b>par nature</b>
/// pour une préquelle : dans <i>La Légende de Drizzt</i>, le premier livre à lire porte
/// « 4 » sur sa couverture.
/// <para>
/// ⚠️ C'est aussi pourquoi le numéro ne pouvait pas se loger dans <c>Position</c> :
/// <c>PUT /api/series/{id}/ordre</c> renumérote toutes les positions, donc un numéro rangé
/// là serait détruit au premier réordonnancement par flèches.
/// </para>
/// <para>
/// <b>Une chaîne, jamais un entier</b> — même choix que <c>NumeroRevue.Numero</c> : les
/// sagas produisent des « hors-série », des « 3.5 » et des « intégrale 1-3 » qu'un
/// numérique refuserait.
/// </para>
/// </remarks>
public string? Numero { get; set; }
public void RecalculerFormes()
{
Titre = Titre.Trim();
// Une chaîne vide vaut une absence de numéro : sans cela, un champ effacé se
// distinguerait d'un champ jamais rempli, pour rien.
Numero = string.IsNullOrWhiteSpace(Numero) ? null : Numero.Trim();
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace MaBibli.Shared.Isbn;
/// <summary>
/// Reconnaît qu'un terme de recherche est un <b>code</b> et non des mots, et donne les formes
/// sous lesquelles le chercher dans le catalogue.
/// </summary>
/// <remarks>
/// <para>
/// Le geste visé : scanner un livre qu'on tient en main pour retrouver sa fiche. La douchette
/// « tape » l'EAN-13 dans le champ de recherche, la caméra le pose de la même façon — mais la
/// recherche du catalogue ne portait que sur le titre et les auteurs, donc ne rendait rien.
/// </para>
/// <para>
/// ⚠️ <b>Les deux formes d'ISBN sont indispensables</b>, ici comme partout ailleurs dans le
/// projet : un livre paru avant 2007 porte un ISBN-10 en base, alors qu'un code-barres se lit
/// toujours en 13. Ne chercher qu'une forme ferait répondre « vous ne l'avez pas » à propos d'un
/// livre posé sur l'étagère.
/// </para>
/// <para>
/// ⚠️ <b>Le seuil de huit chiffres n'est pas décoratif.</b> Sans lui, chercher « 1984 » — un
/// titre — remonterait tous les livres dont l'ISBN contient ces quatre chiffres, c'est-à-dire du
/// bruit à la place d'une réponse. Aucun titre ne s'écrit avec huit chiffres d'affilée.
/// </para>
/// </remarks>
public static class RechercheIsbn
{
/// <summary>En deçà, un terme numérique est un titre bien plus probablement qu'un code.</summary>
private const int ChiffresMinimum = 8;
/// <summary>
/// Les formes à confronter à <c>Livre.Isbn</c>, ou <c>null</c> si le terme n'est pas un code.
/// </summary>
/// <remarks>
/// Un terme n'est un code que s'il ne contient <b>rien d'autre</b> que des chiffres, la
/// ponctuation d'un ISBN (tirets, espaces, points) et l'éventuel <c>X</c> final : « Zola 1984 »
/// reste une recherche de mots.
/// </remarks>
public static IReadOnlyList<string>? Formes(string? recherche)
{
if (string.IsNullOrWhiteSpace(recherche) || !QueDuCode(recherche))
{
return null;
}
var chiffres = IsbnUtils.Normaliser(recherche);
if (chiffres is null || chiffres.Length < ChiffresMinimum)
{
return null;
}
// La conversion 13 → 10 ne vaut que pour un ISBN-13 complet et valide ; sur un fragment,
// elle n'a pas de sens et on s'en passe — la forme longue suffit alors.
return IsbnUtils.TryConvertirEnIsbn10(chiffres, out var court) && court is not null
? [chiffres, court]
: [chiffres];
}
/// <summary>Vrai si le terme est un ISBN complet et valide — donc s'il désigne UNE édition.</summary>
/// <remarks>
/// C'est ce qui autorise le catalogue à ouvrir directement la fiche quand un seul livre
/// correspond : sur un fragment, le même résultat unique serait un hasard de frappe.
/// </remarks>
public static bool EstCodeComplet(string? recherche) =>
QueDuCode(recherche) && IsbnUtils.EstValide(IsbnUtils.Normaliser(recherche));
private static bool QueDuCode(string? recherche)
{
if (string.IsNullOrWhiteSpace(recherche))
{
return false;
}
foreach (var c in recherche)
{
if (!char.IsDigit(c) && c is not ('-' or ' ' or '.' or 'x' or 'X'))
{
return false;
}
}
return true;
}
}
+121
View File
@@ -0,0 +1,121 @@
namespace MaBibli.Shared.Textes;
/// <summary>
/// Compare des libellés qui contiennent des nombres, en lisant les nombres comme des nombres.
/// </summary>
/// <remarks>
/// <b>Le tri ordinaire range les tomes 1, 10, 11, 2, 9</b> — parce que le caractère <c>1</c>
/// précède le <c>2</c>. C'est correct alphabétiquement, et absurde pour une saga. Ce
/// comparateur découpe le libellé en blocs de chiffres et blocs de texte, et compare chaque
/// bloc de chiffres comme un entier.
/// <para>
/// ⚠️ <b>Aucune collation culturelle</b>, comme partout ailleurs dans le projet
/// (<c>GroupementCatalogue</c>, <c>FiltreLivresLocal</c>) : la comparaison des blocs de texte
/// est <b>ordinale</b>, sur leur forme normalisée. C'est ce qui garantit que deux appareils
/// classent une série dans le même ordre, quelle que soit la langue du système.
/// </para>
/// <para>
/// Ordre retenu, tranché avec l'utilisateur le 2026-08-22 :
/// </para>
/// <list type="number">
/// <item>le libellé <b>vide</b> — ce dont on ignore le numéro ;</item>
/// <item>ce qui <b>ne commence pas par un chiffre</b> (« Hors-série », « Intégrale 1-3 »),
/// entre eux de a à z ;</item>
/// <item>ce qui <b>commence par un chiffre</b>, de 0 à 9 en tri naturel.</item>
/// </list>
/// <para>
/// ⚠️ Cet ordre <b>diverge</b> de celui des numéros de revue, où ce qui n'a pas de date
/// <i>ferme</i> la liste. C'est délibéré : devant un kiosque on cherche le dernier paru,
/// devant une étagère on cherche ce qu'il reste à compléter. Ne pas uniformiser les deux au
/// nom de la cohérence — ce serait remplacer deux règles justes par une qui se trompe une
/// fois sur deux.
/// </para>
/// </remarks>
public static class TriNaturel
{
/// <summary>Comparateur prêt à l'emploi pour <c>OrderBy</c>.</summary>
public static IComparer<string?> Comparateur { get; } = new ComparateurNaturel();
/// <summary>
/// Compare deux libellés. <c>null</c> et la chaîne vide sont équivalents et passent en tête.
/// </summary>
public static int Comparer(string? gauche, string? droite)
{
var a = NormalisationTexte.Normaliser(gauche);
var b = NormalisationTexte.Normaliser(droite);
// 1. Le vide d'abord — on ignore le numéro, et c'est ce qui demande une décision.
if (a.Length == 0 || b.Length == 0)
{
return a.Length == b.Length ? 0 : a.Length == 0 ? -1 : 1;
}
// 2. Puis ce qui ne commence pas par un chiffre. Un seul test sur le PREMIER caractère :
// « Intégrale 1-3 » est du texte, « 3.5 » est un nombre, et la frontière est là.
var aChiffre = char.IsAsciiDigit(a[0]);
var bChiffre = char.IsAsciiDigit(b[0]);
if (aChiffre != bChiffre)
{
return aChiffre ? 1 : -1;
}
return ComparerBlocs(a, b);
}
/// <summary>
/// Parcourt les deux libellés en parallèle, bloc de chiffres contre bloc de chiffres,
/// bloc de texte contre bloc de texte.
/// </summary>
private static int ComparerBlocs(string a, string b)
{
int i = 0, j = 0;
while (i < a.Length && j < b.Length)
{
if (char.IsAsciiDigit(a[i]) && char.IsAsciiDigit(b[j]))
{
var debutA = i;
var debutB = j;
while (i < a.Length && char.IsAsciiDigit(a[i])) i++;
while (j < b.Length && char.IsAsciiDigit(b[j])) j++;
// ⚠️ Comparaison par LONGUEUR puis par caractères, jamais par conversion en int :
// un numéro n'est pas borné par int.MaxValue, et « 000000000000000012 » ne doit
// pas faire échouer le tri. Les zéros de tête sont ignorés d'abord.
var chiffresA = a.AsSpan(debutA, i - debutA).TrimStart('0');
var chiffresB = b.AsSpan(debutB, j - debutB).TrimStart('0');
if (chiffresA.Length != chiffresB.Length)
{
return chiffresA.Length < chiffresB.Length ? -1 : 1;
}
var ecart = chiffresA.SequenceCompareTo(chiffresB);
if (ecart != 0)
{
return Math.Sign(ecart);
}
}
else
{
var ecart = a[i].CompareTo(b[j]);
if (ecart != 0)
{
return Math.Sign(ecart);
}
i++;
j++;
}
}
// Le plus court d'abord quand l'un est le préfixe de l'autre : « 3 » avant « 3 bis ».
return (a.Length - i).CompareTo(b.Length - j);
}
private sealed class ComparateurNaturel : IComparer<string?>
{
public int Compare(string? x, string? y) => Comparer(x, y);
}
}