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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user