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>
564 lines
22 KiB
C#
564 lines
22 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using MaBibli.Shared.Isbn;
|
|
using MaBibli.Shared.Textes;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Api.Services.Catalogue;
|
|
|
|
/// <summary>
|
|
/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
|
|
/// </summary>
|
|
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
|
|
{
|
|
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null);
|
|
|
|
public static ResultatEcriture Invalide(string message) => new(null, message, null);
|
|
|
|
/// <summary>
|
|
/// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ce n'est <b>pas</b> une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
|
|
/// D'où un troisième état plutôt qu'un message dans <c>Erreur</c> : l'écran doit proposer
|
|
/// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
|
|
/// </remarks>
|
|
public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
|
|
|
|
/// <summary>Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.</summary>
|
|
public static readonly ResultatEcriture Introuvable = new(null, null, null);
|
|
|
|
public bool EstIntrouvable => Livre is null && Erreur is null && Doublons is null;
|
|
}
|
|
|
|
public interface IServiceCatalogue
|
|
{
|
|
Task<IReadOnlyList<LivreDto>> ListerAsync(
|
|
CritereLivres criteres, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<ResultatEcriture> CreerAsync(
|
|
EnregistrementLivre saisie,
|
|
string? utilisateur,
|
|
bool confirmerDoublon = false,
|
|
CancellationToken ct = default);
|
|
|
|
Task<ResultatEcriture> ModifierAsync(
|
|
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<LivreDto?> ChangerStatutAsync(
|
|
int id, Statut? statut, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
|
|
/// <see cref="Format"/>. Il n'y a qu'un seul catalogue.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Le paramètre <c>utilisateur</c> ne restreint jamais la liste des livres.</b> Il sert
|
|
/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule
|
|
/// la lecture est personnelle.
|
|
/// </remarks>
|
|
public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue
|
|
{
|
|
public async Task<IReadOnlyList<LivreDto>> ListerAsync(
|
|
CritereLivres criteres, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
|
|
// la bibliothèque est commune à tout le foyer (CLAUDE.md).
|
|
var requete = FiltreLivres.Appliquer(
|
|
db.Livres.AsNoTracking()
|
|
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
|
.Include(l => l.Themes).ThenInclude(lt => lt.Theme),
|
|
criteres,
|
|
utilisateur);
|
|
|
|
var livres = await requete.ToListAsync(ct);
|
|
|
|
var ids = livres.Select(l => l.Id).ToList();
|
|
var statuts = await StatutsAsync(ids, utilisateur, ct);
|
|
var prets = await PretsOuvertsAsync(ids, ct);
|
|
|
|
return livres
|
|
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
var livre = await db.Livres
|
|
.AsNoTracking()
|
|
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
|
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
|
|
.FirstOrDefaultAsync(l => l.Id == id, ct);
|
|
|
|
if (livre is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var statuts = await StatutsAsync([id], utilisateur, ct);
|
|
var prets = await PretsOuvertsAsync([id], ct);
|
|
return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id));
|
|
}
|
|
|
|
public async Task<ResultatEcriture> CreerAsync(
|
|
EnregistrementLivre saisie,
|
|
string? utilisateur,
|
|
bool confirmerDoublon = false,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (Valider(saisie, out var isbn, out var erreur) is false)
|
|
{
|
|
return ResultatEcriture.Invalide(erreur!);
|
|
}
|
|
|
|
if (!confirmerDoublon)
|
|
{
|
|
var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct);
|
|
if (deja is not null)
|
|
{
|
|
return ResultatEcriture.Doublon(deja);
|
|
}
|
|
}
|
|
|
|
var livre = new Livre
|
|
{
|
|
Isbn = isbn,
|
|
Titre = saisie.Titre,
|
|
Editeur = Vide(saisie.Editeur),
|
|
Format = saisie.Format,
|
|
TypeDocument = saisie.TypeDocument,
|
|
NombrePages = saisie.NombrePages,
|
|
CoverUrl = Vide(saisie.CoverUrl),
|
|
UrlNotice = Vide(saisie.UrlNotice),
|
|
DateAjout = DateTime.UtcNow,
|
|
|
|
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
|
|
AjoutePar = utilisateur,
|
|
};
|
|
|
|
livre.RecalculerFormes();
|
|
db.Livres.Add(livre);
|
|
|
|
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
|
|
await RattacherThemesAsync(livre, saisie.Themes, ct);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
// Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
|
|
// ligne est « non commencé », et c'est le cas de départ le plus fréquent.
|
|
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
|
|
|
|
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
|
|
}
|
|
|
|
public async Task<ResultatEcriture> ModifierAsync(
|
|
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
var livre = await db.Livres
|
|
.Include(l => l.Auteurs)
|
|
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
|
|
.FirstOrDefaultAsync(l => l.Id == id, ct);
|
|
if (livre is null)
|
|
{
|
|
return ResultatEcriture.Introuvable;
|
|
}
|
|
|
|
if (Valider(saisie, out var isbn, out var erreur) is false)
|
|
{
|
|
return ResultatEcriture.Invalide(erreur!);
|
|
}
|
|
|
|
livre.Isbn = isbn;
|
|
livre.Titre = saisie.Titre;
|
|
livre.Editeur = Vide(saisie.Editeur);
|
|
livre.Format = saisie.Format;
|
|
livre.TypeDocument = saisie.TypeDocument;
|
|
livre.NombrePages = saisie.NombrePages;
|
|
livre.CoverUrl = Vide(saisie.CoverUrl);
|
|
livre.UrlNotice = Vide(saisie.UrlNotice);
|
|
livre.RecalculerFormes();
|
|
|
|
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
|
|
// d'origine, pas des champs éditables.
|
|
|
|
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
|
|
await RattacherThemesAsync(livre, saisie.Themes, ct);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
|
|
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
|
|
await auteurs.SupprimerOrphelinsAsync(ct);
|
|
|
|
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
|
|
}
|
|
|
|
public async Task<LivreDto?> ChangerStatutAsync(
|
|
int id, Statut? statut, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (!await db.Livres.AnyAsync(l => l.Id == id, ct))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await AppliquerStatutAsync(id, statut, utilisateur, ct);
|
|
return await ObtenirAsync(id, utilisateur, ct);
|
|
}
|
|
|
|
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
|
|
if (livre is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles
|
|
// sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche.
|
|
db.Livres.Remove(livre);
|
|
await db.SaveChangesAsync(ct);
|
|
await auteurs.SupprimerOrphelinsAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cherche ce que le catalogue contient déjà de semblable, ou <c>null</c> s'il n'y a rien.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Deux critères, et aucun n'est une clé.</b> L'ISBN identique attrape le cas visé — le
|
|
/// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est
|
|
/// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le
|
|
/// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le
|
|
/// résultat est un <b>avertissement</b> et non un refus : un index unique interdirait le
|
|
/// second exemplaire, qui est un cas parfaitement normal.
|
|
/// <para>
|
|
/// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
|
|
/// <see cref="RapprochementAuteurs"/> — sinon deux « Nouvelles » sans rapport se
|
|
/// signaleraient l'une l'autre.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<DoublonsLivre?> DoublonsAsync(
|
|
string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct)
|
|
{
|
|
// Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit
|
|
// apparaître qu'une fois.
|
|
var candidats = new Dictionary<int, Livre>();
|
|
|
|
if (isbn is not null)
|
|
{
|
|
foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct))
|
|
{
|
|
candidats[livre.Id] = livre;
|
|
}
|
|
}
|
|
|
|
var cle = CleOeuvre.Cle(saisie.Titre);
|
|
if (cle.Length > 0)
|
|
{
|
|
// La clé d'œuvre est un PRÉFIXE de TitreNormalise : la troncature du sous-titre
|
|
// précède la normalisation, qui préserve l'ordre des caractères. SQLite dégrossit
|
|
// donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire
|
|
// — sans quoi il faudrait charger tout le catalogue à chaque ajout.
|
|
var parTitre = await AvecAuteurs()
|
|
.Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " "))
|
|
.ToListAsync(ct);
|
|
|
|
foreach (var livre in parTitre)
|
|
{
|
|
if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs))
|
|
{
|
|
candidats[livre.Id] = livre;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (candidats.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var trouves = candidats.Values.OrderBy(l => l.Id).ToList();
|
|
var ids = trouves.Select(l => l.Id).ToList();
|
|
var statuts = await StatutsAsync(ids, utilisateur, ct);
|
|
var prets = await PretsOuvertsAsync(ids, ct);
|
|
|
|
return new DoublonsLivre
|
|
{
|
|
Message = Avertir(trouves),
|
|
Livres = trouves
|
|
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
|
|
.ToList(),
|
|
};
|
|
}
|
|
|
|
private IQueryable<Livre> AvecAuteurs() =>
|
|
db.Livres.AsNoTracking()
|
|
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
|
.Include(l => l.Themes).ThenInclude(lt => lt.Theme);
|
|
|
|
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
|
|
/// <remarks>Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici.</remarks>
|
|
private static bool MemeAuteur(Livre livre, IReadOnlyList<AuteurSaisi> saisis) =>
|
|
RapprochementAuteurs.PartagentUnAuteur(
|
|
livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom));
|
|
|
|
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
|
|
/// <remarks>
|
|
/// Elle nomme ce qu'on possède déjà et <b>invite à passer outre</b> : c'est un doute soumis à
|
|
/// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le
|
|
/// texte n'a pas à les décrire.
|
|
/// </remarks>
|
|
private static string Avertir(IReadOnlyList<Livre> trouves)
|
|
{
|
|
var debut = trouves.Count == 1
|
|
? $"« {trouves[0].Titre} » est déjà au catalogue."
|
|
: $"{trouves.Count} livres du catalogue ressemblent à celui-ci.";
|
|
|
|
return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition.";
|
|
}
|
|
|
|
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
|
|
private async Task<Dictionary<int, Statut?>> StatutsAsync(
|
|
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
|
|
{
|
|
// Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre.
|
|
if (utilisateur is null || livreIds.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
// Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne
|
|
// ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et
|
|
// « non commencé » deviendrait indiscernable d'un choix explicite.
|
|
return await db.StatutsLecture
|
|
.AsNoTracking()
|
|
.Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId))
|
|
.ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prêt en cours de chacun des livres demandés, quand il y en a un.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Aucun paramètre « utilisateur » ici, volontairement</b> : contrairement au statut de
|
|
/// lecture, un livre absent l'est pour tout le foyer. La question « où est ce livre ? » a une
|
|
/// seule réponse, la même pour tout le monde.
|
|
/// <para>
|
|
/// L'index unique partiel sur <c>Prets</c> garantit qu'il n'y a qu'un prêt ouvert par livre :
|
|
/// ce dictionnaire ne peut donc pas avoir à départager deux candidats.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<Dictionary<int, Pret>> PretsOuvertsAsync(
|
|
IReadOnlyList<int> livreIds, CancellationToken ct)
|
|
{
|
|
if (livreIds.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return await db.Prets
|
|
.AsNoTracking()
|
|
.Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId))
|
|
.ToDictionaryAsync(p => p.LivreId, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
|
|
/// </summary>
|
|
private async Task AppliquerStatutAsync(
|
|
int livreId, Statut? statut, string? utilisateur, CancellationToken ct)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
// Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste
|
|
// enregistré, simplement sans lecture rattachée à personne.
|
|
return;
|
|
}
|
|
|
|
var ligne = await db.StatutsLecture
|
|
.FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct);
|
|
|
|
if (statut is null)
|
|
{
|
|
if (ligne is not null)
|
|
{
|
|
// Retour à « non commencé » : on supprime la ligne au lieu d'inventer une
|
|
// quatrième valeur d'énumération qui aurait fallu migrer.
|
|
db.StatutsLecture.Remove(ligne);
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (ligne is null)
|
|
{
|
|
db.StatutsLecture.Add(new StatutLecture
|
|
{
|
|
LivreId = livreId,
|
|
Utilisateur = utilisateur,
|
|
Statut = statut.Value,
|
|
DateMaj = DateTime.UtcNow,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
ligne.Statut = statut.Value;
|
|
ligne.DateMaj = DateTime.UtcNow;
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre et leur rôle.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Le <b>rôle vit sur le lien</b>, pas sur la fiche de l'auteur : le même dessinateur
|
|
/// peut avoir scénarisé un autre album, et il ne doit pas exister en deux fiches pour
|
|
/// autant. C'est pour cela que <c>ResoudreAsync</c> ne reçoit que des noms.
|
|
/// </remarks>
|
|
private async Task RattacherAuteursAsync(
|
|
Livre livre, IReadOnlyList<AuteurSaisi> saisis, CancellationToken ct)
|
|
{
|
|
var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), ct);
|
|
|
|
// Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
|
|
// gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
|
|
if (livre.Auteurs.Count > 0)
|
|
{
|
|
db.LivreAuteurs.RemoveRange(livre.Auteurs);
|
|
livre.Auteurs.Clear();
|
|
}
|
|
|
|
// ResoudreAsync écarte les noms vides et fusionne les variantes : sa liste peut être
|
|
// plus courte que la saisie. Les rôles se retrouvent donc par NOM RÉSOLU, jamais par
|
|
// position — sans quoi un nom vide au milieu décalerait tous les rôles suivants.
|
|
var roles = new Dictionary<string, RoleAuteur>(StringComparer.Ordinal);
|
|
foreach (var saisi in saisis)
|
|
{
|
|
roles.TryAdd(RapprochementAuteurs.Cle(saisi.Nom), saisi.Role);
|
|
}
|
|
|
|
for (var i = 0; i < resolus.Count; i++)
|
|
{
|
|
livre.Auteurs.Add(new LivreAuteur
|
|
{
|
|
Auteur = resolus[i],
|
|
Position = i,
|
|
Role = roles.GetValueOrDefault(resolus[i].CleRegroupement, RoleAuteur.NonPrecise),
|
|
});
|
|
}
|
|
}
|
|
|
|
private async Task RattacherThemesAsync(
|
|
Livre livre, IReadOnlyList<string> saisis, CancellationToken ct)
|
|
{
|
|
var themes = saisis
|
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
|
.Select(t => t.Trim())
|
|
.GroupBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
if (livre.Themes.Count > 0)
|
|
{
|
|
db.LivreThemes.RemoveRange(livre.Themes);
|
|
livre.Themes.Clear();
|
|
}
|
|
|
|
foreach (var nom in themes)
|
|
{
|
|
var normalise = NormalisationTexte.Normaliser(nom);
|
|
var theme = await db.Themes.FirstOrDefaultAsync(t => t.NomNormalise == normalise, ct);
|
|
if (theme is null)
|
|
{
|
|
theme = new Theme { Nom = nom };
|
|
theme.RecalculerFormes();
|
|
db.Themes.Add(theme);
|
|
}
|
|
|
|
livre.Themes.Add(new LivreTheme { Livre = livre, Theme = theme });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// L'ISBN est facultatif (livres anciens, tirages sans ISBN) mais, s'il est saisi, il doit
|
|
/// être valide : un ISBN faux est pire que pas d'ISBN, il empêcherait tout rapprochement
|
|
/// futur avec les catalogues.
|
|
/// </remarks>
|
|
private static bool Valider(EnregistrementLivre saisie, out string? isbn, out string? erreur)
|
|
{
|
|
isbn = null;
|
|
erreur = null;
|
|
|
|
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
|
{
|
|
erreur = "Le titre est obligatoire.";
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
|
|
{
|
|
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
|
|
if (!IsbnUtils.EstValide(normalise))
|
|
{
|
|
erreur = $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si le livre n'en a pas.";
|
|
return false;
|
|
}
|
|
|
|
isbn = normalise;
|
|
}
|
|
|
|
// ⚠️ On refuse « 0 page » plutôt que de l'enregistrer : la colonne est nullable
|
|
// précisément pour que l'absence ait sa propre valeur, et un zéro stocké se lirait
|
|
// comme une donnée. Laisser le champ vide est la façon de dire « je ne sais pas ».
|
|
if (saisie.NombrePages is { } pages && pages <= 0)
|
|
{
|
|
erreur = "Le nombre de pages doit être positif. Laissez le champ vide si vous ne l'avez pas.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string? Vide(string? valeur) =>
|
|
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
|
|
|
private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new()
|
|
{
|
|
Id = livre.Id,
|
|
Isbn = livre.Isbn,
|
|
Titre = livre.Titre,
|
|
Auteurs = livre.Auteurs
|
|
.OrderBy(la => la.Position)
|
|
.Where(la => la.Auteur is not null)
|
|
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom, Role = la.Role })
|
|
.ToList(),
|
|
Themes = livre.Themes
|
|
.Where(lt => lt.Theme is not null)
|
|
.Select(lt => lt.Theme!.Nom)
|
|
.OrderBy(nom => NormalisationTexte.Normaliser(nom), StringComparer.Ordinal)
|
|
.ToList(),
|
|
Editeur = livre.Editeur,
|
|
Format = livre.Format,
|
|
TypeDocument = livre.TypeDocument,
|
|
NombrePages = livre.NombrePages,
|
|
Statut = statut,
|
|
CoverUrl = livre.CoverUrl,
|
|
UrlNotice = livre.UrlNotice,
|
|
DateAjout = livre.DateAjout,
|
|
AjoutePar = livre.AjoutePar,
|
|
PreteA = pretOuvert?.Emprunteur,
|
|
PreteDepuis = pretOuvert?.DatePret,
|
|
};
|
|
}
|