From 5cf566bc33167bdefef65bdeb853e0f954140848 Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 19 Aug 2026 00:14:01 +0200 Subject: [PATCH] =?UTF-8?q?Traiter=20les=20retours=20d'usage=20du=202026-0?= =?UTF-8?q?8-18=20(2=E1=B5=89=20s=C3=A9rie)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 379 +++++++++++++++++- IDEES.md | 63 +++ .../20260818213512_RangDesEnvies.Designer.cs | 304 ++++++++++++++ .../20260818213512_RangDesEnvies.cs | 65 +++ .../MaBibliDbContextModelSnapshot.cs | 3 + MaBibli.Api/Endpoints/IsbnEndpoints.cs | 33 ++ MaBibli.Api/Endpoints/SouhaitsEndpoints.cs | 27 +- .../Catalogue/ServiceBibliographie.cs | 4 + .../Services/Isbn/BnfBibliographieParser.cs | 80 ++++ MaBibli.Api/Services/Isbn/BnfClient.cs | 167 +++++++- MaBibli.Api/Services/Isbn/BnfSruParser.cs | 45 +++ .../Services/Isbn/IsbnLookupService.cs | 42 ++ MaBibli.Api/Services/Isbn/NettoyageIsbd.cs | 14 + .../Services/Souhaits/ExportSouhaits.cs | 9 +- .../Services/Souhaits/ServiceSouhaits.cs | 78 +++- MaBibli.Client/Layout/MainLayout.razor | 20 + MaBibli.Client/Layout/MainLayout.razor.css | 49 ++- MaBibli.Client/Pages/AjoutIsbn.razor | 128 +++++- MaBibli.Client/Pages/AjoutSouhait.razor | 336 ++++++++++++++++ MaBibli.Client/Pages/Auteurs.razor | 5 - MaBibli.Client/Pages/Bibliographie.razor | 104 ++++- MaBibli.Client/Pages/Catalogue.razor | 136 +++++-- MaBibli.Client/Pages/FicheLivre.razor | 4 +- MaBibli.Client/Pages/Prets.razor | 4 - MaBibli.Client/Pages/Souhaits.razor | 311 ++++++++------ MaBibli.Client/Services/CacheHorsLigne.cs | 16 + MaBibli.Client/Services/ServiceLivresApi.cs | 117 +++++- MaBibli.Client/_Imports.razor | 1 + MaBibli.Client/wwwroot/css/app.css | 70 +++- MaBibli.Shared/Dtos/PeriodiqueDetecte.cs | 30 ++ MaBibli.Shared/Dtos/ResultatLookupIsbn.cs | 7 + MaBibli.Shared/Dtos/SouhaitDto.cs | 42 ++ MaBibli.Shared/Entites/LivreSouhaite.cs | 14 + MaBibli.Shared/Isbn/CodePeriodique.cs | 62 +++ MaBibli.Shared/Isbn/FormatageIsbn.cs | 205 ++++++++++ MaBibli.Tests/BnfBibliographieParserTests.cs | 121 +++++- MaBibli.Tests/CodePeriodiqueTests.cs | 183 +++++++++ MaBibli.Tests/ExportSouhaitsTests.cs | 19 +- MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml | 24 ++ .../Fixtures/bnf-titre-bete-humaine.xml | 291 ++++++++++++++ MaBibli.Tests/FormatageIsbnTests.cs | 86 ++++ MaBibli.Tests/IsbnLookupServiceTests.cs | 26 ++ MaBibli.Tests/MigrationRangDesEnviesTests.cs | 111 +++++ MaBibli.Tests/ServiceSouhaitsTests.cs | 235 ++++++++++- 44 files changed, 3857 insertions(+), 213 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.cs create mode 100644 MaBibli.Client/Pages/AjoutSouhait.razor create mode 100644 MaBibli.Shared/Dtos/PeriodiqueDetecte.cs create mode 100644 MaBibli.Shared/Isbn/CodePeriodique.cs create mode 100644 MaBibli.Shared/Isbn/FormatageIsbn.cs create mode 100644 MaBibli.Tests/CodePeriodiqueTests.cs create mode 100644 MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml create mode 100644 MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml create mode 100644 MaBibli.Tests/FormatageIsbnTests.cs create mode 100644 MaBibli.Tests/MigrationRangDesEnviesTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index e3869b0..c82af3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,133 @@ public static class IsbnScanner } ``` +## Saisie d'un code-barres — décisions actées le 2026-08-18 (2ᵉ série) + +### La douchette USB est le vrai remède au scan raté sur PC + +Constaté en usage : sur PC, la webcam décode mal l'EAN-13. Le décodeur n'y est pour rien +(4-6 ms/frame mesurés) — c'est l'optique : mise au point fixe, cadrage large, peu de lumière, +là où un capteur de téléphone fait le point à 10 cm. + +**Une douchette USB se présente au système comme un clavier** : elle « tape » les chiffres puis +`Entrée`. Elle ne demande donc **aucune permission, aucun HTTPS, aucun décodage** — rien de ce +qui rend le scan caméra fragile. Tout ce qu'elle exige du code tient en deux points, et les deux +sont maintenant tenus par `AjoutIsbn.razor` : + +| Exigence | Mise en œuvre | +|---|---| +| Le champ ISBN est **focalisé à l'ouverture** | `FocusAsync` dans `OnAfterRenderAsync`, une seule fois par entrée dans l'étape de saisie | +| Le champ **valide sur `Entrée`** | déjà présent (`SurTouche`) | + +⚠️ **Ne jamais déclencher la recherche à chaque frappe.** Une douchette tape plus vite qu'un +humain : chaque caractère partirait en requête réseau. Le champ se lie bien sur `oninput`, mais +seul `Entrée` (ou le bouton) lance le lookup. + +⚠️ **Ne pas reprendre le focus à chaque rendu** : le drapeau `_focusAFaire` existe pour ça. +Sans lui, le curseur serait arraché à l'utilisateur en pleine frappe manuelle. + +Le préfixe/suffixe que certaines douchettes ajoutent ne demande aucun traitement : +`IsbnUtils.Normaliser` ne garde que les chiffres et le `X` final. + +Pistes **non retenues pour l'instant**, si la douchette ne suffisait pas : choix de la caméra, +résolution plus haute, zoom/torche, dépôt d'une photo à décoder. + +### Après un scan réussi, la recherche s'enchaîne — et ça doit se voir + +**Décidé : enchaînement automatique**, pas de bouton de confirmation. Le champ garde le code lu, +donc un décodage erroné se corrige et se relance ; et c'est le seul comportement cohérent avec la +douchette, qui envoie `Entrée` d'elle-même. + +⚠️ Le défaut d'origine n'était pas l'absence d'enchaînement — il existait déjà — mais son +**invisibilité** : l'écran retombait sur la saisie avec un bouton « Chercher » intact, et l'on +croyait que le scan n'avait rien déclenché. D'où une étape `Recherche` à part entière, qui +affiche le code interrogé. Ne pas la supprimer en la prenant pour du décor. + +### Le préfixe `977` est un magazine, pas un livre — et on le dit + +Un EAN-13 en `977` désigne une **publication en série** : il porte un **ISSN**, donc un *titre de +revue*, là où un livre porte `978`/`979`. Aucune recherche `bib.isbn` ne peut aboutir. Avant +correction, `9772466671438` traversait toute la cascade pour finir sur « aucun résultat » — ce +qui envoie chercher un défaut là où il n'y en a pas. + +⚠️ **L'interception se fait avant la validation ISBN**, dans `IsbnLookupService` : un code de +périodique est un EAN-13 **parfaitement valide** (`EstIsbn13Valide` le confirme), seul son +préfixe le trahit. Un test le verrouille : ni la BnF ni OpenLibrary ne sont interrogées sur +l'ISBN. + +Structure du code, vérifiée le 2026-08-18 : + +``` +977 | 2466671 | 43 | 8 + └ ISSN sans sa clé └ parution └ clé EAN-13 +``` + +La clé de l'ISSN **n'est pas dans le code-barres**, elle se recalcule (somme pondérée 8→2, +modulo 11, `X` si 10) — même principe que la clé ISBN-10, sur sept chiffres. `CodePeriodique` +fait les deux. + +**Décidé : on nomme la revue** plutôt que de se contenter d'un message générique. La BnF indexe +l'ISSN sous `bib.issn`, sans clé, avec le tiret : + +``` +bib.issn all "2466-6718" → 1 notice + dc:title = Médor (Bruxelles) ⚠ le parenthésé est un qualificatif de + dc:title = Médor : les yeux ouverts… désambiguïsation, pas une ville — il se retire + dc:date = 2015 (NettoyageIsbd.TitrePeriodique) +``` + +Deux points à ne pas « corriger » : + +- **Une notice de revue porte plusieurs `dc:title`.** Le premier est le titre clé, le second + l'accroche commerciale avec sa mention de responsabilité. C'est le **premier** qu'on retient. +- **Une BnF muette ou injoignable n'est pas un échec** : l'ISSN se déduit du code-barres, on le + donne quand même. Retomber sur « aucun résultat » serait reproduire le bug d'origine. + +**Ce qui n'est délibérément pas fait** : cataloguer la revue. Le modèle de données n'a ni numéro +ni date de parution, et les deux chiffres de parution du code ne sont pas exploitables comme +numéro fiable — douze numéros d'un même magazine partagent leur ISSN et créeraient douze fiches +identiques. L'écran bascule donc sur la saisie manuelle avec le **titre** pré-rempli, et +**surtout pas l'ISBN** : ranger un code de périodique dans `Livre.Isbn` ferait échouer tout +lookup ultérieur sur cette fiche. + +### Les ISBN s'affichent avec des tirets — table officielle, français exact + +`9782846391009` → `978-2-84639-100-9`. **Purement de l'affichage** : la valeur stockée reste nue, +et les tirets ne franchissent jamais la frontière de la base ni celle des requêtes aux sources — +la BnF indexe l'ISBN **sans** tiret. Le seul point d'entrée est `FormatageIsbn.Afficher` ; un test +vérifie que `IsbnUtils.Normaliser` défait exactement ce que `Afficher` a posé. + +Les tranches ne se devinent pas : elles dépendent du groupe linguistique **puis** de l'éditeur, et +sont publiées par l'agence internationale. Les tables embarquées sont extraites du +[`RangeMessage.xml`](https://www.isbn-international.org/export_rangemessage.xml) officiel, version +du 2026-08-18 — **ne pas les réécrire de mémoire**, plusieurs tranches ne sont pas celles qu'on +suppose (`8400000-8999999` vaut 5 chiffres d'éditeur, pas 6, et c'est précisément la tranche du +cas remonté). + +⚠️ **Le principe tenu n'est pas « un découpage approché » mais « aucune coupure fausse ».** +IDEES.md envisageait d'assumer une erreur d'un chiffre hors du français ; ce n'était pas +nécessaire : + +| Cas | Découpage | Pourquoi | +|---|---|---| +| `978-2` (francophone) | **complet**, éditeur compris | 19 tranches, exactes | +| autres groupes | groupe et clé **seulement** (`978-0-26203384-8`) | la frontière éditeur/publication demanderait ~220 tables ; on l'omet au lieu de la deviner | +| pas un ISBN valide | **inchangé** | un code mal saisi doit se voir mal saisi, pas déguisé par des tirets | + +Une tranche en moins se lit encore ; une tranche fausse trompe. C'est aussi ce qui borne le poids +embarqué dans le WASM : seule la table francophone est présente, la collection l'étant aussi. + +**Où c'est appliqué** : fiche livre, liste d'envies, écran d'ajout par ISBN (code recherché et +provenance d'un candidat), export `.txt` et export `.csv`. Le catalogue **n'affiche pas d'ISBN** +et n'a donc rien à découper — ne pas en ajouter un pour la circonstance. + +⚠️ Le CSV y gagne plus que de la lisibilité : **un ISBN nu de treize chiffres est lu comme un +nombre par Excel**, affiché « 9,78225E+12 », derniers chiffres perdus. Les tirets en font un +texte. Ne pas « simplifier » en remettant la valeur nue dans l'export. + +Les champs de **saisie** gardent la valeur nue : découper pendant la frappe se battrait avec +l'utilisateur, et la douchette tape des chiffres. + ## Stratégie hors-ligne — décidé : consultation seule **Besoin réel** : consulter la bibliothèque **déjà enregistrée** sans réseau (liste des livres, statuts, prêts en cours). Il ne s'agit **pas** d'enrichir de nouveaux livres hors-ligne — le lookup ISBN exige de toute façon un accès réseau. @@ -165,8 +292,9 @@ public static class IsbnScanner | `Services/FiltreLivresLocal.cs` | Recherche, filtres et tri hors-ligne sur l'instantané | | `Services/ServiceLivresApi.cs` | Lectures avec repli sur le cache, écritures refusées | -**Quatre instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue -**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`. C'est exactement ce qui +**Cinq instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue +**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`, et `souhaits` depuis le +2026-08-18 (voir « La liste d'envies est le cinquième instantané »). C'est exactement ce qui justifie IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne restituerait que les URL déjà visitées, donc **une recherche jamais tapée en ligne ne rendrait rien**. Vérifié en exécution — hors-ligne, chercher un auteur jamais affiché auparavant remonte @@ -216,6 +344,55 @@ livre ajouté côté serveur pendant la coupure. de synchronisation). Les écrans se rechargent sur `Change` uniquement. Avec un événement unique, un rechargement écrirait un instantané → nouvel événement → nouveau rechargement, sans fin. +### La liste d'envies est le cinquième instantané — et pourquoi elle manquait + +Symptôme remonté en usage : `net_http_message_not_success_statuscode_reason, 404, Not Found` +en plein écran, réseau coupé. **Deux défauts distincts**, et il faut retenir les deux : + +1. `ListerSouhaitsAsync` appelait `http.GetFromJsonAsync` **directement**, sans passer par + `EssayerAsync`. Elle était donc le seul point de lecture hors du dispositif hors-ligne : pas + de repli sur instantané, pas de `SignalerEchecReseau`, et l'exception HTTP remontait telle + quelle jusqu'à l'écran. +2. L'écran affichait `ex.Message`. ⚠️ **Aucun message HTTP brut ne doit atteindre l'utilisateur** : + il n'a aucun moyen d'interpréter un « 404 » qui, ici, ne veut même pas dire ce qu'il dit. + +La correction rend la liste d'envies conforme au reste : lecture avec repli sur `souhaits`, +écritures passées par `EcrireAsync`, et rafraîchissement de l'instantané après chaque écriture. + +**Pourquoi elle mérite un instantané et pas une simple erreur polie** : la liste s'emporte **en +librairie**, précisément là où le réseau manque. C'est exactement l'usage que servait l'export +`.txt` — et cet export est produit **par le serveur**, donc mort hors-ligne. Sans instantané, la +fonctionnalité n'existait pas au seul endroit où elle sert. + +⚠️ **C'est le seul instantané personnel.** Catalogue, auteurs et prêts sont communs au foyer ; +`souhaits` ne contient que ce que le serveur a rendu à l'appelant. Ne jamais lui ajouter de +paramètre d'utilisateur : l'API ne sait pas produire la liste d'un autre, et c'est voulu. + +Trois pièges rencontrés en chemin : + +- **La suppression ne passe pas par `EcrireAsync`.** Elle répond `204 No Content`, et + désérialiser un corps vide lèverait une exception là où tout s'est bien passé. Elle reprend + la forme de la suppression d'un livre. +- **Les liens d'export basculent en boutons désactivés hors-ligne.** Un `` ne se désactive + pas : laissé actif, il quitterait l'application pour une page d'erreur du navigateur. +- **Hors-ligne, « liste vide » et « jamais synchronisée » sont indiscernables.** L'écran dit les + deux au lieu d'affirmer la première — même règle que pour une fiche absente de l'instantané. + +#### Vérifié en exécution le 2026-08-18 + +API lancée, une envie créée, puis **serveur réellement arrêté** : + +- la liste s'affiche depuis l'instantané `souhaits`, avec le bandeau « Données enregistrées + aujourd'hui à 22:20 » ; +- **plus aucun message HTTP** — le 404 a disparu de l'écran ; +- « Chercher », « Ajouter à ma liste » et « Retirer » désactivés avec le motif hors-ligne ; + « Exporter en texte » et « Exporter en CSV » désactivés avec **leur** motif, distinct + (« produit par le serveur ») ; +- serveur redémarré : le bandeau disparaît, les liens d'export redeviennent des `` et + « Retirer » se réactive **à la navigation suivante**, `navigator.onLine` n'ayant jamais changé + et aucun événement `online` n'ayant donc été émis. Le piège documenté plus haut se vérifie + aussi sur cet écran. + ### Ce que le hors-ligne ne couvre pas, volontairement - **L'historique des prêts d'un livre** (`GET /api/livres/{id}/prets`) : une requête par livre pour @@ -531,6 +708,7 @@ LivreSouhaite (la liste d'envies est PERSONNELLE — voir sa sec ├── AuteurNormalise (jamais NULL, sinon l'unicité ci-dessous ne tient pas) ├── Editeur / Annee / Isbn / CoverUrl / Note ├── DateAjout +├── Rang (ordre CHOISI, 0 = le plus désiré — personnel comme le reste) └── UNIQUE (Utilisateur, TitreNormalise, AuteurNormalise) une œuvre par personne ; deux personnes peuvent souhaiter le même livre ``` @@ -702,6 +880,90 @@ attachment`). ⚠️ Le lien de téléchargement **doit** porter l'attribut `dow routeur Blazor intercepte le clic comme une navigation interne et affiche sa page « introuvable » au lieu de télécharger. Vérifié dans le navigateur. +### La liste s'ordonne à la main — migration `RangDesEnvies` (2026-08-18) + +Une liste d'envies se lit « qu'est-ce que je veux le plus ? ». Elle était classée **par auteur +puis par titre** : l'alphabet répondait à une question que personne ne se pose. La colonne +`Rang` porte désormais l'ordre choisi, `0` en tête. + +⚠️ **Le rang est personnel comme toute la table** : la numérotation repart de zéro pour chaque +`Utilisateur`, sans quoi l'ordre de l'un dépendrait de celui de l'autre. + +**Une envie nouvelle se pose en fin de liste**, jamais en tête : on vient de la noter, on n'a +pas dit qu'on la voulait plus que les autres. La poser en tête déclasserait le choix de +l'utilisateur à chaque ajout. + +#### La migration ne se réduit pas à `AddColumn` + +⚠️ À `0` partout, l'ordre serait retombé sur l'`Id`, c'est-à-dire l'ordre d'ajout : **toutes les +listes existantes se seraient réordonnées** sous les yeux de leur propriétaire, sans qu'il ait +rien demandé. Le `migrationBuilder.Sql` qui suit l'`AddColumn` **reconduit exactement l'ordre +affiché jusqu'ici** — auteurs renseignés d'abord, puis par auteur, puis par titre. + +Il s'appuie sur les **row values** de SQLite (`(a, b) < (c, d)`, disponibles depuis 3.15), qui +expriment « combien de lignes se classaient avant celle-ci » en une comparaison. L'`Id` ferme +le tri : sans lui, deux lignes égales recevraient le même rang. + +⚠️ `ServiceSouhaitsTests` crée son schéma avec `EnsureCreated`, qui **ne joue aucune migration** : +ce SQL n'y passerait jamais. `MigrationRangDesEnviesTests` migre donc jusqu'à `ListeDEnvies`, +insère en SQL brut, puis applique `RangDesEnvies` et vérifie les rangs des deux utilisateurs. +Vérifié aussi en exécution sur la base de développement. + +#### Un seul point d'entrée : `PUT /api/souhaits/ordre` + +Il attend la liste **entière** des identifiants, dans l'ordre voulu. C'est ce dont a besoin le +glisser-déposer, et les flèches monter/descendre s'y ramènent — **une seule opération à +vérifier** plutôt qu'un « monter » et un « descendre » séparés. + +Deux protections, toutes deux couvertes par des tests : + +- **Le service relit la liste de l'appelant et ne se fie pas à ce que le client envoie.** Un + identifiant appartenant à quelqu'un d'autre est ignoré, jamais renuméroté — le filtre fait + partie de la clé, comme pour la suppression. +- **Une envie absente de l'ordre reçu est conservée**, rangée à la suite. La liste du client + peut être périmée (envie ajoutée depuis un autre appareil) : mal classée vaut infiniment mieux + que disparue. + +#### Les flèches ne sont pas un ornement + +⚠️ **Le glisser-déposer HTML5 ne fonctionne pas au doigt sur mobile.** Sans les flèches +monter/descendre, réordonner n'existerait que sur PC — c'est-à-dire pas là où l'on consulte sa +liste. Les deux gestes passent par le même code client (`DeplacerAsync`), la liste étant +réordonnée **avant** l'aller-retour réseau pour que la flèche réponde tout de suite ; un échec +recharge l'ordre du serveur plutôt que de laisser croire à un enregistrement. + +**L'export CSV suit le rang** (utile pour se répartir les achats), le `.txt` reste **groupé par +auteur** : c'est ce qu'on épelle à un libraire. Les deux gardent leur usage. + +### L'ajout d'une envie a son propre écran + +`/souhaits/ajout`, séparé de `/souhaits` comme la fiche livre l'est de son édition : la liste ne +montre plus que la liste, et l'ajout a la place de proposer une vraie recherche. Trois voies, +dans l'ordre où le besoin se présente : + +| Voie | Quand | Source | +|---|---|---| +| **Par titre** (défaut) | le cas **normal** — on souhaite un livre qu'on n'a pas en main | `bib.title`, auteur facultatif en `and` | +| **Par ISBN** | on l'a en main (librairie, chez quelqu'un) | cascade ISBN existante | +| **À la main** | aucune source ne le connaît | rien | + +⚠️ **Le filtre de type de document est indispensable ici aussi.** Mesuré le 2026-08-18 : +`bib.title all "la bête humaine"` croisé avec Zola rend 20 notices dont **12 films** de Renoir, +un manuscrit et un multimédia. `ParserRecherche` réutilise donc la liste positive de +`BnfBibliographieParser`. + +⚠️ **En revanche aucun post-filtre sur l'auteur**, contrairement à la bibliographie : la +contrainte de titre resserre déjà, l'auteur est facultatif, et surtout **l'utilisateur choisit +dans la liste en voyant les auteurs de chaque notice**. C'est ce qui laisse passer la BD signée +Dobbs tirée de *La bête humaine* — une adaptation se souhaite parfaitement, et rien ne trompe +puisque son auteur est affiché. + +**`LivreSouhaite.CoverUrl` est enfin alimenté** : la couverture vient d'OpenLibrary dès qu'un +ISBN est connu, y compris pour les résultats d'une recherche par titre (l'ISBN est repris du +`dc:identifier` de la notice). **Sans ISBN, pas de couverture et on n'en invente pas** — la +vignette retombe sur son substitut à initiale. Décidé de **ne pas** offrir de champ « URL de +couverture » : il inviterait à coller des liens morts pour un gain nul. + ## Bibliographie par auteur — SRU BnF, validé le 2026-08-18 Le déclencheur décrit dans IDEES.md : depuis un auteur déjà présent, voir tout ce qu'il a écrit, @@ -777,6 +1039,63 @@ Conséquence de conception : l'écran **grise** ce qu'il reconnaît et ne **masq Un faux négatif se voit et se corrige d'un coup d'œil ; une ligne masquée à tort serait invisible. L'interface énonce cette limite en bas de liste plutôt que de laisser l'utilisateur la découvrir. +### Une source muette n'est pas une bibliographie vide — corrigé le 2026-08-19 + +Symptôme remonté : sur « Robert A. Harper », l'écran affichait +`BnF injoignable (TaskCanceledException)` **puis**, juste en dessous, « La BnF ne connaît aucun +livre de cet auteur ». Deux phrases contradictoires, dont la seconde était **fausse** : on n'avait +pas pu poser la question, donc on ne savait rien. + +C'est la pire des deux erreurs possibles ici — elle **ferme** la question au lieu de la laisser +ouverte, et avec une explication rassurante qui rend l'absence crédible. + +`BibliographieDto.Etat` (`EtatSourceBibliographie`) distingue désormais quatre issues, et +`SourceMuette` est ce que l'interface doit consulter **avant** d'expliquer une absence de +résultats : + +| État | Sens | Ce que fait l'écran | +|---|---|---| +| `Ok` | la BnF a répondu | affiche la liste, **même vide** — et explique alors l'absence | +| `DelaiDepasse` | pas de réponse à temps | dit la panne, propose **Réessayer** | +| `Injoignable` | pas jointe, ou erreur HTTP | idem | +| `ReponseIllisible` | réponse inexploitable | idem | + +⚠️ **Les deux branches s'excluent dans le balisage.** « La BnF ne connaît aucun livre de cet +auteur » n'est rendu que sous `!SourceMuette`. Ne jamais les remettre en séquence. + +⚠️ `DelaiDepasse` se distingue de `Injoignable` parce qu'il n'appelle pas la même chose : un +délai dépassé se retente **tout de suite**, et c'est le cas le plus fréquent. `HttpClient` +l'annonce par une `TaskCanceledException` dont l'exception *interne* est un `TimeoutException` — +sans ce test, il se confondrait avec une annulation de l'appelant. + +⚠️ **Les motifs sont écrits pour un humain.** `BnfClient.MotifDeSourceMuette` est **public** à +dessein : ce texte atteint l'utilisateur, c'est un contrat, et des tests vérifient qu'il ne +contient ni « Exception », ni « Task », ni « Http », que les trois motifs diffèrent, et que celui +du délai invite à réessayer. + +Un bouton **Réessayer** existe aussi sur l'échec de l'appel à *notre* serveur (délai du +navigateur) : sans lui, il fallait recharger la page entière. + +#### ⚠️ Le diagnostic d'IDEES.md était faux sur un point — vérifié en exécution + +IDEES.md tenait « Robert A. Harper » pour **deux** problèmes : un délai dépassé, **et** un auteur +qui n'aurait « effectivement aucune notice à son nom » dans l'index BnF, faute d'être un auteur +francophone. Le second n'existe pas. + +Mesuré le 2026-08-19 sur la base de développement : la BnF annonce **85 notices** pour cet +auteur, 78 sont écartées (autre support, ou signées de quelqu'un d'autre), et il reste +**7 œuvres** — dont *La thérapie émotivo-rationnelle*, correctement reconnue comme déjà possédée. +Le premier chargement expirait, le second aboutissait : c'est **le même défaut observé deux +fois**, pris pour deux. + +La leçon vaut au-delà du cas : une source intermittente produit exactement les symptômes d'une +source lacunaire. C'est précisément pourquoi il faut distinguer les deux en machine plutôt qu'à +l'œil. + +**Conséquence sur OpenLibrary** : la justification qui la rendait urgente tombe. Elle reste une +piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas +du catalogue par `CleOeuvre`. + ## Interface — décisions actées le 2026-08-18 Retours d'usage d'`IDEES.md`, appliqués et donc retirés de ce fichier-là. @@ -829,6 +1148,62 @@ sinon on créerait des fiches sans format en croyant que « rien d'affiché = ph acquis sans code particulier (`Format.Physique` vaut 0, valeur par défaut de l'énumération) — mais si l'énumération change d'ordre un jour, ce comportement tombe. +### La navigation tient dans quatre onglets, en bas de l'écran + +`MainLayout` porte une barre fixe **Catalogue / Auteurs / Prêts / Envies**. Elle remplace les +listes de liens que chaque page traînait dans sa barre d'actions : cinq boutons y passaient sur +deux ou trois lignes sur un téléphone, et « Envies » ne figurait pas partout. + +Conséquence tenue partout : **`.actions-flottantes` ne porte plus que des *actions***. Tout lien +qui doublonnait exactement une destination d'onglet a été retiré — « Retour au catalogue », +« Retour aux auteurs », « Ma liste d'envies ». Ce qui reste est ce qu'aucun onglet ne sait faire +(« Ses livres chez vous », qui est un catalogue *restreint*). + +⚠️ **En bas, pas sous le bandeau** : le pouce atteint le bas de l'écran. Les deux barres fixes +coexistent grâce à `--mb-onglets-hauteur`, partagée entre `MainLayout.razor.css` et `app.css` — +sans elle, la barre d'actions recouvrirait les onglets. Vérifié à 375 px : onglets 770-812, +actions 706-770, aucun chevauchement. + +⚠️ `NavLinkMatch.All` sur « Catalogue » est **obligatoire** : son `href` est la racine, et sans +cela l'onglet resterait allumé sur les quatre écrans. + +Les onglets **restent actifs hors-ligne** : les quatre écrans se consultent depuis leurs +instantanés. Ce sont les écritures qui se désactivent, jamais la navigation. + +### Les filtres du catalogue se replient derrière un bouton + +Deux rangées de segments occupaient en permanence le haut de l'écran pour un réglage qu'on +change rarement. Elles tiennent maintenant derrière « Filtrer », près du titre. + +⚠️ **Le compteur sur le bouton (« Filtrer (1) ») est ce qui rend le repli sans danger** : un +filtre actif et invisible ferait conclure que des livres ont disparu. Il ne compte que ce qui +serait invisible une fois replié — ni la recherche, ni la restriction à un auteur, toutes deux +déjà visibles à l'écran. + +**La ligne « Tous / Physique / Numérique » disparaît quand le fonds n'a qu'un seul format**, +dans le prolongement de « le format ne s'affiche que pour les ebooks ». Elle réapparaît d' +elle-même au premier ebook — vérifié en exécution. + +⚠️ Les formats présents ne se déduisent que d'un chargement **sans aucun critère** +(`EstSansCritere`). Les tirer d'une liste filtrée escamoterait la ligne dès qu'une recherche ne +remonte qu'un format, et l'utilisateur ne pourrait plus l'élargir. Dans le doute, la ligne reste +affichée : un filtre inutile est bénin, un filtre escamoté ne l'est pas. + +### Une seule entrée d'ajout, désactivée hors-ligne — pas masquée + +« Saisie manuelle » quitte le catalogue : elle reste accessible un écran plus loin, depuis +« Ajouter un livre », qui la propose dès qu'un ISBN ne donne rien. + +⚠️ **Décision confirmée le 2026-08-18** : IDEES.md proposait de *masquer* les entrées d'ajout +hors-ligne. C'est refusé, et c'est le maintien de la règle actée. Un bouton disparu est +indiscernable d'une fonction supprimée : l'utilisateur conclurait à une régression au lieu +d'apprendre que c'est le réseau — même raisonnement que « pas dans les données enregistrées sur +cet appareil » plutôt que « n'existe plus ». **Le désencombrement passe par le regroupement** +(deux boutons en un), jamais par l'effacement. + +Vérifié serveur arrêté : un seul bouton, `disabled`, portant son motif ; les quatre onglets +intacts ; les livres servis par l'instantané. + ### Couverture agrandissable au clic Vignette et fiche : cliquer l'image l'affiche en grand sur fond sombre, fermeture au clic diff --git a/IDEES.md b/IDEES.md index 1ddb16b..935fb0f 100644 --- a/IDEES.md +++ b/IDEES.md @@ -62,3 +62,66 @@ La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes no l'écran affiche alors une liste vide et l'explique. OpenLibrary expose les œuvres d'un auteur (`/authors/{id}/works.json`) et pourrait prendre le relais — à ne faire que si le cas se présente réellement en usage. + +--- + +# Retours d'usage du 2026-08-18 (2ᵉ série) + +Demandes et anomalies remontées après quelques jours d'usage réel. **Rien n'est acté** : +ce qui suit est la matière brute, classée par sujet, avec ce que le diagnostic a déjà établi. + +## Scan et saisie d'ISBN + +### Le scan caméra rate souvent sur ordinateur + +**La douchette USB est faite** (voir `CLAUDE.md`, « La douchette USB est le vrai remède ») : +champ focalisé à l'ouverture, validation sur `Entrée`. Reste à confirmer en usage réel qu'elle +suffit. + +Si elle ne suffit pas, pistes non traitées : choisir la caméra quand il y en a plusieurs, +demander une résolution plus haute, exposer un curseur de zoom/torche là où l'API le permet, +et laisser **déposer une photo** du code-barres à décoder (ZXing lit un fichier aussi bien +qu'une frame). + +### ISBN `9782846391009` : le scan n'aboutit pas + +**La donnée existe pourtant côté BnF** — vérifié le 2026-08-18 : + +``` +bib.isbn all "9782846391009" → 1 notice + dc:title = La thérapie émotivo-rationnelle / Albert Ellis et Robert A. Harper + dc:creator = Ellis, Albert (1913-2007). Auteur du texte + dc:creator = Harper, Robert A. (1915-2004). Auteur du texte +(la forme ISBN-10 « 2846391009 » ne renvoie rien : ici c'est bien l'ISBN-13 qui indexe) +``` + +L'échec est donc **au décodage de l'image**, pas au lookup. Rejoint le point ci-dessus. +⚠️ À reproduire en saisissant l'ISBN à la main avant de conclure : si la saisie manuelle échoue +aussi, le défaut est ailleurs et ce diagnostic tombe. + +## Auteurs et bibliographie + +### Homonymes : « Between two worlds », un autre Robert Harper + +Constaté le 2026-08-19 en vérifiant la bibliographie de Robert A. Harper : parmi les 7 œuvres +retenues figure *Between two worlds : a new introduction to geography* (1973, Houghton Mifflin), +qui est d'un **géographe homonyme**, pas du psychothérapeute. + +Le post-filtre d'auteur ne peut rien : `RapprochementAuteurs` compare des **noms**, et ces +deux-là portent le même. Départager demanderait les dates de vie (`dc:creator` les porte souvent : +« Harper, Robert A. (1915-2004) »), ce que le parser jette aujourd'hui au nettoyage ISBD. + +⚠️ Ne pas en faire une urgence : une œuvre en trop se voit et s'ignore, contrairement à une œuvre +manquante. Piste si le cas se répète : conserver les dates extraites de `dc:creator` et écarter +une notice dont les dates contredisent celles majoritairement observées pour l'auteur. + +### OpenLibrary en second rideau — la justification est retombée + +Le cas « Robert A. Harper » semblait l'imposer : il s'est révélé être un **délai dépassé**, pas un +trou de couverture (voir `CLAUDE.md`). La BnF connaît bien cet auteur. + +La piste reste valable pour les auteurs étrangers **non traduits**, avec ses deux difficultés +inchangées : résoudre un nom vers un identifiant OpenLibrary (`/search/authors.json`), étape sans +équivalent BnF et sensible aux homonymes ; et des œuvres remontées **en langue originale**, donc +non rapprochables du catalogue par `CleOeuvre` — l'écran afficherait une liste où presque rien ne +serait marqué « possédé ». À ne faire que si le cas se présente réellement. diff --git a/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.Designer.cs b/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.Designer.cs new file mode 100644 index 0000000..0044516 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.Designer.cs @@ -0,0 +1,304 @@ +// +using System; +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + [DbContext(typeof(MaBibliDbContext))] + [Migration("20260818213512_RangDesEnvies")] + partial class RangDesEnvies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.cs b/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.cs new file mode 100644 index 0000000..449d8a1 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818213512_RangDesEnvies.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + /// Ajoute le rang manuel de la liste d'envies (du plus désiré au moins désiré). + /// + /// + /// ⚠️ La colonne seule ne suffit pas : à 0 partout, l'ordre retomberait sur l'Id, + /// c'est-à-dire l'ordre d'ajout, et les listes existantes se réordonneraient sous les yeux + /// de leur propriétaire sans qu'il ait rien demandé. Le remplissage ci-dessous reconduit + /// exactement l'ordre affiché jusqu'ici — auteurs renseignés d'abord, puis par auteur, + /// puis par titre — de sorte que la mise à jour ne se voie pas. + /// + /// Le rang est personnel, comme toute la table : la numérotation repart de zéro pour + /// chaque Utilisateur, d'où la corrélation sur cette colonne dans la sous-requête. + /// + /// + public partial class RangDesEnvies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Rang", + table: "LivresSouhaites", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + // Row values (a, b) < (c, d) : SQLite les gère depuis la 3.15, et c'est ce qui + // permet d'exprimer « combien de lignes se classaient avant celle-ci » en une + // seule comparaison lexicographique. L'Id ferme le tri : sans lui, deux envies + // de même auteur et même titre recevraient le même rang. + migrationBuilder.Sql(""" + UPDATE LivresSouhaites SET Rang = ( + SELECT COUNT(*) + FROM LivresSouhaites AS autre + WHERE autre.Utilisateur = LivresSouhaites.Utilisateur + AND ( + CASE WHEN autre.AuteurNormalise = '' THEN 1 ELSE 0 END, + autre.AuteurNormalise, + autre.TitreNormalise, + autre.Id + ) < ( + CASE WHEN LivresSouhaites.AuteurNormalise = '' THEN 1 ELSE 0 END, + LivresSouhaites.AuteurNormalise, + LivresSouhaites.TitreNormalise, + LivresSouhaites.Id + ) + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Rang", + table: "LivresSouhaites"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 7ac7812..77ef9df 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -137,6 +137,9 @@ namespace MaBibli.Api.Data.Migrations b.Property("Note") .HasColumnType("TEXT"); + b.Property("Rang") + .HasColumnType("INTEGER"); + b.Property("Titre") .IsRequired() .HasColumnType("TEXT"); diff --git a/MaBibli.Api/Endpoints/IsbnEndpoints.cs b/MaBibli.Api/Endpoints/IsbnEndpoints.cs index d909468..9059288 100644 --- a/MaBibli.Api/Endpoints/IsbnEndpoints.cs +++ b/MaBibli.Api/Endpoints/IsbnEndpoints.cs @@ -32,6 +32,39 @@ public static class IsbnEndpoints .Produces() .Produces(StatusCodes.Status400BadRequest); + // Recherche par titre : on ne connaît pas toujours l'ISBN d'un livre qu'on souhaite — + // c'est même le cas normal, puisqu'on ne l'a pas en main. Hors du groupe /api/isbn, + // qui parle d'un code-barres, alors qu'ici on part de mots. + routes.MapGet("/api/notices", async ( + string? titre, + string? auteur, + IBnfClient bnf, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(titre)) + { + return Results.BadRequest(new { erreur = "Indiquez au moins un titre à chercher." }); + } + + var (candidats, avertissement) = await bnf.RechercherParTitreAsync(titre, auteur, ct); + + return Results.Ok(new ResultatLookupIsbn + { + IsbnDemande = string.Empty, + Candidats = candidats, + Avertissements = avertissement is null ? [] : [avertissement], + }); + }) + .WithTags("ISBN") + .WithName("RechercherNotices") + .WithSummary("Cherche des notices BnF par titre, éventuellement restreint à un auteur.") + .WithDescription( + "Les notices qui ne décrivent pas un livre (film, disque, jeu) sont écartées. " + + "L'auteur n'est PAS post-filtré : le choix revient à l'utilisateur, qui voit " + + "les auteurs de chaque notice.") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + return routes; } } diff --git a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs index 190aca7..cbf0a10 100644 --- a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs +++ b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs @@ -27,12 +27,35 @@ public static class SouhaitsEndpoints CancellationToken ct) => Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct))) .WithName("ListerSouhaits") - .WithSummary("La liste d'envies de l'appelant, groupée par auteur.") + .WithSummary("La liste d'envies de l'appelant, du plus désiré au moins désiré.") .WithDescription( "Personnelle : deux membres du foyer obtiennent deux listes différentes. " - + "Sans identité, la liste est vide.") + + "Sans identité, la liste est vide. L'ordre est celui que le propriétaire a " + + "choisi ; il n'est pas alphabétique.") .Produces>(); + groupe.MapPut("/ordre", async ( + int[] ids, + IServiceSouhaits service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var fait = await service.ReordonnerAsync( + ids, utilisateurs.Obtenir().Identifiant, ct); + + return fait ? Results.NoContent() : Results.NotFound(); + }) + .WithName("ReordonnerSouhaits") + .WithSummary("Fixe l'ordre de la liste d'envies de l'appelant.") + .WithDescription( + "Attend la liste ENTIÈRE des identifiants, dans l'ordre voulu — c'est ce dont a " + + "besoin le glisser-déposer, et les flèches monter/descendre s'y ramènent. " + + "Les identifiants inconnus ou appartenant à quelqu'un d'autre sont ignorés ; " + + "les envies absentes de la liste envoyée sont conservées, à la suite. " + + "404 si l'appelant n'a aucune envie.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + groupe.MapPost("/", async ( EnregistrementSouhait saisie, IServiceSouhaits service, diff --git a/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs index 575e4d5..862e365 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs @@ -88,6 +88,10 @@ public sealed class ServiceBibliographie( NoticesLues = resultat.NombreLu, NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre, Avertissement = avertissement, + + // Transmis tel quel : c'est l'écran qui décide quoi en dire, mais lui seul ne + // pourrait pas deviner qu'une liste vide vient d'une panne. + Etat = resultat.Etat, }; } diff --git a/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs b/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs index 6b41c0c..2dbadf4 100644 --- a/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs +++ b/MaBibli.Api/Services/Isbn/BnfBibliographieParser.cs @@ -1,4 +1,6 @@ using System.Xml.Linq; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; namespace MaBibli.Api.Services.Isbn; @@ -37,6 +39,13 @@ public sealed record ResultatBibliographie /// Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé. public int EcarteesAutreAuteur { get; init; } + + /// + /// Ce qu'il est advenu de l'interrogation. ⚠️ À ne pas confondre avec « aucune notice » : + /// une source muette rend elle aussi une liste vide, et il ne faut surtout pas les lire + /// de la même façon. + /// + public EtatSourceBibliographie Etat { get; init; } } /// @@ -171,6 +180,77 @@ public static class BnfBibliographieParser }; } + /// + /// Convertit une réponse SRU interrogée par titre en candidats à choisir. + /// + /// + /// Le filtre de type est indispensable ici aussi, et pour la même raison qu'en + /// bibliographie : mesuré le 2026-08-18, bib.title all "la bête humaine" croisé avec + /// Zola remonte 116 notices, dont les films de Jean Renoir (« image animée ») et une + /// plaquette de présentation. Une liste d'envies de livres n'a pas à proposer un DVD. + /// + /// ⚠️ En revanche aucun post-filtre sur l'auteur, contrairement à + /// : la contrainte de titre resserre déjà beaucoup, l'auteur est + /// facultatif dans cette recherche, et surtout l'utilisateur choisit dans la liste + /// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions + /// légitimes qu'il aurait su reconnaître. + /// + /// + /// L'ISBN est repris de la notice quand elle en porte un : c'est lui qui donne la + /// couverture, la BnF n'en fournissant aucune. + /// + /// + public static IReadOnlyList ParserRecherche(string xml) + { + var doc = XDocument.Parse(xml); + var candidats = new List(); + + foreach (var record in doc.Descendants(Srw + "record")) + { + var data = record.Element(Srw + "recordData"); + if (data is null || !EstUnLivre(data)) + { + continue; + } + + var auteurs = Tous(data, "creator") + .Select(NettoyageIsbd.Auteur) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a!) + .Distinct() + .ToList(); + + var titre = NettoyageIsbd.RetirerAuteurEnFin( + NettoyageIsbd.Titre(Premier(data, "title")), auteurs); + + if (titre is null) + { + continue; + } + + var isbn = IsbnUtils.Normaliser(Isbn(data)); + if (!IsbnUtils.EstValide(isbn)) + { + isbn = null; + } + + candidats.Add(new CandidatLivre + { + Titre = titre, + Auteurs = auteurs, + Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), + Annee = Premier(data, "date"), + Langue = Premier(data, "language"), + Source = SourceMetadonnees.Bnf, + IsbnInterroge = isbn, + CoverUrl = isbn is null ? null : IsbnUtils.UrlCouverture(isbn), + IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(), + }); + } + + return candidats; + } + /// /// La notice décrit-elle un livre ? /// diff --git a/MaBibli.Api/Services/Isbn/BnfClient.cs b/MaBibli.Api/Services/Isbn/BnfClient.cs index 7671b44..bf040a6 100644 --- a/MaBibli.Api/Services/Isbn/BnfClient.cs +++ b/MaBibli.Api/Services/Isbn/BnfClient.cs @@ -21,6 +21,28 @@ public interface IBnfClient /// Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default); + + /// + /// Interroge le SRU par ISSN pour nommer un périodique. + /// + /// + /// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le + /// préfixe 977 : rien n'est catalogué à partir de là. Une BnF injoignable renvoie + /// null et un avertissement — l'écran dira alors « un magazine, titre inconnu » + /// plutôt que de laisser croire à un échec de recherche de livre. + /// + Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default); + + /// + /// Interroge le SRU par titre, éventuellement restreint à un auteur. + /// + /// + /// Sert l'ajout à la liste d'envies quand on n'a pas le livre en main : on souhaite une + /// œuvre dont on connaît le titre, rarement l'ISBN. + /// + Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default); } /// @@ -70,6 +92,106 @@ public sealed class BnfClient(HttpClient http, ILogger logger) : IBnf } } + public async Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(issn)) + { + return (null, null); + } + + // bib.issn, vérifié le 2026-08-18 : l'ISSN doit porter son tiret (« 2466-6718 »), + // forme canonique sous laquelle la BnF l'indexe. + var url = "api/SRU" + + "?version=1.2" + + "&operation=searchRetrieve" + + $"&query={Uri.EscapeDataString($"bib.issn all \"{issn}\"")}" + + "&recordSchema=dublincore" + + "&maximumRecords=1"; + + try + { + using var reponse = await http.GetAsync(url, ct); + if (!reponse.IsSuccessStatusCode) + { + logger.LogWarning("BnF a répondu {Code} pour l'ISSN {Issn}", (int)reponse.StatusCode, issn); + return (null, $"La BnF a répondu {(int)reponse.StatusCode} pour l'ISSN {issn}."); + } + + var xml = await reponse.Content.ReadAsStringAsync(ct); + return (BnfSruParser.ParserPeriodique(xml, issn), null); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "BnF injoignable pour l'ISSN {Issn}", issn); + return (null, $"BnF injoignable ({ex.GetType().Name}) pour l'ISSN {issn}."); + } + catch (System.Xml.XmlException ex) + { + logger.LogWarning(ex, "Réponse BnF illisible pour l'ISSN {Issn}", issn); + return (null, $"Réponse BnF illisible pour l'ISSN {issn}."); + } + } + + /// Notices rendues par une recherche par titre : de quoi choisir sans faire défiler. + public const int NoticesRecherche = 20; + + public async Task<(IReadOnlyList Candidats, string? Avertissement)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(titre)) + { + return ([], null); + } + + // Les deux index se combinent en CQL par « and ». L'auteur est facultatif : sans lui la + // recherche porte sur le seul titre, ce qui suffit pour un titre un peu distinctif. + var cql = $"bib.title all \"{Echapper(titre)}\""; + if (!string.IsNullOrWhiteSpace(auteur)) + { + cql += $" and bib.author all \"{Echapper(auteur)}\""; + } + + var url = "api/SRU" + + "?version=1.2" + + "&operation=searchRetrieve" + + $"&query={Uri.EscapeDataString(cql)}" + + "&recordSchema=dublincore" + + $"&maximumRecords={NoticesRecherche}"; + + try + { + using var reponse = await http.GetAsync(url, ct); + if (!reponse.IsSuccessStatusCode) + { + logger.LogWarning("BnF a répondu {Code} pour le titre {Titre}", (int)reponse.StatusCode, titre); + return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour « {titre} »."); + } + + var xml = await reponse.Content.ReadAsStringAsync(ct); + return (BnfBibliographieParser.ParserRecherche(xml), null); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "BnF injoignable pour le titre {Titre}", titre); + return ([], $"BnF injoignable ({ex.GetType().Name}) pour « {titre} »."); + } + catch (System.Xml.XmlException ex) + { + logger.LogWarning(ex, "Réponse BnF illisible pour le titre {Titre}", titre); + return ([], $"Réponse BnF illisible pour « {titre} »."); + } + } + + /// + /// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL. + /// + /// + /// Un titre contenant un guillemet fermerait la chaîne et rendrait la requête invalide — + /// « L'homme qui plantait des arbres » n'en a pas, mais rien ne l'interdit à un utilisateur. + /// + private static string Echapper(string terme) => terme.Trim().Replace("\"", " "); + /// /// Notices lues par page, et nombre de pages : au plus 200 notices. /// @@ -109,23 +231,62 @@ public sealed class BnfClient(HttpClient http, ILogger logger) : IBnf var lues = pages.Where(p => p is not null).Select(p => p!).ToList(); if (lues.Count == 0) { - return (new ResultatBibliographie(), $"La BnF n'a pas répondu pour « {auteur} »."); + return Echec(EtatSourceBibliographie.Injoignable, auteur); } return (Fusionner(lues), null); } + // ⚠️ Le délai dépassé se distingue de l'injoignable, et ce n'est pas du détail : c'est + // le cas remonté en usage, et le seul qui vaille la peine d'être retenté tout de suite. + // HttpClient l'annonce par une TaskCanceledException dont l'exception interne est un + // TimeoutException — sans ce test, il se confondrait avec une annulation de l'appelant. + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogWarning(ex, "Délai dépassé par la BnF pour l'auteur {Auteur}", auteur); + return Echec(EtatSourceBibliographie.DelaiDepasse, auteur); + } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur); - return (new ResultatBibliographie(), $"BnF injoignable ({ex.GetType().Name}) pour « {auteur} »."); + return Echec(EtatSourceBibliographie.Injoignable, auteur); } catch (System.Xml.XmlException ex) { logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur); - return (new ResultatBibliographie(), $"Réponse BnF illisible pour « {auteur} »."); + return Echec(EtatSourceBibliographie.ReponseIllisible, auteur); } } + /// + /// Motif lisible d'une bibliographie que la BnF n'a pas pu fournir. + /// + /// + /// ⚠️ Public parce que ce texte atteint l'utilisateur : c'est un contrat d'interface, + /// pas un détail d'implémentation, et il est verrouillé par des tests. Il ne contient aucun + /// nom de classe .NET — « BnF injoignable (TaskCanceledException) » est ce qui s'affichait + /// avant, et « TaskCanceledException » ne veut rien dire pour qui range ses livres. + /// + /// Les trois motifs se distinguent parce qu'ils n'appellent pas la même chose : un délai + /// dépassé se retente tout de suite, une réponse illisible non. + /// + /// + public static string MotifDeSourceMuette(EtatSourceBibliographie etat, string auteur) => + etat switch + { + EtatSourceBibliographie.DelaiDepasse => + $"La BnF n'a pas répondu à temps pour « {auteur} ». Son catalogue est parfois lent ;" + + " réessayer suffit le plus souvent.", + EtatSourceBibliographie.ReponseIllisible => + $"La BnF a répondu quelque chose d'inexploitable pour « {auteur} ».", + _ => + $"La BnF n'a pas pu être jointe pour « {auteur} ». Vérifiez votre connexion," + + " ou réessayez plus tard.", + }; + + /// Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif. + private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) => + (new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, auteur)); + private async Task LirePageAsync( string auteur, int premiereNotice, CancellationToken ct) { diff --git a/MaBibli.Api/Services/Isbn/BnfSruParser.cs b/MaBibli.Api/Services/Isbn/BnfSruParser.cs index 166aa1d..c8ade46 100644 --- a/MaBibli.Api/Services/Isbn/BnfSruParser.cs +++ b/MaBibli.Api/Services/Isbn/BnfSruParser.cs @@ -79,6 +79,51 @@ public static class BnfSruParser return candidats; } + /// + /// Lit la première notice d'une réponse SRU interrogée par bib.issn. + /// + /// + /// On ne renvoie qu'une notice, et sans laisser le choix : le but n'est pas de cataloguer + /// la revue — le modèle de données ne le permet pas — mais de nommer ce que + /// l'utilisateur vient de scanner. Un ISSN désigne un et un seul titre de périodique, donc + /// les notices suivantes ne diraient rien de plus. + /// + /// Une notice de revue porte souvent deux dc:title : le titre clé qualifié + /// (« Médor (Bruxelles) ») puis le titre développé avec sa mention de responsabilité. + /// C'est le premier qu'on retient — le second est l'accroche commerciale, pas le nom. + /// + /// + /// null si la réponse ne contient aucune notice exploitable. + public static PeriodiqueDetecte? ParserPeriodique(string xml, string issn) + { + var doc = XDocument.Parse(xml); + + foreach (var record in doc.Descendants(Srw + "record")) + { + var data = record.Element(Srw + "recordData"); + if (data is null) + { + continue; + } + + var titre = NettoyageIsbd.TitrePeriodique(Premier(data, "title")); + if (titre is null) + { + continue; + } + + return new PeriodiqueDetecte + { + Issn = issn, + Titre = titre, + Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), + Annee = Premier(data, "date"), + }; + } + + return null; + } + private static string? Premier(XElement recordData, string nomLocal) => Tous(recordData, nomLocal).FirstOrDefault(); diff --git a/MaBibli.Api/Services/Isbn/IsbnLookupService.cs b/MaBibli.Api/Services/Isbn/IsbnLookupService.cs index a236753..51cd5c0 100644 --- a/MaBibli.Api/Services/Isbn/IsbnLookupService.cs +++ b/MaBibli.Api/Services/Isbn/IsbnLookupService.cs @@ -29,6 +29,17 @@ public sealed class IsbnLookupService( public async Task RechercherAsync(string isbnBrut, CancellationToken ct = default) { var isbn = IsbnUtils.Normaliser(isbnBrut); + + // Un code en 977 est un PÉRIODIQUE : il porte un ISSN, donc un titre de revue, et + // aucune recherche bib.isbn ne peut aboutir. Sans cette interception la cascade + // s'exécutait en entier pour répondre « aucun résultat » — ce qui envoie l'utilisateur + // chercher un défaut là où il n'y en a pas. On sort AVANT la validation ISBN parce + // qu'un code de périodique est un EAN-13 parfaitement valide : seul son préfixe le trahit. + if (CodePeriodique.EstCodePeriodique(isbn)) + { + return await DecrirePeriodiqueAsync(isbn!, ct); + } + if (!IsbnUtils.EstValide(isbn)) { return null; @@ -76,6 +87,37 @@ public sealed class IsbnLookupService( }; } + /// + /// Nomme le périodique scanné, sans rien chercher du côté des livres. + /// + /// + /// Le résultat ne porte jamais de candidat : le modèle de données ne gère pas les + /// périodiques (ni numéro, ni date de parution), et cataloguer douze numéros d'une même + /// revue créerait douze fiches identiques. L'objectif est uniquement de dire à l'utilisateur + /// ce qu'il tient en main. Une BnF muette ou injoignable n'est pas un échec : on connaît + /// l'ISSN de toute façon, puisqu'il se déduit du code-barres. + /// + private async Task DecrirePeriodiqueAsync(string ean, CancellationToken ct) + { + var issn = CodePeriodique.IssnDepuis(ean)!; + var (periodique, avertissement) = await bnf.RechercherPeriodiqueAsync(issn, ct); + + var avertissements = new List(); + Ajouter(avertissements, avertissement); + + logger.LogInformation( + "Code {Ean} : périodique ISSN {Issn} ({Titre})", + ean, issn, periodique?.Titre ?? "titre inconnu"); + + return new ResultatLookupIsbn + { + IsbnDemande = ean, + Candidats = [], + Avertissements = avertissements, + Periodique = periodique ?? new PeriodiqueDetecte { Issn = issn }, + }; + } + private static void Ajouter(List avertissements, string? message) { if (!string.IsNullOrEmpty(message)) diff --git a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs index 563b85e..b6df0d3 100644 --- a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs +++ b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs @@ -166,6 +166,20 @@ public static partial class NettoyageIsbd return s.Length == 0 ? null : s; } + /// + /// Titre d'un périodique : couper la mention de responsabilité puis retirer le + /// qualificatif entre parenthèses. + /// + /// + /// Mesuré sur l'ISSN 2466-6718 le 2026-08-18 : la BnF donne + /// « Médor (Bruxelles) ». Le parenthésé n'est pas une ville d'édition mais le + /// qualificatif de désambiguïsation propre aux notices de revues — deux périodiques + /// peuvent porter le même titre. Il se retire comme la ville d'un éditeur, et pour la même + /// raison : ce qu'on montre à l'utilisateur doit ressembler à ce qui est écrit sur la + /// couverture du magazine qu'il tient en main. + /// + public static string? TitrePeriodique(string? brut) => Editeur(Titre(brut)); + /// /// Retire les virgules et espaces de fin, et le point final sauf s'il appartient /// à une initiale (« Cormen, Thomas H. » doit garder son point). diff --git a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs index 1df7979..923b9f0 100644 --- a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; namespace MaBibli.Api.Services.Souhaits; @@ -136,7 +137,7 @@ public static class ExportSouhaits souhait.Auteur, souhait.Editeur, souhait.Annee, - souhait.Isbn, + FormatageIsbn.Afficher(souhait.Isbn), souhait.Note, souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } @@ -144,8 +145,12 @@ public static class ExportSouhaits return sortie.ToString(); } + /// + /// ISBN découpé pour la ligne texte : c'est un numéro qu'on épelle à un libraire, + /// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres. + /// private static string? Isbn(string? isbn) => - string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {isbn}"; + string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {FormatageIsbn.Afficher(isbn)}"; private static void Ligne(StringBuilder sortie, params string?[] champs) { diff --git a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs index b9b284a..e750df4 100644 --- a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs @@ -23,6 +23,17 @@ public interface IServiceSouhaits EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default); + + /// + /// Réordonne la liste de l'appelant selon la suite d'identifiants fournie. + /// + /// + /// Prend la liste entière plutôt qu'un déplacement unitaire : c'est ce dont a besoin + /// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération + /// est idempotente — réenvoyer le même ordre ne change rien. + /// + Task ReordonnerAsync( + IReadOnlyList idsOrdonnes, string? utilisateur, CancellationToken ct = default); } /// @@ -49,12 +60,16 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits return []; } + // L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a + // remplacé un tri par auteur puis titre : classer une liste d'envies par ordre + // alphabétique répondait à une question que personne ne se pose. + // L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister, + // mais un tri instable ferait sautiller la liste entre deux affichages. var souhaits = await db.LivresSouhaites .AsNoTracking() .Where(s => s.Utilisateur == utilisateur) - .OrderBy(s => s.AuteurNormalise == string.Empty) - .ThenBy(s => s.AuteurNormalise) - .ThenBy(s => s.TitreNormalise) + .OrderBy(s => s.Rang) + .ThenBy(s => s.Id) .ToListAsync(ct); return souhaits.Select(Projeter).ToList(); @@ -118,6 +133,16 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies."); } + // En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable + // que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur + // à chaque ajout. + var dernierRang = await db.LivresSouhaites + .Where(s => s.Utilisateur == utilisateur) + .Select(s => (int?)s.Rang) + .MaxAsync(ct); + + souhait.Rang = (dernierRang ?? -1) + 1; + db.LivresSouhaites.Add(souhait); await db.SaveChangesAsync(ct); @@ -148,6 +173,53 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits return true; } + public async Task ReordonnerAsync( + IReadOnlyList idsOrdonnes, string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return false; + } + + // ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client + // envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas + // pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression), + // et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil + // n'y figure pas, et la perdre serait pire que de mal la classer. + var siennes = await db.LivresSouhaites + .Where(s => s.Utilisateur == utilisateur) + .ToListAsync(ct); + + if (siennes.Count == 0) + { + return false; + } + + var parId = siennes.ToDictionary(s => s.Id); + var rang = 0; + + foreach (var id in idsOrdonnes.Distinct()) + { + if (parId.Remove(id, out var souhait)) + { + souhait.Rang = rang++; + } + + // Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est + // simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs. + } + + // Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent, + // plutôt que d'être renuméroté au hasard. + foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id)) + { + oublie.Rang = rang++; + } + + await db.SaveChangesAsync(ct); + return true; + } + private static string? Vide(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index f740d01..353a56d 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -43,6 +43,26 @@ @Body +@* + Navigation permanente, en bas de l'écran : quatre onglets, toujours au même endroit. + Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq + boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne + figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS. + + ⚠️ Le pouce atteint le bas de l'écran, pas le haut : c'est ce qui justifie le bas plutôt + qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus + (variable --mb-onglets-hauteur, partagée avec .actions-flottantes). + + Les onglets restent actifs hors-ligne : les quatre écrans se consultent depuis leurs + instantanés. Ce sont les écritures qui se désactivent, pas la navigation. +*@ + + @code { private UtilisateurCourant? _utilisateur; private bool _etaitEnLigne = true; diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index be2e48e..77f201d 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -58,6 +58,51 @@ width: 100%; max-width: 46rem; margin: 0 auto; - /* Marge basse généreuse : les actions flottantes ne doivent pas masquer le dernier livre. */ - padding: 1rem 1rem 6rem; + /* Marge basse généreuse : ni les onglets ni les actions flottantes ne doivent masquer + le dernier livre de la liste. */ + padding: 1rem 1rem 9rem; +} + +/* --- Onglets de navigation --- */ + +.onglets { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 20; + display: flex; + background: #fff; + border-top: 1px solid var(--mb-bord); + padding-bottom: env(safe-area-inset-bottom); + + /* Hauteur figée et partagée : .actions-flottantes s'en sert pour se poser juste au-dessus. */ + height: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom)); +} + +.onglet { + flex: 1 1 0; + padding: 0.4rem 0.25rem; + line-height: 1.4; + text-align: center; + text-decoration: none; + font-size: 0.85rem; + color: var(--mb-texte-doux); + + /* Le trait actif est posé en haut de l'onglet, du côté du contenu qu'il désigne. */ + border-top: 3px solid transparent; + + /* Le libellé ne doit jamais passer sur deux lignes : quatre onglets tiennent sur 320 px. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* NavLink pose « active » tout seul ; « Catalogue » utilise NavLinkMatch.All, sans quoi + il resterait allumé sur toutes les pages — son href est la racine. */ +.onglet.active { + color: var(--mb-accent); + font-weight: 600; + border-top-color: var(--mb-accent); + background: var(--mb-fond-doux); } diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index bd85885..c3bf0f9 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -12,6 +12,7 @@ {

Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre. + Une douchette USB fonctionne telle quelle : le champ est déjà actif.

