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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user