Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
11 KiB
C#
292 lines
11 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>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<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))
|
|
{
|
|
db.LivreAuteurs.Remove(lien);
|
|
continue;
|
|
}
|
|
|
|
db.LivreAuteurs.Remove(lien);
|
|
db.LivreAuteurs.Add(new LivreAuteur
|
|
{
|
|
LivreId = lien.LivreId,
|
|
AuteurId = cible.Id,
|
|
Position = lien.Position,
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|