@* @@ -27,8 +28,17 @@

} + @* + Le champ est focalisé à l'ouverture et valide sur Entrée : c'est tout ce qu'exige une + douchette USB, qui se présente au système comme un CLAVIER — elle « tape » les chiffres + puis Entrée. Aucune permission, aucun HTTPS, aucun décodage : c'est le contournement le + plus rentable du scan caméra, qui rate souvent sur la webcam d'un PC. + La recherche n'est délibérément PAS déclenchée à chaque frappe : une douchette tape trop + vite, et chaque caractère partirait en requête. + *@
@@ -51,6 +61,18 @@ } +@* + Le scan enchaîne la recherche tout seul (décision actée). Encore faut-il que ça SE VOIE : + sans cet écran, on retombait sur la saisie avec un bouton « Chercher » intact, et l'on + croyait que le scan n'avait rien déclenché. +*@ +@if (_etape == Etape.Recherche) +{ +

+ Recherche de @FormatageIsbn.Afficher(_isbn) à la BnF, puis chez OpenLibrary… +

+} + @if (_etape == Etape.Scan) { @@ -61,6 +83,11 @@ } +@if (_messagePeriodique is not null) +{ +

@_messagePeriodique

+} + @foreach (var avertissement in _avertissements) {

@avertissement

@@ -98,7 +125,7 @@ @candidat.Source @if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge)) { - trouvé via @candidat.IsbnInterroge + trouvé via @FormatageIsbn.Afficher(candidat.IsbnInterroge) }

