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>
219 lines
8.3 KiB
C#
219 lines
8.3 KiB
C#
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Textes;
|
|
|
|
namespace MaBibli.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une <b>série de séries</b>
|
|
/// (<c>Serie.SerieParenteId</c>), et le catalogue le montre tel quel depuis le 2026-08-22 —
|
|
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
|
|
/// </remarks>
|
|
public sealed record EntreeCatalogue
|
|
{
|
|
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
|
|
public SerieDto? Serie { get; init; }
|
|
|
|
/// <summary>Les livres rattachés <b>directement</b> à cette série ; un seul hors série.</summary>
|
|
public required IReadOnlyList<LivreDto> Livres { get; init; }
|
|
|
|
/// <summary>Les séries contenues, quand ce bloc est un cycle. Vide sinon.</summary>
|
|
public IReadOnlyList<EntreeCatalogue> SousEntrees { get; init; } = [];
|
|
|
|
public bool EstGroupe => Serie is not null;
|
|
|
|
/// <summary>
|
|
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// C'est ce que le bloc annonce, y compris <b>replié</b> : replier ne doit pas faire
|
|
/// perdre le compte de ce qu'on vient de cacher.
|
|
/// </remarks>
|
|
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regroupe les livres du catalogue par série, en respectant les cycles.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Le bloc se place là où son premier tome serait tombé</b> dans l'ordre du catalogue
|
|
/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
|
|
/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
|
|
/// tomes suivent l'<b>ordre de lecture</b>, seul ordre qui ait un sens pour une saga — c'est
|
|
/// même la raison d'être de <c>ElementSerie.Position</c>, et les sous-séries d'un cycle
|
|
/// suivent le leur (<c>Serie.Position</c>), comme sur l'écran des séries.
|
|
/// <para>
|
|
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
|
|
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
|
|
/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
|
|
/// pas.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ Un livre peut appartenir à <b>plusieurs</b> séries (le modèle l'autorise, sans unicité sur
|
|
/// <c>LivreId</c> seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
|
|
/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
|
|
/// <b>première par ordre alphabétique</b> — un critère explicable, à défaut d'être le bon dans
|
|
/// tous les cas ; sa fiche livre, elle, les montre toutes.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>Seuls les niveaux qui portent quelque chose existent.</b> Un cycle de cinq séries dont
|
|
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
|
|
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
|
|
/// replierait sur rien.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class GroupementCatalogue
|
|
{
|
|
public static IReadOnlyList<EntreeCatalogue> Grouper(
|
|
IReadOnlyList<LivreDto> livres, IReadOnlyList<SerieDto>? series)
|
|
{
|
|
if (series is null || series.Count == 0)
|
|
{
|
|
return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
|
|
}
|
|
|
|
var place = PlaceDesLivres(series);
|
|
var parId = series.ToDictionary(s => s.Id);
|
|
|
|
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
|
|
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
|
|
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
|
|
var brouillons = new List<Noeud>();
|
|
var noeuds = new Dictionary<int, Noeud>();
|
|
|
|
foreach (var livre in livres)
|
|
{
|
|
if (!place.TryGetValue(livre.Id, out var appartenance))
|
|
{
|
|
brouillons.Add(new Noeud(null) { Livres = { (0, livre) } });
|
|
continue;
|
|
}
|
|
|
|
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
|
|
noeud.Livres.Add((appartenance.Position, livre));
|
|
}
|
|
|
|
return [.. brouillons.Select(Materialiser)];
|
|
}
|
|
|
|
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
|
|
private sealed class Noeud(SerieDto? serie)
|
|
{
|
|
public SerieDto? Serie { get; } = serie;
|
|
|
|
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
|
|
|
|
public List<Noeud> Enfants { get; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// La chaîne de la racine jusqu'à <paramref name="serie"/>, cycle compris.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ La remontée est <b>bornée par le nombre de séries</b>, comme celle du serveur : une
|
|
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
|
|
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
|
|
/// </remarks>
|
|
private static List<SerieDto> Ascendance(SerieDto serie, Dictionary<int, SerieDto> parId)
|
|
{
|
|
var chaine = new List<SerieDto> { serie };
|
|
var vues = new HashSet<int> { serie.Id };
|
|
var courante = serie;
|
|
|
|
while (courante.SerieParenteId is { } parenteId
|
|
&& parId.TryGetValue(parenteId, out var parente)
|
|
&& vues.Add(parente.Id)
|
|
&& chaine.Count <= parId.Count)
|
|
{
|
|
chaine.Add(parente);
|
|
courante = parente;
|
|
}
|
|
|
|
chaine.Reverse();
|
|
return chaine;
|
|
}
|
|
|
|
/// <summary>Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.</summary>
|
|
private static Noeud Descendre(
|
|
List<SerieDto> chaine, Dictionary<int, Noeud> noeuds, List<Noeud> brouillons)
|
|
{
|
|
Noeud? parent = null;
|
|
|
|
foreach (var serie in chaine)
|
|
{
|
|
if (!noeuds.TryGetValue(serie.Id, out var noeud))
|
|
{
|
|
noeud = new Noeud(serie);
|
|
noeuds[serie.Id] = noeud;
|
|
|
|
if (parent is null)
|
|
{
|
|
brouillons.Add(noeud);
|
|
}
|
|
else
|
|
{
|
|
parent.Enfants.Add(noeud);
|
|
}
|
|
}
|
|
|
|
parent = noeud;
|
|
}
|
|
|
|
return parent!;
|
|
}
|
|
|
|
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
|
|
{
|
|
Serie = noeud.Serie,
|
|
Livres =
|
|
[
|
|
.. noeud.Livres
|
|
.OrderBy(t => t.Position)
|
|
.ThenBy(t => t.Livre.Id)
|
|
.Select(t => t.Livre),
|
|
],
|
|
|
|
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
|
|
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
|
|
SousEntrees =
|
|
[
|
|
.. noeud.Enfants
|
|
.OrderBy(e => e.Serie!.Position)
|
|
.ThenBy(e => e.Serie!.Id)
|
|
.Select(Materialiser),
|
|
],
|
|
};
|
|
|
|
/// <summary>
|
|
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
|
|
/// <c>TryAdd</c> garde donc la <b>première</b> : sans cet ordre, la série retenue pour un
|
|
/// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
|
|
/// rien de compréhensible.
|
|
/// </remarks>
|
|
private static Dictionary<int, (SerieDto Serie, int Position)> PlaceDesLivres(
|
|
IReadOnlyList<SerieDto> series)
|
|
{
|
|
var place = new Dictionary<int, (SerieDto, int)>();
|
|
|
|
foreach (var serie in series
|
|
.OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
|
|
.ThenBy(s => s.Id))
|
|
{
|
|
foreach (var element in serie.Elements)
|
|
{
|
|
if (element.LivreId is { } livreId)
|
|
{
|
|
place.TryAdd(livreId, (serie, element.Position));
|
|
}
|
|
}
|
|
}
|
|
|
|
return place;
|
|
}
|
|
}
|