MaBibli 1.0.0
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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Shared.Catalogue;
|
||||
|
||||
/// <summary>
|
||||
/// Critères de consultation du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Il n'existe volontairement aucun critère sur <c>AjoutePar</c>.</b> La bibliothèque est
|
||||
/// commune à tout le foyer : filtrer les lectures sur ce champ serait un bug de conception.
|
||||
/// L'absence de ce critère est l'endroit où l'invariant est le plus visible.
|
||||
/// <para>
|
||||
/// <see cref="Statut"/> n'est pas une exception : il filtre sur le statut de <b>l'appelant</b>,
|
||||
/// que le serveur connaît par les en-têtes SSOwat. Le client ne désigne jamais de qui il parle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public record CritereLivres
|
||||
{
|
||||
/// <summary>
|
||||
/// Texte cherché dans le titre <i>ou</i> les auteurs. Insensible à la casse
|
||||
/// <b>et aux accents</b> : « emile » trouve « Émile ».
|
||||
/// </summary>
|
||||
public string? Recherche { get; init; }
|
||||
|
||||
/// <summary>Physique ou numérique. <c>null</c> = les deux : le catalogue est unique.</summary>
|
||||
public Format? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Roman, bande dessinée… <c>null</c> = tous.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>TypeDocument.NonPrecise</c> <b>est</b> une valeur filtrable : « ce que je n'ai pas
|
||||
/// encore rangé » est justement la question qu'on se pose après avoir introduit ce champ.
|
||||
/// C'est ce qui la distingue du statut de lecture, dont l'absence n'est pas une valeur.
|
||||
/// </remarks>
|
||||
public TypeDocument? TypeDocument { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statut de lecture de l'utilisateur courant. <c>null</c> = tous.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un livre sans statut pour cette personne ne remonte sous <b>aucune</b> valeur : « non
|
||||
/// commencé » n'est pas un statut, c'est l'absence de statut. Il reste visible sans filtre.
|
||||
/// </remarks>
|
||||
public Statut? Statut { get; init; }
|
||||
|
||||
/// <summary>Ne garder que les livres d'un auteur — le « cliquer sur un auteur » d'CLAUDE.md.</summary>
|
||||
public int? AuteurId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> = ce qui est dehors, <c>false</c> = ce qui est à la maison, <c>null</c> = tout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Contrairement à <see cref="Statut"/>, ce critère est <b>commun au foyer</b> : un prêt
|
||||
/// n'appartient à personne en particulier, et aucune identité n'entre dans son évaluation.
|
||||
/// C'est la même dissymétrie que partout ailleurs — le livre absent l'est pour tout le monde,
|
||||
/// sa lecture ne l'est que pour soi.
|
||||
/// <para>
|
||||
/// L'écran « Prêts » répond déjà à « qu'est-ce qui est dehors ? ». Ce critère-ci répond à
|
||||
/// l'autre moitié de la question, celle qu'aucun écran ne servait : « qu'est-ce que j'ai
|
||||
/// réellement sous la main ? », par exemple avant de promettre un livre à quelqu'un.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? Prete { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Shared.Catalogue;
|
||||
|
||||
/// <summary>
|
||||
/// Filtrage, recherche et tri du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Écrit sur <see cref="IQueryable{T}"/> pour être exécuté par EF Core côté base <b>et</b>
|
||||
/// testable en mémoire sur une simple liste, sans base ni réseau.
|
||||
/// <para>
|
||||
/// La recherche porte sur les colonnes <b>normalisées</b> (<see cref="Livre.TitreNormalise"/>,
|
||||
/// <see cref="Auteur.NomNormalise"/>), pas sur les colonnes d'affichage : SQLite ne sait pas
|
||||
/// ignorer les accents, et sa collation <c>NOCASE</c> s'arrête à l'ASCII. Le terme cherché passe
|
||||
/// par la même normalisation que la valeur stockée — c'est ce qui fait que « emile » trouve
|
||||
/// « Émile ».
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FiltreLivres
|
||||
{
|
||||
/// <param name="utilisateur">
|
||||
/// Personne dont on regarde le statut. Sert <b>uniquement</b> à
|
||||
/// <see cref="CritereLivres.Statut"/> : il ne restreint jamais la liste elle-même.
|
||||
/// </param>
|
||||
public static IQueryable<Livre> Appliquer(
|
||||
IQueryable<Livre> source, CritereLivres criteres, string? utilisateur)
|
||||
{
|
||||
// AUCUN filtre sur AjoutePar : la bibliothèque est commune (CLAUDE.md).
|
||||
// Ce commentaire est là pour qu'une régression future saute aux yeux en relecture.
|
||||
|
||||
if (criteres.Format is { } format)
|
||||
{
|
||||
source = source.Where(l => l.Format == format);
|
||||
}
|
||||
|
||||
if (criteres.TypeDocument is { } type)
|
||||
{
|
||||
source = source.Where(l => l.TypeDocument == type);
|
||||
}
|
||||
|
||||
if (criteres.Statut is { } statut)
|
||||
{
|
||||
// Le statut vit dans une table par personne : on interroge la ligne de l'appelant,
|
||||
// et lui seulement. Sans utilisateur identifié, personne n'a de statut : la sélection
|
||||
// est vide, ce qui vaut mieux que de montrer celui d'un autre.
|
||||
source = utilisateur is null
|
||||
? source.Where(_ => false)
|
||||
: source.Where(l => l.Statuts.Any(s => s.Utilisateur == utilisateur && s.Statut == statut));
|
||||
}
|
||||
|
||||
if (criteres.AuteurId is { } auteurId)
|
||||
{
|
||||
source = source.Where(l => l.Auteurs.Any(la => la.AuteurId == auteurId));
|
||||
}
|
||||
|
||||
if (criteres.Prete is { } prete)
|
||||
{
|
||||
// Un prêt EN COURS est une ligne sans date de retour : l'historique clos ne compte
|
||||
// pas. Aucune identité n'intervient — le prêt est commun au foyer, contrairement au
|
||||
// statut de lecture juste au-dessus.
|
||||
source = prete
|
||||
? source.Where(l => l.Prets.Any(p => p.DateRetour == null))
|
||||
: source.Where(l => !l.Prets.Any(p => p.DateRetour == null));
|
||||
}
|
||||
|
||||
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
|
||||
if (terme.Length > 0)
|
||||
{
|
||||
// Le terme est confronté aux deux formes stockées de chaque auteur : le nom dans
|
||||
// l'ordre de saisie, et la clé aux mots triés. La seconde est ce qui fait que
|
||||
// « zola émile » trouve « Émile Zola » — le terme est trié de la même façon.
|
||||
var cle = RapprochementAuteurs.Cle(criteres.Recherche);
|
||||
|
||||
source = source.Where(l =>
|
||||
l.TitreNormalise.Contains(terme)
|
||||
|| l.Auteurs.Any(la =>
|
||||
la.Auteur!.NomNormalise.Contains(terme)
|
||||
|| la.Auteur.CleRegroupement.Contains(cle)));
|
||||
}
|
||||
|
||||
return Trier(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tri par titre, puis par identifiant pour rendre l'ordre déterministe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CLAUDE.md ne tranche pas le tri. L'ordre alphabétique a été retenu parce qu'une
|
||||
/// bibliothèque se parcourt par titre ; l'ordre d'ajout n'a d'intérêt que le jour de la saisie.
|
||||
/// <para>
|
||||
/// Le tri porte sur le titre normalisé : sinon « Œuvres » et « Éducation » atterrissent
|
||||
/// après « Zola » dans l'ordre des points de code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IQueryable<Livre> Trier(IQueryable<Livre> source) =>
|
||||
source.OrderBy(l => l.TitreNormalise).ThenBy(l => l.Id);
|
||||
}
|
||||
Reference in New Issue
Block a user