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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user