@@ -132,10 +159,17 @@ } @code { - private enum Etape { Saisie, Scan, Choix, Formulaire } + private enum Etape { Saisie, Scan, Recherche, Choix, Formulaire } private Etape _etape = Etape.Saisie; private string _isbn = string.Empty; + private ElementReference _champIsbn; + + // Le focus ne se redonne qu'une fois par entrée dans l'étape de saisie : le reprendre à + // chaque rendu arracherait le curseur à l'utilisateur en pleine frappe. + private bool _focusAFaire = true; + + private string? _messagePeriodique; private bool _chargement; private bool _enregistrement; private string? _erreur; @@ -146,6 +180,33 @@ protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + /// + /// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche. + /// + /// + /// C'est ce qui rend une douchette USB utilisable sans rien d'autre : elle tape dans + /// le champ actif, quel qu'il soit. Sans focus automatique, l'utilisateur devrait cliquer + /// dans le champ avant chaque livre — et le gain sur le scan caméra disparaîtrait. + /// + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_etape != Etape.Saisie || !_focusAFaire) + { + return; + } + + _focusAFaire = false; + + try + { + await _champIsbn.FocusAsync(); + } + catch (InvalidOperationException) + { + // L'élément a disparu entre le rendu et l'appel (navigation rapide) : sans intérêt. + } + } + private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); public void Dispose() => Reseau.Change -= SurChangementReseau; @@ -156,16 +217,17 @@ private void OuvrirScanner() { _erreur = null; + _messagePeriodique = null; _avertissements = []; _etape = Etape.Scan; } // Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur - // ne retape jamais ce qui vient d'être scanné. + // ne retape jamais ce qui vient d'être scanné, et n'a aucun bouton à confirmer. + // Le code lu reste dans le champ : si le décodage était mauvais, il se corrige et se relance. private async Task SurCodeDetecteAsync(string isbn) { _isbn = isbn; - _etape = Etape.Saisie; await ChercherAsync(); } @@ -181,7 +243,9 @@ { _chargement = true; _erreur = null; + _messagePeriodique = null; _avertissements = []; + _etape = Etape.Recherche; try { @@ -190,12 +254,21 @@ if (resultat is null) { _erreur = $"« {_isbn} » n'est pas un ISBN valide."; + RevenirALaSaisie(); return; } _avertissements = resultat.Avertissements; _candidats = resultat.Candidats; + // Un code de périodique (977) n'a JAMAIS de candidat : ce n'est pas un échec de + // recherche, c'est un code qui ne décrit pas un livre. Le dire avant tout le reste. + if (resultat.Periodique is { } revue) + { + DecrireLeMagazine(revue); + return; + } + if (_candidats.Count == 0) { // Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie. @@ -213,9 +286,12 @@ _etape = Etape.Choix; } - catch (Exception ex) + catch (Exception) { - _erreur = $"La recherche a échoué : {ex.Message}"; + _erreur = Reseau.EnLigne + ? "La recherche a échoué. Réessayez, ou saisissez la fiche à la main." + : "La recherche par ISBN interroge la BnF et OpenLibrary : indisponible hors ligne."; + RevenirALaSaisie(); } finally { @@ -223,6 +299,43 @@ } } + /// + /// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle. + /// + /// + /// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est + /// pré-rempli, mais pas l'ISBN — le code-barres d'une revue n'en est pas un, et le + /// ranger dans Livre.Isbn ferait échouer tout lookup ultérieur sur cette fiche. + /// + private void DecrireLeMagazine(PeriodiqueDetecte revue) + { + var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; + + _messagePeriodique = + $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : " + + "il commence par 977, réservé aux publications en série. " + + "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, " + + "sans ISBN."; + + _saisie = new EnregistrementLivre + { + Titre = revue.Titre ?? string.Empty, + Editeur = revue.Editeur, + Format = Format.Physique, + Statut = Statut.ALire, + }; + + _erreur = null; + _etape = Etape.Formulaire; + } + + /// Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette. + private void RevenirALaSaisie() + { + _etape = Etape.Saisie; + _focusAFaire = true; + } + private void Choisir(CandidatLivre candidat) { _saisie = new EnregistrementLivre @@ -242,10 +355,11 @@ private void Recommencer() { - _etape = Etape.Saisie; _erreur = null; + _messagePeriodique = null; _erreurFormulaire = null; _candidats = []; + RevenirALaSaisie(); } private async Task EnregistrerAsync() diff --git a/MaBibli.Client/Pages/AjoutSouhait.razor b/MaBibli.Client/Pages/AjoutSouhait.razor new file mode 100644 index 0000000..e3264d2 --- /dev/null +++ b/MaBibli.Client/Pages/AjoutSouhait.razor @@ -0,0 +1,336 @@ +@page "/souhaits/ajout" +@inject ServiceLivresApi Api +@inject EtatReseau Reseau +@inject NavigationManager Navigation +@implements IDisposable + +@* + Écran séparé, comme la fiche livre l'est de son édition : la page « Envies » ne montre plus + que la liste, et l'ajout a la place de proposer une vraie recherche. + + Trois façons d'alimenter la liste, dans l'ordre où le besoin se présente : + — par titre/auteur, le cas NORMAL : on souhaite un livre qu'on n'a pas en main ; + — par ISBN, quand on l'a en main (en librairie, chez quelqu'un) ; + — à la main, quand aucune source ne connaît le livre. +*@ + +MaBibli — ajouter une envie + +

Ajouter une envie

+ +@if (!Reseau.EnLigne) +{ +

+ Hors ligne : la recherche interroge la BnF et OpenLibrary, et l'enregistrement passe par + le serveur. Votre liste reste consultable. +

+} + +
+ + + +
+ +@if (_mode == Mode.Titre) +{ +
+ + +
+ +
+ + +
+ +
+ +
+} + +@if (_mode == Mode.Isbn) +{ +
+ + +

Le livre est en main : le code-barres suffit.

+
+ +
+ +
+} + +@if (_erreur is not null) +{ + +} + +@foreach (var avertissement in _avertissements) +{ +

@avertissement

+} + +@if (_candidats.Count > 0) +{ + @* On ne choisit jamais à la place de l'utilisateur : ce sont l'éditeur, l'année et + l'auteur qui départagent des notices au titre identique. *@ +

+ @_candidats.Count notice@(_candidats.Count > 1 ? "s" : "") trouvée@(_candidats.Count > 1 ? "s" : ""). + Choisissez celle qui correspond, ou complétez à la main. +

+ +
    + @foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i))) + { +
  • + + +
    +

    @candidat.Titre

    + @if (!string.IsNullOrWhiteSpace(candidat.Auteur)) + { +

    @candidat.Auteur

    + } + +

    + @(candidat.Editeur ?? "éditeur inconnu") + @(candidat.Annee ?? "année inconnue") +

    + + @if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge)) + { +

    + ISBN @FormatageIsbn.Afficher(candidat.IsbnInterroge) +

    + } + + +
    +
  • + } +
