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. CLAUDE.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"; /// /// Produit le fichier, les deux tables réunies. /// /// /// ⚠️ Les revues souhaitées viennent d'une table sœur et doivent traverser l'export : c'est /// le fichier qu'on emporte, donc précisément l'endroit où les oublier ne se verrait pas. /// /// Elles forment une section à part plutôt que d'être entrelacées : les deux tables /// numérotent leur rang indépendamment, et mélanger deux suites sans rapport produirait un /// ordre que personne n'a choisi. Une revue n'a d'ailleurs pas d'auteur, donc rien à faire /// dans un fichier groupé par auteur. /// /// public static string Produire( FormatExportSouhaits format, IReadOnlyList souhaits, IReadOnlyList revues, string? proprietaire, DateTime maintenant) => format switch { FormatExportSouhaits.Csv => Csv(souhaits, revues), _ => Texte(souhaits, revues, 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, IReadOnlyList revues, 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 && revues.Count == 0) { sortie.Append("\n(aucune envie dans la liste)\n"); return sortie.ToString(); } if (souhaits.Count > 0) { sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); Livres(sortie, souhaits); } Revues(sortie, revues); return sortie.ToString(); } /// Les livres souhaités, groupés par auteur. private static void Livres(StringBuilder sortie, IReadOnlyList souhaits) { // ⚠️ Le groupement par auteur DÉTRUIT l'ordre d'envie : c'est le prix, assumé, d'une // liste rangée comme une librairie. Le rang est donc réinscrit devant chaque titre, // faute de quoi le fichier emporté ne dit plus par quoi commencer — précisément // l'information que l'écran de réordonnancement sert à établir. var rangs = Rangs(souhaits); sortie.Append("(le chiffre entre crochets est votre ordre d'envie, 1 = le plus désiré)\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(rangs[souhait.Id]).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'); } } } } /// /// Les revues souhaitées, en section à part, à la fin. /// /// /// ⚠️ Elles ne peuvent pas rejoindre les groupes ci-dessus : une revue n'a pas d'auteur, et /// « Auteur non précisé » réunirait des magazines et des livres dont l'auteur est simplement /// inconnu. Au kiosque comme en librairie, ce ne sont pas les mêmes rayons. /// private static void Revues(StringBuilder sortie, IReadOnlyList revues) { if (revues.Count == 0) { return; } sortie.Append('\n').Append(new string('-', 40)).Append('\n'); sortie.Append(revues.Count).Append(revues.Count > 1 ? " revues" : " revue").Append('\n'); // La numérotation repart de 1 : le rang d'une revue est celui de sa propre section, pas // une place dans un classement commun qui n'existe pas. var rang = 0; foreach (var revue in revues) { sortie.Append(" - [").Append(++rang).Append("] ").Append(revue.TitreComplet); if (!string.IsNullOrWhiteSpace(revue.Issn)) { sortie.Append(" (ISSN ").Append(revue.Issn).Append(')'); } sortie.Append('\n'); if (!string.IsNullOrWhiteSpace(revue.Note)) { sortie.Append(" ").Append(revue.Note.Trim()).Append('\n'); } } } /// /// Tableau ouvrable dans un tableur, une envie par ligne, les deux tables réunies. /// /// /// ⚠️ Une colonne « Type » ouvre le tableau à côté du rang : sans elle, une revue et un /// livre seraient indiscernables une fois le fichier trié par titre, et la colonne /// « ISBN / ISSN » deviendrait illisible — deux codes de nature différente y cohabitent. /// Les revues suivent les livres, chaque table gardant sa propre numérotation. /// public static string Csv(IReadOnlyList souhaits, IReadOnlyList revues) { var sortie = new StringBuilder(); // Le rang ouvre le tableau : c'est la colonne sur laquelle on trie dans un tableur, et // celle qui répond à « par quoi commence-t-on ? » quand on se répartit des achats. // Le CSV suit déjà cet ordre, mais un ordre implicite se perd au premier tri par titre. Ligne(sortie, "Rang", "Type", "Titre", "Auteur", "Éditeur", "Année", "ISBN / ISSN", "Note", "Ajouté le"); var rangs = Rangs(souhaits); foreach (var souhait in souhaits) { Ligne( sortie, rangs[souhait.Id].ToString(CultureInfo.InvariantCulture), "Livre", souhait.Titre, souhait.Auteur, souhait.Editeur, souhait.Annee, FormatageIsbn.Afficher(souhait.Isbn), souhait.Note, souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } var rangRevue = 0; foreach (var revue in revues) { // Ni auteur, ni éditeur, ni année : une revue n'en a pas au sens de cette liste, et // inventer des colonnes remplies au hasard serait pire que des cases vides. Ligne( sortie, (++rangRevue).ToString(CultureInfo.InvariantCulture), "Revue", revue.TitreComplet, null, null, null, revue.Issn, revue.Note, revue.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } return sortie.ToString(); } /// /// Rang affiché de chaque envie, à partir de 1. /// /// /// Déduit de la position dans la liste reçue, et non lu sur une propriété du DTO : /// ListerAsync trie déjà par Rang, et la colonne en base peut comporter des /// trous (une suppression ne renumérote pas). Ce qu'on veut imprimer est « troisième de ma /// liste », pas la valeur brute stockée. /// private static Dictionary Rangs(IReadOnlyList souhaits) => souhaits.Select((s, i) => (s.Id, Rang: i + 1)).ToDictionary(x => x.Id, x => x.Rang); /// /// 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; } }