Signaler les envies déjà entrées au catalogue, sans les supprimer

La demande initiale était de retirer l'envie ; c'est refusé. Le catalogue
est commun et la liste d'envies personnelle : supprimer modifierait la
liste d'un autre en silence, avec sa note. Et le rapprochement par clé
d'œuvre est faillible, alors qu'une suppression ne se rattrape pas.

L'étiquette « Déjà au catalogue » mène à la fiche, pour vérifier avant de
retirer. Le critère est celui des doublons, la convention d'auteur commun
étant désormais partagée par les deux (RapprochementAuteurs).

Au passage : accolade manquante sur .etiquette-souhaite, qui avalait la
règle suivante — le bandeau de mise à jour perdait son style.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 21:30:55 +02:00
co-authored by Claude Opus 5
parent 383ed43227
commit a49125ddc3
9 changed files with 301 additions and 42 deletions
+44
View File
@@ -1049,6 +1049,50 @@ ISBN est connu, y compris pour les résultats d'une recherche par titre (l'ISBN
vignette retombe sur son substitut à initiale. Décidé de **ne pas** offrir de champ « URL de
couverture » : il inviterait à coller des liens morts pour un gain nul.
## Une envie déjà au catalogue est signalée, jamais supprimée (2026-08-19)
Demande d'origine : quand un livre souhaité entre au catalogue, retirer l'envie correspondante.
**Décidé : on signale.** `SouhaitDto.Possede` / `LivreId` portent le rapprochement, et l'écran
des envies affiche une étiquette « Déjà au catalogue » **cliquable vers la fiche**.
Deux raisons, dont la première est structurelle :
- **le catalogue est commun, la liste d'envies personnelle.** L'envie n'est pas forcément celle
de la personne qui a saisi le livre : la supprimer modifierait la liste d'un autre en silence,
et lui ferait perdre sa note (« demandé à Noël »). L'API ne sait pas — délibérément — écrire
dans la liste d'autrui, et lui ouvrir cette porte percerait un invariant tenu partout ailleurs ;
- **le rapprochement est faillible** (`CleOeuvre` : titre retraduit, tome, intégrale). Une
suppression fondée sur un rapprochement faux est irréversible ; un signalement se corrige d'un
coup d'œil — d'où l'étiquette qui **mène à la fiche**, pour vérifier avant de retirer.
C'est la même règle que le grisage de la bibliographie, dans l'autre sens : on **marque**, on ne
masque ni ne supprime jamais une ligne.
### Le critère est celui des doublons, et c'est voulu
ISBN identique (il tranche seul, désignant une édition précise), sinon même clé d'œuvre **et**
un auteur commun. La convention « deux jeux de noms tous deux vides concordent » vit désormais
en un seul endroit, `RapprochementAuteurs.PartagentUnAuteur`, partagée avec la détection de
doublons du catalogue — deux règles voisines qui divergeraient en silence seraient pires qu'une
règle imparfaite.
⚠️ `LivreSouhaite.TitreNormalise` **est déjà** la clé d'œuvre (voir l'entité) : rien à
recalculer de ce côté, contrairement au catalogue dont `TitreNormalise` garde le titre entier.
⚠️ **Le catalogue est chargé en entier** (projection minimale) pour ce rapprochement. Assumé à
l'échelle d'un foyer, et cohérent avec l'instantané hors-ligne qui l'emporte déjà tout entier
dans le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est là qu'il
faudrait dégrossir en SQL — pas renoncer au signalement.
⚠️ Piège rencontré, et pris par les tests seuls : `GetValueOrDefault` sur un
`Dictionary<int, int>` rend **zéro**, pas `null`. Toute envie se disait donc possédée, en
pointant vers un livre inexistant. Le compilateur ne pouvait rien dire — `0` est un `int`
parfaitement valide.
**Ce qui n'est délibérément pas fait** : les exports `.txt` et `.csv` ne portent pas la mention.
Ce sont des instantanés qu'on emporte, alors que le signalement appelle une action — vérifier,
puis retirer — qui a lieu dans l'application.
## Bibliographie par auteur — SRU BnF, validé le 2026-08-18
Le déclencheur décrit dans IDEES.md : depuis un auteur déjà présent, voir tout ce qu'il a écrit,
-18
View File
@@ -80,24 +80,6 @@ rendu en canvas, pas sur une image de caméra.
# Retours d'usage du 2026-08-19 (3ᵉ série)
## À l'ajout d'un livre, signaler l'envie correspondante
**Décidé le 2026-08-19 : on ne supprime rien, on signale.** La liste d'envies marque l'entrée
« déjà au catalogue ».
Pourquoi pas la suppression, qui était la demande initiale :
- le catalogue est **commun**, la liste d'envies **personnelle**. Supprimer l'envie d'un autre
modifierait sa liste en silence et lui ferait perdre sa note (« demandé à Noël ») ; l'API ne
sait pas — délibérément — écrire dans la liste d'autrui, et l'ouvrir serait une brèche dans un
invariant tenu partout ailleurs ;
- le rapprochement se fait par `CleOeuvre` + auteur, donc **faillible** (titre retraduit, tome,
intégrale — voir `CLAUDE.md`). Une suppression fondée sur un rapprochement faux est
irréversible ; un signalement se corrige d'un coup d'œil.
Reste à implémenter : le marquage dans `SouhaitDto`, calculé comme l'est déjà `Possede` dans la
bibliographie, et son rendu sur l'écran des envies.
## Bandes dessinées et magazines
Deux demandes distinctes, de coût très différent.
@@ -285,27 +285,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
private IQueryable<Livre> AvecAuteurs() =>
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
/// <summary>
/// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?
/// </summary>
/// <remarks>
/// Deux fiches sans aucun auteur comptent comme concordantes : le titre est alors la seule
/// information disponible, et refuser de le signaler reviendrait à ne rien dire du tout.
/// </remarks>
private static bool MemeAuteur(Livre livre, IReadOnlyList<string> saisis)
{
var connus = livre.Auteurs
.Select(la => la.Auteur?.Nom)
.Where(nom => !string.IsNullOrWhiteSpace(nom))
.ToList();
if (connus.Count == 0 && saisis.Count == 0)
{
return true;
}
return connus.Any(connu => saisis.Any(saisi => RapprochementAuteurs.SontLeMemeAuteur(connu, saisi)));
}
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
private static bool MemeAuteur(Livre livre, IReadOnlyList<string> saisis) =>
RapprochementAuteurs.PartagentUnAuteur(livre.Auteurs.Select(la => la.Auteur?.Nom), saisis);
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
/// <remarks>
@@ -72,7 +72,9 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
.ThenBy(s => s.Id)
.ToListAsync(ct);
return souhaits.Select(Projeter).ToList();
var possedes = await CorrespondancesAsync(souhaits, ct);
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
}
public async Task<ResultatSouhait> AjouterAsync(
@@ -146,7 +148,9 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
db.LivresSouhaites.Add(souhait);
await db.SaveChangesAsync(ct);
return ResultatSouhait.Ok(Projeter(souhait));
var possede = await CorrespondancesAsync([souhait], ct);
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
@@ -220,6 +224,97 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
return true;
}
/// <summary>
/// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
/// </summary>
/// <remarks>
/// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
/// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
/// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
/// d'acheter à nouveau.
/// <para>
/// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
/// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
/// commun. Le titre seul confondrait deux recueils homonymes.
/// </para>
/// <para>
/// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle
/// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans
/// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici
/// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
/// </para>
/// </remarks>
private async Task<Dictionary<int, int>> CorrespondancesAsync(
IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
{
if (souhaits.Count == 0)
{
return [];
}
var livres = await db.Livres
.AsNoTracking()
.Select(l => new
{
l.Id,
l.Titre,
l.Isbn,
Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
})
.ToListAsync(ct);
var parCle = livres
.Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
.Where(x => x.Cle.Length > 0)
.GroupBy(x => x.Cle)
.ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());
var parIsbn = new Dictionary<string, int>();
foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
{
parIsbn.TryAdd(livre.Isbn!, livre.Id);
}
var correspondances = new Dictionary<int, int>();
foreach (var souhait in souhaits)
{
if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
{
correspondances[souhait.Id] = idIsbn;
continue;
}
// TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
// recalculer de ce côté-là.
if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
{
continue;
}
var trouve = homonymes.FirstOrDefault(
l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));
if (trouve is not null)
{
correspondances[souhait.Id] = trouve.Id;
}
}
return correspondances;
}
/// <summary>
/// Identifiant du livre correspondant, ou <c>null</c>.
/// </summary>
/// <remarks>
/// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
/// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
/// été pris par les tests, pas par le compilateur.
/// </remarks>
private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
@@ -227,7 +322,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
/// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
/// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
/// </summary>
internal static SouhaitDto Projeter(LivreSouhaite souhait) => new()
internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
{
Id = souhait.Id,
Titre = souhait.Titre,
@@ -238,5 +333,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
CoverUrl = souhait.CoverUrl,
Note = souhait.Note,
DateAjout = souhait.DateAjout,
Possede = livreId is not null,
LivreId = livreId,
};
}
+15
View File
@@ -147,6 +147,21 @@ else
}
<p class="carte-details">
@*
⚠️ On SIGNALE, on ne supprime pas : la liste d'envies est
personnelle et le catalogue est commun, donc l'envie n'est
peut-être pas de celui qui a ajouté le livre — et la note
(« demandé à Noël ») disparaîtrait avec elle. Le rapprochement
est en outre faillible : l'étiquette mène à la fiche pour qu'on
puisse vérifier avant de retirer quoi que ce soit.
*@
@if (souhait is { Possede: true, LivreId: { } livreId })
{
<a class="etiquette etiquette-possede" href="@($"livres/{livreId}")">
Déjà au catalogue
</a>
}
@if (!string.IsNullOrWhiteSpace(souhait.Editeur))
{
<span>@souhait.Editeur</span>
+2
View File
@@ -839,6 +839,8 @@ body {
background: #e6e6f5;
color: #3b3b7a;
font-weight: 600;
}
/* --- Mise à jour de l'application --- */
/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher
+15
View File
@@ -29,6 +29,21 @@ public record SouhaitDto
public string? Note { get; init; }
public required DateTime DateAjout { get; init; }
/// <summary>
/// Vrai si un livre du catalogue correspond à cette envie.
/// </summary>
/// <remarks>
/// <b>C'est un signalement, jamais une suppression.</b> Le catalogue est commun, la liste
/// d'envies personnelle : retirer l'envie de quelqu'un d'autre modifierait sa liste en
/// silence et lui ferait perdre sa note (« demandé à Noël »). Le rapprochement est en outre
/// faillible (voir <c>CleOeuvre</c>) — un signalement erroné s'ignore d'un coup d'œil, une
/// suppression erronée ne se rattrape pas.
/// </remarks>
public bool Possede { get; init; }
/// <summary>Le livre du catalogue en cause, s'il y en a un : de quoi ouvrir sa fiche.</summary>
public int? LivreId { get; init; }
}
/// <summary>Charge utile d'ajout d'une envie.</summary>
@@ -90,6 +90,33 @@ public static class RapprochementAuteurs
public static bool SontLeMemeAuteur(string? a, string? b) =>
Comparer(a, b) == NiveauRapprochement.Certain;
/// <summary>
/// Deux jeux de noms ont-ils au moins un auteur en commun ?
/// </summary>
/// <remarks>
/// Sert partout où l'on rapproche deux fiches par leur titre : le titre seul ne suffit
/// jamais — deux recueils « Nouvelles » sans rapport se confondraient — mais un auteur
/// commun tranche.
/// <para>
/// <b>Convention</b> : deux jeux <b>tous deux vides</b> concordent. Le titre est alors la
/// seule information disponible des deux côtés, et exiger un auteur reviendrait à ne jamais
/// rien rapprocher. Un jeu vide face à un jeu renseigné, en revanche, ne concorde pas :
/// l'information manque d'un seul côté, ce qui n'est pas une raison de conclure.
/// </para>
/// </remarks>
public static bool PartagentUnAuteur(IEnumerable<string?> a, IEnumerable<string?> b)
{
var gauche = a.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
var droite = b.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
if (gauche.Count == 0 && droite.Count == 0)
{
return true;
}
return gauche.Any(g => droite.Any(d => SontLeMemeAuteur(g, d)));
}
/// <summary>
/// Cherche à placer chaque mot de <paramref name="aPlacer"/> sur un mot distinct de
/// <paramref name="cible"/>, par égalité ou par initiale.
+95
View File
@@ -52,6 +52,101 @@ public class ServiceSouhaitsTests : IDisposable
new EnregistrementSouhait { Titre = titre, Auteur = auteur, Isbn = isbn, Note = note },
utilisateur);
// ── « Déjà au catalogue » : on signale, on ne supprime jamais ────────────
//
// Le catalogue est COMMUN, la liste d'envies PERSONNELLE : c'est ce croisement de portées
// que ces tests protègent. Retirer l'envie d'un autre, comme le demandait la version
// initiale de l'idée, modifierait sa liste en silence.
[Fact]
public async Task Une_envie_deja_au_catalogue_est_signalee_avec_sa_fiche()
{
var livre = (await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu)).Livre!;
// Sous-titre et ordre du nom différents : ce que la clé d'œuvre et le rapprochement
// d'auteurs doivent absorber.
await AjouterAsync("Les fourmis : roman", "Werber, Bernard");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.True(souhait.Possede);
Assert.Equal(livre.Id, souhait.LivreId);
}
[Fact]
public async Task Une_envie_signalee_reste_dans_la_liste_avec_sa_note()
{
await AjouterAsync("Les fourmis", note: "demandé à Noël");
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Camille);
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
// L'ajout au catalogue par QUELQU'UN D'AUTRE ne touche pas à la liste de Mathieu.
Assert.True(souhait.Possede);
Assert.Equal("demandé à Noël", souhait.Note);
}
[Fact]
public async Task Un_titre_identique_dun_autre_auteur_nest_pas_signale()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Nouvelles", Auteur = "Guy de Maupassant" }, Mathieu);
await AjouterAsync("Nouvelles", "Anton Tchekhov");
Assert.False((await _souhaits.ListerAsync(Mathieu)).Single().Possede);
}
[Fact]
public async Task Le_meme_isbn_suffit_a_signaler_malgre_un_titre_different()
{
// L'ISBN désigne une édition précise : il tranche seul, et rattrape un titre saisi
// autrement qu'à la source.
var livre = (await _catalogue.CreerAsync(
new EnregistrementLivre
{
Titre = "Le Horla et autres contes",
Auteur = "Guy de Maupassant",
Isbn = "9782080704092",
},
Mathieu)).Livre!;
await AjouterAsync("Le Horla", "Maupassant", isbn: "9782080704092");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.Equal(livre.Id, souhait.LivreId);
}
[Fact]
public async Task Une_envie_sans_equivalent_nest_pas_signalee()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
await AjouterAsync("La prophétie des abeilles");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.False(souhait.Possede);
Assert.Null(souhait.LivreId);
}
[Fact]
public async Task Le_signalement_apparait_des_lajout_de_lenvie()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
var ajout = await AjouterAsync("Les fourmis");
// Sinon l'écran d'ajout renverrait une fiche muette, et il faudrait recharger la liste
// pour apprendre qu'on possède déjà le livre.
Assert.True(ajout.Souhait!.Possede);
}
// ── Ordre choisi à la main ───────────────────────────────────────────────
private async Task<IReadOnlyList<string>> TitresAsync(string utilisateur = Mathieu) =>