Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
13 KiB
C#
362 lines
13 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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 <b>pas</b> enregistrées.
|
|
/// </summary>
|
|
Task<IReadOnlyList<Auteur>> ResoudreAsync(IEnumerable<string> noms, CancellationToken ct = default);
|
|
|
|
Task<IReadOnlyList<AuteurDto>> ListerAsync(CancellationToken ct = default);
|
|
|
|
Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);
|
|
|
|
/// <summary>Renomme une fiche, ou signale la fiche cible à fusionner.</summary>
|
|
Task<ResultatRenommageAuteur> RenommerAsync(
|
|
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default);
|
|
|
|
/// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
|
|
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
|
|
|
|
/// <summary>Fusionne <c>source</c> dans <c>cible</c>. <c>source</c> disparaît.</summary>
|
|
Task<bool> FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default);
|
|
|
|
/// <summary>Mémorise que ces deux auteurs sont bien deux personnes différentes.</summary>
|
|
Task<bool> RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default);
|
|
|
|
/// <summary>Supprime les auteurs qui ne sont plus rattachés à aucun livre.</summary>
|
|
Task SupprimerOrphelinsAsync(CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gestion de la table des auteurs : résolution des noms, regroupement et fusions.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Tout se joue sur une table volontairement petite.</b> 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.
|
|
/// </remarks>
|
|
public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
|
|
{
|
|
public async Task<IReadOnlyList<Auteur>> ResoudreAsync(
|
|
IEnumerable<string> noms, CancellationToken ct = default)
|
|
{
|
|
var resolus = new List<Auteur>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cherche l'auteur existant qui correspond <b>sûrement</b> à ce nom, ou <c>null</c>.
|
|
/// </summary>
|
|
private async Task<Auteur?> 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<IReadOnlyList<AuteurDto>> 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<AuteurDto?> 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<ResultatRenommageAuteur> 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<IReadOnlyList<SuggestionRapprochement>> 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<SuggestionRapprochement>();
|
|
|
|
// 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<bool> 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<bool> 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);
|
|
}
|
|
|
|
/// <summary>Couple rangé, pour qu'un refus vaille dans les deux sens.</summary>
|
|
private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x);
|
|
}
|