+} + +@if (_mode == Mode.Manuel) +{ + @* Le recours quand aucune source ne connaît le livre — un ouvrage étranger, un titre à + paraître, un livre d'occasion sans ISBN. Il ne disparaît jamais derrière la recherche. *@ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+} + +
+ +@code { + private enum Mode { Titre, Isbn, Manuel } + + private Mode _mode = Mode.Titre; + private ElementReference _premierChamp; + private bool _focusAFaire = true; + + private string _titreCherche = string.Empty; + private string _auteurCherche = string.Empty; + private string _isbn = string.Empty; + + private EnregistrementSouhait _saisie = new(); + private IReadOnlyList _candidats = []; + private IReadOnlyList _avertissements = []; + private bool _recherche; + private bool _ajout; + private string? _erreur; + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + /// + /// Focalise le premier champ à l'ouverture et à chaque changement de mode. + /// + /// + /// Même raison qu'à l'écran d'ajout par ISBN : on arrive ici pour taper, et le mode « Par + /// ISBN » doit rester utilisable à la douchette. Le drapeau évite de reprendre le focus à + /// chaque rendu, ce qui arracherait le curseur en pleine frappe. + /// + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (!_focusAFaire) + { + return; + } + + _focusAFaire = false; + + try + { + await _premierChamp.FocusAsync(); + } + catch (InvalidOperationException) + { + } + } + + private void Basculer(Mode mode) + { + _mode = mode; + _focusAFaire = true; + _erreur = null; + _candidats = []; + _avertissements = []; + } + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key != "Enter") + { + return; + } + + if (_mode == Mode.Titre && !string.IsNullOrWhiteSpace(_titreCherche)) + { + await ChercherParTitreAsync(); + } + else if (_mode == Mode.Isbn && !string.IsNullOrWhiteSpace(_isbn)) + { + await ChercherParIsbnAsync(); + } + } + + private async Task ChercherParTitreAsync() => + await ChercherAsync(() => Api.ChercherNoticesAsync(_titreCherche, _auteurCherche)); + + private async Task ChercherParIsbnAsync() => + await ChercherAsync(() => Api.ChercherIsbnAsync(_isbn.Trim())); + + private async Task ChercherAsync(Func> appel) + { + _recherche = true; + _erreur = null; + _candidats = []; + _avertissements = []; + + try + { + var resultat = await appel(); + + if (resultat is null) + { + _erreur = Reseau.EnLigne + ? "La recherche n'a rien pu ramener. Réessayez, ou complétez à la main." + : "La recherche interroge la BnF : indisponible hors ligne."; + return; + } + + _avertissements = resultat.Avertissements; + + // Un code de périodique n'est pas un livre : on le dit ici aussi, plutôt que de + // laisser un « aucun résultat » qui enverrait chercher un défaut inexistant. + if (resultat.Periodique is { } revue) + { + var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; + _erreur = $"Ce code désigne {nom} (ISSN {revue.Issn}), pas un livre."; + return; + } + + _candidats = resultat.Candidats; + + if (_candidats.Count == 0) + { + _erreur = "Aucune notice ne correspond. Vous pouvez l'ajouter à la main."; + } + } + finally + { + _recherche = false; + } + } + + /// + /// Enregistre le candidat choisi, couverture comprise. + /// + /// + /// C'est ce qui remplit enfin LivreSouhaite.CoverUrl, jusqu'ici toujours vide : la + /// couverture vient d'OpenLibrary dès qu'un ISBN est connu. Sans ISBN il n'y en a pas, et + /// l'on n'en invente pas — la vignette retombe sur son substitut à initiale. + /// + private Task AjouterCandidatAsync(CandidatLivre candidat) => + AjouterAsync(new EnregistrementSouhait + { + Titre = candidat.Titre, + Auteur = candidat.Auteur, + Editeur = candidat.Editeur, + Annee = candidat.Annee, + Isbn = candidat.IsbnInterroge, + CoverUrl = candidat.CoverUrl, + }); + + private async Task AjouterAsync(EnregistrementSouhait saisie) + { + _ajout = true; + _erreur = null; + + var resultat = await Api.AjouterSouhaitAsync(saisie); + + _ajout = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + // Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe. + Navigation.NavigateTo("souhaits"); + } +} diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index 4fa749a..8bfec29 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -84,11 +84,6 @@ else } - - @code { private IReadOnlyList? _auteurs; private IReadOnlyList? _suggestions; diff --git a/MaBibli.Client/Pages/Bibliographie.razor b/MaBibli.Client/Pages/Bibliographie.razor index 571e5a7..6f82d41 100644 --- a/MaBibli.Client/Pages/Bibliographie.razor +++ b/MaBibli.Client/Pages/Bibliographie.razor @@ -1,5 +1,6 @@ @page "/auteurs/{AuteurId:int}/bibliographie" @inject ServiceLivresApi Api +@inject EtatReseau Reseau MaBibli — bibliographie @@ -12,23 +13,72 @@ else if (_erreur is not null) {

Bibliographie

+ + @* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait + recharger la page entière pour retenter. *@ + @if (_reessayable) + { +
+ +
+ } } else if (_bibliographie is { } biblio) {

@biblio.Auteur.Nom

- @if (biblio.Avertissement is not null) - { - - } + @* + ⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc. - @if (biblio.Oeuvres.Count == 0) + Avant correction, une BnF muette produisait une liste vide, que l'écran commentait + d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous + l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas + pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la + question au lieu de la laisser ouverte. + + La règle : on n'explique une absence de résultats QUE si la source a répondu. + *@ + @if (biblio.SourceMuette) { + + +

+ La bibliographie n'a donc pas pu être établie : cet écran ne dit + rien de ce que cet auteur a écrit. +

+ +
+ +
+ } + else if (biblio.Oeuvres.Count == 0) + { + @* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut + l'expliquer. Le cas est fréquent et n'a rien d'anormal. *@

La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs - étrangers : le dépôt légal français ne couvre que l'édition francophone. + étrangers : le dépôt légal français ne couvre que l'édition francophone, et un + auteur traduit n'y a souvent pas de notice à son nom.

} + else if (biblio.Avertissement is not null) + { + @* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut + quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@ + + } else { @* Ces compteurs disent à l'utilisateur CE QU'IL REGARDE. Sur un auteur très réédité, @@ -150,14 +200,14 @@ else if (_bibliographie is { } biblio) } } - +} @code { [Parameter] @@ -166,6 +216,11 @@ else if (_bibliographie is { } biblio) private BibliographieDto? _bibliographie; private bool _seulementManquants; private bool _enCours; + private bool _chargement; + + /// Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens. + private bool _reessayable; + private string? _erreur; private string? _message; @@ -179,6 +234,8 @@ else if (_bibliographie is { } biblio) private async Task ChargerAsync() { _erreur = null; + _reessayable = false; + _chargement = true; try { @@ -186,15 +243,32 @@ else if (_bibliographie is { } biblio) if (_bibliographie is null) { + // Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera + // pas apparaître. _erreur = "Cet auteur n'existe pas dans votre bibliothèque."; return; } _possedees = _bibliographie.Oeuvres.Count(o => o.Possede); } - catch (Exception ex) + catch (Exception) { - _erreur = $"Impossible d'obtenir la bibliographie : {ex.Message}"; + // ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found » + // du service worker que l'utilisateur n'a aucun moyen d'interpréter. La + // bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien + // à réparer, seulement à dire. + // + // Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ; + // une BnF muette, elle, revient en 200 avec un état, et se traite plus haut. + _erreur = Reseau.EnLigne + ? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre." + : "La bibliographie vient de la BnF : indisponible hors ligne."; + + _reessayable = true; + } + finally + { + _chargement = false; } } diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index a20f22a..50270f6 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -5,7 +5,21 @@ MaBibli — catalogue -

Catalogue

+@* + Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments + occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change + rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif + et replié serait invisible, et l'on conclurait que des livres ont disparu. +*@ +
+

Catalogue

+ + +
@if (_auteur is not null) { @@ -21,29 +35,44 @@ value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" /> -
-
- - @foreach (var format in Enum.GetValues()) +@if (_filtresOuverts) +{ +
+ @* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien + et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les + ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@ + @if (PlusieursFormats) { - +
+ + @foreach (var format in Enum.GetValues()) + { + + } +
} -
- @* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi - ne remonte sous aucune valeur, il reste visible sous « Tous ». *@ -
- - @foreach (var statut in Enum.GetValues()) - { - - } + @* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut + pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran + l'assume au lieu de laisser croire à une vue commune. *@ +
+ + @foreach (var statut in Enum.GetValues()) + { + + } +
+ +

+ Le statut de lecture est le vôtre : filtrer dessus montre votre + lecture, pas celle du foyer. +

-
+} @if (_erreur is not null) { @@ -122,25 +151,27 @@ else if (_livres is not null) } -@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui - mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche, - filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@ +@* + Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans + les onglets, en bas. + + « Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis + « Ajouter », qui la propose dès qu'un ISBN ne donne rien. + + ⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable + d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le + désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement. +*@ @code { @@ -155,8 +186,33 @@ else if (_livres is not null) private Format? _format; private Statut? _statut; private bool _chargement; + private bool _filtresOuverts; private string? _erreur; + /// + /// Formats réellement présents dans le fonds, ou null tant qu'on ne le sait pas. + /// + /// + /// ⚠️ Ne se calcule que sur un chargement sans aucun critère. Le déduire d'une liste + /// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche + /// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute + /// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre + /// inutile est bénin, en escamoter un qui sert ne l'est pas. + /// + private IReadOnlySet? _formatsDuFonds; + + private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1; + + /// + /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». + /// + /// + /// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient + /// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce + /// qui serait invisible une fois le panneau refermé. + /// + private int FiltresActifs => (_format is null ? 0 : 1) + (_statut is null ? 0 : 1); + // Une frappe au clavier annule la recherche précédente encore en vol. private CancellationTokenSource _cts = new(); @@ -205,6 +261,13 @@ else if (_livres is not null) return ChargerAsync(); } + /// Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte. + private static bool EstSansCritere(CritereLivres criteres) => + string.IsNullOrWhiteSpace(criteres.Recherche) + && criteres.Format is null + && criteres.Statut is null + && criteres.AuteurId is null; + private async Task ChargerAsync() { _cts.Cancel(); @@ -226,14 +289,21 @@ else if (_livres is not null) }; _livres = await Api.ListerAsync(criteres, jeton); + + if (EstSansCritere(criteres)) + { + _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); + } } catch (OperationCanceledException) { return; // Une recherche plus récente a pris le relais. } - catch (Exception ex) + catch (Exception) { - _erreur = $"Impossible de charger le catalogue : {ex.Message}"; + _erreur = Reseau.EnLigne + ? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant." + : "Aucune donnée n'a encore pu être enregistrée sur cet appareil."; } finally { diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index 373c471..f80680d 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -115,9 +115,10 @@ else
Éditeur
@_livre.Editeur
} + @* Tirets à l'affichage seulement : la valeur stockée reste nue. *@ @if (!string.IsNullOrWhiteSpace(_livre.Isbn)) { -
ISBN
@_livre.Isbn
+
ISBN
@FormatageIsbn.Afficher(_livre.Isbn)
} @* Comme dans la liste : le format n'apparaît que pour un ebook, le physique @@ -166,7 +167,6 @@ else } - Catalogue } diff --git a/MaBibli.Client/Pages/Prets.razor b/MaBibli.Client/Pages/Prets.razor index 7f56552..8c72444 100644 --- a/MaBibli.Client/Pages/Prets.razor +++ b/MaBibli.Client/Pages/Prets.razor @@ -73,10 +73,6 @@ else } - - @code { private IReadOnlyList? _prets; private string? _erreur; diff --git a/MaBibli.Client/Pages/Souhaits.razor b/MaBibli.Client/Pages/Souhaits.razor index 238c1e4..69a7d5c 100644 --- a/MaBibli.Client/Pages/Souhaits.razor +++ b/MaBibli.Client/Pages/Souhaits.razor @@ -1,5 +1,7 @@ @page "/souhaits" @inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable MaBibli — liste d'envies @@ -24,56 +26,28 @@ } else { -
-

Ajouter une envie

- - @* Deux façons d'alimenter la liste : l'ISBN quand on a le livre en main (en librairie, - chez quelqu'un), la saisie libre quand on n'a qu'un titre. La bibliographie d'un - auteur, elle, alimente la liste depuis l'écran des auteurs. *@ -
- -
- - -
-

Facultatif : les champs ci-dessous restent modifiables.

-
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
- @if (_souhaits.Count == 0) { + @* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais + synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit + donc les deux plutôt que d'affirmer la première — même règle que pour une fiche + absente de l'instantané. *@

- Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un - auteur depuis la liste des auteurs. + @if (Reseau.EnLigne) + { + + Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la + bibliographie d'un auteur depuis la liste des auteurs. + + } + else + { + + Aucune envie dans les données enregistrées sur cet appareil. Votre liste est + peut-être vide, ou simplement pas encore synchronisée : le retour du réseau + tranchera. + + }

} else @@ -82,19 +56,85 @@ else @* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on part en librairie, elle ne doit pas être au bout d'un défilement. *@ + @* + ⚠️ L'export est produit PAR LE SERVEUR : hors-ligne il n'y a pas de fichier à + télécharger, et un lien laissé actif finirait sur une page d'erreur du navigateur. + Un lien ne se désactive pas — d'où la bascule vers un bouton inerte qui porte sa + raison, comme au catalogue. + La consolation est dite juste en dessous : l'usage que servait l'export « .txt », + emporter sa liste en librairie, est désormais couvert par la liste elle-même. + *@
- - Exporter en texte - - - Exporter en CSV - + @if (Reseau.EnLigne) + { + + Exporter en texte + + + Exporter en CSV + + } + else + { + + + }
+ @if (!Reseau.EnLigne) + { +

+ Les exports sont fabriqués par le serveur : ils reviendront avec le réseau. + La liste ci-dessous, elle, est celle enregistrée sur cet appareil — c'est + exactement ce qu'on emportait dans le fichier texte. +

+ } + + @if (_souhaits.Count > 1) + { +

+ Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie — + sur ordinateur, vous pouvez aussi la faire glisser. +

+ } + + @* + ⚠️ Les flèches ne sont PAS un ornement à côté du glisser-déposer : le drag & drop + HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait + que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie. + *@
    - @foreach (var souhait in _souhaits) + @foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i))) { -
  • +
  • + + @if (_souhaits.Count > 1) + { +
    + + + + + +
    + } + @@ -117,7 +157,7 @@ else } @if (!string.IsNullOrWhiteSpace(souhait.Isbn)) { - ISBN @souhait.Isbn + ISBN @FormatageIsbn.Afficher(souhait.Isbn) }

    @@ -128,7 +168,8 @@ else
