Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
13 KiB
C#
307 lines
13 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Isbn;
|
|
|
|
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> CLAUDE.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";
|
|
|
|
/// <summary>
|
|
/// Produit le fichier, <b>les deux tables réunies</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ 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.
|
|
/// <para>
|
|
/// Elles forment une <b>section à part</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static string Produire(
|
|
FormatExportSouhaits format,
|
|
IReadOnlyList<SouhaitDto> souhaits,
|
|
IReadOnlyList<RevueSouhaiteeDto> revues,
|
|
string? proprietaire,
|
|
DateTime maintenant) =>
|
|
format switch
|
|
{
|
|
FormatExportSouhaits.Csv => Csv(souhaits, revues),
|
|
_ => Texte(souhaits, revues, 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,
|
|
IReadOnlyList<RevueSouhaiteeDto> 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();
|
|
}
|
|
|
|
/// <summary>Les livres souhaités, groupés par auteur.</summary>
|
|
private static void Livres(StringBuilder sortie, IReadOnlyList<SouhaitDto> 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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Les revues souhaitées, en <b>section à part</b>, à la fin.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ 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.
|
|
/// </remarks>
|
|
private static void Revues(StringBuilder sortie, IReadOnlyList<RevueSouhaiteeDto> 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tableau ouvrable dans un tableur, une envie par ligne, <b>les deux tables réunies</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ 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.
|
|
/// </remarks>
|
|
public static string Csv(IReadOnlyList<SouhaitDto> souhaits, IReadOnlyList<RevueSouhaiteeDto> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rang affiché de chaque envie, à partir de 1.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Déduit de la <b>position dans la liste reçue</b>, et non lu sur une propriété du DTO :
|
|
/// <c>ListerAsync</c> trie déjà par <c>Rang</c>, 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.
|
|
/// </remarks>
|
|
private static Dictionary<int, int> Rangs(IReadOnlyList<SouhaitDto> souhaits) =>
|
|
souhaits.Select((s, i) => (s.Id, Rang: i + 1)).ToDictionary(x => x.Id, x => x.Rang);
|
|
|
|
/// <summary>
|
|
/// ISBN découpé pour la ligne texte : c'est un numéro qu'on <b>épelle à un libraire</b>,
|
|
/// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|