Ajoute les notices sources et le renommage fusionné des auteurs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 14:27:42 +02:00
co-authored by Copilot
parent 6b8f08ef26
commit e28d5bce32
24 changed files with 935 additions and 3 deletions
+27
View File
@@ -1981,3 +1981,30 @@ exception explicite à la règle générale « griser, ne jamais masquer ».
Enfin, plusieurs œuvres non possédées peuvent être cochées puis ajoutées aux envies en une seule Enfin, plusieurs œuvres non possédées peuvent être cochées puis ajoutées aux envies en une seule
action d'écran. Chaque ajout reprend le même rapprochement et les mêmes validations que l'ajout action d'écran. Chaque ajout reprend le même rapprochement et les mêmes validations que l'ajout
unitaire ; les doublons sont rapportés sans effacer la sélection des autres œuvres. unitaire ; les doublons sont rapportés sans effacer la sélection des autres œuvres.
## Lot F — enrichir la fiche d'un livre (F1 et F3, 2026-08-20)
### F1 : lien vers la notice source
`Livre.UrlNotice` conserve l'URL de la notice utilisée pour préremplir une fiche depuis un
lookup ISBN. La BnF expose dans `srw:recordIdentifier` un ARK stable
(`ark:/12148/cb…`) : il est transformé en `https://catalogue.bnf.fr/{ark}`. OpenLibrary expose
la clé d'édition (`/books/OL…M`) : elle devient
`https://openlibrary.org/{clé}`. Ce sont des pages de notice directement consultables, et non
les endpoints JSON.
L'URL traverse `CandidatLivre`, `EnregistrementLivre` et le client jusqu'à la création du livre.
Une saisie manuelle laisse la colonne nulle. Les livres déjà en base n'ont pas cette information :
aucun rétro-remplissage n'est possible puisque la notice choisie n'a pas été conservée à l'époque.
La fiche affiche « Voir la notice source » uniquement quand l'URL existe.
### F3 : renommer et fusionner un auteur
`PUT /api/auteurs/{id}` recalcule `NomNormalise` et `CleRegroupement`. Si la clé de regroupement
est déjà portée par une autre fiche, l'API répond `409` avec la fiche cible ; l'écran explique
la fusion et demande confirmation. La confirmation réutilise la fusion existante : tous les liens
`LivreAuteur` sont réattribués, les doublons de lien pour un même livre sont réunis en conservant
la position la plus précoce et un rôle renseigné plutôt que `NonPrecise`, puis la fiche source et
ses refus deviennent obsolètes. Sans collision, le renommage est direct. Cette opération est
volontairement distincte de la renormalisation automatique : c'est un choix explicite de
l'utilisateur.
+6 -2
View File
@@ -233,7 +233,9 @@ L'écran `/souhaits/ajout` rend aujourd'hui une liste plate.
## Lot F — Enrichir la fiche d'un livre ## Lot F — Enrichir la fiche d'un livre
- **F1. Un lien vers la notice source** (BnF, OpenLibrary) sur la fiche. Peu coûteux, et c'est le - ~~**F1. Un lien vers la notice source** (BnF, OpenLibrary) sur la fiche.~~ Traité le 2026-08-20 :
l'URL est conservée à la création quand le livre vient d'un lookup ISBN et affichée sur la fiche.
Peu coûteux, et c'est le
recours quand une donnée paraît fausse. ⚠️ Suppose de **conserver l'identifiant de la notice** recours quand une donnée paraît fausse. ⚠️ Suppose de **conserver l'identifiant de la notice**
au lookup, ce que le modèle ne fait pas aujourd'hui : ce n'est donc pas un simple lien à au lookup, ce que le modèle ne fait pas aujourd'hui : ce n'est donc pas un simple lien à
afficher, mais une colonne de plus. afficher, mais une colonne de plus.
@@ -241,7 +243,9 @@ L'écran `/souhaits/ajout` rend aujourd'hui une liste plate.
table de tags, saisie, filtre, et la question de la **portée** (commune ? personnelle ?). Une table de tags, saisie, filtre, et la question de la **portée** (commune ? personnelle ?). Une
source automatique est douteuse — le Dublin Core BnF porte `dc:subject`, mais avec un vocabulaire source automatique est douteuse — le Dublin Core BnF porte `dc:subject`, mais avec un vocabulaire
Rameau qui ne ressemble pas à ces mots-là. À instruire séparément du reste du lot. Rameau qui ne ressemble pas à ces mots-là. À instruire séparément du reste du lot.
- **F3. Renommer un auteur**, la correction se répercutant sur tous ses livres. ⚠️ Le n-n est déjà - ~~**F3. Renommer un auteur**, la correction se répercutant sur tous ses livres.~~ Traité le
2026-08-20 : le renommage recalcule les formes et propose une fusion confirmable en cas de
collision. ⚠️ Le n-n est déjà
en place, donc un seul `Auteur.Nom` à changer — mais **`NomNormalise` et `CleRegroupement` se en place, donc un seul `Auteur.Nom` à changer — mais **`NomNormalise` et `CleRegroupement` se
recalculent**, et la clé porte un index **unique**. Renommer « Hamilton » en « Peter F. Hamilton » recalculent**, et la clé porte un index **unique**. Renommer « Hamilton » en « Peter F. Hamilton »
peut donc **entrer en collision** avec une fiche existante : le geste est alors une **fusion**, peut donc **entrer en collision** avec une fiche existante : le geste est alors une **fusion**,
+1
View File
@@ -50,6 +50,7 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
{ {
livre.Property(l => l.Titre).IsRequired(); livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired(); livre.Property(l => l.TitreNormalise).IsRequired();
livre.Property(l => l.UrlNotice);
livre.HasIndex(l => l.Isbn); livre.HasIndex(l => l.Isbn);
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table. // Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
@@ -0,0 +1,532 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260820115411_NoticeBibliographique")]
partial class NoticeBibliographique
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class NoticeBibliographique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "UrlNotice",
table: "Livres",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UrlNotice",
table: "Livres");
}
}
}
@@ -139,6 +139,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<int>("TypeDocument") b.Property<int>("TypeDocument")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Isbn"); b.HasIndex("Isbn");
+25
View File
@@ -186,6 +186,31 @@ public static class LivresEndpoints
.Produces<AuteurDto>() .Produces<AuteurDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}", async (
int id,
RenommerAuteur demande,
bool? confirmerFusion,
IServiceAuteurs service,
CancellationToken ct) =>
{
var resultat = await service.RenommerAsync(
id, demande.Nom, confirmerFusion ?? false, ct);
if (resultat.Auteur is null && resultat.Collision is null)
{
return Results.NotFound();
}
return resultat.Collision is not null
? Results.Conflict(resultat)
: Results.Ok(resultat);
})
.WithName("RenommerAuteur")
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
.Produces<ResultatRenommageAuteur>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) => groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.SuggestionsAsync(ct))) Results.Ok(await service.SuggestionsAsync(ct)))
.WithName("ListerRapprochements") .WithName("ListerRapprochements")
@@ -18,6 +18,10 @@ public interface IServiceAuteurs
Task<AuteurDto?> ObtenirAsync(int id, 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> /// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default); Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
@@ -139,6 +143,62 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
}) })
.FirstOrDefaultAsync(ct); .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( public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
CancellationToken ct = default) CancellationToken ct = default)
{ {
@@ -217,6 +277,15 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
// la clé primaire du lien est (LivreId, AuteurId). // la clé primaire du lien est (LivreId, AuteurId).
if (!dejaLies.Add(lien.LivreId)) 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); db.LivreAuteurs.Remove(lien);
continue; continue;
} }
@@ -227,6 +296,7 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
LivreId = lien.LivreId, LivreId = lien.LivreId,
AuteurId = cible.Id, AuteurId = cible.Id,
Position = lien.Position, Position = lien.Position,
Role = lien.Role,
}); });
} }
@@ -132,6 +132,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
Format = saisie.Format, Format = saisie.Format,
TypeDocument = saisie.TypeDocument, TypeDocument = saisie.TypeDocument,
CoverUrl = Vide(saisie.CoverUrl), CoverUrl = Vide(saisie.CoverUrl),
UrlNotice = Vide(saisie.UrlNotice),
DateAjout = DateTime.UtcNow, DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client. // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
@@ -171,6 +172,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
livre.Format = saisie.Format; livre.Format = saisie.Format;
livre.TypeDocument = saisie.TypeDocument; livre.TypeDocument = saisie.TypeDocument;
livre.CoverUrl = Vide(saisie.CoverUrl); livre.CoverUrl = Vide(saisie.CoverUrl);
livre.UrlNotice = Vide(saisie.UrlNotice);
livre.RecalculerFormes(); livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
@@ -494,6 +496,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
TypeDocument = livre.TypeDocument, TypeDocument = livre.TypeDocument,
Statut = statut, Statut = statut,
CoverUrl = livre.CoverUrl, CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
DateAjout = livre.DateAjout, DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar, AjoutePar = livre.AjoutePar,
PreteA = pretOuvert?.Emprunteur, PreteA = pretOuvert?.Emprunteur,
+9 -1
View File
@@ -62,6 +62,8 @@ public static class BnfSruParser
continue; continue;
} }
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
candidats.Add(new CandidatLivre candidats.Add(new CandidatLivre
{ {
Titre = titre, Titre = titre,
@@ -72,13 +74,19 @@ public static class BnfSruParser
CoverUrl = urlCouverture, CoverUrl = urlCouverture,
Source = SourceMetadonnees.Bnf, Source = SourceMetadonnees.Bnf,
IsbnInterroge = isbnInterroge, IsbnInterroge = isbnInterroge,
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(), IdentifiantSource = identifiant,
UrlNotice = UrlNotice(identifiant),
}); });
} }
return candidats; return candidats;
} }
private static string? UrlNotice(string? identifiant) =>
string.IsNullOrWhiteSpace(identifiant)
? null
: $"https://catalogue.bnf.fr/{identifiant}";
/// <summary> /// <summary>
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>. /// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
/// </summary> /// </summary>
@@ -83,9 +83,15 @@ public static class OpenLibraryMapper
Source = SourceMetadonnees.OpenLibrary, Source = SourceMetadonnees.OpenLibrary,
IsbnInterroge = isbnInterroge, IsbnInterroge = isbnInterroge,
IdentifiantSource = edition.Key, IdentifiantSource = edition.Key,
UrlNotice = UrlNotice(edition.Key),
}; };
} }
private static string? UrlNotice(string? cle) =>
string.IsNullOrWhiteSpace(cle)
? null
: $"https://openlibrary.org/{cle.TrimStart('/')}";
private static IReadOnlyList<string> Liste(string? valeur) => private static IReadOnlyList<string> Liste(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur]; string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];
+1
View File
@@ -431,6 +431,7 @@
Auteur = candidat.Auteur, Auteur = candidat.Auteur,
Editeur = candidat.Editeur, Editeur = candidat.Editeur,
CoverUrl = candidat.CoverUrl, CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
Format = Format.Physique, Format = Format.Physique,
Statut = Statut.ALire, Statut = Statut.ALire,
}; };
+95
View File
@@ -79,15 +79,61 @@ else
tout ce qu'il a ecrit et marquer ce qui manque. *@ tout ce qu'il a ecrit et marquer ce qui manque. *@
<a class="bouton bouton-compact bouton-discret" <a class="bouton bouton-compact bouton-discret"
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a> href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
@if (_auteurRenomme == auteur.Id)
{
<input class="champ-texte champ-auteur-renomme" @bind="_nomRenomme" />
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_renommageEnCours" @onclick="RenommerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
Annuler
</button>
}
else
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@MotifBlocage"
@onclick="() => CommencerRenommage(auteur)">
Renommer
</button>
}
</li> </li>
} }
</ul> </ul>
} }
@if (_collisionRenommage is not null)
{
<section class="message-avertissement" role="alert">
<p>
Un auteur existe déjà sous le nom « @_collisionRenommage.Nom ».
Les livres seront regroupés sous cette fiche.
Confirmer la fusion ?
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@_renommageEnCours" @onclick="ConfirmerFusionAsync">
Oui, fusionner
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
Annuler
</button>
</div>
</section>
}
@code { @code {
private IReadOnlyList<AuteurDto>? _auteurs; private IReadOnlyList<AuteurDto>? _auteurs;
private IReadOnlyList<SuggestionRapprochement>? _suggestions; private IReadOnlyList<SuggestionRapprochement>? _suggestions;
private bool _enCours; private bool _enCours;
private bool _renommageEnCours;
private int? _auteurRenomme;
private string _nomRenomme = string.Empty;
private AuteurDto? _collisionRenommage;
private string? _erreur; private string? _erreur;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau; protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
@@ -128,6 +174,55 @@ else
private Task RefuserAsync(SuggestionRapprochement suggestion) => private Task RefuserAsync(SuggestionRapprochement suggestion) =>
DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id)); DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
private void CommencerRenommage(AuteurDto auteur)
{
_auteurRenomme = auteur.Id;
_nomRenomme = auteur.Nom;
_collisionRenommage = null;
_erreur = null;
}
private void AnnulerRenommage()
{
_auteurRenomme = null;
_collisionRenommage = null;
_nomRenomme = string.Empty;
}
private Task RenommerAsync() => EnvoyerRenommageAsync(false);
private Task ConfirmerFusionAsync() => EnvoyerRenommageAsync(true);
private async Task EnvoyerRenommageAsync(bool confirmerFusion)
{
if (_auteurRenomme is null || string.IsNullOrWhiteSpace(_nomRenomme))
{
_erreur = "Le nom de l'auteur est obligatoire.";
return;
}
_renommageEnCours = true;
_erreur = null;
var resultat = await Api.RenommerAuteurAsync(
_auteurRenomme.Value, _nomRenomme, confirmerFusion);
_renommageEnCours = false;
if (resultat is null)
{
_erreur = "Le renommage n'a pas pu être enregistré.";
return;
}
if (resultat.Collision is not null)
{
_collisionRenommage = resultat.Collision;
return;
}
AnnulerRenommage();
await ChargerAsync();
}
private async Task DeciderAsync(Func<Task<string?>> action) private async Task DeciderAsync(Func<Task<string?>> action)
{ {
_enCours = true; _enCours = true;
+6
View File
@@ -149,6 +149,11 @@ else
{ {
<div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div> <div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div>
} }
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
{
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></dd></div>
}
</dl> </dl>
</div> </div>
</div> </div>
@@ -329,6 +334,7 @@ else
TypeDocument = livre.TypeDocument, TypeDocument = livre.TypeDocument,
Statut = livre.Statut, Statut = livre.Statut,
CoverUrl = livre.CoverUrl, CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
}; };
/// <summary> /// <summary>
+1
View File
@@ -646,6 +646,7 @@ else
Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(), Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
Editeur = candidat.Editeur, Editeur = candidat.Editeur,
CoverUrl = candidat.CoverUrl, CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
}; };
var resultat = await Api.CreerAsync(saisie); var resultat = await Api.CreerAsync(saisie);
_enCours = false; _enCours = false;
@@ -240,6 +240,41 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees.FirstOrDefault(a => a.Id == id); return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
} }
public async Task<ResultatRenommageAuteur?> RenommerAuteurAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
{
if (!reseau.EnLigne)
{
return null;
}
var suffixe = confirmerFusion ? "?confirmerFusion=true" : string.Empty;
try
{
var reponse = await http.PutAsJsonAsync(
$"api/auteurs/{id}{suffixe}", new RenommerAuteur(nom), Json, ct);
reseau.SignalerSuccesReseau();
if (reponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (reponse.StatusCode == HttpStatusCode.Conflict
|| reponse.IsSuccessStatusCode)
{
return await LireAsync<ResultatRenommageAuteur>(reponse, ct);
}
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return null;
}
}
/// <summary> /// <summary>
/// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul. /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
/// </summary> /// </summary>
+3
View File
@@ -60,4 +60,7 @@ public record CandidatLivre
/// <summary>Identifiant chez la source : ark BnF (<c>ark:/12148/cb…</c>) ou clé OpenLibrary (<c>/books/OL…M</c>).</summary> /// <summary>Identifiant chez la source : ark BnF (<c>ark:/12148/cb…</c>) ou clé OpenLibrary (<c>/books/OL…M</c>).</summary>
public string? IdentifiantSource { get; init; } public string? IdentifiantSource { get; init; }
/// <summary>URL de consultation de la notice chez la source.</summary>
public string? UrlNotice { get; init; }
} }
@@ -46,6 +46,11 @@ public record EnregistrementLivre
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
/// <summary>
/// Notice ayant servi au préremplissage, ou <c>null</c> pour une saisie manuelle.
/// </summary>
public string? UrlNotice { get; set; }
/// <summary> /// <summary>
/// Vue « une seule ligne » de <see cref="Auteurs"/>, pour les champs de saisie simples. /// Vue « une seule ligne » de <see cref="Auteurs"/>, pour les champs de saisie simples.
/// </summary> /// </summary>
@@ -103,6 +108,19 @@ public record EnregistrementLivre
/// </remarks> /// </remarks>
public record AuteurSaisi(string Nom, RoleAuteur Role = RoleAuteur.NonPrecise); public record AuteurSaisi(string Nom, RoleAuteur Role = RoleAuteur.NonPrecise);
/// <summary>Demande de renommage d'une fiche auteur.</summary>
public record RenommerAuteur(string Nom);
/// <summary>Résultat d'un renommage, avec collision éventuelle à confirmer.</summary>
public record ResultatRenommageAuteur
{
public AuteurDto? Auteur { get; init; }
public AuteurDto? Collision { get; init; }
public bool EstCollision => Collision is not null;
}
/// <summary>Charge utile du changement de statut de lecture — l'action la plus fréquente.</summary> /// <summary>Charge utile du changement de statut de lecture — l'action la plus fréquente.</summary>
/// <remarks> /// <remarks>
/// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur », /// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur »,
+2
View File
@@ -58,6 +58,8 @@ public record LivreDto
public string? CoverUrl { get; init; } public string? CoverUrl { get; init; }
public string? UrlNotice { get; init; }
public required DateTime DateAjout { get; init; } public required DateTime DateAjout { get; init; }
/// <summary> /// <summary>
+3
View File
@@ -40,6 +40,9 @@ public class Livre
/// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary> /// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary>
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
/// <summary>URL de la notice bibliographique qui a servi à préremplir la fiche.</summary>
public string? UrlNotice { get; set; }
public DateTime DateAjout { get; set; } public DateTime DateAjout { get; set; }
/// <summary> /// <summary>
+1
View File
@@ -36,6 +36,7 @@ public class BnfSruParserTests
Assert.Equal("9782070612758", c.IsbnInterroge); Assert.Equal("9782070612758", c.IsbnInterroge);
Assert.Equal("https://couverture", c.CoverUrl); Assert.Equal("https://couverture", c.CoverUrl);
Assert.StartsWith("ark:/12148/", c.IdentifiantSource); Assert.StartsWith("ark:/12148/", c.IdentifiantSource);
Assert.Equal($"https://catalogue.bnf.fr/{c.IdentifiantSource}", c.UrlNotice);
} }
[Fact] [Fact]
+1
View File
@@ -43,6 +43,7 @@ public class OpenLibraryMapperTests
Assert.Equal("March 2007", c.Annee); Assert.Equal("March 2007", c.Annee);
Assert.Equal("fre", c.Langue); Assert.Equal("fre", c.Langue);
Assert.Equal(SourceMetadonnees.OpenLibrary, c.Source); Assert.Equal(SourceMetadonnees.OpenLibrary, c.Source);
Assert.Equal("https://openlibrary.org/books/OL9567312M", c.UrlNotice);
Assert.Equal("/books/OL9567312M", c.IdentifiantSource); Assert.Equal("/books/OL9567312M", c.IdentifiantSource);
Assert.Equal("https://couverture", c.CoverUrl); Assert.Equal("https://couverture", c.CoverUrl);
} }
+39
View File
@@ -189,6 +189,45 @@ public class ServiceAuteursTests : IDisposable
Assert.Single(livre.Auteurs); Assert.Single(livre.Auteurs);
} }
[Fact]
public async Task Renommer_un_auteur_recalcule_ses_formes()
{
var livre = await AjouterAsync("Pandora's Star", "Hamilton");
var resultat = await _auteurs.RenommerAsync(
livre.Auteurs.Single().Id, "Peter F. Hamilton");
Assert.False(resultat.EstCollision);
Assert.Equal("Peter F. Hamilton", resultat.Auteur!.Nom);
var auteur = await _db.Auteurs.SingleAsync();
Assert.Equal("peter f hamilton", auteur.NomNormalise);
Assert.Equal("f hamilton peter", auteur.CleRegroupement);
}
[Fact]
public async Task Renommer_avec_collision_demande_puis_fusionne()
{
var court = await AjouterAsync("Judas Unchained", "Hamilton");
var complet = await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
var idCourt = court.Auteurs.Single().Id;
var idComplet = complet.Auteurs.Single().Id;
var avertissement = await _auteurs.RenommerAsync(idCourt, "Peter F. Hamilton");
Assert.True(avertissement.EstCollision);
Assert.Equal(idComplet, avertissement.Collision!.Id);
Assert.Equal(2, await _db.Auteurs.CountAsync());
var fusion = await _auteurs.RenommerAsync(
idCourt, "Peter F. Hamilton", confirmerFusion: true);
Assert.False(fusion.EstCollision);
Assert.Single(await _db.Auteurs.ToListAsync());
Assert.Equal(2, await _db.LivreAuteurs.CountAsync());
Assert.Equal(2, (await _auteurs.ListerAsync()).Single().NombreDeLivres);
}
public void Dispose() public void Dispose()
{ {
_db.Dispose(); _db.Dispose();
+19
View File
@@ -70,6 +70,25 @@ public class ServiceCatalogueTests : IDisposable
Assert.Equal("9782253004226", resultat.Livre!.Isbn); Assert.Equal("9782253004226", resultat.Livre!.Isbn);
} }
[Fact]
public async Task Creer_conserve_lurl_de_la_notice_source()
{
var resultat = await _service.CreerAsync(
new EnregistrementLivre
{
Titre = "Germinal",
UrlNotice = "https://catalogue.bnf.fr/ark:/12148/cb34676750b",
},
"mathieu");
Assert.Equal(
"https://catalogue.bnf.fr/ark:/12148/cb34676750b",
resultat.Livre!.UrlNotice);
Assert.Equal(
resultat.Livre.UrlNotice,
(await _db.Livres.SingleAsync()).UrlNotice);
}
[Fact] [Fact]
public async Task Creer_refuse_un_titre_vide() public async Task Creer_refuse_un_titre_vide()
{ {