Files
mabibli/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs
T
mathieuandClaude Opus 5 8b8fe3be6e Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur
La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un
statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans
Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au
prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée
est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie
frontière : toute lecture filtre dessus.

Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux
filtres mesurés sur des réponses réelles sont indispensables — le type de
document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur
réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs :
« Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola.
Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51
œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui
désigne une édition et non une œuvre ; ses limites sont dites à l'écran.

Export en deux formats, tous deux du texte sans dépendance : .txt groupé par
auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le
tableur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:06 +02:00

178 lines
7.0 KiB
C#

using System.Globalization;
using System.Text;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Services.Souhaits;
/// <summary>
/// Sérialisation de la liste d'envies en fichier emportable.
/// </summary>
/// <remarks>
/// <b>Fonctions pures</b> : 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.
/// <para>
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> IDEES.md demandait un format
/// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte.
/// </para>
/// </remarks>
public static class ExportSouhaits
{
/// <summary>
/// Séparateur du CSV : le <b>point-virgule</b>, pas la virgule.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public const char SeparateurCsv = ';';
/// <summary>
/// Marque d'ordre des octets, <b>indispensable</b> en tête du CSV.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
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<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant) =>
format switch
{
FormatExportSouhaits.Csv => Csv(souhaits),
_ => Texte(souhaits, proprietaire, maintenant),
};
/// <summary>
/// Liste lisible telle quelle, groupée par auteur — la version qu'on emporte en librairie.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static string Texte(IReadOnlyList<SouhaitDto> 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();
}
/// <summary>Tableau ouvrable dans un tableur, une envie par ligne.</summary>
public static string Csv(IReadOnlyList<SouhaitDto> 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,
souhait.Isbn,
souhait.Note,
souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
return sortie.ToString();
}
private static string? Isbn(string? isbn) =>
string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {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");
}
/// <summary>
/// Échappement RFC 4180 : guillemets doublés, champ encadré dès qu'il contient un
/// séparateur, un guillemet ou un saut de ligne.
/// </summary>
/// <remarks>
/// Une note libre peut contenir n'importe quoi : sans échappement, un simple « offert ; à
/// rendre » décalerait toutes les colonnes suivantes.
/// </remarks>
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;
}
}