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
@@ -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,
};
}