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)