Files
mabibli/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs
T
mathieuandClaude Opus 5 5cf566bc33 Traiter les retours d'usage du 2026-08-18 (2ᵉ série)
Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.

1. Douchette USB, ISSN et enchaînement du scan
   - Champ ISBN focalisé à l'ouverture : c'est tout ce qu'exige une
     douchette USB, qui se présente au système comme un clavier. Remède
     au scan caméra, qui rate sur la webcam d'un PC (optique, pas
     décodage).
   - Un EAN en 977 est un périodique : il porte un ISSN, donc un titre de
     revue. Intercepté AVANT la validation ISBN (c'est un EAN-13
     parfaitement valide), l'ISSN est déduit du code puis nommé via
     bib.issn. Auparavant la cascade s'exécutait en entier pour finir sur
     « aucun résultat ».
   - L'enchaînement après scan existait déjà mais était invisible : une
     étape « Recherche » affiche désormais le code interrogé.

2. ISBN affichés avec des tirets
   Tables extraites du RangeMessage.xml officiel — plusieurs tranches ne
   sont pas celles qu'on suppose. Le francophone est découpé en entier,
   ailleurs on s'arrête au groupe et à la clé : aucune coupure fausse.
   Corrige au passage l'export CSV, où un ISBN nu était lu comme un
   nombre par Excel.

3. Hors-ligne : la liste d'envies
   ListerSouhaitsAsync était le seul point de lecture hors du dispositif
   hors-ligne, d'où le « 404 Not Found » brut à l'écran. Cinquième
   instantané, écritures refusées, plus aucun message HTTP. Même défaut
   corrigé sur la bibliographie.

4. Navigation par onglets
   Catalogue / Auteurs / Prêts / Envies dans MainLayout ; les barres
   d'actions ne portent plus que des actions. Filtres repliés derrière un
   bouton compteur, ligne « format » masquée quand le fonds n'a qu'un
   format. Une seule entrée d'ajout, désactivée hors-ligne — pas masquée.

5. Liste d'envies : ordre, recherche, couvertures
   Migration RangDesEnvies. Le remplissage reconduit l'ordre affiché
   jusqu'ici : sans lui, les listes existantes se seraient réordonnées
   toutes seules. Réordonnancement par flèches et glisser-déposer (le
   drag & drop HTML5 ne marche pas au doigt). Ajout dans son propre
   écran, avec recherche par titre (bib.title) et couvertures enfin
   alimentées.

6. Bibliographie : une source muette n'est pas une liste vide
   L'écran affichait « BnF injoignable » PUIS « la BnF ne connaît aucun
   livre de cet auteur » — la seconde phrase étant fausse. Les deux cas
   s'excluent désormais, et un bouton Réessayer est offert.

   Vérifié en exécution : le diagnostic d'IDEES.md était faux sur un
   point. Robert A. Harper a bien 7 œuvres à la BnF (85 notices
   annoncées) ; c'était le même délai dépassé observé deux fois, pris
   pour deux causes distinctes.

380 tests, dont un qui applique réellement la migration (EnsureCreated
n'en joue aucune) et un qui verrouille les messages atteignant
l'utilisateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:14:01 +02:00

183 lines
7.3 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> 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,
FormatageIsbn.Afficher(souhait.Isbn),
souhait.Note,
souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
return sortie.ToString();
}
/// <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;
}
}