using MaBibli.Api.Data; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Catalogue; public interface IServiceAuteurs { /// /// Convertit des noms saisis en fiches auteur, en réutilisant l'existant quand c'est sûr. /// Les nouvelles fiches sont ajoutées au contexte mais pas enregistrées. /// Task> ResoudreAsync(IEnumerable noms, CancellationToken ct = default); Task> ListerAsync(CancellationToken ct = default); Task ObtenirAsync(int id, CancellationToken ct = default); /// Renomme une fiche, ou signale la fiche cible à fusionner. Task RenommerAsync( int id, string nom, bool confirmerFusion = false, CancellationToken ct = default); /// Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés. Task> SuggestionsAsync(CancellationToken ct = default); /// Fusionne source dans cible. source disparaît. Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default); /// Mémorise que ces deux auteurs sont bien deux personnes différentes. Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default); /// Supprime les auteurs qui ne sont plus rattachés à aucun livre. Task SupprimerOrphelinsAsync(CancellationToken ct = default); } /// /// Gestion de la table des auteurs : résolution des noms, regroupement et fusions. /// /// /// Tout se joue sur une table volontairement petite. Une bibliothèque de foyer compte /// quelques centaines d'auteurs : les comparaisons mot à mot (initiales, ordre inversé) sont /// faites en mémoire, parce qu'elles ne s'expriment pas en SQL. Le premier essai — la clé de /// regroupement — reste, lui, un simple index en base et couvre l'écrasante majorité des cas. /// public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs { public async Task> ResoudreAsync( IEnumerable noms, CancellationToken ct = default) { var resolus = new List(); foreach (var brut in noms) { var nom = brut?.Trim(); if (string.IsNullOrEmpty(nom)) { continue; } var cle = RapprochementAuteurs.Cle(nom); if (cle.Length == 0) { continue; // Une saisie sans la moindre lettre ne désigne pas un auteur. } // Déjà résolu dans ce même enregistrement : « Zola » saisi deux fois ne doit pas // produire deux liens vers le même auteur (la clé primaire du lien s'y opposerait). if (resolus.Any(a => a.CleRegroupement == cle)) { continue; } var auteur = await TrouverAsync(nom, cle, ct); if (auteur is null) { auteur = new Auteur { Nom = nom }; auteur.RecalculerFormes(); db.Auteurs.Add(auteur); } resolus.Add(auteur); } return resolus; } /// /// Cherche l'auteur existant qui correspond sûrement à ce nom, ou null. /// private async Task TrouverAsync(string nom, string cle, CancellationToken ct) { // 1. Clé identique : même mots normalisés, ordre indifférent. Résolu par l'index unique. // Le Local est consulté aussi, sinon deux auteurs identiques créés dans la même // requête violeraient l'unicité au SaveChanges. var parCle = db.Auteurs.Local.FirstOrDefault(a => a.CleRegroupement == cle) ?? await db.Auteurs.FirstOrDefaultAsync(a => a.CleRegroupement == cle, ct); if (parCle is not null) { return parCle; } // 2. Variantes que la clé ne capture pas : les initiales abrégées. Il faut aligner les // mots un à un, ce qui n'a pas d'équivalent SQL — d'où le balayage en mémoire. var candidats = await db.Auteurs.ToListAsync(ct); candidats.AddRange(db.Auteurs.Local.Where(a => a.Id == 0)); var surs = candidats .Where(a => RapprochementAuteurs.SontLeMemeAuteur(nom, a.Nom)) .OrderBy(a => a.Id) .ToList(); // Plusieurs correspondances sûres signifient que la table contient déjà des doublons // (ils se seraient rapprochés entre eux). On prend la plus ancienne plutôt que d'en // créer une troisième : l'utilisateur les réunira depuis l'écran des auteurs. return surs.FirstOrDefault(); } public async Task> ListerAsync(CancellationToken ct = default) => await db.Auteurs .AsNoTracking() .OrderBy(a => a.NomNormalise) .Select(a => new AuteurDto { Id = a.Id, Nom = a.Nom, NombreDeLivres = a.Livres.Count, }) .ToListAsync(ct); public async Task ObtenirAsync(int id, CancellationToken ct = default) => await db.Auteurs .AsNoTracking() .Where(a => a.Id == id) .Select(a => new AuteurDto { Id = a.Id, Nom = a.Nom, NombreDeLivres = a.Livres.Count, }) .FirstOrDefaultAsync(ct); public async Task RenommerAsync( int id, string nom, bool confirmerFusion = false, CancellationToken ct = default) { var auteur = await db.Auteurs.FirstOrDefaultAsync(a => a.Id == id, ct); if (auteur is null) { return new ResultatRenommageAuteur(); } nom = nom.Trim(); var cle = RapprochementAuteurs.Cle(nom); if (cle.Length == 0) { return new ResultatRenommageAuteur { Auteur = new AuteurDto { Id = id, Nom = auteur.Nom }, }; } var collision = await db.Auteurs .AsNoTracking() .FirstOrDefaultAsync(a => a.Id != id && a.CleRegroupement == cle, ct); if (collision is not null) { var dto = new AuteurDto { Id = collision.Id, Nom = collision.Nom, NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == collision.Id, ct), }; if (!confirmerFusion) { return new ResultatRenommageAuteur { Collision = dto }; } await FusionnerAsync(id, collision.Id, ct); return new ResultatRenommageAuteur { Auteur = dto }; } auteur.Nom = nom; auteur.RecalculerFormes(); await db.SaveChangesAsync(ct); return new ResultatRenommageAuteur { Auteur = new AuteurDto { Id = auteur.Id, Nom = auteur.Nom, NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == auteur.Id, ct), }, }; } public async Task> SuggestionsAsync( CancellationToken ct = default) { var auteurs = await db.Auteurs .AsNoTracking() .Select(a => new { a.Id, a.Nom, Livres = a.Livres.Count }) .OrderBy(a => a.Id) .ToListAsync(ct); var refuses = await db.RapprochementsRefuses .AsNoTracking() .Select(r => new { r.AuteurAId, r.AuteurBId }) .ToListAsync(ct); var ecartes = refuses.Select(r => (r.AuteurAId, r.AuteurBId)).ToHashSet(); var suggestions = new List(); // Comparaison de toutes les paires : quadratique, mais sur quelques centaines d'auteurs // c'est instantané, et le résultat n'est calculé qu'à l'ouverture de l'écran des auteurs. for (var i = 0; i < auteurs.Count; i++) { for (var j = i + 1; j < auteurs.Count; j++) { var a = auteurs[i]; var b = auteurs[j]; if (RapprochementAuteurs.Comparer(a.Nom, b.Nom) != NiveauRapprochement.Suggere) { continue; } if (ecartes.Contains(Ordonner(a.Id, b.Id))) { continue; // L'utilisateur a déjà dit non : ne pas le lui redemander. } // La fusion fait disparaître la source : c'est le nom le moins complet qui // s'efface, pour ne pas perdre « Peter F. Hamilton » au profit de « Hamilton ». var (source, cible) = NormalisationTexte.Mots(a.Nom).Length <= NormalisationTexte.Mots(b.Nom).Length ? (a, b) : (b, a); suggestions.Add(new SuggestionRapprochement { Source = new AuteurDto { Id = source.Id, Nom = source.Nom, NombreDeLivres = source.Livres }, Cible = new AuteurDto { Id = cible.Id, Nom = cible.Nom, NombreDeLivres = cible.Livres }, }); } } return suggestions; } public async Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default) { if (sourceId == cibleId) { return false; } var source = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == sourceId, ct); var cible = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == cibleId, ct); if (source is null || cible is null) { return false; } var dejaLies = cible.Livres.Select(l => l.LivreId).ToHashSet(); foreach (var lien in source.Livres.ToList()) { // Un livre co-signé par les deux fiches ne doit pas donner deux liens identiques : // la clé primaire du lien est (LivreId, AuteurId). if (!dejaLies.Add(lien.LivreId)) { var existant = cible.Livres.FirstOrDefault(l => l.LivreId == lien.LivreId); if (existant is not null) { existant.Position = Math.Min(existant.Position, lien.Position); if (existant.Role == RoleAuteur.NonPrecise) { existant.Role = lien.Role; } } db.LivreAuteurs.Remove(lien); continue; } db.LivreAuteurs.Remove(lien); db.LivreAuteurs.Add(new LivreAuteur { LivreId = lien.LivreId, AuteurId = cible.Id, Position = lien.Position, Role = lien.Role, }); } // Les refus qui portaient sur la fiche absorbée n'ont plus d'objet. var refusObsoletes = await db.RapprochementsRefuses .Where(r => r.AuteurAId == sourceId || r.AuteurBId == sourceId) .ToListAsync(ct); db.RapprochementsRefuses.RemoveRange(refusObsoletes); db.Auteurs.Remove(source); await db.SaveChangesAsync(ct); return true; } public async Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default) { if (sourceId == cibleId) { return false; } var (a, b) = Ordonner(sourceId, cibleId); var existants = await db.Auteurs.CountAsync(x => x.Id == a || x.Id == b, ct); if (existants != 2) { return false; } var deja = await db.RapprochementsRefuses.AnyAsync(r => r.AuteurAId == a && r.AuteurBId == b, ct); if (!deja) { db.RapprochementsRefuses.Add(new RapprochementRefuse { AuteurAId = a, AuteurBId = b }); await db.SaveChangesAsync(ct); } return true; } public async Task SupprimerOrphelinsAsync(CancellationToken ct = default) { // Après une édition ou une suppression de livre, une fiche auteur peut n'avoir plus aucun // livre. La garder polluerait l'écran des auteurs et les suggestions de rapprochement. var orphelins = await db.Auteurs.Where(a => a.Livres.Count == 0).ToListAsync(ct); if (orphelins.Count == 0) { return; } var ids = orphelins.Select(a => a.Id).ToList(); var refus = await db.RapprochementsRefuses .Where(r => ids.Contains(r.AuteurAId) || ids.Contains(r.AuteurBId)) .ToListAsync(ct); db.RapprochementsRefuses.RemoveRange(refus); db.Auteurs.RemoveRange(orphelins); await db.SaveChangesAsync(ct); } /// Couple rangé, pour qu'un refus vaille dans les deux sens. private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x); }