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>
171 lines
6.8 KiB
C#
171 lines
6.8 KiB
C#
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);
|
|
}
|
|
}
|