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>
456 lines
19 KiB
C#
456 lines
19 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using MaBibli.Shared.Isbn;
|
|
using MaBibli.Shared.Textes;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Api.Services.Souhaits;
|
|
|
|
/// <summary>Issue d'une écriture d'envie : la ligne, un message, ou rien du tout.</summary>
|
|
/// <remarks>
|
|
/// ⚠️ « Introuvable » est un <b>troisième état</b>, et pas un message d'erreur de plus : c'est
|
|
/// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par
|
|
/// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables.
|
|
/// </remarks>
|
|
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false)
|
|
{
|
|
public static readonly ResultatSouhait Introuvable = new(null, null, true);
|
|
|
|
public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);
|
|
|
|
public static ResultatSouhait Invalide(string message) => new(null, message);
|
|
}
|
|
|
|
public interface IServiceSouhaits
|
|
{
|
|
Task<IReadOnlyList<SouhaitDto>> ListerAsync(string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<ResultatSouhait> AjouterAsync(
|
|
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Corrige une envie existante de l'appelant.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <c>Rang</c> n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un
|
|
/// titre n'est pas dire qu'on le veut davantage.
|
|
/// </remarks>
|
|
Task<ResultatSouhait> ModifierAsync(
|
|
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Réordonne la liste de l'appelant selon la suite d'identifiants fournie.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Prend la liste <b>entière</b> plutôt qu'un déplacement unitaire : c'est ce dont a besoin
|
|
/// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération
|
|
/// est idempotente — réenvoyer le même ordre ne change rien.
|
|
/// </remarks>
|
|
Task<bool> ReordonnerAsync(
|
|
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// La liste d'envies, <b>personnelle</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Toute méthode filtre sur <paramref name="utilisateur"/>, sans exception.</b> C'est la
|
|
/// différence de fond avec <c>ServiceCatalogue</c>, dont les lectures ne filtrent jamais sur
|
|
/// <c>AjoutePar</c> : le catalogue est commun, la liste d'envies ne l'est pas. Une envie qui
|
|
/// fuiterait vers un autre membre du foyer gâcherait exactement ce que la fonctionnalité sert
|
|
/// à préparer.
|
|
/// <para>
|
|
/// Sans identité (ni en-tête SSOwat ni utilisateur simulé), il n'y a pas de liste : on ne rend
|
|
/// rien et on n'écrit rien, plutôt que de rattacher des envies à un propriétaire inventé.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
|
{
|
|
public async Task<IReadOnlyList<SouhaitDto>> ListerAsync(
|
|
string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
// L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a
|
|
// remplacé un tri par auteur puis titre : classer une liste d'envies par ordre
|
|
// alphabétique répondait à une question que personne ne se pose.
|
|
// L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister,
|
|
// mais un tri instable ferait sautiller la liste entre deux affichages.
|
|
var souhaits = await db.LivresSouhaites
|
|
.AsNoTracking()
|
|
.Where(s => s.Utilisateur == utilisateur)
|
|
.OrderBy(s => s.Rang)
|
|
.ThenBy(s => s.Id)
|
|
.ToListAsync(ct);
|
|
|
|
var possedes = await CorrespondancesAsync(souhaits, ct);
|
|
|
|
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
|
|
}
|
|
|
|
public async Task<ResultatSouhait> AjouterAsync(
|
|
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
return ResultatSouhait.Invalide(
|
|
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
|
{
|
|
return ResultatSouhait.Invalide("Le titre est obligatoire.");
|
|
}
|
|
|
|
// L'ISBN reste facultatif — on souhaite souvent une œuvre sans avoir choisi son édition —
|
|
// mais s'il est saisi il doit tenir debout, comme pour un livre du catalogue.
|
|
string? isbn = null;
|
|
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
|
|
{
|
|
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
|
|
if (!IsbnUtils.EstValide(normalise))
|
|
{
|
|
return ResultatSouhait.Invalide(
|
|
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
|
|
}
|
|
|
|
isbn = normalise;
|
|
}
|
|
|
|
var souhait = new LivreSouhaite
|
|
{
|
|
Utilisateur = utilisateur,
|
|
Titre = saisie.Titre,
|
|
Auteur = saisie.Auteur,
|
|
Editeur = Vide(saisie.Editeur),
|
|
Annee = Vide(saisie.Annee),
|
|
Isbn = isbn,
|
|
CoverUrl = Vide(saisie.CoverUrl),
|
|
Note = Vide(saisie.Note),
|
|
DateAjout = DateTime.UtcNow,
|
|
};
|
|
|
|
souhait.RecalculerFormes();
|
|
|
|
// Le doublon est refusé avec un message plutôt que laissé à l'index unique : l'écran de
|
|
// bibliographie rend le double clic facile, et « UNIQUE constraint failed » ne veut rien
|
|
// dire pour l'utilisateur.
|
|
var deja = await db.LivresSouhaites.AnyAsync(
|
|
s => s.Utilisateur == utilisateur
|
|
&& s.TitreNormalise == souhait.TitreNormalise
|
|
&& s.AuteurNormalise == souhait.AuteurNormalise,
|
|
ct);
|
|
|
|
if (deja)
|
|
{
|
|
return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies.");
|
|
}
|
|
|
|
// En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable
|
|
// que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur
|
|
// à chaque ajout.
|
|
var dernierRang = await db.LivresSouhaites
|
|
.Where(s => s.Utilisateur == utilisateur)
|
|
.Select(s => (int?)s.Rang)
|
|
.MaxAsync(ct);
|
|
|
|
souhait.Rang = (dernierRang ?? -1) + 1;
|
|
|
|
db.LivresSouhaites.Add(souhait);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
var possede = await CorrespondancesAsync([souhait], ct);
|
|
|
|
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
|
}
|
|
|
|
public async Task<ResultatSouhait> ModifierAsync(
|
|
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
return ResultatSouhait.Introuvable;
|
|
}
|
|
|
|
// Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne
|
|
// vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un
|
|
// autre. C'est ce qui rend le 404 exact plutôt que poli.
|
|
var souhait = await db.LivresSouhaites
|
|
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
|
|
|
|
if (souhait is null)
|
|
{
|
|
return ResultatSouhait.Introuvable;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
|
{
|
|
return ResultatSouhait.Invalide("Le titre est obligatoire.");
|
|
}
|
|
|
|
string? isbn = null;
|
|
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
|
|
{
|
|
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
|
|
if (!IsbnUtils.EstValide(normalise))
|
|
{
|
|
return ResultatSouhait.Invalide(
|
|
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
|
|
}
|
|
|
|
isbn = normalise;
|
|
}
|
|
|
|
souhait.Titre = saisie.Titre;
|
|
souhait.Auteur = saisie.Auteur;
|
|
souhait.Editeur = Vide(saisie.Editeur);
|
|
souhait.Annee = Vide(saisie.Annee);
|
|
souhait.Isbn = isbn;
|
|
souhait.Note = Vide(saisie.Note);
|
|
|
|
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux
|
|
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une
|
|
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un
|
|
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image
|
|
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à
|
|
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu.
|
|
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl;
|
|
|
|
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
|
|
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
|
|
// se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise.
|
|
souhait.RecalculerFormes();
|
|
|
|
// ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise),
|
|
// contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en
|
|
// une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message
|
|
// plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire.
|
|
//
|
|
// 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux
|
|
// exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a
|
|
// rien à confirmer. C'est une saisie à corriger, pas un choix à trancher.
|
|
var deja = await db.LivresSouhaites.AnyAsync(
|
|
s => s.Id != souhait.Id
|
|
&& s.Utilisateur == utilisateur
|
|
&& s.TitreNormalise == souhait.TitreNormalise
|
|
&& s.AuteurNormalise == souhait.AuteurNormalise,
|
|
ct);
|
|
|
|
if (deja)
|
|
{
|
|
return ResultatSouhait.Invalide(
|
|
$"« {souhait.Titre} » est déjà dans votre liste d'envies.");
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
var possede = await CorrespondancesAsync([souhait], ct);
|
|
|
|
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
|
}
|
|
|
|
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Le filtre sur l'utilisateur fait partie de la CLÉ de recherche, pas d'une vérification
|
|
// ultérieure : ainsi personne ne peut supprimer l'envie d'un autre en devinant son
|
|
// identifiant. Une envie inexistante et une envie appartenant à un autre sont
|
|
// indiscernables de l'extérieur, ce qui est bien le comportement voulu.
|
|
var souhait = await db.LivresSouhaites
|
|
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
|
|
|
|
if (souhait is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
db.LivresSouhaites.Remove(souhait);
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> ReordonnerAsync(
|
|
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default)
|
|
{
|
|
if (utilisateur is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client
|
|
// envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas
|
|
// pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression),
|
|
// et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil
|
|
// n'y figure pas, et la perdre serait pire que de mal la classer.
|
|
var siennes = await db.LivresSouhaites
|
|
.Where(s => s.Utilisateur == utilisateur)
|
|
.ToListAsync(ct);
|
|
|
|
if (siennes.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var parId = siennes.ToDictionary(s => s.Id);
|
|
var rang = 0;
|
|
|
|
foreach (var id in idsOrdonnes.Distinct())
|
|
{
|
|
if (parId.Remove(id, out var souhait))
|
|
{
|
|
souhait.Rang = rang++;
|
|
}
|
|
|
|
// Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est
|
|
// simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs.
|
|
}
|
|
|
|
// Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent,
|
|
// plutôt que d'être renuméroté au hasard.
|
|
foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id))
|
|
{
|
|
oublie.Rang = rang++;
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
|
|
/// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
|
|
/// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
|
|
/// d'acheter à nouveau.
|
|
/// <para>
|
|
/// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
|
|
/// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
|
|
/// commun. Le titre seul confondrait deux recueils homonymes.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle
|
|
/// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans
|
|
/// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici
|
|
/// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<Dictionary<int, int>> CorrespondancesAsync(
|
|
IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
|
|
{
|
|
if (souhaits.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var livres = await db.Livres
|
|
.AsNoTracking()
|
|
.Select(l => new
|
|
{
|
|
l.Id,
|
|
l.Titre,
|
|
l.Isbn,
|
|
Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
var parCle = livres
|
|
.Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
|
|
.Where(x => x.Cle.Length > 0)
|
|
.GroupBy(x => x.Cle)
|
|
.ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());
|
|
|
|
var parIsbn = new Dictionary<string, int>();
|
|
foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
|
|
{
|
|
parIsbn.TryAdd(livre.Isbn!, livre.Id);
|
|
}
|
|
|
|
var correspondances = new Dictionary<int, int>();
|
|
|
|
foreach (var souhait in souhaits)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
|
|
{
|
|
correspondances[souhait.Id] = idIsbn;
|
|
continue;
|
|
}
|
|
|
|
// TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
|
|
// recalculer de ce côté-là.
|
|
if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var trouve = homonymes.FirstOrDefault(
|
|
l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));
|
|
|
|
if (trouve is not null)
|
|
{
|
|
correspondances[souhait.Id] = trouve.Id;
|
|
}
|
|
}
|
|
|
|
return correspondances;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identifiant du livre correspondant, ou <c>null</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
|
|
/// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
|
|
/// été pris par les tests, pas par le compilateur.
|
|
/// </remarks>
|
|
private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
|
|
correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;
|
|
|
|
private static string? Vide(string? valeur) =>
|
|
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
|
|
|
/// <summary>
|
|
/// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
|
|
/// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
|
|
/// </summary>
|
|
internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
|
|
{
|
|
Id = souhait.Id,
|
|
Titre = souhait.Titre,
|
|
Auteur = souhait.Auteur,
|
|
Editeur = souhait.Editeur,
|
|
Annee = souhait.Annee,
|
|
Isbn = souhait.Isbn,
|
|
|
|
// ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie.
|
|
//
|
|
// Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le
|
|
// composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie »
|
|
// remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie
|
|
// d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais
|
|
// aucune image.
|
|
//
|
|
// Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour
|
|
// les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN,
|
|
// pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on
|
|
// applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.
|
|
CoverUrl = souhait.CoverUrl
|
|
?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)),
|
|
Note = souhait.Note,
|
|
DateAjout = souhait.DateAjout,
|
|
Possede = livreId is not null,
|
|
LivreId = livreId,
|
|
};
|
|
}
|