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:
Mathieu Limonier
2026-08-22 22:36:16 +02:00
co-authored by Claude Opus 5
commit 6a6d745af4
207 changed files with 35543 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
namespace MaBibli.Client.Services;
/// <summary>
/// Parenté des routes de l'application : à quel écran remonte la flèche de retour du bandeau.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>Ceci renverse la décision actée le 2026-08-20</b> (« le retour passe par l'historique du
/// navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même
/// écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les
/// <b>allers-retours</b> (filtre, ordre, édition) : on cliquait cinq fois sans quitter le même
/// écran. Une remontée d'un cran de route répond à « où suis-je ? », qui est la question posée.
/// </para>
/// <para>
/// La table est <b>explicite</b>, et non un découpage naïf de l'URL : toutes les routes n'ont pas
/// la forme d'une arborescence (<c>/souhaits/ordre</c> remonte à <c>/souhaits</c>, mais
/// <c>/auteurs/{id}/bibliographie</c> remonte à <c>/auteurs</c> — la fiche d'un auteur n'existe
/// pas). Une route qui apparaît dans l'application doit apparaître ici.
/// </para>
/// <para>
/// ⚠️ <b>Le retour ne sort jamais de l'application</b> : la fonction rend toujours un chemin
/// interne, et la racine d'une branche rend la destination de menu correspondante — le catalogue
/// en dernier ressort. C'est ce qui remplace le test de <c>history.length</c> qui vivait en
/// JavaScript, et qui ne disait pas ce qu'on croyait dans une PWA <c>standalone</c> : la pile
/// d'une session y contient aussi ce qui précède l'application.
/// </para>
/// </remarks>
public static class RemonteeRoutes
{
/// <summary>Le catalogue est la racine : son parent est lui-même, on ne remonte pas plus haut.</summary>
public const string Racine = "/";
/// <summary>
/// Table de parenté, dans l'ordre de lecture. <c>{id}</c> représente un segment numérique.
/// </summary>
/// <remarks>
/// La première ligne dont le modèle correspond gagne : les modèles les plus longs sont donc
/// écrits <b>avant</b> ceux dont ils sont un prolongement.
/// </remarks>
private static readonly (string Modele, string Parent)[] Parents =
[
// Fiche livre : l'édition retombe sur la consultation, la consultation sur le catalogue.
("/livres/{id}/edition", "/livres/{id}"),
("/livres/{id}", Racine),
// Séries : les deux écrans de modification retombent sur la consultation de la série.
("/series/{id}/edition", "/series/{id}"),
("/series/{id}/ordre", "/series/{id}"),
("/series/{id}", "/series"),
("/series", Racine),
// Envies : l'ordre, l'ajout et les deux écrans d'édition sont des écrans de la même
// liste. ⚠️ « /souhaits/revues/{id}/edition » ne peut pas se confondre avec
// « /souhaits/{id}/edition » : les modèles n'ont pas le même nombre de segments.
("/souhaits/revues/{id}/edition", "/souhaits"),
("/souhaits/{id}/edition", "/souhaits"),
("/souhaits/ordre", "/souhaits"),
("/souhaits/ajout", "/souhaits"),
("/souhaits", Racine),
// Ajout d'un ouvrage : on y entre depuis le catalogue comme depuis les revues, mais le
// catalogue est la destination de menu de ce qu'on y saisit.
("/ajout/manuel", Racine),
("/ajout/isbn", Racine),
("/ajout", Racine),
// Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un
// identifiant, mais l'ordre de lecture est ce qui le garantit sans ambiguïté.
("/revues/ajout", "/revues"),
("/revues/{id}/edition", "/revues/{id}"),
("/revues/{id}", "/revues"),
("/revues", Racine),
// La bibliographie remonte à la LISTE des auteurs : il n'existe pas de fiche auteur.
("/auteurs/{id}/bibliographie", "/auteurs"),
("/auteurs", Racine),
("/prets", Racine),
// « À propos » est une annexe du menu : elle remonte au catalogue, comme toute racine de
// branche. Elle n'a pas d'enfants, et n'en aura pas.
("/a-propos", Racine),
("/not-found", Racine),
];
/// <summary>
/// Écran d'où l'on vient hiérarchiquement, à partir d'un chemin d'application.
/// </summary>
/// <param name="chemin">
/// Chemin absolu ou relatif à la base, avec ou sans requête (<c>?auteur=3</c>) ni fragment.
/// </param>
/// <returns>Un chemin interne, toujours ; jamais <c>null</c>, jamais une URL absolue.</returns>
public static string Parent(string? chemin)
{
var segments = Segments(chemin);
if (segments.Length == 0)
{
return Racine;
}
foreach (var (modele, parent) in Parents)
{
if (Correspond(Segments(modele), segments))
{
return Rendre(parent, segments);
}
}
// Route inconnue : on retombe sur la destination de menu de sa branche, plutôt que sur
// un chemin deviné. Une route ajoutée sans sa ligne de table reste ainsi utilisable —
// mais elle doit être ajoutée : c'est un repli, pas le mécanisme.
return Parents.FirstOrDefault(p => p.Modele == $"/{segments[0]}").Parent is not null
? $"/{segments[0]}"
: Racine;
}
/// <summary>Découpe en segments non vides, requête et fragment retirés.</summary>
private static string[] Segments(string? chemin)
{
if (string.IsNullOrWhiteSpace(chemin))
{
return [];
}
var utile = chemin.Split('?', '#')[0];
return utile.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool Correspond(string[] modele, string[] segments)
{
if (modele.Length != segments.Length)
{
return false;
}
for (var i = 0; i < modele.Length; i++)
{
var attendu = modele[i];
var ok = attendu == "{id}"
? int.TryParse(segments[i], out _)
: string.Equals(attendu, segments[i], StringComparison.OrdinalIgnoreCase);
if (!ok)
{
return false;
}
}
return true;
}
/// <summary>Remplace <c>{id}</c> du parent par l'identifiant lu dans le chemin d'origine.</summary>
private static string Rendre(string parent, string[] segments)
{
if (!parent.Contains("{id}", StringComparison.Ordinal))
{
return parent;
}
var identifiant = segments.FirstOrDefault(s => int.TryParse(s, out _));
// Sans identifiant lisible, on ne fabrique pas une route bancale : la racine est sûre.
return identifiant is null
? Racine
: parent.Replace("{id}", identifiant, StringComparison.Ordinal);
}
}