Compare commits
2
Commits
2f1e854ea3
...
455c2b711a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455c2b711a | ||
|
|
5cf566bc33 |
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
@using MaBibli.Shared.Catalogue
|
||||
@using MaBibli.Shared.Dtos
|
||||
@using MaBibli.Shared.Entites
|
||||
@using MaBibli.Shared.Isbn
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user