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>
183 lines
7.3 KiB
C#
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;
|
|
}
|
|
}
|