diff --git a/CLAUDE.md b/CLAUDE.md index 25c3553..16cfe4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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, diff --git a/IDEES.md b/IDEES.md index a79180e..9d34e35 100644 --- a/IDEES.md +++ b/IDEES.md @@ -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. diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index 6d541b9..3aa522e 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -285,27 +285,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur private IQueryable AvecAuteurs() => db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur); - /// - /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? - /// - /// - /// 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. - /// - private static bool MemeAuteur(Livre livre, IReadOnlyList 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))); - } + /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? + private static bool MemeAuteur(Livre livre, IReadOnlyList saisis) => + RapprochementAuteurs.PartagentUnAuteur(livre.Auteurs.Select(la => la.Auteur?.Nom), saisis); /// Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur. /// diff --git a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs index e750df4..8d569dd 100644 --- a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs @@ -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 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 SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default) @@ -220,6 +224,97 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits return true; } + /// + /// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un. + /// + /// + /// Ce qui se croise ici, ce sont deux portées différentes : la liste d'envies est + /// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est dans la + /// maison », pas « vous l'avez acheté » — et c'est bien l'information utile avant + /// d'acheter à nouveau. + /// + /// 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 et un auteur + /// commun. Le titre seul confondrait deux recueils homonymes. + /// + /// + /// ⚠️ 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. + /// + /// + private async Task> CorrespondancesAsync( + IReadOnlyList 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(); + foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn))) + { + parIsbn.TryAdd(livre.Isbn!, livre.Id); + } + + var correspondances = new Dictionary(); + + 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; + } + + /// + /// Identifiant du livre correspondant, ou null. + /// + /// + /// ⚠️ Surtout pas GetValueOrDefault : sur un dictionnaire de int, l'absence + /// rend zéro, pas null — et toute envie se serait dite possédée. Le défaut a + /// été pris par les tests, pas par le compilateur. + /// + private static int? Correspondant(IReadOnlyDictionary 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. Sans le champ Utilisateur : il ne sort jamais de /// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle. /// - 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, }; } diff --git a/MaBibli.Client/Pages/Souhaits.razor b/MaBibli.Client/Pages/Souhaits.razor index 69a7d5c..b28fd4d 100644 --- a/MaBibli.Client/Pages/Souhaits.razor +++ b/MaBibli.Client/Pages/Souhaits.razor @@ -147,6 +147,21 @@ else }

+ @* + ⚠️ 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 }) + { + + Déjà au catalogue + + } + @if (!string.IsNullOrWhiteSpace(souhait.Editeur)) { @souhait.Editeur diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 02a50ea..9327e3d 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -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 diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs index 7808d25..1364390 100644 --- a/MaBibli.Shared/Dtos/SouhaitDto.cs +++ b/MaBibli.Shared/Dtos/SouhaitDto.cs @@ -29,6 +29,21 @@ public record SouhaitDto public string? Note { get; init; } public required DateTime DateAjout { get; init; } + + ///

+ /// Vrai si un livre du catalogue correspond à cette envie. + /// + /// + /// C'est un signalement, jamais une suppression. 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 CleOeuvre) — un signalement erroné s'ignore d'un coup d'œil, une + /// suppression erronée ne se rattrape pas. + /// + public bool Possede { get; init; } + + /// Le livre du catalogue en cause, s'il y en a un : de quoi ouvrir sa fiche. + public int? LivreId { get; init; } } /// Charge utile d'ajout d'une envie. diff --git a/MaBibli.Shared/Textes/RapprochementAuteurs.cs b/MaBibli.Shared/Textes/RapprochementAuteurs.cs index d442925..d551ac4 100644 --- a/MaBibli.Shared/Textes/RapprochementAuteurs.cs +++ b/MaBibli.Shared/Textes/RapprochementAuteurs.cs @@ -90,6 +90,33 @@ public static class RapprochementAuteurs public static bool SontLeMemeAuteur(string? a, string? b) => Comparer(a, b) == NiveauRapprochement.Certain; + /// + /// Deux jeux de noms ont-ils au moins un auteur en commun ? + /// + /// + /// 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. + /// + /// Convention : deux jeux tous deux vides 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. + /// + /// + public static bool PartagentUnAuteur(IEnumerable a, IEnumerable 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))); + } + /// /// Cherche à placer chaque mot de sur un mot distinct de /// , par égalité ou par initiale. diff --git a/MaBibli.Tests/ServiceSouhaitsTests.cs b/MaBibli.Tests/ServiceSouhaitsTests.cs index 1e0f25b..b8ca629 100644 --- a/MaBibli.Tests/ServiceSouhaitsTests.cs +++ b/MaBibli.Tests/ServiceSouhaitsTests.cs @@ -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> TitresAsync(string utilisateur = Mathieu) =>