using System.Globalization; using System.Text; using MaBibli.Shared.Dtos; using MaBibli.Shared.Isbn; namespace MaBibli.Api.Services.Souhaits; /// /// Sérialisation de la liste d'envies en fichier emportable. /// /// /// Fonctions pures : elles prennent une liste et rendent une chaîne, sans base ni HTTP. /// C'est ce qui permet de vérifier le contenu exact du fichier en test plutôt que de se fier à /// une inspection visuelle. /// /// Aucune dépendance, ni côté production ni côté lecture. IDEES.md demandait un format /// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte. /// /// public static class ExportSouhaits { /// /// Séparateur du CSV : le point-virgule, pas la virgule. /// /// /// Le CSV n'a qu'un seul travail — s'ouvrir proprement dans un tableur. Or Excel en locale /// française attend le point-virgule comme séparateur de liste : un fichier à virgules y /// atterrit intégralement dans la colonne A, ce qui rend l'export inutilisable pour l'usage /// visé (se répartir des achats avant un anniversaire). La collection étant francophone, /// c'est ce cas-là qu'il faut servir. Les titres contenant un point-virgule restent corrects : /// ils sont entre guillemets, conformément à RFC 4180. /// public const char SeparateurCsv = ';'; /// /// Marque d'ordre des octets, indispensable en tête du CSV. /// /// /// Sans elle, Excel lit un CSV en codage hérité et affiche « Émile Zola ». Sur une liste de /// livres français, à peu près chaque ligne serait touchée. Le BOM ne gêne aucun autre /// lecteur (LibreOffice, tableurs en ligne, éditeurs de texte). /// public static readonly byte[] Bom = Encoding.UTF8.GetPreamble(); public static string NomFichier(FormatExportSouhaits format, DateTime maintenant) => $"liste-envies-{maintenant:yyyy-MM-dd}.{(format == FormatExportSouhaits.Csv ? "csv" : "txt")}"; public static string TypeMime(FormatExportSouhaits format) => format == FormatExportSouhaits.Csv ? "text/csv; charset=utf-8" : "text/plain; charset=utf-8"; public static string Produire( FormatExportSouhaits format, IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) => format switch { FormatExportSouhaits.Csv => Csv(souhaits), _ => Texte(souhaits, proprietaire, maintenant), }; /// /// Liste lisible telle quelle, groupée par auteur — la version qu'on emporte en librairie. /// /// /// Le groupement par auteur n'est pas cosmétique : c'est ainsi qu'une librairie est rangée. /// Une liste à plat obligerait à parcourir tout le fichier pour chaque rayon. /// public static string Texte(IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) { var sortie = new StringBuilder(); sortie.Append("Liste d'envies"); if (!string.IsNullOrWhiteSpace(proprietaire)) { sortie.Append(" de ").Append(proprietaire); } sortie.Append(" — ").Append(maintenant.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)).Append('\n'); sortie.Append(new string('=', 40)).Append('\n'); if (souhaits.Count == 0) { sortie.Append("\n(aucun livre dans la liste)\n"); return sortie.ToString(); } sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); // « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins // exploitables en rayon, elles ne doivent pas ouvrir le fichier. var groupes = souhaits .GroupBy(s => string.IsNullOrWhiteSpace(s.Auteur) ? null : s.Auteur!.Trim()) .OrderBy(g => g.Key is null) .ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase); foreach (var groupe in groupes) { sortie.Append('\n').Append(groupe.Key ?? "Auteur non précisé").Append('\n'); foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase)) { sortie.Append(" - ").Append(souhait.Titre); // Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on // épelle à un libraire, et ça reste lisible sur un écran de téléphone. var precisions = new[] { souhait.Editeur, souhait.Annee, Isbn(souhait.Isbn) } .Where(p => !string.IsNullOrWhiteSpace(p)) .ToList(); if (precisions.Count > 0) { sortie.Append(" (").Append(string.Join(", ", precisions)).Append(')'); } sortie.Append('\n'); if (!string.IsNullOrWhiteSpace(souhait.Note)) { sortie.Append(" ").Append(souhait.Note.Trim()).Append('\n'); } } } return sortie.ToString(); } /// Tableau ouvrable dans un tableur, une envie par ligne. public static string Csv(IReadOnlyList souhaits) { var sortie = new StringBuilder(); Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le"); foreach (var souhait in souhaits) { Ligne( sortie, souhait.Titre, souhait.Auteur, souhait.Editeur, souhait.Annee, FormatageIsbn.Afficher(souhait.Isbn), souhait.Note, souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } return sortie.ToString(); } /// /// ISBN découpé pour la ligne texte : c'est un numéro qu'on épelle à un libraire, /// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres. /// private static string? Isbn(string? isbn) => string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {FormatageIsbn.Afficher(isbn)}"; private static void Ligne(StringBuilder sortie, params string?[] champs) { sortie.AppendJoin(SeparateurCsv, champs.Select(Echapper)); // CRLF : RFC 4180 le prescrit, et c'est ce qu'attendent les tableurs sous Windows. sortie.Append("\r\n"); } /// /// Échappement RFC 4180 : guillemets doublés, champ encadré dès qu'il contient un /// séparateur, un guillemet ou un saut de ligne. /// /// /// Une note libre peut contenir n'importe quoi : sans échappement, un simple « offert ; à /// rendre » décalerait toutes les colonnes suivantes. /// private static string Echapper(string? champ) { var valeur = champ ?? string.Empty; var doitEncadrer = valeur.Contains(SeparateurCsv) || valeur.Contains('"') || valeur.Contains('\n') || valeur.Contains('\r'); return doitEncadrer ? $"\"{valeur.Replace("\"", "\"\"")}\"" : valeur; } }