using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; namespace MaBibli.Client.Services; /// /// Recherche, filtrage et tri du catalogue hors-ligne, appliqués à l'instantané complet /// rangé en IndexedDB. /// /// /// C'est le pendant navigateur de , qui s'exécute côté base. Les deux /// ne peuvent pas être le même code : le serveur travaille sur des entités aux colonnes /// normalisées déjà calculées, le client sur des qui n'en portent pas. /// La normalisation est donc refaite ici à la volée — sans coût perceptible sur une bibliothèque /// de foyer, et surtout par les mêmes fonctions (, /// ), ce qui est la seule garantie que « emile » trouve /// « Émile » des deux côtés. /// /// Un test compare les deux implémentations sur les mêmes données : une divergence silencieuse /// donnerait une bibliothèque qui ne se cherche pas pareil selon qu'on a du réseau ou non. /// /// /// Aucun filtre sur AjoutePar, ici comme côté serveur : la bibliothèque est commune. /// /// public static class FiltreLivresLocal { public static IReadOnlyList Appliquer( IEnumerable source, CritereLivres criteres) { var livres = source; if (criteres.Format is { } format) { livres = livres.Where(l => l.Format == format); } if (criteres.TypeDocument is { } type) { livres = livres.Where(l => l.TypeDocument == type); } if (criteres.Statut is { } statut) { // LivreDto.Statut est déjà celui de l'utilisateur courant : le serveur l'a résolu // avant de l'envoyer. Le client n'a donc personne à désigner, et ne le pourrait pas. livres = livres.Where(l => l.Statut == statut); } if (criteres.AuteurId is { } auteurId) { livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId)); } if (criteres.Prete is { } prete) { // LivreDto.PreteA porte l'emprunteur du prêt EN COURS, ou null : le serveur a déjà // écarté les prêts clos. C'est exactement ce que le filtre serveur exprime par // « une ligne sans date de retour », d'où l'équivalence des deux implémentations. livres = livres.Where(l => (l.PreteA is not null) == prete); } if (criteres.SansCouverture is { } sansCouverture) { // Même convention que côté serveur : une URL vide vaut une URL absente. livres = livres.Where(l => string.IsNullOrEmpty(l.CoverUrl) == sansCouverture); } // Même exclusion que côté serveur : un code désigne un objet, des mots désignent une // œuvre. Les deux branches ne se mélangent pas. if (RechercheIsbn.Formes(criteres.Recherche) is { } formes) { livres = livres.Where(l => l.Isbn is not null && formes.Any(forme => l.Isbn.Contains(forme, StringComparison.Ordinal))); return Ordonner(livres); } var terme = NormalisationTexte.Normaliser(criteres.Recherche); if (terme.Length > 0) { var cle = RapprochementAuteurs.Cle(criteres.Recherche); livres = livres.Where(l => NormalisationTexte.Normaliser(l.Titre).Contains(terme, StringComparison.Ordinal) || l.Auteurs.Any(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme, StringComparison.Ordinal) || RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal))); } return Ordonner(livres); } /// /// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri /// déterministe. /// /// /// La comparaison est ordinale parce que c'est ce que fait SQLite sur une colonne sans /// collation — un tri « intelligent » ici ferait diverger les deux vues. /// private static IReadOnlyList Ordonner(IEnumerable livres) => livres .OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal) .ThenBy(l => l.Id) .ToList(); }