Compare commits

2 Commits
Author SHA1 Message Date
mathieuandClaude Opus 5 455c2b711a Retirer d'IDEES.md une entrée déjà traitée
« Titres BnF contenant le nom de l'auteur » y figurait encore comme
correction envisagée, alors que NettoyageIsbd.RetirerAuteurEnFin la met
en œuvre depuis la phase du lookup ISBN — avec des tests sur le cas réel
qui l'avait motivée (ISBN 9782749942636, « … Aurélien Barrau » recollé
en fin de titre sans séparateur ISBD).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:15:09 +02:00
mathieuandClaude Opus 5 5cf566bc33 Traiter les retours d'usage du 2026-08-18 (2ᵉ série)
Six lots, issus d'IDEES.md. Les décisions actées sont reportées dans
CLAUDE.md, et les entrées traitées retirées d'IDEES.md.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:14:01 +02:00
44 changed files with 3857 additions and 228 deletions
+377 -2
View File
@@ -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 `<a>` 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 `<a>` 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
+63 -15
View File
@@ -8,21 +8,6 @@ Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé vo
## Qualité des données
### Titres BnF contenant le nom de l'auteur
**Cas constaté** — ISBN `9782749942636` :
```
dc:title = "Le plus grand défi de l'histoire de l'humanité : face à la
catastrophe écologique et sociale (Nouvelle éd. augmentée)
Aurélien Barrau"
dc:creator = "Barrau, Aurélien (1973-....). Auteur du texte"
```
Le nom de l'auteur est collé en fin de titre **sans le séparateur ISBD ` / `**. La règle de nettoyage documentée dans `CLAUDE.md` ne peut donc pas s'appliquer : il n'y a pas de ponctuation à couper. Ce n'est ni un doublon, ni un défaut de notre parsing — la notice est sale à la source.
Correction envisagée : une fois `dc:creator` nettoyé, si le titre **se termine** par ce nom exact, le retirer. Heuristique volontairement stricte (comparaison en fin de chaîne uniquement) pour ne pas mutiler les titres qui contiennent légitimement un nom de personne.
### Recherche insensible aux accents
« Emile » ne trouve pas « Émile ». Gênant sur un fonds francophone.
@@ -62,3 +47,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.
@@ -0,0 +1,304 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <summary>
/// Ajoute le rang manuel de la liste d'envies (du plus désiré au moins désiré).
/// </summary>
/// <remarks>
/// ⚠️ La colonne seule ne suffit pas : à <c>0</c> 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 <b>reconduit
/// exactement l'ordre affiché jusqu'ici</b> — auteurs renseignés d'abord, puis par auteur,
/// puis par titre — de sorte que la mise à jour ne se voie pas.
/// <para>
/// Le rang est <b>personnel</b>, comme toute la table : la numérotation repart de zéro pour
/// chaque <c>Utilisateur</c>, d'où la corrélation sur cette colonne dans la sous-requête.
/// </para>
/// </remarks>
public partial class RangDesEnvies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
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
)
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Rang",
table: "LivresSouhaites");
}
}
}
@@ -137,6 +137,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
+33
View File
@@ -32,6 +32,39 @@ public static class IsbnEndpoints
.Produces<ResultatLookupIsbn>()
.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<ResultatLookupIsbn>()
.Produces(StatusCodes.Status400BadRequest);
return routes;
}
}
+25 -2
View File
@@ -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<IReadOnlyList<SouhaitDto>>();
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,
@@ -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,
};
}
@@ -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
/// <summary>Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé.</summary>
public int EcarteesAutreAuteur { get; init; }
/// <summary>
/// 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.
/// </summary>
public EtatSourceBibliographie Etat { get; init; }
}
/// <summary>
@@ -171,6 +180,77 @@ public static class BnfBibliographieParser
};
}
/// <summary>
/// Convertit une réponse SRU interrogée <b>par titre</b> en candidats à choisir.
/// </summary>
/// <remarks>
/// Le filtre de type est <b>indispensable ici aussi</b>, et pour la même raison qu'en
/// bibliographie : mesuré le 2026-08-18, <c>bib.title all "la bête humaine"</c> 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.
/// <para>
/// ⚠️ En revanche <b>aucun post-filtre sur l'auteur</b>, contrairement à
/// <see cref="Parser"/> : la contrainte de titre resserre déjà beaucoup, l'auteur est
/// facultatif dans cette recherche, et surtout l'utilisateur <b>choisit dans la liste</b>
/// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions
/// légitimes qu'il aurait su reconnaître.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static IReadOnlyList<CandidatLivre> ParserRecherche(string xml)
{
var doc = XDocument.Parse(xml);
var candidats = new List<CandidatLivre>();
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;
}
/// <summary>
/// La notice décrit-elle un livre ?
/// </summary>
+164 -3
View File
@@ -21,6 +21,28 @@ public interface IBnfClient
/// </remarks>
Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par ISSN</b> pour nommer un périodique.
/// </summary>
/// <remarks>
/// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le
/// préfixe <c>977</c> : rien n'est catalogué à partir de là. Une BnF injoignable renvoie
/// <c>null</c> et un avertissement — l'écran dira alors « un magazine, titre inconnu »
/// plutôt que de laisser croire à un échec de recherche de livre.
/// </remarks>
Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par titre</b>, éventuellement restreint à un auteur.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default);
}
/// <summary>
@@ -70,6 +92,106 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> 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}.");
}
}
/// <summary>Notices rendues par une recherche par titre : de quoi choisir sans faire défiler.</summary>
public const int NoticesRecherche = 20;
public async Task<(IReadOnlyList<CandidatLivre> 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} ».");
}
}
/// <summary>
/// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static string Echapper(string terme) => terme.Trim().Replace("\"", " ");
/// <summary>
/// Notices lues par page, et nombre de pages : au plus <b>200 notices</b>.
/// </summary>
@@ -109,23 +231,62 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> 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);
}
}
/// <summary>
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
/// </summary>
/// <remarks>
/// ⚠️ Public parce que ce texte <b>atteint l'utilisateur</b> : 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
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.",
};
/// <summary>Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif.</summary>
private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) =>
(new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, auteur));
private async Task<ResultatBibliographie?> LirePageAsync(
string auteur, int premiereNotice, CancellationToken ct)
{
+45
View File
@@ -79,6 +79,51 @@ public static class BnfSruParser
return candidats;
}
/// <summary>
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
/// </summary>
/// <remarks>
/// 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 <b>nommer</b> 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.
/// <para>
/// Une notice de revue porte souvent <b>deux</b> <c>dc:title</c> : 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.
/// </para>
/// </remarks>
/// <returns><c>null</c> si la réponse ne contient aucune notice exploitable.</returns>
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();
@@ -29,6 +29,17 @@ public sealed class IsbnLookupService(
public async Task<ResultatLookupIsbn?> 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(
};
}
/// <summary>
/// Nomme le périodique scanné, sans rien chercher du côté des livres.
/// </summary>
/// <remarks>
/// Le résultat ne porte <b>jamais</b> 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.
/// </remarks>
private async Task<ResultatLookupIsbn> DecrirePeriodiqueAsync(string ean, CancellationToken ct)
{
var issn = CodePeriodique.IssnDepuis(ean)!;
var (periodique, avertissement) = await bnf.RechercherPeriodiqueAsync(issn, ct);
var avertissements = new List<string>();
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<string> avertissements, string? message)
{
if (!string.IsNullOrEmpty(message))
@@ -166,6 +166,20 @@ public static partial class NettoyageIsbd
return s.Length == 0 ? null : s;
}
/// <summary>
/// Titre d'un <b>périodique</b> : couper la mention de responsabilité puis retirer le
/// qualificatif entre parenthèses.
/// </summary>
/// <remarks>
/// Mesuré sur l'ISSN <c>2466-6718</c> le 2026-08-18 : la BnF donne
/// « <c>Médor (Bruxelles)</c> ». Le parenthésé n'est pas une ville d'édition mais le
/// <i>qualificatif de désambiguïsation</i> 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.
/// </remarks>
public static string? TitrePeriodique(string? brut) => Editeur(Titre(brut));
/// <summary>
/// Retire les virgules et espaces de fin, et le point final <b>sauf</b> s'il appartient
/// à une initiale (« Cormen, Thomas H. » doit garder son point).
@@ -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();
}
/// <summary>
/// ISBN découpé pour la ligne texte : c'est un numéro qu'on <b>épelle à un libraire</b>,
/// et les tranches sont ce qui permet de ne pas se perdre au milieu de treize chiffres.
/// </summary>
private static string? Isbn(string? isbn) =>
string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {isbn}";
string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {FormatageIsbn.Afficher(isbn)}";
private static void Ligne(StringBuilder sortie, params string?[] champs)
{
@@ -23,6 +23,17 @@ public interface IServiceSouhaits
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
/// <summary>
/// Réordonne la liste de l'appelant selon la suite d'identifiants fournie.
/// </summary>
/// <remarks>
/// Prend la liste <b>entière</b> 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.
/// </remarks>
Task<bool> ReordonnerAsync(
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
@@ -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<bool> ReordonnerAsync(
IReadOnlyList<int> 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();
+20
View File
@@ -43,6 +43,26 @@
@Body
</main>
@*
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.
*@
<nav class="onglets" aria-label="Navigation principale">
<NavLink class="onglet" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="onglet" href="auteurs">Auteurs</NavLink>
<NavLink class="onglet" href="prets">Prêts</NavLink>
<NavLink class="onglet" href="souhaits">Envies</NavLink>
</nav>
@code {
private UtilisateurCourant? _utilisateur;
private bool _etaitEnLigne = true;
+47 -2
View File
@@ -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);
}
+121 -7
View File
@@ -12,6 +12,7 @@
{
<p class="message-discret">
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
Une douchette USB fonctionne telle quelle : le champ est déjà actif.
</p>
@*
@@ -27,8 +28,17 @@
</p>
}
@*
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.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
@ref="_champIsbn"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="ISBN" autocomplete="off" />
</div>
@@ -51,6 +61,18 @@
</div>
}
@*
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)
{
<p class="message-discret" role="status">
Recherche de <span class="code-isbn">@FormatageIsbn.Afficher(_isbn)</span> à la BnF, puis chez OpenLibrary…
</p>
}
@if (_etape == Etape.Scan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
@@ -61,6 +83,11 @@
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_messagePeriodique is not null)
{
<p class="message-avertissement" role="status">@_messagePeriodique</p>
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
@@ -98,7 +125,7 @@
<span class="etiquette">@candidat.Source</span>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<span>trouvé via @candidat.IsbnInterroge</span>
<span>trouvé via <span class="code-isbn">@FormatageIsbn.Afficher(candidat.IsbnInterroge)</span></span>
}
</p>
@@ -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;
/// <summary>
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
/// </summary>
/// <remarks>
/// C'est ce qui rend une <b>douchette USB</b> 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.
/// </remarks>
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 @@
}
}
/// <summary>
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
/// </summary>
/// <remarks>
/// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est
/// pré-rempli, mais <b>pas l'ISBN</b> — le code-barres d'une revue n'en est pas un, et le
/// ranger dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche.
/// </remarks>
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;
}
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
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()
+336
View File
@@ -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.
*@
<PageTitle>MaBibli — ajouter une envie</PageTitle>
<h1 class="titre-page">Ajouter une envie</h1>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">
Hors ligne : la recherche interroge la BnF et OpenLibrary, et l'enregistrement passe par
le serveur. Votre liste reste consultable.
</p>
}
<div class="onglets-secondaires" role="group" aria-label="Manière de chercher">
<button type="button" class="segment @(_mode == Mode.Titre ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Titre)">Par titre</button>
<button type="button" class="segment @(_mode == Mode.Isbn ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
<button type="button" class="segment @(_mode == Mode.Manuel ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Manuel)">À la main</button>
</div>
@if (_mode == Mode.Titre)
{
<div class="champ">
<label class="champ-libelle" for="titre-cherche">Titre <span class="obligatoire">*</span></label>
<input id="titre-cherche" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_titreCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="La bête humaine" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-cherche">Auteur</label>
<input id="auteur-cherche" class="champ-saisie" type="text"
@bind="_auteurCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="Facultatif : resserre la recherche" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titreCherche))"
title="@MotifBlocage" @onclick="ChercherParTitreAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
}
@if (_mode == Mode.Isbn)
{
<div class="champ">
<label class="champ-libelle" for="isbn-cherche">ISBN</label>
<input id="isbn-cherche" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @ref="_premierChamp"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche" />
<p class="champ-aide">Le livre est en main : le code-barres suffit.</p>
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_isbn))"
title="@MotifBlocage" @onclick="ChercherParIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
}
@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. *@
<p class="message-discret">
@_candidats.Count notice@(_candidats.Count > 1 ? "s" : "") trouvée@(_candidats.Count > 1 ? "s" : "").
Choisissez celle qui correspond, ou complétez à la main.
</p>
<ul class="liste-candidats">
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
{
<li class="carte-candidat" @key="index">
<Couverture Url="@candidat.CoverUrl" Titre="@candidat.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
@if (!string.IsNullOrWhiteSpace(candidat.Auteur))
{
<p class="carte-auteur">@candidat.Auteur</p>
}
<p class="candidat-distinction">
<span class="candidat-editeur">@(candidat.Editeur ?? "éditeur inconnu")</span>
<span class="candidat-annee">@(candidat.Annee ?? "année inconnue")</span>
</p>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<p class="carte-details">
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(candidat.IsbnInterroge)</span>
</p>
}
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterCandidatAsync(candidat)">
Ajouter à ma liste
</button>
</div>
</li>
}
</ul>
}
@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. *@
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text"
@bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
title="@MotifBlocage" @onclick="() => AjouterAsync(_saisie)">
Ajouter à ma liste
</button>
</div>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="souhaits">Retour à ma liste</a>
</nav>
@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<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _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;
/// <summary>
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<Task<ResultatLookupIsbn?>> 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;
}
}
/// <summary>
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
/// </summary>
/// <remarks>
/// C'est ce qui remplit enfin <c>LivreSouhaite.CoverUrl</c>, 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.
/// </remarks>
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");
}
}
-5
View File
@@ -84,11 +84,6 @@ else
</ul>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
</nav>
@code {
private IReadOnlyList<AuteurDto>? _auteurs;
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
+89 -15
View File
@@ -1,5 +1,6 @@
@page "/auteurs/{AuteurId:int}/bibliographie"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
<PageTitle>MaBibli — bibliographie</PageTitle>
@@ -12,23 +13,72 @@ else if (_erreur is not null)
{
<h1 class="titre-page">Bibliographie</h1>
<p class="message-erreur" role="alert">@_erreur</p>
@* 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)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="ChargerAsync">
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
</button>
</div>
}
}
else if (_bibliographie is { } biblio)
{
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
@if (biblio.Avertissement is not null)
{
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
}
@*
⚠️ 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)
{
<p class="message-avertissement" role="alert">
@biblio.Avertissement
</p>
<p class="message-discret">
La bibliographie n'a donc <strong>pas pu être établie</strong> : cet écran ne dit
rien de ce que cet auteur a écrit.
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="ChargerAsync">
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
</button>
</div>
}
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. *@
<p class="message-discret">
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.
</p>
}
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. *@
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
}
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)
}
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="auteurs">Retour aux auteurs</a>
@if (_bibliographie is { } b)
{
@* « Retour aux auteurs » est parti : l'onglet Auteurs fait exactement cela, en permanence.
Ne reste que ce qu'aucun onglet ne sait faire — le catalogue restreint à cet auteur. *@
@if (_bibliographie is { } b)
{
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
}
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
</nav>
</nav>
}
@code {
[Parameter]
@@ -166,6 +216,11 @@ else if (_bibliographie is { } biblio)
private BibliographieDto? _bibliographie;
private bool _seulementManquants;
private bool _enCours;
private bool _chargement;
/// <summary>Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.</summary>
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;
}
}
+103 -33
View File
@@ -5,7 +5,21 @@
<PageTitle>MaBibli — catalogue</PageTitle>
<h1 class="titre-page">Catalogue</h1>
@*
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.
*@
<div class="entete-page">
<h1 class="titre-page">Catalogue</h1>
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@_filtresOuverts" aria-controls="filtres-catalogue"
@onclick="() => _filtresOuverts = !_filtresOuverts">
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
</button>
</div>
@if (_auteur is not null)
{
@@ -21,29 +35,44 @@
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
</div>
<div class="filtres">
<div class="segments" role="group" aria-label="Filtrer par format">
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(null)">Tous</button>
@foreach (var format in Enum.GetValues<Format>())
@if (_filtresOuverts)
{
<div class="filtres" id="filtres-catalogue">
@* 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)
{
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
<div class="segments" role="group" aria-label="Filtrer par format">
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(null)">Tous</button>
@foreach (var format in Enum.GetValues<Format>())
{
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
}
</div>
}
</div>
@* 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 ». *@
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
}
@* ⚠️ 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. *@
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
}
</div>
<p class="champ-aide">
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
lecture, pas celle du foyer.
</p>
</div>
</div>
}
@if (_erreur is not null)
{
@@ -122,25 +151,27 @@ else if (_livres is not null)
</ul>
}
@* 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.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter un livre</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter par ISBN</button>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Saisie manuelle</button>
title="@EtatReseau.MotifHorsLigne">Ajouter un livre</button>
}
<a class="bouton bouton-discret" href="prets">Prêts</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
<a class="bouton bouton-discret" href="souhaits">Envies</a>
</nav>
@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;
/// <summary>
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
/// </summary>
/// <remarks>
/// ⚠️ Ne se calcule que sur un chargement <b>sans aucun critère</b>. 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.
/// </remarks>
private IReadOnlySet<Format>? _formatsDuFonds;
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
/// <summary>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
/// <remarks>
/// 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é.
/// </remarks>
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();
}
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
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
{
+2 -2
View File
@@ -115,9 +115,10 @@ else
<div><dt>Éditeur</dt><dd>@_livre.Editeur</dd></div>
}
@* Tirets à l'affichage seulement : la valeur stockée reste nue. *@
@if (!string.IsNullOrWhiteSpace(_livre.Isbn))
{
<div><dt>ISBN</dt><dd>@_livre.Isbn</dd></div>
<div><dt>ISBN</dt><dd class="code-isbn">@FormatageIsbn.Afficher(_livre.Isbn)</dd></div>
}
@* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
@@ -166,7 +167,6 @@ else
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Éditer</button>
}
<a class="bouton bouton-discret" href="">Catalogue</a>
</nav>
}
-4
View File
@@ -73,10 +73,6 @@ else
</ul>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Catalogue</a>
</nav>
@code {
private IReadOnlyList<PretDto>? _prets;
private string? _erreur;
+195 -116
View File
@@ -1,5 +1,7 @@
@page "/souhaits"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — liste d'envies</PageTitle>
@@ -24,56 +26,28 @@
}
else
{
<section class="bloc-souhait-ajout">
<h2 class="titre-section">Ajouter une envie</h2>
@* 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. *@
<div class="champ">
<label class="champ-libelle" for="isbn-souhait">Pré-remplir depuis un ISBN</label>
<div class="ligne-isbn">
<input id="isbn-souhait" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @bind="_isbn" @bind:event="oninput" />
<button type="button" class="bouton bouton-discret"
disabled="@(_recherche || string.IsNullOrWhiteSpace(_isbn))"
@onclick="ChercherIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
<p class="champ-aide">Facultatif : les champs ci-dessous restent modifiables.</p>
</div>
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || string.IsNullOrWhiteSpace(_saisie.Titre))"
@onclick="AjouterAsync">
Ajouter à ma liste
</button>
</div>
</section>
@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é. *@
<p class="message-discret">
Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un
auteur depuis <a href="auteurs">la liste des auteurs</a>.
@if (Reseau.EnLigne)
{
<text>
Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la
bibliographie d'un auteur depuis <a href="auteurs">la liste des auteurs</a>.
</text>
}
else
{
<text>
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.
</text>
}
</p>
}
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.
*@
<div class="actions-export">
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en texte</button>
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en CSV</button>
}
</div>
@if (!Reseau.EnLigne)
{
<p class="message-discret">
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.
</p>
}
@if (_souhaits.Count > 1)
{
<p class="champ-aide">
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.
</p>
}
@*
⚠️ 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.
*@
<ul class="liste-souhaits">
@foreach (var souhait in _souhaits)
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
{
<li class="carte-souhait" @key="souhait.Id">
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
@key="souhait.Id"
draggable="@((Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => _glisse = souhait.Id"
@ondragend="() => _glisse = null"
@ondragover:preventDefault="true"
@ondrop="() => DeposerAsync(index)">
@if (_souhaits.Count > 1)
{
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _souhaits.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
</div>
}
<Couverture Url="@souhait.CoverUrl" Titre="@souhait.Titre"
Classe="couverture-petite" Agrandissable="true" />
@@ -117,7 +157,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(souhait.Isbn))
{
<span class="etiquette">ISBN @souhait.Isbn</span>
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(souhait.Isbn)</span>
}
</p>
@@ -128,7 +168,8 @@ else
</div>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@_enCours" @onclick="() => SupprimerAsync(souhait)"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
@@ -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. *@
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
}
</nav>
@code {
private IReadOnlyList<SouhaitDto>? _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();
}
/// <summary>
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
/// </summary>
/// <remarks>
/// On s'abonne à <c>Change</c> et jamais à <c>SynchroChange</c> : recharger écrit un
/// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin.
/// </remarks>
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private const string MotifExport =
"L'export est produit par le serveur : indisponible hors ligne.";
/// <remarks>
/// ⚠️ 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.
/// </remarks>
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.";
}
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <b>localement</b> 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.
/// <para>
/// La liste affichée est réordonnée <b>avant</b> 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.
/// </para>
/// </remarks>
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();
}
}
/// <summary>Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.</summary>
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)
+16
View File
@@ -18,6 +18,22 @@ public static class ClesCache
public const string PretsEnCours = "prets-en-cours";
public const string Utilisateur = "utilisateur";
/// <summary>
/// La liste d'envies de l'utilisateur courant.
/// </summary>
/// <remarks>
/// ⚠️ Cet instantané est le seul à contenir des données <b>personnelles</b> : 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.
/// <para>
/// Sa raison d'être : la liste s'emporte <b>en librairie</b>, exactement l'endroit où le
/// réseau manque. C'est le même usage que l'export <c>.txt</c>, lequel est produit côté
/// serveur et devient donc impossible hors-ligne.
/// </para>
/// </remarks>
public const string Souhaits = "souhaits";
}
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
+108 -9
View File
@@ -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.
/// </remarks>
public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct) ?? [];
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default)
public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
return await InterpreterAsync<SouhaitDto>(reponse, ct);
var (ok, souhaits) = await EssayerAsync(
() => http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []);
return souhaits ?? [];
}
var instantane = await LireCacheAsync<List<SouhaitDto>>(ClesCache.Souhaits);
return instantane?.Donnees ?? [];
}
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default) =>
await EcrireAsync<SouhaitDto>(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
/// <summary>
/// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher.
/// </summary>
/// <remarks>
/// ⚠️ Ne <b>pas</b> passer par <see cref="EcrireAsync{T}"/> : la suppression répond
/// <c>204 No Content</c>, 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.
/// </remarks>
public async Task<string?> 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;
}
}
/// <summary>
/// Fixe l'ordre de la liste d'envies. <c>null</c> si c'est fait, sinon le motif à afficher.
/// </summary>
/// <remarks>
/// Envoie la liste <b>entière</b> 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 <c>204 No Content</c> et ne
/// passe donc pas par <see cref="EcrireAsync{T}"/>.
/// </remarks>
public async Task<string?> ReordonnerSouhaitsAsync(
IReadOnlyList<int> 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;
}
}
/// <summary>
/// Cherche des notices par titre, éventuellement restreint à un auteur.
/// </summary>
/// <remarks>
/// <b>Exige le réseau</b>, 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.
/// </remarks>
public async Task<ResultatLookupIsbn?> 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<ResultatLookupIsbn>(reponse, ct)
: null;
});
return ok ? resultat : null;
}
/// <summary>
+1
View File
@@ -13,3 +13,4 @@
@using MaBibli.Shared.Catalogue
@using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites
@using MaBibli.Shared.Isbn
+69 -1
View File
@@ -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;
}
+30
View File
@@ -0,0 +1,30 @@
namespace MaBibli.Shared.Dtos;
/// <summary>
/// Ce qu'on sait d'un code-barres qui s'est révélé être celui d'un <b>périodique</b>
/// (préfixe <c>977</c>) et non d'un livre.
/// </summary>
/// <remarks>
/// Le modèle de données ne gère <b>pas</b> les périodiques : ni numéro, ni date de parution.
/// Cet objet n'existe donc pas pour cataloguer une revue, mais pour <b>dire à l'utilisateur ce
/// qu'il vient de scanner</b> 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.
/// </remarks>
public record PeriodiqueDetecte
{
/// <summary>ISSN déduit du code-barres, forme canonique à tiret (<c>2466-6718</c>).</summary>
public required string Issn { get; init; }
/// <summary>
/// Titre de la revue d'après la BnF, ou <c>null</c> 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.
/// </summary>
public string? Titre { get; init; }
/// <summary>Éditeur de la revue, quand la notice en porte un.</summary>
public string? Editeur { get; init; }
/// <summary>Année de début de publication de la revue (et non du numéro scanné).</summary>
public string? Annee { get; init; }
}
@@ -23,4 +23,11 @@ public record ResultatLookupIsbn
/// Une source en panne ne fait pas échouer la cascade, elle laisse une trace ici.
/// </summary>
public IReadOnlyList<string> Avertissements { get; init; } = [];
/// <summary>
/// Renseigné <b>uniquement</b> quand le code scanné s'est révélé être celui d'un périodique
/// (préfixe <c>977</c>). Dans ce cas <see cref="Candidats"/> est toujours vide : la cascade
/// ISBN n'a pas été lancée, elle ne pouvait pas aboutir.
/// </summary>
public PeriodiqueDetecte? Periodique { get; init; }
}
+42
View File
@@ -106,6 +106,34 @@ public record OeuvreBibliographie
public bool ADecouvrir => !Possede && !Souhaite;
}
/// <summary>
/// Ce qu'il est advenu de l'interrogation de la BnF.
/// </summary>
/// <remarks>
/// ⚠️ <b>Une source muette n'est pas une bibliographie vide</b>, 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
public enum EtatSourceBibliographie
{
/// <summary>La BnF a répondu. La liste rendue est ce qu'elle sait, y compris si elle est vide.</summary>
Ok = 0,
/// <summary>La BnF n'a pas répondu à temps. Cas le plus fréquent, et le plus souvent passager.</summary>
DelaiDepasse = 1,
/// <summary>La BnF n'a pas pu être jointe, ou a répondu par une erreur.</summary>
Injoignable = 2,
/// <summary>La BnF a répondu, mais sa réponse n'était pas exploitable.</summary>
ReponseIllisible = 3,
}
/// <summary>
/// Bibliographie d'un auteur du catalogue, telle que la BnF la connaît.
/// </summary>
@@ -134,4 +162,18 @@ public record BibliographieDto
/// <summary>Message à afficher quand la source n'a pas pu être interrogée normalement.</summary>
public string? Avertissement { get; init; }
/// <summary>
/// Ce qu'il est advenu de l'interrogation. <see cref="EtatSourceBibliographie.Ok"/> ne veut
/// pas dire « des résultats », mais « une réponse ».
/// </summary>
public EtatSourceBibliographie Etat { get; init; }
/// <summary>
/// Vrai quand la BnF n'a pas répondu : la liste vide ne prouve alors <b>rien</b>.
/// </summary>
/// <remarks>
/// C'est ce que l'interface doit consulter avant d'expliquer une absence de résultats.
/// </remarks>
public bool SourceMuette => Etat != EtatSourceBibliographie.Ok;
}
+14
View File
@@ -75,6 +75,20 @@ public class LivreSouhaite
public DateTime DateAjout { get; set; }
/// <summary>
/// Rang dans la liste, <b>du plus désiré (0) au moins désiré</b>.
/// </summary>
/// <remarks>
/// L'ordre est choisi à la main : c'est une préférence, elle ne se calcule pas. Il est
/// <b>personnel comme le reste de la table</b> — 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.
/// <para>
/// Une envie nouvelle se pose <b>en fin</b> 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.
/// </para>
/// </remarks>
public int Rang { get; set; }
public void RecalculerFormes()
{
Titre = Titre.Trim();
+62
View File
@@ -0,0 +1,62 @@
using System.Globalization;
namespace MaBibli.Shared.Isbn;
/// <summary>
/// Reconnaissance des codes-barres de <b>périodiques</b> et extraction de leur ISSN.
/// </summary>
/// <remarks>
/// Un livre porte un EAN-13 préfixé <c>978</c> ou <c>979</c> ; un magazine porte <c>977</c>,
/// qui encode un <b>ISSN</b> — c'est-à-dire un <i>titre de revue</i>, pas un ouvrage. Aucune
/// recherche <c>bib.isbn</c> 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.
/// <para>
/// Structure du code, vérifiée sur <c>9772466671438</c> le 2026-08-18 :
/// <c>977</c> + les <b>7 premiers chiffres de l'ISSN</b> (sa clé n'y figure pas, elle est
/// recalculée) + <b>2 chiffres de parution</b> + 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
/// <b>pas</b> exploitables comme numéro d'édition fiable, et ne sont donc pas exposés ici.
/// </para>
/// </remarks>
public static class CodePeriodique
{
/// <summary>Préfixe EAN-13 réservé aux publications en série.</summary>
public const string Prefixe = "977";
/// <summary>
/// Vrai si <paramref name="ean"/> (déjà normalisé) est un EAN-13 valide de périodique.
/// </summary>
public static bool EstCodePeriodique(string? ean) =>
IsbnUtils.EstIsbn13Valide(ean) && ean!.StartsWith(Prefixe, StringComparison.Ordinal);
/// <summary>
/// ISSN porté par le code-barres, sous sa forme canonique à tiret (<c>2466-6718</c>),
/// ou <c>null</c> si le code n'est pas celui d'un périodique.
/// </summary>
/// <remarks>
/// La clé de l'ISSN n'est pas dans le code-barres : elle se recalcule (somme pondérée 8→2,
/// modulo 11, <c>X</c> si le complément vaut 10). C'est le même principe que la clé ISBN-10,
/// sur sept chiffres au lieu de neuf.
/// </remarks>
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}";
}
}
+205
View File
@@ -0,0 +1,205 @@
namespace MaBibli.Shared.Isbn;
/// <summary>
/// Découpage d'un ISBN en tranches séparées par des tirets, <b>pour l'affichage seulement</b>.
/// </summary>
/// <remarks>
/// <c>9782846391009</c> → <c>978-2-84639-100-9</c>. La valeur <b>stockée reste nue</b> : 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 <see cref="Afficher"/>.
/// <para>
/// Le découpage dépend du <b>groupe linguistique</b> puis de l'<b>éditeur</b>, et les tranches
/// sont publiées par l'agence internationale — elles ne se devinent pas. Les tables ci-dessous
/// sont extraites du <c>RangeMessage.xml</c> officiel (<c>isbn-international.org</c>), version
/// du 2026-08-18.
/// </para>
/// <para>
/// <b>Principe tenu :</b> on n'affiche <b>jamais</b> une coupure qu'on ne sait pas juste. Le
/// français (<c>978-2</c>) 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.
/// </para>
/// </remarks>
public static class FormatageIsbn
{
/// <summary>Longueur du groupe linguistique, d'après les 7 chiffres qui suivent <c>978</c>.</summary>
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),
];
/// <summary>Idem sous <c>979</c> : seuls deux groupes existent à ce jour.</summary>
private static readonly (int Debut, int Fin, int Longueur)[] GroupesSous979 =
[
(1000000, 1599999, 2),
(8000000, 8999999, 1),
];
/// <summary>
/// Longueur du numéro d'éditeur dans le groupe <b>francophone</b>, d'après les 7 chiffres
/// qui suivent <c>978-2</c>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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";
/// <summary>
/// Rend l'ISBN lisible, tirets compris.
/// </summary>
/// <returns>
/// 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.
/// </returns>
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);
}
/// <summary>
/// Recolle <c>groupe - [éditeur - publication] - clé</c> à partir du corps de l'ISBN
/// (tout ce qui suit le groupe, clé comprise).
/// </summary>
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}";
}
/// <summary>
/// Cherche la tranche contenant les 7 premiers chiffres de <paramref name="chiffres"/>.
/// </summary>
/// <remarks>
/// Les tables de l'agence sont toutes indexées sur <b>7 chiffres</b>, complétés par des
/// zéros quand il en manque — c'est ce que fait le remplissage ci-dessous.
/// </remarks>
private static bool TrouverLongueur(
(int Debut, int Fin, int Longueur)[] table, ReadOnlySpan<char> 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;
}
}
+120 -1
View File
@@ -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) ─────────────────────────────────
/// <summary>
/// ⚠️ Le filtre de type est aussi indispensable ici qu'en bibliographie. Mesuré sur la
/// réponse réelle enregistrée : <c>bib.title all "la bête humaine"</c> croisé avec Zola
/// rend 20 notices, dont <b>12 films</b> de Jean Renoir, un manuscrit et un multimédia.
/// Sans filtre, la liste d'envies proposerait d'acheter un DVD.
/// </summary>
[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)));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// Contrairement à la bibliographie, <b>aucun post-filtre sur l'auteur</b> : c'est
/// l'utilisateur qui choisit dans la liste, en voyant les auteurs de chaque notice.
/// </summary>
/// <remarks>
/// Les deux notices vérifiées ici sont précisément celles que le post-filtre de
/// <see cref="BnfBibliographieParser.Parser"/> écarterait, et les garder est le bon choix
/// pour une liste d'envies :
/// <list type="bullet">
/// <item>la <b>bande dessinée signée Dobbs</b> — une adaptation de « La bête humaine » se
/// souhaite parfaitement, et son auteur est affiché, donc rien ne trompe ;</item>
/// <item>la notice <b>sans aucun <c>dc:creator</c></b>, qui n'aurait pu correspondre à
/// aucun auteur demandé.</item>
/// </list>
/// </remarks>
[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 ──────────────────────────────────────────────
/// <summary>
/// ⚠️ Ces messages <b>atteignent l'utilisateur</b>. 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Un_delai_depasse_invite_a_reessayer()
=> Assert.Contains("réessay", MotifEchec(EtatSourceBibliographie.DelaiDepasse),
StringComparison.OrdinalIgnoreCase);
/// <summary>Les trois motifs se distinguent : les confondre reviendrait à ne rien dire.</summary>
[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");
}
+183
View File
@@ -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;
/// <summary>
/// Codes-barres de périodiques (préfixe <c>977</c>) : reconnaissance, dérivation de l'ISSN,
/// et lecture de la notice BnF correspondante.
/// </summary>
public class CodePeriodiqueTests
{
/// <summary>
/// Le cas remonté en usage réel : ce code renvoyait « aucun résultat », alors qu'il
/// n'a jamais pu décrire un livre.
/// </summary>
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));
/// <summary>
/// La clé de l'ISSN n'est <b>pas</b> 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 ».
/// </summary>
[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"));
/// <summary>
/// Les deux chiffres qui précèdent la clé encodent la parution : deux numéros d'une même
/// revue doivent donner le <b>même</b> ISSN, sinon chaque magazine créerait une fiche.
/// </summary>
[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 = """
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:numberOfRecords>0</srw:numberOfRecords>
</srw:searchRetrieveResponse>
""";
Assert.Null(BnfSruParser.ParserPeriodique(vide, "2466-6718"));
}
// ── La cascade ne doit pas partir du tout ────────────────────────────────
/// <summary>
/// Client BnF simulé : compte les appels ISBN pour prouver qu'aucun n'a lieu.
/// </summary>
private sealed class BnfFake(PeriodiqueDetecte? revue) : IBnfClient
{
public List<string> IsbnInterroges { get; } = [];
public List<string> IssnInterroges { get; } = [];
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default)
{
IsbnInterroges.Add(isbn);
return Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (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));
}
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
private sealed class OpenLibraryFake : IOpenLibraryClient
{
public int Appels { get; private set; }
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default)
{
Appels++;
return Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
}
/// <summary>
/// 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é.
/// </summary>
[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<IsbnLookupService>.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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task BnF_muette_laisse_quand_meme_lIssn()
{
var service = new IsbnLookupService(
new BnfFake(null), new OpenLibraryFake(), NullLogger<IsbnLookupService>.Instance);
var resultat = await service.RechercherAsync(EanMedor);
Assert.Equal("2466-6718", resultat?.Periodique?.Issn);
Assert.Null(resultat?.Periodique?.Titre);
}
/// <summary>Un livre ordinaire n'est jamais pris pour un périodique.</summary>
[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<IsbnLookupService>.Instance);
var resultat = await service.RechercherAsync("9782070612758");
Assert.Null(resultat?.Periodique);
Assert.Empty(bnf.IssnInterroges);
Assert.NotEmpty(bnf.IsbnInterroges);
}
}
+18 -1
View File
@@ -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);
}
/// <summary>
/// Le découpage du CSV ne sert pas qu'à la lisibilité : <b>un ISBN nu de treize chiffres est
/// lu comme un nombre par Excel</b>, 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.
/// </summary>
[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()
{
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?><srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/" xmlns="http://catalogue.bnf.fr/namespaces/InterXMarc" xmlns:ixm="http://catalogue.bnf.fr/namespaces/InterXMarc" xmlns:mn="http://catalogue.bnf.fr/namespaces/motsnotices" xmlns:sd="http://www.loc.gov/zing/srw/diagnostic/">
<srw:version>1.2</srw:version>
<srw:echoedSearchRetrieveRequest>
<srw:version>1.2</srw:version>
<srw:query>bib.issn all "2466-6718"</srw:query>
</srw:echoedSearchRetrieveRequest>
<srw:numberOfRecords>1</srw:numberOfRecords>
<srw:records>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb45787531c</dc:identifier> <dc:title>Médor (Bruxelles)</dc:title> <dc:title>Médor : les yeux ouverts / [dir. publ. Laurence Jénard]</dc:title> <dc:publisher>Médor (Bruxelles)</dc:publisher> <dc:date>2015</dc:date> <dc:identifier>ISSN 24666718</dc:identifier> <dc:language>fre</dc:language> <dc:type xml:lang="fre">publication en série imprimée</dc:type> <dc:type xml:lang="eng">printed serial</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb45787531c</srw:recordIdentifier>
<srw:recordPosition>1</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20190820</ixm:attr>
<ixm:attr name="LastModificationDate">20210416</ixm:attr>
<mn:score>8.210089</mn:score>
</srw:extraRecordData>
</srw:record>
</srw:records>
</srw:searchRetrieveResponse>
@@ -0,0 +1,291 @@
<?xml version="1.0" encoding="UTF-8"?><srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/" xmlns="http://catalogue.bnf.fr/namespaces/InterXMarc" xmlns:ixm="http://catalogue.bnf.fr/namespaces/InterXMarc" xmlns:mn="http://catalogue.bnf.fr/namespaces/motsnotices" xmlns:sd="http://www.loc.gov/zing/srw/diagnostic/">
<srw:version>1.2</srw:version>
<srw:echoedSearchRetrieveRequest>
<srw:version>1.2</srw:version>
<srw:query>bib.title all "la bete humaine" and bib.author all "zola"</srw:query>
</srw:echoedSearchRetrieveRequest>
<srw:numberOfRecords>116</srw:numberOfRecords>
<srw:records>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb39770395m</dc:identifier> <dc:title>La Bête humaine / [plaquette de présentation du film de Jean Renoir d'après le roman d'Emile Zola]</dc:title> <dc:contributor>Renoir, Jean (1894-1979). Réalisateur</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:publisher>Paris film production (Paris)</dc:publisher> <dc:date>1938</dc:date> <dc:format>16 p. : ill. ; 49 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb39770395m</srw:recordIdentifier>
<srw:recordPosition>1</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20060222</ixm:attr>
<ixm:attr name="LastModificationDate">20160129</ixm:attr>
<mn:score>19.750183</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb38223462m</dc:identifier> <dc:title>La bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1981</dc:date> <dc:description>Numéro commercial : référence éditoriale VPV1024</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 39 min) : n. et b. (SECAM)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb38223462m</srw:recordIdentifier>
<srw:recordPosition>2</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">19930317</ixm:attr>
<ixm:attr name="LastModificationDate">20120924</ixm:attr>
<mn:score>19.369736</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb437350049</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:publisher>Universal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt)</dc:publisher> <dc:date>2013</dc:date> <dc:description>Numéro commercial : EDV 1392 829 380-0</dc:description> <dc:description>Code à barres commercial : EAN 5050582938005</dc:description> <dc:format>1 BD vidéo (1 h 39 min) : 4/3, n. et b., son. + 1 brochure (19 p. : ill. ; 17 cm)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb437350049</srw:recordIdentifier>
<srw:recordPosition>3</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20131224</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>19.368761</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb43734223w</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:publisher>Universal StudioCanal vidéo [éd., distrib.] (Boulogne-Billancourt)</dc:publisher> <dc:date>2013</dc:date> <dc:description>Numéro commercial : EDV 1392 829 551-4</dc:description> <dc:description>Code à barres commercial : EAN 5050582955149</dc:description> <dc:format>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)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb43734223w</srw:recordIdentifier>
<srw:recordPosition>4</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20131223</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>19.368483</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb487401586</dc:identifier> <dc:title>La Bête humaine</dc:title> <dc:creator>Zola, Émile (1840-1902)</dc:creator> <dc:publisher>Editions Gallmeister (Paris)</dc:publisher> <dc:date>2026</dc:date> <dc:identifier>ISBN 9782351789643</dc:identifier> <dc:description>Code à barres commercial : EAN 9782351789643</dc:description> <dc:format>1 vol. (484 p.) ; 20 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb487401586</srw:recordIdentifier>
<srw:recordPosition>5</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20260326</ixm:attr>
<ixm:attr name="LastModificationDate">20260326</ixm:attr>
<mn:score>18.988321</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb38397662r</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1998</dc:date> <dc:description>Collection : Vidéo classique</dc:description> <dc:description>Numéro commercial : référence éditoriale 27263</dc:description> <dc:description>Code à barres commercial : EAN 3339161272634</dc:description> <dc:format>1 cass. vidéo (1 h 36 min) : n. et b., SECAM ; 1/2 pouce VHS</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb38397662r</srw:recordIdentifier>
<srw:recordPosition>6</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">19980608</ixm:attr>
<ixm:attr name="LastModificationDate">20120924</ixm:attr>
<mn:score>18.954409</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb47104447b</dc:identifier> <dc:title>La bête humaine</dc:title> <dc:creator>Zola, Émile (1840-1902). Auteur du texte</dc:creator> <dc:publisher>Books on Demand (Paris)</dc:publisher> <dc:date>2022</dc:date> <dc:identifier>ISBN 9782322437740</dc:identifier> <dc:description>Code à barres commercial : EAN 9782322437740</dc:description> <dc:format>non paginé ; 22 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb47104447b</srw:recordIdentifier>
<srw:recordPosition>7</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20220923</ixm:attr>
<ixm:attr name="LastModificationDate">20240207</ixm:attr>
<mn:score>18.93142</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb408956237</dc:identifier> <dc:title>La bête humaine / Roger Pillaudin ; adaptation radiophonique d'après Émile Zola</dc:title> <dc:creator>Pillaudin, Roger. Adaptateur</dc:creator> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:date>1965</dc:date> <dc:format>35 ff. multigr. ; 27 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">manuscrit moderne ou document d'archive</dc:type> <dc:type xml:lang="eng">modern manuscript or archive</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb408956237</srw:recordIdentifier>
<srw:recordPosition>8</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20080611</ixm:attr>
<ixm:attr name="LastModificationDate">20080612</ixm:attr>
<mn:score>18.888391</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb40940932z</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:contributor>Carette, Julien (1897-1966). Acteur</dc:contributor> <dc:publisher>Warner home video [éd.] (London)</dc:publisher> <dc:date>2000</dc:date> <dc:description>Collection : World cinema</dc:description> <dc:description>Code à barres commercial : EAN 5014780383890</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 37 min) : n. et b. (PAL), son.</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb40940932z</srw:recordIdentifier>
<srw:recordPosition>9</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20061116</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>18.866568</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb45582948n</dc:identifier> <dc:title>La bête humaine / scénario, Dobbs ; dessin, Germano Giorgiani ; couleur, Isabelle Lebeau ; [d'après l'oeuvre d'Émile Zola]</dc:title> <dc:creator>Dobbs (1972-....). Auteur du texte</dc:creator> <dc:contributor>Giorgiani, Germano. Illustrateur</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:publisher>Robinson (Vanves)</dc:publisher> <dc:date>2018</dc:date> <dc:identifier>ISBN 9782012905313</dc:identifier> <dc:description>Code à barres commercial : EAN 9782012905313</dc:description> <dc:format>1 vol. (81 p.) : ill. en coul. ; 27 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb45582948n</srw:recordIdentifier>
<srw:recordPosition>10</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20180920</ixm:attr>
<ixm:attr name="LastModificationDate">20221205</ixm:attr>
<mn:score>18.83721</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb409409261</dc:identifier> <dc:title xml:lang="fre">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.</dc:title> <dc:title xml:lang="eng">The human beast</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:publisher>the Criterion collection [éd.] (Irvington, N.Y.)</dc:publisher> <dc:date>2006</dc:date> <dc:identifier>ISBN 0780026241</dc:identifier> <dc:description>Numéro commercial : 324</dc:description> <dc:description>Code à barres commercial : EAN 037429173824</dc:description> <dc:format>1 DVD vidéo monoface double couche zone 1 (1 h 36 min) : 4/3, n. et b. (NTSC), mono, surround (Dolby)</dc:format> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb409409261</srw:recordIdentifier>
<srw:recordPosition>11</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20061116</ixm:attr>
<ixm:attr name="LastModificationDate">20120924</ixm:attr>
<mn:score>18.757744</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb383634420</dc:identifier> <dc:title>La bête humaine / Jean Renoir, réal. ; Emile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1997</dc:date> <dc:description>Collection : Vous comprenez le français ?</dc:description> <dc:description>Code à barres commercial : EAN 9782090324563</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 40 min) : n. et b. (PAL), son. + 1 brochure (47 p.; 20 cm)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb383634420</srw:recordIdentifier>
<srw:recordPosition>12</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">19970509</ixm:attr>
<ixm:attr name="LastModificationDate">20251212</ixm:attr>
<mn:score>18.566822</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb385406522</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1991</dc:date> <dc:description>Collection : Les génies du cinéma ; 12</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 40 min) : n. et b. (SECAM)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb385406522</srw:recordIdentifier>
<srw:recordPosition>13</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20010614</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>18.38218</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb38523388j</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1989</dc:date> <dc:description>Collection : L'âge d'or du cinéma</dc:description> <dc:description>Numéro commercial : référence éditoriale 2855018</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 40 min) n. et b. (SECAM)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb38523388j</srw:recordIdentifier>
<srw:recordPosition>14</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20010216</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>18.20326</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb38460416w</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:date>1989</dc:date> <dc:description>Collection : L'âge d'or du cinéma</dc:description> <dc:description>Numéro commercial : référence éditoriale 0990348</dc:description> <dc:description>Code à barres commercial : EAN 3346030990346</dc:description> <dc:format>1 cass. vidéo (VHS) (1 h 36 min) : n. et b. (SECAM)</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb38460416w</srw:recordIdentifier>
<srw:recordPosition>15</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">19991123</ixm:attr>
<ixm:attr name="LastModificationDate">20120924</ixm:attr>
<mn:score>18.202312</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb31690508j</dc:identifier> <dc:title>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]</dc:title> <dc:creator>Zola, Émile (1840-1902). Auteur du texte</dc:creator> <dc:contributor>Csernus, Tibor (1927-2007). Illustrateur</dc:contributor> <dc:contributor>Terles, Jean (1909-1976). Illustrateur</dc:contributor> <dc:contributor>Fontanarosa, Lucien Joseph (1912-1975). Illustrateur</dc:contributor> <dc:publisher>Fasquelle (Paris)</dc:publisher> <dc:publisher>Gallimard (Paris)</dc:publisher> <dc:date>1958</dc:date> <dc:format>807 p. : pl. et cartonnage ill. en coul. ; petit in-4</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb31690508j</srw:recordIdentifier>
<srw:recordPosition>16</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">19970701</ixm:attr>
<ixm:attr name="LastModificationDate">20260318</ixm:attr>
<mn:score>17.954374</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb472750729</dc:identifier> <dc:title>La bête humaine / Émile Zola</dc:title> <dc:creator>Zola, Émile (1840-1902). Auteur du texte</dc:creator> <dc:publisher>Éditions Ararauna (Lunel)</dc:publisher> <dc:date>2023</dc:date> <dc:description>Collection : Cycle Les Rougon-Macquart</dc:description> <dc:identifier>ISBN 9782378848453</dc:identifier> <dc:description>Code à barres commercial : EAN 9782378848453</dc:description> <dc:format>1 vol. (318 p.) ; 22 cm</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">texte imprimé</dc:type> <dc:type xml:lang="eng">printed text</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb472750729</srw:recordIdentifier>
<srw:recordPosition>17</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20230623</ixm:attr>
<ixm:attr name="LastModificationDate">20240207</ixm:attr>
<mn:score>17.683867</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb389426109</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Spaak, Charles (1903-1975). Scénariste</dc:contributor> <dc:contributor>Spaak, Charles (1903-1975). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Parlo, Dita (1906-1971). Acteur</dc:contributor> <dc:contributor>Fresnay, Pierre (1897-1975). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:publisher>StudioCanal vidéo [éd.] (Boulogne-Billancourt)</dc:publisher> <dc:publisher>Universal music group [distrib.] (Paris)</dc:publisher> <dc:date>2003</dc:date> <dc:description>Collection : Classique</dc:description> <dc:description>Comprend : La grande illusion ; La bête humaine</dc:description> <dc:description>Numéro commercial : StudioCanal 196 909-9</dc:description> <dc:description>Code à barres commercial : EAN 3259119690993</dc:description> <dc:format>2 DVD vidéo monofaces double couche toutes zones (1h 48 min, 1 h 33 min) : 4/3, n. et b. (PAL), son.</dc:format> <dc:language>fre</dc:language> <dc:language>ger</dc:language> <dc:language>spa</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb389426109</srw:recordIdentifier>
<srw:recordPosition>18</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20030121</ixm:attr>
<ixm:attr name="LastModificationDate">20211118</ixm:attr>
<mn:score>17.638731</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb39205438z</dc:identifier> <dc:title>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.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:contributor>Renoir, Jean (1894-1979). Scénariste</dc:contributor> <dc:contributor>Renoir, Jean (1894-1979). Dialoguiste</dc:contributor> <dc:contributor>Zola, Émile (1840-1902). Auteur adapté</dc:contributor> <dc:contributor>Kosma, Joseph (1905-1969). Compositeur</dc:contributor> <dc:contributor>Gabin, Jean (1904-1976). Acteur</dc:contributor> <dc:contributor>Simon, Simone (1911-2005). Acteur</dc:contributor> <dc:contributor>Ledoux, Fernand (1897-1993). Acteur</dc:contributor> <dc:publisher>Studiocanal vidéo [éd.] (Issy-les-Moulineaux)</dc:publisher> <dc:publisher>Universal music [distrib.] (Paris)</dc:publisher> <dc:date>2004</dc:date> <dc:description>Collection : Acteurs, actrices de légendes</dc:description> <dc:description>Numéro commercial : Studiocanal vidéo 302 172-6</dc:description> <dc:description>Code à barres commercial : EAN 3259130217261</dc:description> <dc:format>1 DVD monoface simple couche zone 2 (1 h 40 min) : n. et b. (PAL), son.</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">image animée</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb39205438z</srw:recordIdentifier>
<srw:recordPosition>19</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20040702</ixm:attr>
<ixm:attr name="LastModificationDate">20260414</ixm:attr>
<mn:score>17.539799</mn:score>
</srw:extraRecordData>
</srw:record>
<srw:record>
<srw:recordSchema>dc</srw:recordSchema>
<srw:recordPacking>xml</srw:recordPacking>
<srw:recordData>
<oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"> <dc:identifier>http://catalogue.bnf.fr/ark:/12148/cb400818070</dc:identifier> <dc:title>La bête humaine / Jean Renoir, réal. ; Émile Zola, aut. adapté ; Joseph Kosma, comp. ; Jean Gabin, Simone Simon, Fernand Ledoux... [et al.], act.</dc:title> <dc:creator>Renoir, Jean (1894-1979). Réalisateur</dc:creator> <dc:creator>Zola, Émile (1840-1902). Auteur adapté</dc:creator> <dc:creator>Kosma, Joseph (1905-1969). Compositeur</dc:creator> <dc:creator>Gabin, Jean (1904-1976). Acteur</dc:creator> <dc:creator>Simon, Simone (1911-2005). Acteur</dc:creator> <dc:creator>Ledoux, Fernand (1897-1993). Acteur</dc:creator> <dc:publisher>M6 interactions (Neuilly-sur-Seine)</dc:publisher> <dc:publisher>Nouvelles messageries de la presse parisienne [distrib.] (Paris)</dc:publisher> <dc:date>2005</dc:date> <dc:description>Numéro commercial : NMPP M09353</dc:description> <dc:description>Code à barres commercial : EAN 3780935314997</dc:description> <dc:format>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.</dc:format> <dc:language>fre</dc:language> <dc:type xml:lang="fre">multimédia multisupport</dc:type> <dc:type xml:lang="eng">kit</dc:type> <dc:type xml:lang="eng">text</dc:type> <dc:type xml:lang="eng">moving image</dc:type> <dc:rights xml:lang="fre">Catalogue en ligne de la Bibliothèque nationale de France</dc:rights> <dc:rights xml:lang="eng">French National Library online Catalog</dc:rights>
</oai_dc:dc> </srw:recordData>
<srw:recordIdentifier>ark:/12148/cb400818070</srw:recordIdentifier>
<srw:recordPosition>20</srw:recordPosition>
<srw:extraRecordData>
<ixm:attr name="CreationDate">20051209</ixm:attr>
<ixm:attr name="LastModificationDate">20120924</ixm:attr>
<mn:score>17.236567</mn:score>
</srw:extraRecordData>
</srw:record>
</srw:records>
<srw:nextRecordPosition>21</srw:nextRecordPosition>
</srw:searchRetrieveResponse>
+86
View File
@@ -0,0 +1,86 @@
using MaBibli.Shared.Isbn;
namespace MaBibli.Tests;
/// <summary>
/// Découpage des ISBN pour l'affichage.
/// </summary>
/// <remarks>
/// Les valeurs attendues ci-dessous ne sont pas devinées : elles ont été calculées à partir du
/// <c>RangeMessage.xml</c> officiel de l'agence internationale (version du 2026-08-18), qui est
/// aussi la source des tables embarquées.
/// </remarks>
public class FormatageIsbnTests
{
/// <summary>
/// Le fonds francophone est découpé <b>en entier</b>, éditeur compris.
/// </summary>
[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));
/// <summary>
/// Un ISBN-10 est un ISBN-13 en <c>978</c> amputé de son préfixe : mêmes tranches,
/// une frontière de moins.
/// </summary>
[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));
/// <summary>
/// Hors du francophone, on s'arrête au groupe et à la clé : <b>deux frontières toujours
/// exactes</b>. Le vrai découpage est <c>978-0-262-03384-8</c> — on ne l'invente pas, on
/// affiche une tranche de moins. Une coupure absente se lit encore, une coupure fausse trompe.
/// </summary>
[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));
/// <summary>
/// Ce qui n'est pas un ISBN valide ressort <b>tel quel</b> : un code mal saisi doit se voir
/// mal saisi, pas déguisé en ISBN par des tirets.
/// </summary>
[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));
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Est_idempotent()
{
var une = FormatageIsbn.Afficher("9782846391009");
Assert.Equal(une, FormatageIsbn.Afficher(une));
}
/// <summary>
/// ⚠️ Le contrat central : la valeur <b>stockée</b> 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.
/// </summary>
[Fact]
public void Le_decoupage_se_defait_par_Normaliser()
=> Assert.Equal(
"9782846391009",
IsbnUtils.Normaliser(FormatageIsbn.Afficher("9782846391009")));
}
+26
View File
@@ -32,6 +32,19 @@ public class IsbnLookupServiceTests
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((new ResultatBibliographie(), (string?)null));
/// <summary>
/// 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.
/// </summary>
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default) =>
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (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));
/// <summary>
/// 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.
/// </summary>
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default) =>
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
}
@@ -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;
/// <summary>
/// La migration <c>RangDesEnvies</c>, appliquée pour de vrai à une base déjà peuplée.
/// </summary>
/// <remarks>
/// Ce test ne double pas <c>ServiceSouhaitsTests</c> : celui-ci crée son schéma avec
/// <c>EnsureCreated</c>, qui <b>ne joue aucune migration</b>. Le remplissage de la colonne
/// <c>Rang</c> 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.
/// </remarks>
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<MaBibliDbContext>().UseSqlite(_connexion).Options);
}
private IMigrator Migrateur => _db.GetInfrastructure().GetRequiredService<IMigrator>();
/// <summary>
/// Le cœur du test : la migration doit reconduire <b>l'ordre affiché jusqu'ici</b> —
/// 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.
/// </summary>
[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"));
}
/// <summary>Une base sans aucune envie doit traverser la migration sans broncher.</summary>
[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<List<(int Rang, string Titre)>> 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<MaBibliDbContext>().UseSqlite(_connexion).Options);
return await db.LivresSouhaites
.Where(s => s.Utilisateur == utilisateur)
.OrderBy(s => s.Rang)
.Select(s => new ValueTuple<int, string>(s.Rang, s.Titre))
.ToListAsync();
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}
+230 -5
View File
@@ -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<IReadOnlyList<string>> TitresAsync(string utilisateur = Mathieu) =>
(await _souhaits.ListerAsync(utilisateur)).Select(s => s.Titre).ToList();
/// <summary>
/// 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.
/// </summary>
[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());
}
/// <summary>Réenvoyer le même ordre ne doit rien bouger.</summary>
[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());
}
/// <summary>
/// ⚠️ 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 <b>survivre</b>, rangée à la
/// suite — mal classée vaut infiniment mieux que disparue.
/// </summary>
[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());
}
/// <summary>
/// ⚠️ 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// ⚠️ <b>Contrat changé le 2026-08-18</b> : la liste était classée par auteur puis titre,
/// elle suit désormais le <b>rang choisi par son propriétaire</b>. 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.
/// <para>
/// L'ancien ordre n'est pas perdu pour autant : la migration <c>RangDesEnvies</c> le
/// reconduit dans les rangs initiaux, pour que les listes existantes ne se réordonnent pas
/// toutes seules. C'est <c>MigrationRangDesEnviesTests</c> qui le vérifie.
/// </para>
/// </summary>
[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));
/// <summary>Les périodiques n'interviennent pas dans la bibliographie par auteur.</summary>
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default) =>
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
/// <summary>
/// Client BnF qui <b>ne répond pas</b> : c'est le cas « Robert A. Harper » remonté en usage.
/// </summary>
private sealed class BnfMuetFake(EtatSourceBibliographie etat, string message) : IBnfClient
{
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (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<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
/// <summary>
/// ⚠️ <b>Le défaut que ce lot corrige.</b> Une BnF muette rend une liste vide, exactement
/// comme un auteur réellement absent de son catalogue. Sans <c>SourceMuette</c>, l'écran
/// affirmait « la BnF ne connaît aucun livre de cet auteur » à propos d'une question qu'il
/// n'avait pas pu poser.
/// </summary>
[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);
}
/// <summary>
/// Le pendant : la BnF <b>a</b> répondu, elle ne connaît simplement rien. Là, et là
/// seulement, l'écran a le droit d'expliquer l'absence.
/// </summary>
[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);
}
/// <summary>Une bibliographie qui a abouti n'est jamais signalée comme muette.</summary>
[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 = """
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:numberOfRecords>0</srw:numberOfRecords>
</srw:searchRetrieveResponse>
""";
/// <summary>Bibliographie de Werber avec le client BnF fourni : seule la source varie.</summary>
/// <summary>
/// Bibliographie de Werber avec le client BnF fourni : <b>seule la source varie</b>.
/// L'auteur doit exister au catalogue, une bibliographie partant toujours d'un auteur à soi.
/// </summary>
private async Task<BibliographieDto> 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<BibliographieDto> BibliographieWerberAsync(string utilisateur = Mathieu)