@@ -138,93 +179,131 @@ else } } +@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la + fiche livre ne montre plus son formulaire. *@ @code { private IReadOnlyList? _souhaits; - private EnregistrementSouhait _saisie = new(); - private string _isbn = string.Empty; - private bool _ajout; - private bool _recherche; private bool _enCours; + private int? _glisse; private string? _erreur; - protected override Task OnInitializedAsync() => ChargerAsync(); + protected override async Task OnInitializedAsync() + { + Reseau.Change += SurChangementReseau; + await ChargerAsync(); + } + /// + /// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran. + /// + /// + /// On s'abonne à Change et jamais à SynchroChange : recharger écrit un + /// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin. + /// + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + /// Raison du blocage des écritures, ou null quand tout est possible. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + private const string MotifExport = + "L'export est produit par le serveur : indisponible hors ligne."; + + /// + /// ⚠️ Aucun message HTTP brut ne doit remonter jusqu'ici. C'était le défaut d'origine : + /// hors-ligne, l'écran affichait « net_http_message_not_success_statuscode_reason, 404, + /// Not Found ». La liste passe désormais par le même repli sur instantané que le reste de + /// l'application, et une panne se dit en français. + /// private async Task ChargerAsync() { try { + _erreur = null; _souhaits = await Api.ListerSouhaitsAsync(); } - catch (Exception ex) + catch (Exception) { - _erreur = $"Impossible de charger la liste d'envies : {ex.Message}"; + _souhaits ??= []; + _erreur = Reseau.EnLigne + ? "La liste d'envies n'a pas pu être chargée. Réessayez dans un instant." + : "Liste d'envies indisponible hors ligne : aucune donnée n'a encore pu être " + + "enregistrée sur cet appareil."; } } /// - /// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary). + /// Déplace une envie d'une position à une autre, puis enregistre l'ordre complet. /// /// - /// On retient le premier candidat sans demander de choisir : contrairement à l'ajout au - /// catalogue, une envie ne fige pas d'édition — l'utilisateur achètera celle qu'il trouvera. - /// Tous les champs restent modifiables. + /// La nouvelle suite est calculée localement et envoyée en entier : le serveur ne + /// reçoit pas « monte de un » mais « voici l'ordre ». C'est la même opération pour les + /// flèches et pour le glisser-déposer, donc un seul chemin à vérifier. + /// + /// La liste affichée est réordonnée avant l'aller-retour réseau : un déplacement qui + /// attendrait la réponse donnerait l'impression que la flèche n'a pas répondu. En cas + /// d'échec, le rechargement remet l'ordre du serveur. + /// /// - private async Task ChercherIsbnAsync() + private async Task DeplacerAsync(int depuis, int vers) { - _recherche = true; - _erreur = null; - - try + if (_souhaits is null || depuis == vers || vers < 0 || vers >= _souhaits.Count) { - var resultat = await Api.ChercherIsbnAsync(_isbn.Trim()); - var candidat = resultat?.Candidats.FirstOrDefault(); - - if (candidat is null) - { - _erreur = $"Aucune notice trouvée pour « {_isbn.Trim()} ». Complétez à la main."; - return; - } - - _saisie.Titre = candidat.Titre; - _saisie.Auteur = string.Join(", ", candidat.Auteurs); - _saisie.Editeur = candidat.Editeur; - _saisie.Annee = candidat.Annee; - _saisie.Isbn = _isbn.Trim(); - _saisie.CoverUrl = candidat.CoverUrl; - } - catch (Exception ex) - { - _erreur = $"La recherche a échoué : {ex.Message}"; - } - finally - { - _recherche = false; - } - } - - private async Task AjouterAsync() - { - _ajout = true; - _erreur = null; - - var resultat = await Api.AjouterSouhaitAsync(_saisie); - - _ajout = false; - - if (!resultat.EstOk) - { - _erreur = resultat.Erreur; return; } - _saisie = new EnregistrementSouhait(); - _isbn = string.Empty; - await ChargerAsync(); + var ordonnee = _souhaits.ToList(); + var deplacee = ordonnee[depuis]; + ordonnee.RemoveAt(depuis); + ordonnee.Insert(vers, deplacee); + + _souhaits = ordonnee; + _enCours = true; + + _erreur = await Api.ReordonnerSouhaitsAsync(ordonnee.Select(s => s.Id).ToList()); + + _enCours = false; + + if (_erreur is not null) + { + // L'ordre affiché n'est pas celui du serveur : on reprend le sien plutôt que de + // laisser croire que le déplacement a été enregistré. + await ChargerAsync(); + } + } + + /// Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée. + private async Task DeposerAsync(int cible) + { + if (_souhaits is null || _glisse is not { } id) + { + return; + } + + var depuis = _souhaits.ToList().FindIndex(s => s.Id == id); + _glisse = null; + + if (depuis >= 0) + { + await DeplacerAsync(depuis, cible); + } } private async Task SupprimerAsync(SouhaitDto souhait) diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs index 572755c..47f57c3 100644 --- a/MaBibli.Client/Services/CacheHorsLigne.cs +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -18,6 +18,22 @@ public static class ClesCache public const string PretsEnCours = "prets-en-cours"; public const string Utilisateur = "utilisateur"; + + /// + /// La liste d'envies de l'utilisateur courant. + /// + /// + /// ⚠️ Cet instantané est le seul à contenir des données personnelles : le catalogue, + /// les auteurs et les prêts sont communs au foyer. Il n'est donc écrit qu'avec ce que le + /// serveur a bien voulu rendre à l'appelant — jamais la liste d'un autre, que l'API ne sait + /// de toute façon pas produire. + /// + /// Sa raison d'être : la liste s'emporte en librairie, exactement l'endroit où le + /// réseau manque. C'est le même usage que l'export .txt, lequel est produit côté + /// serveur et devient donc impossible hors-ligne. + /// + /// + public const string Souhaits = "souhaits"; } /// Un instantané relu du cache, avec la date de la synchronisation qui l'a produit. diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 5e0c4e0..32b917a 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -46,6 +46,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerAsync(new CritereLivres(), ct); await ListerAuteursAsync(ct); await ListerPretsEnCoursAsync(ct); + await ListerSouhaitsAsync(ct); await ObtenirUtilisateurAsync(ct); return reseau.EnLigne; } @@ -434,6 +435,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerAsync(new CritereLivres()); await ListerPretsEnCoursAsync(); await ListerAuteursAsync(); + await ListerSouhaitsAsync(); } catch (Exception) { @@ -513,20 +515,117 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat /// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait /// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre. /// - public async Task> ListerSouhaitsAsync(CancellationToken ct = default) => - await http.GetFromJsonAsync>("api/souhaits", Json, ct) ?? []; - - public async Task> AjouterSouhaitAsync( - EnregistrementSouhait saisie, CancellationToken ct = default) + public async Task> ListerSouhaitsAsync(CancellationToken ct = default) { - var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct); - return await InterpreterAsync(reponse, ct); + var (ok, souhaits) = await EssayerAsync( + () => http.GetFromJsonAsync>("api/souhaits", Json, ct)); + + if (ok) + { + await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []); + return souhaits ?? []; + } + + var instantane = await LireCacheAsync>(ClesCache.Souhaits); + return instantane?.Donnees ?? []; } + public async Task> AjouterSouhaitAsync( + EnregistrementSouhait saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct); + + /// + /// Retire une envie. null si c'est fait, sinon le motif à afficher. + /// + /// + /// ⚠️ Ne pas passer par : la suppression répond + /// 204 No Content, et désérialiser un corps vide lèverait une exception là où tout + /// s'est bien passé. Même raison que pour la suppression d'un livre, dont ceci reprend + /// exactement la forme. + /// public async Task SupprimerSouhaitAsync(int id, CancellationToken ct = default) { - var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct); - return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); + if (!reseau.EnLigne) + { + return EtatReseau.MotifHorsLigne; + } + + try + { + var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct); + reseau.SignalerSuccesReseau(); + return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); + } + catch (HttpRequestException) + { + reseau.SignalerEchecReseau(); + return EtatReseau.MotifHorsLigne; + } + } + + /// + /// Fixe l'ordre de la liste d'envies. null si c'est fait, sinon le motif à afficher. + /// + /// + /// Envoie la liste entière des identifiants : c'est ce que réclame le glisser-déposer, + /// et les flèches s'y ramènent. Comme la suppression, elle répond 204 No Content et ne + /// passe donc pas par . + /// + public async Task ReordonnerSouhaitsAsync( + IReadOnlyList ids, CancellationToken ct = default) + { + if (!reseau.EnLigne) + { + return EtatReseau.MotifHorsLigne; + } + + try + { + var reponse = await http.PutAsJsonAsync("api/souhaits/ordre", ids, Json, ct); + reseau.SignalerSuccesReseau(); + + if (!reponse.IsSuccessStatusCode) + { + return await MessageErreurAsync(reponse, ct); + } + + // L'instantané porte l'ordre : sans ce rafraîchissement, une coupure juste après un + // déplacement rendrait la liste d'avant. + _ = RafraichirEnFondAsync(); + return null; + } + catch (HttpRequestException) + { + reseau.SignalerEchecReseau(); + return EtatReseau.MotifHorsLigne; + } + } + + /// + /// Cherche des notices par titre, éventuellement restreint à un auteur. + /// + /// + /// Exige le réseau, comme le lookup ISBN : rien n'est mis en cache. Sert l'ajout + /// d'une envie quand on n'a pas le livre en main — c'est-à-dire le cas normal. + /// + public async Task ChercherNoticesAsync( + string titre, string? auteur, CancellationToken ct = default) + { + var parametres = $"titre={Uri.EscapeDataString(titre.Trim())}"; + if (!string.IsNullOrWhiteSpace(auteur)) + { + parametres += $"&auteur={Uri.EscapeDataString(auteur.Trim())}"; + } + + var (ok, resultat) = await EssayerAsync(async () => + { + var reponse = await http.GetAsync($"api/notices?{parametres}", ct); + return reponse.IsSuccessStatusCode + ? await LireAsync(reponse, ct) + : null; + }); + + return ok ? resultat : null; } /// diff --git a/MaBibli.Client/_Imports.razor b/MaBibli.Client/_Imports.razor index 0ca14fd..882ce93 100644 --- a/MaBibli.Client/_Imports.razor +++ b/MaBibli.Client/_Imports.razor @@ -13,3 +13,4 @@ @using MaBibli.Shared.Catalogue @using MaBibli.Shared.Dtos @using MaBibli.Shared.Entites +@using MaBibli.Shared.Isbn diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 4ba52be..02a50ea 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -127,6 +127,10 @@ code { --mb-fond-doux: #f4f5f7; --mb-texte-doux: #5c6470; --mb-danger: #a4242c; + + /* Hauteur de la barre d'onglets (MainLayout). Partagée pour que les actions flottantes + se posent JUSTE au-dessus : deux barres fixes en bas d'écran se recouvriraient. */ + --mb-onglets-hauteur: 2.6rem; } body { @@ -291,11 +295,13 @@ body { margin: 1rem 0; } +/* Cette barre ne porte plus que des ACTIONS : la navigation est passée dans les onglets. + Elle se pose au-dessus d'eux, jamais dessus. */ .actions-flottantes { position: fixed; left: 0; right: 0; - bottom: 0; + bottom: calc(var(--mb-onglets-hauteur) + env(safe-area-inset-bottom)); display: flex; flex-wrap: wrap; gap: 0.5rem; @@ -867,3 +873,65 @@ body { font-weight: 600; cursor: pointer; } + +/* Un ISBN se relit chiffre à chiffre : une chasse fixe évite de confondre 1 et l, + 0 et O, et rend les tranches séparées par des tirets alignées d'une ligne à l'autre. */ +.code-isbn { + font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; + white-space: nowrap; +} + +/* Titre d'écran et son bouton de réglages sur la même ligne : le bouton « Filtrer » du + catalogue appartient au titre, pas au contenu. */ +.entete-page { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.entete-page .titre-page { + margin-bottom: 0.5rem; +} + +/* --- Liste d'envies : ordre choisi --- */ + +/* Colonne de déplacement à gauche de chaque envie. Les flèches sont larges à dessein : + c'est le seul moyen de réordonner au doigt, le glisser-déposer HTML5 n'existant pas + sur mobile. */ +.rang-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + flex: 0 0 auto; +} + +.rang-actions .bouton { + padding: 0.15rem 0.5rem; + line-height: 1.1; +} + +.rang-numero { + font-size: 0.75rem; + color: var(--mb-texte-doux); + font-variant-numeric: tabular-nums; +} + +/* La carte saisie s'efface pendant le glissé : sans ce repère, on ne sait plus laquelle + on déplace une fois le curseur loin de son point de départ. */ +.carte-glissee { + opacity: 0.4; +} + +/* Trois manières de chercher, sur une ligne : réutilise l'apparence des segments du + catalogue plutôt que d'inventer un troisième style de bascule. */ +.onglets-secondaires { + display: flex; + gap: 0.25rem; + margin: 0.75rem 0; +} + +.onglets-secondaires .segment { + flex: 1 1 0; +} diff --git a/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs new file mode 100644 index 0000000..ef31890 --- /dev/null +++ b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs @@ -0,0 +1,30 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Ce qu'on sait d'un code-barres qui s'est révélé être celui d'un périodique +/// (préfixe 977) et non d'un livre. +/// +/// +/// Le modèle de données ne gère pas les périodiques : ni numéro, ni date de parution. +/// Cet objet n'existe donc pas pour cataloguer une revue, mais pour dire à l'utilisateur ce +/// qu'il vient de scanner au lieu de lui répondre « aucun résultat » — ce qui l'enverrait +/// chercher un défaut là où il n'y en a pas. +/// +public record PeriodiqueDetecte +{ + /// ISSN déduit du code-barres, forme canonique à tiret (2466-6718). + public required string Issn { get; init; } + + /// + /// Titre de la revue d'après la BnF, ou null si elle ne connaît pas cet ISSN + /// ou n'a pas répondu. Le distinguer d'un titre trouvé est ce qui permet de ne pas + /// affirmer un nom qu'on n'a pas. + /// + public string? Titre { get; init; } + + /// Éditeur de la revue, quand la notice en porte un. + public string? Editeur { get; init; } + + /// Année de début de publication de la revue (et non du numéro scanné). + public string? Annee { get; init; } +} diff --git a/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs index 0cd5af5..8d4c569 100644 --- a/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs +++ b/MaBibli.Shared/Dtos/ResultatLookupIsbn.cs @@ -23,4 +23,11 @@ public record ResultatLookupIsbn /// Une source en panne ne fait pas échouer la cascade, elle laisse une trace ici. /// public IReadOnlyList Avertissements { get; init; } = []; + + /// + /// Renseigné uniquement quand le code scanné s'est révélé être celui d'un périodique + /// (préfixe 977). Dans ce cas est toujours vide : la cascade + /// ISBN n'a pas été lancée, elle ne pouvait pas aboutir. + /// + public PeriodiqueDetecte? Periodique { get; init; } } diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs index bdac49b..7808d25 100644 --- a/MaBibli.Shared/Dtos/SouhaitDto.cs +++ b/MaBibli.Shared/Dtos/SouhaitDto.cs @@ -106,6 +106,34 @@ public record OeuvreBibliographie public bool ADecouvrir => !Possede && !Souhaite; } +/// +/// Ce qu'il est advenu de l'interrogation de la BnF. +/// +/// +/// ⚠️ Une source muette n'est pas une bibliographie vide, et la différence n'est pas +/// cosmétique : sans cet état, un délai dépassé produisait une liste vide, que l'écran +/// commentait d'un rassurant « la BnF ne connaît aucun livre de cet auteur ». On affirmait donc +/// une chose qu'on n'avait pas pu vérifier. C'est ce que cet énuméré rend impossible. +/// +/// Le motif est distingué parce qu'il ne demande pas la même chose à l'utilisateur : un délai +/// dépassé se retente, une réponse illisible non. +/// +/// +public enum EtatSourceBibliographie +{ + /// La BnF a répondu. La liste rendue est ce qu'elle sait, y compris si elle est vide. + Ok = 0, + + /// La BnF n'a pas répondu à temps. Cas le plus fréquent, et le plus souvent passager. + DelaiDepasse = 1, + + /// La BnF n'a pas pu être jointe, ou a répondu par une erreur. + Injoignable = 2, + + /// La BnF a répondu, mais sa réponse n'était pas exploitable. + ReponseIllisible = 3, +} + /// /// Bibliographie d'un auteur du catalogue, telle que la BnF la connaît. /// @@ -134,4 +162,18 @@ public record BibliographieDto /// Message à afficher quand la source n'a pas pu être interrogée normalement. public string? Avertissement { get; init; } + + /// + /// Ce qu'il est advenu de l'interrogation. ne veut + /// pas dire « des résultats », mais « une réponse ». + /// + public EtatSourceBibliographie Etat { get; init; } + + /// + /// Vrai quand la BnF n'a pas répondu : la liste vide ne prouve alors rien. + /// + /// + /// C'est ce que l'interface doit consulter avant d'expliquer une absence de résultats. + /// + public bool SourceMuette => Etat != EtatSourceBibliographie.Ok; } diff --git a/MaBibli.Shared/Entites/LivreSouhaite.cs b/MaBibli.Shared/Entites/LivreSouhaite.cs index b20fd3e..9d36dcd 100644 --- a/MaBibli.Shared/Entites/LivreSouhaite.cs +++ b/MaBibli.Shared/Entites/LivreSouhaite.cs @@ -75,6 +75,20 @@ public class LivreSouhaite public DateTime DateAjout { get; set; } + /// + /// Rang dans la liste, du plus désiré (0) au moins désiré. + /// + /// + /// L'ordre est choisi à la main : c'est une préférence, elle ne se calcule pas. Il est + /// personnel comme le reste de la table — le rang n'a de sens qu'à l'intérieur de la + /// liste d'une personne, et deux utilisateurs numérotent la leur indépendamment. + /// + /// Une envie nouvelle se pose en fin de liste, jamais en tête : on vient de la noter, + /// on n'a pas dit qu'on la voulait plus que les autres. C'est à l'utilisateur de la remonter. + /// + /// + public int Rang { get; set; } + public void RecalculerFormes() { Titre = Titre.Trim(); diff --git a/MaBibli.Shared/Isbn/CodePeriodique.cs b/MaBibli.Shared/Isbn/CodePeriodique.cs new file mode 100644 index 0000000..f9e3182 --- /dev/null +++ b/MaBibli.Shared/Isbn/CodePeriodique.cs @@ -0,0 +1,62 @@ +using System.Globalization; + +namespace MaBibli.Shared.Isbn; + +/// +/// Reconnaissance des codes-barres de périodiques et extraction de leur ISSN. +/// +/// +/// Un livre porte un EAN-13 préfixé 978 ou 979 ; un magazine porte 977, +/// qui encode un ISSN — c'est-à-dire un titre de revue, pas un ouvrage. Aucune +/// recherche bib.isbn ne peut donc aboutir sur un tel code, et l'application répondait +/// « aucun résultat » là où le code lui-même dit qu'on ne cherche pas au bon endroit. +/// +/// Structure du code, vérifiée sur 9772466671438 le 2026-08-18 : +/// 977 + les 7 premiers chiffres de l'ISSN (sa clé n'y figure pas, elle est +/// recalculée) + 2 chiffres de parution + la clé EAN-13. Les deux chiffres de parution +/// sont la raison pour laquelle un même ISSN couvre tous les numéros d'une revue : ils ne sont +/// pas exploitables comme numéro d'édition fiable, et ne sont donc pas exposés ici. +/// +/// +public static class CodePeriodique +{ + /// Préfixe EAN-13 réservé aux publications en série. + public const string Prefixe = "977"; + + /// + /// Vrai si (déjà normalisé) est un EAN-13 valide de périodique. + /// + public static bool EstCodePeriodique(string? ean) => + IsbnUtils.EstIsbn13Valide(ean) && ean!.StartsWith(Prefixe, StringComparison.Ordinal); + + /// + /// ISSN porté par le code-barres, sous sa forme canonique à tiret (2466-6718), + /// ou null si le code n'est pas celui d'un périodique. + /// + /// + /// La clé de l'ISSN n'est pas dans le code-barres : elle se recalcule (somme pondérée 8→2, + /// modulo 11, X si le complément vaut 10). C'est le même principe que la clé ISBN-10, + /// sur sept chiffres au lieu de neuf. + /// + public static string? IssnDepuis(string? ean) + { + if (!EstCodePeriodique(ean)) + { + return null; + } + + // Chiffres 4 à 10 du code : l'ISSN amputé de sa clé. + var corps = ean!.Substring(3, 7); + + var somme = 0; + for (var i = 0; i < 7; i++) + { + somme += (corps[i] - '0') * (8 - i); + } + + var cle = (11 - (somme % 11)) % 11; + var caractereCle = cle == 10 ? "X" : cle.ToString(CultureInfo.InvariantCulture); + + return $"{corps[..4]}-{corps[4..]}{caractereCle}"; + } +} diff --git a/MaBibli.Shared/Isbn/FormatageIsbn.cs b/MaBibli.Shared/Isbn/FormatageIsbn.cs new file mode 100644 index 0000000..dca5bec --- /dev/null +++ b/MaBibli.Shared/Isbn/FormatageIsbn.cs @@ -0,0 +1,205 @@ +namespace MaBibli.Shared.Isbn; + +/// +/// Découpage d'un ISBN en tranches séparées par des tirets, pour l'affichage seulement. +/// +/// +/// 9782846391009978-2-84639-100-9. La valeur stockée reste nue : ces +/// tirets ne franchissent jamais la frontière de la base ni celle des requêtes aux sources +/// (la BnF indexe l'ISBN sans tiret). Le seul point d'entrée est . +/// +/// Le découpage dépend du groupe linguistique puis de l'éditeur, et les tranches +/// sont publiées par l'agence internationale — elles ne se devinent pas. Les tables ci-dessous +/// sont extraites du RangeMessage.xml officiel (isbn-international.org), version +/// du 2026-08-18. +/// +/// +/// Principe tenu : on n'affiche jamais une coupure qu'on ne sait pas juste. Le +/// français (978-2) est découpé en entier, jusqu'à l'éditeur. Ailleurs, on s'arrête au +/// groupe et à la clé — deux frontières toujours exactes — et l'on ne devine pas la limite +/// éditeur/publication. Une tranche en moins se lit encore ; une tranche fausse trompe. +/// +/// +public static class FormatageIsbn +{ + /// Longueur du groupe linguistique, d'après les 7 chiffres qui suivent 978. + private static readonly (int Debut, int Fin, int Longueur)[] GroupesSous978 = + [ + (0000000, 5999999, 1), + (6000000, 6499999, 3), + (6500000, 6599999, 2), + (6600000, 6699999, 2), + (6999000, 6999999, 5), + (7000000, 7999999, 1), + (8000000, 9499999, 2), + (9500000, 9899999, 3), + (9900000, 9989999, 4), + (9990000, 9999999, 5), + ]; + + /// Idem sous 979 : seuls deux groupes existent à ce jour. + private static readonly (int Debut, int Fin, int Longueur)[] GroupesSous979 = + [ + (1000000, 1599999, 2), + (8000000, 8999999, 1), + ]; + + /// + /// Longueur du numéro d'éditeur dans le groupe francophone, d'après les 7 chiffres + /// qui suivent 978-2. + /// + /// + /// C'est la seule table d'éditeurs embarquée, et c'est un choix : la collection est + /// majoritairement francophone, et les 19 tranches ci-dessous couvrent donc l'essentiel du + /// fonds. Embarquer les ~220 groupes du monde entier alourdirait le WASM téléchargé par le + /// navigateur pour un gain marginal. + /// + private static readonly (int Debut, int Fin, int Longueur)[] EditeursFrancophones = + [ + (0000000, 1999999, 2), + (2000000, 3499999, 3), + (3500000, 3999999, 5), + (4000000, 4869999, 3), + (4870000, 4949999, 6), + (4950000, 4959999, 3), + (4960000, 4966999, 4), + (4967000, 4969999, 5), + (4970000, 5279999, 3), + (5280000, 5299999, 4), + (5300000, 6999999, 3), + (7000000, 8399999, 4), + (8400000, 8999999, 5), + (9000000, 9197999, 6), + (9198000, 9198099, 5), + (9198100, 9199429, 6), + (9199430, 9199689, 7), + (9199690, 9499999, 6), + (9500000, 9999999, 7), + ]; + + private const string GroupeFrancophone = "2"; + + /// + /// Rend l'ISBN lisible, tirets compris. + /// + /// + /// La forme découpée, ou l'entrée telle quelle si elle n'est pas un ISBN valide — on ne + /// découpe jamais ce qu'on n'a pas reconnu, un ISBN mal saisi doit se voir tel qu'il est. + /// + public static string Afficher(string? brut) + { + if (string.IsNullOrWhiteSpace(brut)) + { + return string.Empty; + } + + var isbn = IsbnUtils.Normaliser(brut); + if (!IsbnUtils.EstValide(isbn)) + { + return brut.Trim(); + } + + var decoupe = isbn!.Length == 13 ? Decouper13(isbn) : Decouper10(isbn); + return decoupe ?? isbn; + } + + private static string? Decouper13(string isbn) + { + var prefixe = isbn[..3]; + var table = prefixe switch + { + "978" => GroupesSous978, + "979" => GroupesSous979, + + // 977 (périodique) et tout autre préfixe EAN ne portent pas d'ISBN : rien à découper. + _ => null, + }; + + if (table is null || !TrouverLongueur(table, isbn.AsSpan(3), out var longueurGroupe)) + { + return null; + } + + var groupe = isbn.Substring(3, longueurGroupe); + var corps = isbn[(3 + longueurGroupe)..]; + + return prefixe + "-" + Assembler(groupe, corps, francophone: prefixe == "978" && groupe == GroupeFrancophone); + } + + private static string? Decouper10(string isbn) + { + // Un ISBN-10 est un ISBN-13 en 978 amputé de son préfixe : les tranches de groupe + // sont donc exactement les mêmes. + if (!TrouverLongueur(GroupesSous978, isbn.AsSpan(), out var longueurGroupe)) + { + return null; + } + + var groupe = isbn[..longueurGroupe]; + var corps = isbn[longueurGroupe..]; + + return Assembler(groupe, corps, francophone: groupe == GroupeFrancophone); + } + + /// + /// Recolle groupe - [éditeur - publication] - clé à partir du corps de l'ISBN + /// (tout ce qui suit le groupe, clé comprise). + /// + private static string Assembler(string groupe, string corps, bool francophone) + { + var cle = corps[^1..]; + var milieu = corps[..^1]; + + if (francophone + && TrouverLongueur(EditeursFrancophones, corps.AsSpan(), out var longueurEditeur) + // Il doit rester au moins un chiffre de publication : sinon la tranche est plus + // longue que le corps, et on afficherait une coupure absurde plutôt que rien. + && longueurEditeur < milieu.Length) + { + var editeur = milieu[..longueurEditeur]; + var publication = milieu[longueurEditeur..]; + return $"{groupe}-{editeur}-{publication}-{cle}"; + } + + return $"{groupe}-{milieu}-{cle}"; + } + + /// + /// Cherche la tranche contenant les 7 premiers chiffres de . + /// + /// + /// Les tables de l'agence sont toutes indexées sur 7 chiffres, complétés par des + /// zéros quand il en manque — c'est ce que fait le remplissage ci-dessous. + /// + private static bool TrouverLongueur( + (int Debut, int Fin, int Longueur)[] table, ReadOnlySpan chiffres, out int longueur) + { + longueur = 0; + + var cle = 0; + for (var i = 0; i < 7; i++) + { + // Le X final d'un ISBN-10 n'est jamais dans les 7 premiers chiffres d'un corps + // exploitable, mais le vérifier coûte moins cher que d'y croire. + var c = i < chiffres.Length ? chiffres[i] : '0'; + if (!char.IsDigit(c)) + { + return false; + } + + cle = (cle * 10) + (c - '0'); + } + + foreach (var (debut, fin, valeur) in table) + { + if (cle >= debut && cle <= fin) + { + longueur = valeur; + return true; + } + } + + // Tranche non attribuée : on ne devine pas. + return false; + } +} diff --git a/MaBibli.Tests/BnfBibliographieParserTests.cs b/MaBibli.Tests/BnfBibliographieParserTests.cs index 68c2f1d..b5d5eff 100644 --- a/MaBibli.Tests/BnfBibliographieParserTests.cs +++ b/MaBibli.Tests/BnfBibliographieParserTests.cs @@ -1,5 +1,7 @@ using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; namespace MaBibli.Tests; @@ -172,4 +174,121 @@ public class BnfBibliographieParserTests [InlineData(null, null)] public void Extrait_une_annee_triable_des_dates_BnF(string? date, int? attendu) => Assert.Equal(attendu, ServiceBibliographie.AnneeTriable(date)); -} + + // ── Recherche par titre (liste d'envies) ───────────────────────────────── + + /// + /// ⚠️ Le filtre de type est aussi indispensable ici qu'en bibliographie. Mesuré sur la + /// réponse réelle enregistrée : bib.title all "la bête humaine" croisé avec Zola + /// rend 20 notices, dont 12 films de Jean Renoir, un manuscrit et un multimédia. + /// Sans filtre, la liste d'envies proposerait d'acheter un DVD. + /// + [Fact] + public void Recherche_par_titre_ecarte_ce_qui_nest_pas_un_livre() + { + var candidats = BnfBibliographieParser.ParserRecherche( + Fixture.Lire("bnf-titre-bete-humaine.xml")); + + // 6 « texte imprimé » sur 20 notices : tout le reste est écarté. + Assert.Equal(6, candidats.Count); + Assert.All(candidats, c => Assert.Equal(SourceMetadonnees.Bnf, c.Source)); + Assert.All(candidats, c => Assert.False(string.IsNullOrWhiteSpace(c.Titre))); + } + + /// + /// La couverture vient de l'ISBN de la notice, la BnF n'en fournissant aucune. Une notice + /// sans ISBN — cas courant pour les éditions anciennes — n'en a simplement pas. + /// + [Fact] + public void Recherche_par_titre_remonte_ISBN_et_couverture_quand_la_notice_en_porte_un() + { + var candidats = BnfBibliographieParser.ParserRecherche( + Fixture.Lire("bnf-titre-bete-humaine.xml")); + + var avecIsbn = candidats.Where(c => c.IsbnInterroge is not null).ToList(); + Assert.NotEmpty(avecIsbn); + + Assert.All(avecIsbn, c => + { + Assert.True(IsbnUtils.EstValide(c.IsbnInterroge)); + Assert.Equal(IsbnUtils.UrlCouverture(c.IsbnInterroge!), c.CoverUrl); + }); + + // Sans ISBN, pas de couverture inventée. + Assert.All( + candidats.Where(c => c.IsbnInterroge is null), + c => Assert.Null(c.CoverUrl)); + } + + /// + /// Contrairement à la bibliographie, aucun post-filtre sur l'auteur : c'est + /// l'utilisateur qui choisit dans la liste, en voyant les auteurs de chaque notice. + /// + /// + /// Les deux notices vérifiées ici sont précisément celles que le post-filtre de + /// écarterait, et les garder est le bon choix + /// pour une liste d'envies : + /// + /// la bande dessinée signée Dobbs — une adaptation de « La bête humaine » se + /// souhaite parfaitement, et son auteur est affiché, donc rien ne trompe ; + /// la notice sans aucun dc:creator, qui n'aurait pu correspondre à + /// aucun auteur demandé. + /// + /// + [Fact] + public void Recherche_par_titre_ne_post_filtre_pas_lauteur() + { + var candidats = BnfBibliographieParser.ParserRecherche( + Fixture.Lire("bnf-titre-bete-humaine.xml")); + + Assert.Contains(candidats, c => c.Auteurs.Any(a => a.Contains("Dobbs"))); + Assert.Contains(candidats, c => c.Auteurs.Count == 0); + } + + // ── Motifs d'échec lisibles ────────────────────────────────────────────── + + /// + /// ⚠️ Ces messages atteignent l'utilisateur. Ils ne doivent porter aucun nom de + /// classe .NET : « BnF injoignable (TaskCanceledException) » est ce qui s'affichait, et + /// « TaskCanceledException » ne veut rien dire pour qui range ses livres. + /// + [Theory] + [InlineData(EtatSourceBibliographie.DelaiDepasse)] + [InlineData(EtatSourceBibliographie.Injoignable)] + [InlineData(EtatSourceBibliographie.ReponseIllisible)] + public void Le_motif_dechec_est_ecrit_pour_un_humain(EtatSourceBibliographie etat) + { + var message = MotifEchec(etat); + + Assert.Contains("Robert A. Harper", message); + Assert.DoesNotContain("Exception", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Task", message, StringComparison.Ordinal); + Assert.DoesNotContain("Http", message, StringComparison.Ordinal); + } + + /// + /// Le délai dépassé se retente, et le message doit le dire : c'est le seul des trois qui + /// appelle une action immédiate de l'utilisateur. + /// + [Fact] + public void Un_delai_depasse_invite_a_reessayer() + => Assert.Contains("réessay", MotifEchec(EtatSourceBibliographie.DelaiDepasse), + StringComparison.OrdinalIgnoreCase); + + /// Les trois motifs se distinguent : les confondre reviendrait à ne rien dire. + [Fact] + public void Les_trois_motifs_sont_distincts() + { + var motifs = new[] + { + EtatSourceBibliographie.DelaiDepasse, + EtatSourceBibliographie.Injoignable, + EtatSourceBibliographie.ReponseIllisible, + }.Select(MotifEchec).ToList(); + + Assert.Equal(motifs.Count, motifs.Distinct().Count()); + } + + private static string MotifEchec(EtatSourceBibliographie etat) => + BnfClient.MotifDeSourceMuette(etat, "Robert A. Harper"); +} \ No newline at end of file diff --git a/MaBibli.Tests/CodePeriodiqueTests.cs b/MaBibli.Tests/CodePeriodiqueTests.cs new file mode 100644 index 0000000..8203d3a --- /dev/null +++ b/MaBibli.Tests/CodePeriodiqueTests.cs @@ -0,0 +1,183 @@ +using MaBibli.Api.Services.Isbn; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MaBibli.Tests; + +/// +/// Codes-barres de périodiques (préfixe 977) : reconnaissance, dérivation de l'ISSN, +/// et lecture de la notice BnF correspondante. +/// +public class CodePeriodiqueTests +{ + /// + /// Le cas remonté en usage réel : ce code renvoyait « aucun résultat », alors qu'il + /// n'a jamais pu décrire un livre. + /// + private const string EanMedor = "9772466671438"; + + [Theory] + [InlineData(EanMedor, true)] + [InlineData("9782070612758", false)] // Le Petit Prince : un vrai livre + [InlineData("9791234567896", false)] // préfixe 979, livre lui aussi + [InlineData("2253004227", false)] // ISBN-10 : trop court pour porter un préfixe + [InlineData("9772466671437", false)] // bon préfixe, clé EAN fausse + [InlineData(null, false)] + [InlineData("", false)] + public void Reconnait_les_codes_de_periodique(string? ean, bool attendu) + => Assert.Equal(attendu, CodePeriodique.EstCodePeriodique(ean)); + + /// + /// La clé de l'ISSN n'est pas dans le code-barres : elle se recalcule. Vérifié + /// contre la BnF le 2026-08-18 — l'ISSN 2466-6718 y désigne bien « Médor ». + /// + [Fact] + public void Deduit_lIssn_du_code_barres() + => Assert.Equal("2466-6718", CodePeriodique.IssnDepuis(EanMedor)); + + [Fact] + public void Ne_deduit_rien_dun_code_de_livre() + => Assert.Null(CodePeriodique.IssnDepuis("9782070612758")); + + /// + /// Les deux chiffres qui précèdent la clé encodent la parution : deux numéros d'une même + /// revue doivent donner le même ISSN, sinon chaque magazine créerait une fiche. + /// + [Fact] + public void Deux_parutions_de_la_meme_revue_donnent_le_meme_Issn() + { + var issns = new[] { "9772466671438", "9772466671391" } + .Select(CodePeriodique.IssnDepuis) + .ToList(); + + Assert.Equal("2466-6718", issns[0]); + Assert.Equal(issns[0], issns[1]); + } + + [Fact] + public void Notice_BnF_de_periodique_est_lue_et_nettoyee() + { + var revue = BnfSruParser.ParserPeriodique( + Fixture.Lire("bnf-issn-2466-6718.xml"), "2466-6718"); + + Assert.NotNull(revue); + + // « Médor (Bruxelles) » : le parenthésé est un qualificatif de désambiguïsation, + // pas une partie du nom écrit sur la couverture. + Assert.Equal("Médor", revue!.Titre); + Assert.Equal("2466-6718", revue.Issn); + Assert.Equal("2015", revue.Annee); + } + + [Fact] + public void Reponse_sans_notice_ne_produit_pas_de_titre_invente() + { + var vide = """ + + 0 + + """; + + Assert.Null(BnfSruParser.ParserPeriodique(vide, "2466-6718")); + } + + // ── La cascade ne doit pas partir du tout ──────────────────────────────── + + /// + /// Client BnF simulé : compte les appels ISBN pour prouver qu'aucun n'a lieu. + /// + private sealed class BnfFake(PeriodiqueDetecte? revue) : IBnfClient + { + public List IsbnInterroges { get; } = []; + + public List IssnInterroges { get; } = []; + + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + IsbnInterroges.Add(isbn); + return Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + + public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default) => + Task.FromResult((new ResultatBibliographie(), (string?)null)); + + public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) + { + IssnInterroges.Add(issn); + return Task.FromResult((revue, (string?)null)); + } + + /// Recherche par titre : couverte par ses propres tests. + public Task<(IReadOnlyList, string?)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + + private sealed class OpenLibraryFake : IOpenLibraryClient + { + public int Appels { get; private set; } + + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) + { + Appels++; + return Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + } + + /// + /// Le point du bug : la cascade s'exécutait en entier pour finir sur « aucun résultat ». + /// Elle ne doit désormais pas partir — seul l'ISSN est interrogé. + /// + [Fact] + public async Task Un_code_977_ne_declenche_aucune_recherche_de_livre() + { + var bnf = new BnfFake(new PeriodiqueDetecte { Issn = "2466-6718", Titre = "Médor" }); + var ol = new OpenLibraryFake(); + var service = new IsbnLookupService(bnf, ol, NullLogger.Instance); + + var resultat = await service.RechercherAsync(EanMedor); + + Assert.NotNull(resultat); + Assert.Empty(bnf.IsbnInterroges); + Assert.Equal(0, ol.Appels); + Assert.Equal(["2466-6718"], bnf.IssnInterroges); + Assert.Empty(resultat!.Candidats); + Assert.Equal("Médor", resultat.Periodique?.Titre); + } + + /// + /// BnF muette : on connaît quand même l'ISSN, puisqu'il se déduit du code-barres. + /// Ne rien renvoyer ferait retomber l'écran sur « aucun résultat », le bug d'origine. + /// + [Fact] + public async Task BnF_muette_laisse_quand_meme_lIssn() + { + var service = new IsbnLookupService( + new BnfFake(null), new OpenLibraryFake(), NullLogger.Instance); + + var resultat = await service.RechercherAsync(EanMedor); + + Assert.Equal("2466-6718", resultat?.Periodique?.Issn); + Assert.Null(resultat?.Periodique?.Titre); + } + + /// Un livre ordinaire n'est jamais pris pour un périodique. + [Fact] + public async Task Un_ISBN_de_livre_passe_par_la_cascade_normale() + { + var bnf = new BnfFake(null); + var service = new IsbnLookupService( + bnf, new OpenLibraryFake(), NullLogger.Instance); + + var resultat = await service.RechercherAsync("9782070612758"); + + Assert.Null(resultat?.Periodique); + Assert.Empty(bnf.IssnInterroges); + Assert.NotEmpty(bnf.IsbnInterroges); + } +} diff --git a/MaBibli.Tests/ExportSouhaitsTests.cs b/MaBibli.Tests/ExportSouhaitsTests.cs index 8c5dcda..b5bcac7 100644 --- a/MaBibli.Tests/ExportSouhaitsTests.cs +++ b/MaBibli.Tests/ExportSouhaitsTests.cs @@ -73,10 +73,27 @@ public class ExportSouhaitsTests null, Jour); - Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2253004227)", texte); + // ISBN découpé : c'est un numéro qu'on épelle à un libraire. La valeur passée est nue + // (« 2253004227 »), les tirets n'existent qu'à la sortie. + Assert.Contains(" - Germinal (Le Livre de poche, 1885, ISBN 2-253-00422-7)", texte); Assert.Contains(" en poche seulement", texte); } + /// + /// Le découpage du CSV ne sert pas qu'à la lisibilité : un ISBN nu de treize chiffres est + /// lu comme un nombre par Excel, qui l'affiche « 9,78225E+12 » et perd les derniers + /// chiffres. Les tirets en font un texte, et le numéro survit à l'ouverture du fichier. + /// + [Fact] + public void Le_csv_decoupe_lIsbn_pour_quExcel_ne_le_prenne_pas_pour_un_nombre() + { + var csv = ExportSouhaits.Csv( + [Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "9782253004226")]); + + Assert.Contains("978-2-253-00422-6", csv); + Assert.DoesNotContain("9782253004226", csv); + } + [Fact] public void Le_texte_le_dit_quand_la_liste_est_vide() { diff --git a/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml b/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml new file mode 100644 index 0000000..acf04eb --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-issn-2466-6718.xml @@ -0,0 +1,24 @@ + +1.2 + +1.2 +bib.issn all "2466-6718" + +1 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb45787531c Médor (Bruxelles) Médor : les yeux ouverts / [dir. publ. Laurence Jénard] Médor (Bruxelles) 2015 ISSN 24666718 fre publication en série imprimée printed serial text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb45787531c +1 + +20190820 +20210416 +8.210089 + + + + diff --git a/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml b/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml new file mode 100644 index 0000000..f655e8b --- /dev/null +++ b/MaBibli.Tests/Fixtures/bnf-titre-bete-humaine.xml @@ -0,0 +1,291 @@ + +1.2 + +1.2 +bib.title all "la bete humaine" and bib.author all "zola" + +116 + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb39770395m La Bête humaine / [plaquette de présentation du film de Jean Renoir d'après le roman d'Emile Zola] Renoir, Jean (1894-1979). Réalisateur Zola, Émile (1840-1902). Auteur adapté Paris film production (Paris) 1938 16 p. : ill. ; 49 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb39770395m +1 + +20060222 +20160129 +19.750183 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb38223462m La bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1981 Numéro commercial : référence éditoriale VPV1024 1 cass. vidéo (VHS) (1 h 39 min) : n. et b. (SECAM) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb38223462m +2 + +19930317 +20120924 +19.369736 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb437350049 La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur Universal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt) 2013 Numéro commercial : EDV 1392 829 380-0 Code à barres commercial : EAN 5050582938005 1 BD vidéo (1 h 39 min) : 4/3, n. et b., son. + 1 brochure (19 p. : ill. ; 17 cm) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb437350049 +3 + +20131224 +20260414 +19.368761 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb43734223w La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur Universal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt) 2013 Numéro commercial : EDV 1392 829 551-4 Code à barres commercial : EAN 5050582955149 1 DVD vidéo monoface double couche zone 2 (1 h 35 min) : 4/3, n. et b. (PAL), son. + 1 brochure (19 p. : ill. ; 18 cm) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb43734223w +4 + +20131223 +20260414 +19.368483 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb487401586 La Bête humaine Zola, Émile (1840-1902) Editions Gallmeister (Paris) 2026 ISBN 9782351789643 Code à barres commercial : EAN 9782351789643 1 vol. (484 p.) ; 20 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb487401586 +5 + +20260326 +20260326 +18.988321 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb38397662r La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1998 Collection : Vidéo classique Numéro commercial : référence éditoriale 27263 Code à barres commercial : EAN 3339161272634 1 cass. vidéo (1 h 36 min) : n. et b., SECAM ; 1/2 pouce VHS fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb38397662r +6 + +19980608 +20120924 +18.954409 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb47104447b La bête humaine Zola, Émile (1840-1902). Auteur du texte Books on Demand (Paris) 2022 ISBN 9782322437740 Code à barres commercial : EAN 9782322437740 non paginé ; 22 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb47104447b +7 + +20220923 +20240207 +18.93142 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb408956237 La bête humaine / Roger Pillaudin ; adaptation radiophonique d'après Émile Zola Pillaudin, Roger. Adaptateur Zola, Émile (1840-1902). Auteur adapté 1965 35 ff. multigr. ; 27 cm fre manuscrit moderne ou document d'archive modern manuscript or archive text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb408956237 +8 + +20080611 +20080612 +18.888391 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb40940932z La bête humaine / Jean Renoir, réal., scénario ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux, Julien Carette, act. Renoir, Jean (1894-1979). Réalisateur Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur Carette, Julien (1897-1966). Acteur Warner home video [éd.] (London) 2000 Collection : World cinema Code à barres commercial : EAN 5014780383890 1 cass. vidéo (VHS) (1 h 37 min) : n. et b. (PAL), son. fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb40940932z +9 + +20061116 +20260414 +18.866568 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb45582948n La bête humaine / scénario, Dobbs ; dessin, Germano Giorgiani ; couleur, Isabelle Lebeau ; [d'après l'oeuvre d'Émile Zola] Dobbs (1972-....). Auteur du texte Giorgiani, Germano. Illustrateur Zola, Émile (1840-1902). Auteur adapté Robinson (Vanves) 2018 ISBN 9782012905313 Code à barres commercial : EAN 9782012905313 1 vol. (81 p.) : ill. en coul. ; 27 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb45582948n +10 + +20180920 +20221205 +18.83721 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb409409261 La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. The human beast Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur the Criterion collection [éd.] (Irvington, N.Y.) 2006 ISBN 0780026241 Numéro commercial : 324 Code à barres commercial : EAN 037429173824 1 DVD vidéo monoface double couche zone 1 (1 h 36 min) : 4/3, n. et b. (NTSC), mono, surround (Dolby) image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb409409261 +11 + +20061116 +20120924 +18.757744 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb383634420 La bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1997 Collection : Vous comprenez le français ? Code à barres commercial : EAN 9782090324563 1 cass. vidéo (VHS) (1 h 40 min) : n. et b. (PAL), son. + 1 brochure (47 p.; 20 cm) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb383634420 +12 + +19970509 +20251212 +18.566822 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb385406522 La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1991 Collection : Les génies du cinéma ; 12 1 cass. vidéo (VHS) (1 h 40 min) : n. et b. (SECAM) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb385406522 +13 + +20010614 +20260414 +18.38218 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb38523388j La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1989 Collection : L'âge d'or du cinéma Numéro commercial : référence éditoriale 2855018 1 cass. vidéo (VHS) (1 h 40 min) n. et b. (SECAM) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb38523388j +14 + +20010216 +20260414 +18.20326 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb38460416w La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur 1989 Collection : L'âge d'or du cinéma Numéro commercial : référence éditoriale 0990348 Code à barres commercial : EAN 3346030990346 1 cass. vidéo (VHS) (1 h 36 min) : n. et b. (SECAM) fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb38460416w +15 + +19991123 +20120924 +18.202312 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb31690508j Autres chefs-d'oeuvre : Pot-Bouille, Germinal, La bête humaine / Émile Zola ; avec trente-deux aquarelles par Tibor Csernus, Fontanarosa, Jean Terles ; [Cartonnage par Paul Bonet] Zola, Émile (1840-1902). Auteur du texte Csernus, Tibor (1927-2007). Illustrateur Terles, Jean (1909-1976). Illustrateur Fontanarosa, Lucien Joseph (1912-1975). Illustrateur Fasquelle (Paris) Gallimard (Paris) 1958 807 p. : pl. et cartonnage ill. en coul. ; petit in-4 fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb31690508j +16 + +19970701 +20260318 +17.954374 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb472750729 La bête humaine / Émile Zola Zola, Émile (1840-1902). Auteur du texte Éditions Ararauna (Lunel) 2023 Collection : Cycle Les Rougon-Macquart ISBN 9782378848453 Code à barres commercial : EAN 9782378848453 1 vol. (318 p.) ; 22 cm fre texte imprimé printed text text Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb472750729 +17 + +20230623 +20240207 +17.683867 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb389426109 La grande illusion / Jean Renoir, réal., scénario, dial. ; Charles Spaak, scénario, dial. ; Joseph Kosma, comp. ; Jean Gabin, Dita Parlo, Pierre Fresnay... [et al.], act.. La bête humaine / Jean Renoir, réal., scénario, dial. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Kosma, Joseph (1905-1969). Compositeur Spaak, Charles (1903-1975). Scénariste Spaak, Charles (1903-1975). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Gabin, Jean (1904-1976). Acteur Parlo, Dita (1906-1971). Acteur Fresnay, Pierre (1897-1975). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur StudioCanal vidéo [éd.] (Boulogne-Billancourt) Universal music group [distrib.] (Paris) 2003 Collection : Classique Comprend : La grande illusion ; La bête humaine Numéro commercial : StudioCanal 196 909-9 Code à barres commercial : EAN 3259119690993 2 DVD vidéo monofaces double couche toutes zones (1h 48 min, 1 h 33 min) : 4/3, n. et b. (PAL), son. fre ger spa image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb389426109 +18 + +20030121 +20211118 +17.638731 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb39205438z La bête humaine / Jean Renoir, réal., scénario, dial. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Renoir, Jean (1894-1979). Scénariste Renoir, Jean (1894-1979). Dialoguiste Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur Studiocanal vidéo [éd.] (Issy-les-Moulineaux) Universal music [distrib.] (Paris) 2004 Collection : Acteurs, actrices de légendes Numéro commercial : Studiocanal vidéo 302 172-6 Code à barres commercial : EAN 3259130217261 1 DVD monoface simple couche zone 2 (1 h 40 min) : n. et b. (PAL), son. fre image animée moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb39205438z +19 + +20040702 +20260414 +17.539799 + + + +dc +xml + + http://catalogue.bnf.fr/ark:/12148/cb400818070 La bête humaine / Jean Renoir, réal. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act. Renoir, Jean (1894-1979). Réalisateur Zola, Émile (1840-1902). Auteur adapté Kosma, Joseph (1905-1969). Compositeur Gabin, Jean (1904-1976). Acteur Simon, Simone (1911-2005). Acteur Ledoux, Fernand (1897-1993). Acteur M6 interactions (Neuilly-sur-Seine) Nouvelles messageries de la presse parisienne [distrib.] (Paris) 2005 Numéro commercial : NMPP M09353 Code à barres commercial : EAN 3780935314997 1 fasc. (14 p.) : ill. en noir et en coul., couv. ill. ; 30 cm. - 1 DVD vidéo monoface double couche zone 2 (1 h 36 min) : n. et b. (PAL), son. fre multimédia multisupport kit text moving image Catalogue en ligne de la Bibliothèque nationale de France French National Library online Catalog + +ark:/12148/cb400818070 +20 + +20051209 +20120924 +17.236567 + + + +21 + diff --git a/MaBibli.Tests/FormatageIsbnTests.cs b/MaBibli.Tests/FormatageIsbnTests.cs new file mode 100644 index 0000000..a9e3930 --- /dev/null +++ b/MaBibli.Tests/FormatageIsbnTests.cs @@ -0,0 +1,86 @@ +using MaBibli.Shared.Isbn; + +namespace MaBibli.Tests; + +/// +/// Découpage des ISBN pour l'affichage. +/// +/// +/// Les valeurs attendues ci-dessous ne sont pas devinées : elles ont été calculées à partir du +/// RangeMessage.xml officiel de l'agence internationale (version du 2026-08-18), qui est +/// aussi la source des tables embarquées. +/// +public class FormatageIsbnTests +{ + /// + /// Le fonds francophone est découpé en entier, éditeur compris. + /// + [Theory] + [InlineData("9782846391009", "978-2-84639-100-9")] // le cas remonté en usage + [InlineData("9782070612758", "978-2-07-061275-8")] // Gallimard : éditeur sur 2 chiffres + [InlineData("9782253004226", "978-2-253-00422-6")] // Livre de poche : 3 chiffres + [InlineData("9782080704092", "978-2-08-070409-2")] // Flammarion : 2 chiffres + public void Decoupe_entierement_le_francophone(string nu, string attendu) + => Assert.Equal(attendu, FormatageIsbn.Afficher(nu)); + + /// + /// Un ISBN-10 est un ISBN-13 en 978 amputé de son préfixe : mêmes tranches, + /// une frontière de moins. + /// + [Theory] + [InlineData("2253004227", "2-253-00422-7")] + [InlineData("2080704095", "2-08-070409-5")] + public void Decoupe_aussi_les_ISBN10(string nu, string attendu) + => Assert.Equal(attendu, FormatageIsbn.Afficher(nu)); + + /// + /// Hors du francophone, on s'arrête au groupe et à la clé : deux frontières toujours + /// exactes. Le vrai découpage est 978-0-262-03384-8 — on ne l'invente pas, on + /// affiche une tranche de moins. Une coupure absente se lit encore, une coupure fausse trompe. + /// + [Theory] + [InlineData("9780262033848", "978-0-26203384-8")] + [InlineData("0262033844", "0-26203384-4")] + public void Ne_devine_pas_lediteur_hors_du_francophone(string nu, string attendu) + => Assert.Equal(attendu, FormatageIsbn.Afficher(nu)); + + /// + /// Ce qui n'est pas un ISBN valide ressort tel quel : un code mal saisi doit se voir + /// mal saisi, pas déguisé en ISBN par des tirets. + /// + [Theory] + [InlineData("9782846391008")] // clé fausse + [InlineData("978284639100")] // trop court + [InlineData("9772466671438")] // périodique : préfixe 977, aucun ISBN à découper + [InlineData("pas un isbn")] + public void Laisse_intact_ce_qui_nest_pas_un_ISBN(string brut) + => Assert.Equal(brut, FormatageIsbn.Afficher(brut)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Vide_reste_vide(string? brut) + => Assert.Equal(string.Empty, FormatageIsbn.Afficher(brut)); + + /// + /// Un ISBN déjà découpé — recopié d'une page web, par exemple — se redécoupe à l'identique + /// plutôt que de cumuler les tirets. + /// + [Fact] + public void Est_idempotent() + { + var une = FormatageIsbn.Afficher("9782846391009"); + Assert.Equal(une, FormatageIsbn.Afficher(une)); + } + + /// + /// ⚠️ Le contrat central : la valeur stockée reste nue. Ces tirets sont un habillage, + /// et la BnF indexe l'ISBN sans eux — les laisser filer dans une requête casserait le lookup. + /// + [Fact] + public void Le_decoupage_se_defait_par_Normaliser() + => Assert.Equal( + "9782846391009", + IsbnUtils.Normaliser(FormatageIsbn.Afficher("9782846391009"))); +} diff --git a/MaBibli.Tests/IsbnLookupServiceTests.cs b/MaBibli.Tests/IsbnLookupServiceTests.cs index 6468971..796bc2d 100644 --- a/MaBibli.Tests/IsbnLookupServiceTests.cs +++ b/MaBibli.Tests/IsbnLookupServiceTests.cs @@ -32,6 +32,19 @@ public class IsbnLookupServiceTests public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default) => Task.FromResult((new ResultatBibliographie(), (string?)null)); + + /// + /// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique) + /// est couvert par ses propres tests. L'interface l'exige, d'où ce stub. + /// + public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) => + Task.FromResult(((PeriodiqueDetecte?)null, (string?)null)); + + /// Recherche par titre : couverte par ses propres tests. + public Task<(IReadOnlyList, string?)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); } private sealed class OpenLibraryFake : IOpenLibraryClient @@ -197,5 +210,18 @@ public class IsbnLookupServiceTests public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default) => Task.FromResult((new ResultatBibliographie(), (string?)null)); + + /// + /// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique) + /// est couvert par ses propres tests. L'interface l'exige, d'où ce stub. + /// + public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) => + Task.FromResult(((PeriodiqueDetecte?)null, (string?)null)); + + /// Recherche par titre : couverte par ses propres tests. + public Task<(IReadOnlyList, string?)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); } } diff --git a/MaBibli.Tests/MigrationRangDesEnviesTests.cs b/MaBibli.Tests/MigrationRangDesEnviesTests.cs new file mode 100644 index 0000000..5929c64 --- /dev/null +++ b/MaBibli.Tests/MigrationRangDesEnviesTests.cs @@ -0,0 +1,111 @@ +using MaBibli.Api.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace MaBibli.Tests; + +/// +/// La migration RangDesEnvies, appliquée pour de vrai à une base déjà peuplée. +/// +/// +/// Ce test ne double pas ServiceSouhaitsTests : celui-ci crée son schéma avec +/// EnsureCreated, qui ne joue aucune migration. Le remplissage de la colonne +/// Rang n'y passerait donc jamais, et une erreur dans son SQL ne se verrait qu'en +/// production — sur les listes d'envies réelles, qui se réordonneraient sous les yeux de leur +/// propriétaire. +/// +public class MigrationRangDesEnviesTests : IDisposable +{ + private const string AvantLeRang = "20260818052834_ListeDEnvies"; + + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + + public MigrationRangDesEnviesTests() + { + // Connexion maintenue ouverte : une base SQLite « :memory: » disparaît avec elle. + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + _db = new MaBibliDbContext( + new DbContextOptionsBuilder().UseSqlite(_connexion).Options); + } + + private IMigrator Migrateur => _db.GetInfrastructure().GetRequiredService(); + + /// + /// Le cœur du test : la migration doit reconduire l'ordre affiché jusqu'ici — + /// auteurs renseignés d'abord, puis par auteur, puis par titre — et repartir de zéro pour + /// chaque utilisateur, le rang étant personnel comme le reste de la table. + /// + [Fact] + public async Task Reconduit_lancien_ordre_daffichage_utilisateur_par_utilisateur() + { + await Migrateur.MigrateAsync(AvantLeRang); + + // Insertion en SQL brut : à ce point du schéma, la colonne Rang n'existe pas encore, + // et l'entité C# la porte déjà. Passer par EF échouerait. + await ExecuterAsync(""" + INSERT INTO LivresSouhaites + (Id, Utilisateur, Titre, TitreNormalise, Auteur, AuteurNormalise, DateAjout) + VALUES + (1, 'mathieu', 'Germinal', 'germinal', 'Émile Zola', 'emile zola', '2026-01-01'), + (2, 'mathieu', 'Sans nom', 'sans nom', NULL, '', '2026-01-02'), + (3, 'mathieu', 'Au bonheur', 'au bonheur', 'Émile Zola', 'emile zola', '2026-01-03'), + (4, 'mathieu', 'Les fourmis','les fourmis','Bernard Werber', 'bernard werber', '2026-01-04'), + (5, 'camille', 'Sa liste', 'sa liste', 'Bernard Werber', 'bernard werber', '2026-01-05'), + (6, 'camille', 'Sans nom', 'sans nom', NULL, '', '2026-01-06'); + """); + + await Migrateur.MigrateAsync(); + + // Werber avant Zola, « Au bonheur » avant « Germinal », et l'envie sans auteur en + // dernier : exactement ce que la liste montrait avant la colonne. + Assert.Equal( + [(0, "Les fourmis"), (1, "Au bonheur"), (2, "Germinal"), (3, "Sans nom")], + await RangsAsync("mathieu")); + + // ⚠️ La numérotation de Camille repart de zéro : le rang n'a de sens qu'à l'intérieur + // d'une liste. Continuer celle de Mathieu ferait dépendre l'ordre de l'un de l'autre. + Assert.Equal([(0, "Sa liste"), (1, "Sans nom")], await RangsAsync("camille")); + } + + /// Une base sans aucune envie doit traverser la migration sans broncher. + [Fact] + public async Task Sapplique_aussi_a_une_base_vide() + { + await Migrateur.MigrateAsync(); + + Assert.Empty(await _db.LivresSouhaites.ToListAsync()); + } + + private async Task ExecuterAsync(string sql) + { + await using var commande = _connexion.CreateCommand(); + commande.CommandText = sql; + await commande.ExecuteNonQueryAsync(); + } + + private async Task> RangsAsync(string utilisateur) + { + // Contexte neuf : le précédent a pu mettre en cache le schéma d'avant la migration. + await using var db = new MaBibliDbContext( + new DbContextOptionsBuilder().UseSqlite(_connexion).Options); + + return await db.LivresSouhaites + .Where(s => s.Utilisateur == utilisateur) + .OrderBy(s => s.Rang) + .Select(s => new ValueTuple(s.Rang, s.Titre)) + .ToListAsync(); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/MaBibli.Tests/ServiceSouhaitsTests.cs b/MaBibli.Tests/ServiceSouhaitsTests.cs index 7dd92f8..1e0f25b 100644 --- a/MaBibli.Tests/ServiceSouhaitsTests.cs +++ b/MaBibli.Tests/ServiceSouhaitsTests.cs @@ -52,6 +52,119 @@ public class ServiceSouhaitsTests : IDisposable new EnregistrementSouhait { Titre = titre, Auteur = auteur, Isbn = isbn, Note = note }, utilisateur); + // ── Ordre choisi à la main ─────────────────────────────────────────────── + + private async Task> TitresAsync(string utilisateur = Mathieu) => + (await _souhaits.ListerAsync(utilisateur)).Select(s => s.Titre).ToList(); + + /// + /// L'ordre d'affichage est celui de la liste, pas l'alphabet : classer une liste d'envies + /// par titre répondait à une question que personne ne se pose. + /// + [Fact] + public async Task Les_envies_sortent_dans_lordre_dajout_par_defaut() + { + await AjouterAsync("Zèbre", auteur: "Zoé Zed"); + await AjouterAsync("Abricot", auteur: "Anne Alpha"); + + Assert.Equal(["Zèbre", "Abricot"], await TitresAsync()); + } + + [Fact] + public async Task Une_envie_nouvelle_se_pose_en_fin_de_liste() + { + await AjouterAsync("Premier"); + await AjouterAsync("Deuxième"); + await AjouterAsync("Troisième"); + + Assert.Equal(["Premier", "Deuxième", "Troisième"], await TitresAsync()); + } + + [Fact] + public async Task Reordonner_fixe_lordre_demande() + { + var a = (await AjouterAsync("Premier")).Souhait!.Id; + var b = (await AjouterAsync("Deuxième")).Souhait!.Id; + var c = (await AjouterAsync("Troisième")).Souhait!.Id; + + Assert.True(await _souhaits.ReordonnerAsync([c, a, b], Mathieu)); + + Assert.Equal(["Troisième", "Premier", "Deuxième"], await TitresAsync()); + } + + /// Réenvoyer le même ordre ne doit rien bouger. + [Fact] + public async Task Reordonner_est_idempotent() + { + var a = (await AjouterAsync("Premier")).Souhait!.Id; + var b = (await AjouterAsync("Deuxième")).Souhait!.Id; + + await _souhaits.ReordonnerAsync([b, a], Mathieu); + await _souhaits.ReordonnerAsync([b, a], Mathieu); + + Assert.Equal(["Deuxième", "Premier"], await TitresAsync()); + } + + /// + /// ⚠️ Le cas qui perdrait des données : la liste du client est périmée (une envie ajoutée + /// depuis un autre appareil n'y figure pas). L'absente doit survivre, rangée à la + /// suite — mal classée vaut infiniment mieux que disparue. + /// + [Fact] + public async Task Une_envie_absente_de_lordre_envoye_est_conservee() + { + var a = (await AjouterAsync("Premier")).Souhait!.Id; + var b = (await AjouterAsync("Deuxième")).Souhait!.Id; + await AjouterAsync("Ajoutée ailleurs"); + + await _souhaits.ReordonnerAsync([b, a], Mathieu); + + Assert.Equal(["Deuxième", "Premier", "Ajoutée ailleurs"], await TitresAsync()); + } + + /// + /// ⚠️ L'invariant de la table : réordonner ne doit pas offrir un moyen détourné de toucher + /// à la liste de quelqu'un d'autre en devinant un identifiant. + /// + [Fact] + public async Task Reordonner_ne_touche_jamais_la_liste_dun_autre() + { + var sienne = (await AjouterAsync("À Camille", utilisateur: Camille)).Souhait!.Id; + var a = (await AjouterAsync("Premier", utilisateur: Mathieu)).Souhait!.Id; + var b = (await AjouterAsync("Deuxième", utilisateur: Mathieu)).Souhait!.Id; + + // Mathieu glisse l'identifiant de Camille en tête de SA demande. + await _souhaits.ReordonnerAsync([sienne, b, a], Mathieu); + + Assert.Equal(["Deuxième", "Premier"], await TitresAsync(Mathieu)); + Assert.Equal(["À Camille"], await TitresAsync(Camille)); + } + + [Fact] + public async Task Reordonner_sans_identite_ne_fait_rien() + { + await AjouterAsync("Premier"); + + Assert.False(await _souhaits.ReordonnerAsync([1], null)); + } + + /// + /// Supprimer laisse des trous dans les rangs ; ils ne doivent pas déranger l'ordre, et le + /// prochain ajout doit tout de même atterrir en dernier. + /// + [Fact] + public async Task Une_suppression_ne_desordonne_pas_la_liste() + { + await AjouterAsync("Premier"); + var b = (await AjouterAsync("Deuxième")).Souhait!.Id; + await AjouterAsync("Troisième"); + + Assert.True(await _souhaits.SupprimerAsync(b, Mathieu)); + await AjouterAsync("Quatrième"); + + Assert.Equal(["Premier", "Troisième", "Quatrième"], await TitresAsync()); + } + // ── Portée personnelle ─────────────────────────────────────────────────── [Fact] @@ -189,18 +302,28 @@ public class ServiceSouhaitsTests : IDisposable Assert.Null(resultat.Souhait!.Isbn); } + /// + /// ⚠️ Contrat changé le 2026-08-18 : la liste était classée par auteur puis titre, + /// elle suit désormais le rang choisi par son propriétaire. L'alphabet répondait à + /// une question que personne ne se pose devant une liste d'envies ; « qu'est-ce que je veux + /// le plus ? » en est une. + /// + /// L'ancien ordre n'est pas perdu pour autant : la migration RangDesEnvies le + /// reconduit dans les rangs initiaux, pour que les listes existantes ne se réordonnent pas + /// toutes seules. C'est MigrationRangDesEnviesTests qui le vérifie. + /// + /// [Fact] - public async Task La_liste_est_ordonnee_par_auteur_puis_titre() + public async Task La_liste_ne_se_classe_plus_par_ordre_alphabetique() { await AjouterAsync("Les fourmis", "Bernard Werber"); await AjouterAsync("Sans auteur", null); await AjouterAsync("Germinal", "Émile Zola"); await AjouterAsync("Demain les chats", "Bernard Werber"); - var titres = (await _souhaits.ListerAsync(Mathieu)).Select(s => s.Titre).ToList(); - - // Werber avant Zola, ses titres classés entre eux, et l'envie sans auteur en dernier. - Assert.Equal(["Demain les chats", "Les fourmis", "Germinal", "Sans auteur"], titres); + Assert.Equal( + ["Les fourmis", "Sans auteur", "Germinal", "Demain les chats"], + await TitresAsync()); } [Fact] @@ -226,6 +349,108 @@ public class ServiceSouhaitsTests : IDisposable public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( string auteur, CancellationToken ct = default) => Task.FromResult((BnfBibliographieParser.Parser(xml, auteur), (string?)null)); + + /// Les périodiques n'interviennent pas dans la bibliographie par auteur. + public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) => + Task.FromResult(((PeriodiqueDetecte?)null, (string?)null)); + + /// Recherche par titre : couverte par ses propres tests. + public Task<(IReadOnlyList, string?)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + + /// + /// Client BnF qui ne répond pas : c'est le cas « Robert A. Harper » remonté en usage. + /// + private sealed class BnfMuetFake(EtatSourceBibliographie etat, string message) : IBnfClient + { + public Task<(IReadOnlyList, string?)> RechercherAsync( + string isbn, string? urlCouverture, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); + + public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync( + string auteur, CancellationToken ct = default) => + Task.FromResult((new ResultatBibliographie { Etat = etat }, (string?)message)); + + public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync( + string issn, CancellationToken ct = default) => + Task.FromResult(((PeriodiqueDetecte?)null, (string?)null)); + + public Task<(IReadOnlyList, string?)> RechercherParTitreAsync( + string titre, string? auteur, CancellationToken ct = default) => + Task.FromResult(((IReadOnlyList)[], (string?)null)); + } + + /// + /// ⚠️ Le défaut que ce lot corrige. Une BnF muette rend une liste vide, exactement + /// comme un auteur réellement absent de son catalogue. Sans SourceMuette, l'écran + /// affirmait « la BnF ne connaît aucun livre de cet auteur » à propos d'une question qu'il + /// n'avait pas pu poser. + /// + [Theory] + [InlineData(EtatSourceBibliographie.DelaiDepasse)] + [InlineData(EtatSourceBibliographie.Injoignable)] + [InlineData(EtatSourceBibliographie.ReponseIllisible)] + public async Task Une_BnF_muette_ne_se_confond_pas_avec_un_auteur_sans_oeuvre( + EtatSourceBibliographie etat) + { + var biblio = await BibliographieAsync(new BnfMuetFake(etat, "La BnF n'a pas répondu.")); + + Assert.Empty(biblio.Oeuvres); + Assert.True(biblio.SourceMuette); + Assert.Equal(etat, biblio.Etat); + Assert.NotNull(biblio.Avertissement); + } + + /// + /// Le pendant : la BnF a répondu, elle ne connaît simplement rien. Là, et là + /// seulement, l'écran a le droit d'expliquer l'absence. + /// + [Fact] + public async Task Une_BnF_qui_repond_sans_rien_connaitre_nest_pas_muette() + { + var biblio = await BibliographieAsync(new BnfBibliographieFake(ReponseVide)); + + Assert.Empty(biblio.Oeuvres); + Assert.False(biblio.SourceMuette); + Assert.Equal(EtatSourceBibliographie.Ok, biblio.Etat); + Assert.Null(biblio.Avertissement); + } + + /// Une bibliographie qui a abouti n'est jamais signalée comme muette. + [Fact] + public async Task Une_bibliographie_obtenue_nest_pas_muette() + { + var biblio = await BibliographieAsync( + new BnfBibliographieFake(Fixture.Lire("bnf-auteur-werber.xml"))); + + Assert.NotEmpty(biblio.Oeuvres); + Assert.False(biblio.SourceMuette); + } + + private const string ReponseVide = """ + + 0 + + """; + + /// Bibliographie de Werber avec le client BnF fourni : seule la source varie. + /// + /// Bibliographie de Werber avec le client BnF fourni : seule la source varie. + /// L'auteur doit exister au catalogue, une bibliographie partant toujours d'un auteur à soi. + /// + private async Task BibliographieAsync(IBnfClient bnf) + { + await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu); + + var auteurs = new ServiceAuteurs(_db); + var service = new ServiceBibliographie(_db, bnf, auteurs); + + var werber = (await auteurs.ListerAsync()).Single(a => a.Nom.Contains("Werber")); + return (await service.ObtenirAsync(werber.Id, Mathieu))!; } private async Task BibliographieWerberAsync(string utilisateur = Mathieu)