Compare commits
40
Commits
main
..
455c2b711a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455c2b711a | ||
|
|
5cf566bc33 | ||
|
|
2f1e854ea3 | ||
|
|
bfbd90fabd | ||
|
|
922fd024e7 | ||
|
|
580d8469ac | ||
|
|
b89b8ac964 | ||
|
|
53dd8cb6bd | ||
|
|
231b0b2a11 | ||
|
|
ebc5f95d49 | ||
|
|
8b8fe3be6e | ||
|
|
3a5a3a7763 | ||
|
|
08e57fbbc4 | ||
|
|
6b783bafa0 | ||
|
|
60de1d8784 | ||
|
|
2a3dfb88de | ||
|
|
94a186f4a7 | ||
|
|
0fde244d0b | ||
|
|
9cb3ca535d | ||
|
|
ab878b4d3c | ||
|
|
d9e6cf425d | ||
|
|
49d1528988 | ||
|
|
30a5cc12cf | ||
|
|
b594a4b2c1 | ||
|
|
4a8bc98f50 | ||
|
|
493cf80615 | ||
|
|
71d4eb6752 | ||
|
|
3a00591117 | ||
|
|
f7ad9582f6 | ||
|
|
b619de29c7 | ||
|
|
a314dcfc79 | ||
|
|
fd6438b912 | ||
|
|
aed03e6b6f | ||
|
|
f8f30dbcf7 | ||
|
|
4990e41f3e | ||
|
|
4467456b8b | ||
|
|
a30197000f | ||
|
|
a5392cedac | ||
|
|
d56061db6b | ||
|
|
36cd4a4559 |
@@ -1,11 +0,0 @@
|
||||
# Le contexte de build ne doit porter que les sources : recopier les `bin/` et `obj/` de
|
||||
# la machine de développement ralentit le build et, pire, peut faire réutiliser des
|
||||
# artefacts compilés pour un autre système.
|
||||
**/bin/
|
||||
**/obj/
|
||||
.git/
|
||||
.idea/
|
||||
.claude/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -13,9 +13,6 @@ publish/
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Rider ignored files
|
||||
/modules.xml
|
||||
/contentModel.xml
|
||||
/projectSettingsUpdater.xml
|
||||
/.idea.MaBibli.iml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+1
@@ -0,0 +1 @@
|
||||
MaBibli
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="mabibli" uuid="5eb43769-66c1-4c25-8eca-b2d220710b99">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/MaBibli.Api/mabibli.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
# Image pour héberger MaBibli AILLEURS que sur YunoHost (Synology, NAS, VPS…).
|
||||
#
|
||||
# ⚠️ Ce n'est PAS le chemin de déploiement de référence : celui-ci reste le paquet
|
||||
# `mabibli_ynh`, qui déploie un publish self-contained sans conteneur (voir README,
|
||||
# « Mettre en production »). Cette image existe pour les hébergements qui n'ont pas de
|
||||
# portail SSO — et elle n'apporte donc AUCUNE authentification. Lire impérativement
|
||||
# « Installer ailleurs que sur YunoHost » dans le README avant de l'exposer.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# `MaBibli.Api` référence `MaBibli.Client` : le client Blazor WebAssembly est compilé au
|
||||
# passage et atterrit dans `wwwroot/`. Un seul projet à publier suffit pour les trois.
|
||||
# Aucun `dotnet workload install` n'est nécessaire : le SDK .NET 10 résout l'outillage
|
||||
# WebAssembly tout seul (vérifié — aucune charge de travail n'est installée en local).
|
||||
COPY . .
|
||||
RUN dotnet publish MaBibli.Api --configuration Release --output /app
|
||||
|
||||
# Pas de `--self-contained` ici, contrairement au paquet YunoHost : l'image `aspnet`
|
||||
# embarque déjà le runtime. Le self-contained sert à ne rien exiger d'un serveur qu'on
|
||||
# ne maîtrise pas ; dans un conteneur, la question ne se pose pas.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
|
||||
# La base vit dans un volume, jamais à côté des binaires : c'est ce qui la fait survivre
|
||||
# au remplacement de l'image. Même règle que le `data_dir` de YunoHost.
|
||||
ENV ConnectionStrings__MaBibli="Data Source=/data/mabibli.db"
|
||||
VOLUME ["/data"]
|
||||
|
||||
# L'image `aspnet` écoute déjà sur 8080 : poser `ASPNETCORE_URLS` ne ferait qu'écraser ce
|
||||
# réglage et journaliser un avertissement à chaque démarrage. Pour changer de port,
|
||||
# `ASPNETCORE_HTTP_PORTS`. ⚠️ Écouter sur toutes les interfaces est sûr DANS un conteneur —
|
||||
# c'est la publication du port qui décide de l'exposition, voir `compose.yaml`.
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["dotnet", "MaBibli.Api.dll"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# IDEES.md — améliorations envisagées
|
||||
|
||||
Ce fichier recueille les idées et corrections identifiées en cours de route, **pas encore implémentées**. Il n'a pas valeur de décision : rien ici n'est acté tant que ce n'est pas repris dans `CLAUDE.md` ou traité dans une phase.
|
||||
|
||||
Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé volontairement : il sera fait quand les fonctionnalités seront en place.
|
||||
|
||||
---
|
||||
|
||||
## Qualité des données
|
||||
|
||||
### Recherche insensible aux accents
|
||||
|
||||
« Emile » ne trouve pas « Émile ». Gênant sur un fonds francophone.
|
||||
|
||||
**Semble réglé par la refonte du schéma** (colonnes normalisées, voir `CLAUDE.md`) : vérifié le 2026-08-18 sur l'API, `recherche=Emile` remonte bien les livres d'« Émile Zola ». Laissé ici faute d'avoir été traité comme un point à part entière — à confirmer sur un fonds réel avant de le retirer.
|
||||
|
||||
---
|
||||
|
||||
## Recherche : la ligature « œ » n'est pas réduite à « oe »
|
||||
|
||||
Repéré le 2026-08-18 en implémentant la liste d'envies. `NormalisationTexte.Normaliser`
|
||||
décompose en **NFD**, qui sépare les accents mais laisse les ligatures intactes — seul NFKD les
|
||||
défait. Conséquence : `L'Œuvre` et `L'oeuvre` ne se rencontrent jamais.
|
||||
|
||||
Ce n'est pas propre à la liste d'envies : la même fonction alimente la **recherche du catalogue**
|
||||
et la **clé unique des auteurs**. Zola a écrit *L'Œuvre*, que la BnF orthographie avec la
|
||||
ligature ; un utilisateur qui tape « oeuvre » ne le trouvera pas, et ne verra pas non plus le
|
||||
livre grisé dans la bibliographie.
|
||||
|
||||
Correction envisagée : passer en NFKD, ou traiter explicitement `œ`/`Œ` et `æ`/`Æ`. **Non fait
|
||||
volontairement** — changer cette fonction déplace deux invariants en base (les colonnes
|
||||
normalisées et l'index unique `CleRegroupement`). `ServiceRenormalisation` sait recalculer les
|
||||
colonnes au démarrage, mais la fusion d'auteurs que la nouvelle règle provoquerait mérite d'être
|
||||
regardée avant. Documenté par un test (`CleOeuvreTests.Ne_reduit_pas_la_ligature_oe`) pour que
|
||||
la limite reste visible.
|
||||
|
||||
---
|
||||
|
||||
## Bibliographie : ce qui reste à creuser
|
||||
|
||||
La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées :
|
||||
|
||||
- **Plafond de 200 notices.** Suffisant pour un auteur contemporain (Werber : 203, Nothomb : 277),
|
||||
très insuffisant pour un classique (Zola : 2692). L'écran le dit, mais on pourrait charger les
|
||||
pages suivantes à la demande plutôt que d'annoncer un extrait.
|
||||
- **OpenLibrary en second rideau.** La BnF ne connaît pas les auteurs étrangers non traduits ;
|
||||
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.
|
||||
@@ -1,33 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace MaBibli.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Fabrique le <see cref="MaBibliDbContext"/> pour les <b>outils</b> (<c>dotnet ef</c>), sans
|
||||
/// démarrer l'application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sans elle, <c>dotnet ef</c> exécute <c>Program.cs</c> pour retrouver les services — et
|
||||
/// <c>Program.cs</c> appelle <c>Database.Migrate()</c> au démarrage. Écrire une migration
|
||||
/// suppose alors d'ouvrir la base de développement, ce qui est au mieux inutile et au pire
|
||||
/// bloquant (constaté sur un système de fichiers réseau, où le verrou SQLite ne se prend
|
||||
/// jamais : l'outil attendait cinq minutes puis renonçait).
|
||||
/// <para>
|
||||
/// ⚠️ La chaîne de connexion ci-dessous ne sert <b>qu'à la génération du code</b> : produire
|
||||
/// une migration n'ouvre aucune base. Elle n'a donc pas à correspondre à quoi que ce soit, et
|
||||
/// surtout pas à la base de production, qui est configurée par <c>ConnectionStrings__MaBibli</c>
|
||||
/// dans l'unité systemd.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FabriqueDbContextConception : IDesignTimeDbContextFactory<MaBibliDbContext>
|
||||
{
|
||||
public MaBibliDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
|
||||
.UseSqlite("Data Source=mabibli-conception.db")
|
||||
.Options;
|
||||
|
||||
return new MaBibliDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
|
||||
public DbSet<LivreAuteur> LivreAuteurs => Set<LivreAuteur>();
|
||||
|
||||
public DbSet<Theme> Themes => Set<Theme>();
|
||||
|
||||
public DbSet<LivreTheme> LivreThemes => Set<LivreTheme>();
|
||||
|
||||
public DbSet<StatutLecture> StatutsLecture => Set<StatutLecture>();
|
||||
|
||||
public DbSet<RapprochementRefuse> RapprochementsRefuses => Set<RapprochementRefuse>();
|
||||
@@ -28,37 +24,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
/// </summary>
|
||||
public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>();
|
||||
|
||||
/// <summary>
|
||||
/// Envies de revues et de numéros, <b>personnelles</b>. Table sœur de
|
||||
/// <see cref="LivresSouhaites"/> : un numéro n'a pas d'auteur, et se distingue par son
|
||||
/// numéro — deux choses que la clé d'unicité des envies de livres ne sait pas dire.
|
||||
/// </summary>
|
||||
public DbSet<RevueSouhaitee> RevuesSouhaitees => Set<RevueSouhaitee>();
|
||||
|
||||
public DbSet<BibliographieMasquee> BibliographiesMasquees => Set<BibliographieMasquee>();
|
||||
|
||||
/// <summary>
|
||||
/// Sagas, cycles et séries. <b>Communs au foyer</b>, comme le catalogue : l'ordre de lecture
|
||||
/// est une propriété de l'œuvre, pas du lecteur.
|
||||
/// </summary>
|
||||
public DbSet<Serie> Series => Set<Serie>();
|
||||
|
||||
public DbSet<ElementSerie> ElementsSerie => Set<ElementSerie>();
|
||||
|
||||
/// <summary>
|
||||
/// Revues et magazines. <b>Table séparée des <see cref="Livres"/></b> : une revue n'est pas
|
||||
/// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons.
|
||||
/// </summary>
|
||||
public DbSet<Revue> Revues => Set<Revue>();
|
||||
|
||||
public DbSet<NumeroRevue> NumerosRevue => Set<NumeroRevue>();
|
||||
|
||||
/// <summary>
|
||||
/// Articles à la une d'un numéro. Table à part de <see cref="Themes"/> : un titre d'article
|
||||
/// est unique à sa parution et ne se réutilise jamais, là où un thème est un vocabulaire.
|
||||
/// </summary>
|
||||
public DbSet<ArticleUne> ArticlesUne => Set<ArticleUne>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -67,7 +32,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
{
|
||||
livre.Property(l => l.Titre).IsRequired();
|
||||
livre.Property(l => l.TitreNormalise).IsRequired();
|
||||
livre.Property(l => l.UrlNotice);
|
||||
livre.HasIndex(l => l.Isbn);
|
||||
|
||||
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
|
||||
@@ -111,30 +75,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
lien.HasIndex(la => la.AuteurId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Theme>(theme =>
|
||||
{
|
||||
theme.Property(t => t.Nom).IsRequired();
|
||||
theme.Property(t => t.NomNormalise).IsRequired();
|
||||
theme.HasIndex(t => t.NomNormalise).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LivreTheme>(lien =>
|
||||
{
|
||||
lien.HasKey(lt => new { lt.LivreId, lt.ThemeId });
|
||||
|
||||
lien.HasOne(lt => lt.Livre)
|
||||
.WithMany(l => l.Themes)
|
||||
.HasForeignKey(lt => lt.LivreId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
lien.HasOne(lt => lt.Theme)
|
||||
.WithMany(t => t.Livres)
|
||||
.HasForeignKey(lt => lt.ThemeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
lien.HasIndex(lt => lt.ThemeId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StatutLecture>(statut =>
|
||||
{
|
||||
statut.Property(s => s.Utilisateur).IsRequired();
|
||||
@@ -181,137 +121,6 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RevueSouhaitee>(envie =>
|
||||
{
|
||||
envie.Property(e => e.Utilisateur).IsRequired();
|
||||
envie.Property(e => e.Titre).IsRequired();
|
||||
envie.Property(e => e.TitreNormalise).IsRequired();
|
||||
|
||||
// Jamais nullable, exactement pour la raison qui l'impose à AuteurNormalise : SQLite
|
||||
// tient deux NULL pour distincts, et « Médor, pas de numéro » s'ajouterait autant de
|
||||
// fois qu'on cliquerait.
|
||||
envie.Property(e => e.NumeroNormalise).IsRequired().HasDefaultValue(string.Empty);
|
||||
|
||||
envie.HasIndex(e => e.Utilisateur, "IX_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
// Le numéro fait partie de la clé : souhaiter le 43 ET le 44 de la même revue est le
|
||||
// cas normal, alors que noter deux fois le même numéro est une fausse manœuvre.
|
||||
// L'utilisateur ouvre l'index : deux personnes peuvent vouloir la même parution.
|
||||
envie.HasIndex(
|
||||
e => new { e.Utilisateur, e.TitreNormalise, e.NumeroNormalise },
|
||||
"IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BibliographieMasquee>(masquee =>
|
||||
{
|
||||
masquee.Property(m => m.Utilisateur).IsRequired();
|
||||
masquee.Property(m => m.TitreNormalise).IsRequired();
|
||||
masquee.HasIndex(
|
||||
m => new { m.AuteurId, m.Utilisateur, m.TitreNormalise },
|
||||
"IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
masquee.HasIndex(m => new { m.Utilisateur, m.AuteurId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Serie>(serie =>
|
||||
{
|
||||
serie.Property(s => s.Titre).IsRequired();
|
||||
serie.Property(s => s.TitreNormalise).IsRequired();
|
||||
|
||||
// Une série, une fiche. Voir l'entité : l'unicité est GLOBALE et non par parent,
|
||||
// le parent étant nullable et deux NULL étant distincts pour SQLite.
|
||||
serie.HasIndex(s => s.TitreNormalise).IsUnique();
|
||||
|
||||
// SetNull et non Cascade : supprimer un cycle ne doit pas emporter les trilogies
|
||||
// qu'il contient — ce sont de vraies séries de vrais livres, qui se tiennent seules.
|
||||
serie.HasOne(s => s.SerieParente)
|
||||
.WithMany(s => s.SousSeries)
|
||||
.HasForeignKey(s => s.SerieParenteId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
serie.HasIndex(s => s.SerieParenteId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ElementSerie>(element =>
|
||||
{
|
||||
element.Property(e => e.Titre).IsRequired();
|
||||
|
||||
element.HasOne(e => e.Serie)
|
||||
.WithMany(s => s.Elements)
|
||||
.HasForeignKey(e => e.SerieId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// ⚠️ SetNull, surtout pas Cascade : supprimer un livre du catalogue doit laisser sa
|
||||
// place dans la saga, devenue un trou nommé. Cascade effacerait le tome 3 de la
|
||||
// série parce qu'on a prêté puis perdu son exemplaire.
|
||||
element.HasOne(e => e.Livre)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.LivreId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
element.HasIndex(e => e.SerieId, "IX_ElementsSerie_SerieId");
|
||||
|
||||
// Un même livre ne tient qu'une place dans une série donnée. Index PARTIEL, comme
|
||||
// pour les prêts : les positions sans livre (les trous) doivent pouvoir se répéter
|
||||
// autant que nécessaire, et deux NULL passeraient de toute façon.
|
||||
// Un livre peut en revanche appartenir à PLUSIEURS séries — une intégrale, une
|
||||
// série, un cycle — d'où l'absence d'unicité sur LivreId seul.
|
||||
element.HasIndex(e => new { e.SerieId, e.LivreId }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Revue>(revue =>
|
||||
{
|
||||
revue.Property(r => r.Titre).IsRequired();
|
||||
revue.Property(r => r.TitreNormalise).IsRequired();
|
||||
|
||||
revue.HasIndex(r => r.TitreNormalise).IsUnique();
|
||||
|
||||
// Index PARTIEL : une revue peut n'avoir aucun ISSN (saisie à la main), et deux
|
||||
// NULL étant distincts pour SQLite, une unicité simple ne dirait rien. Filtré, il
|
||||
// garantit qu'un ISSN connu ne désigne qu'une fiche — ce qui permet au flux « 977 »
|
||||
// de retrouver la revue déjà créée au lieu d'en faire une seconde.
|
||||
revue.HasIndex(r => r.Issn, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<NumeroRevue>(numero =>
|
||||
{
|
||||
numero.Property(n => n.Numero).IsRequired();
|
||||
numero.Property(n => n.NumeroNormalise).IsRequired();
|
||||
|
||||
numero.HasOne(n => n.Revue)
|
||||
.WithMany(r => r.Numeros)
|
||||
.HasForeignKey(n => n.RevueId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Un numéro ne se possède qu'une fois par revue : rescanner le même exemplaire ne
|
||||
// doit pas allonger la liste. Contrairement aux livres, un second exemplaire d'un
|
||||
// numéro de magazine n'a pas d'usage identifié — et l'index se retire si un jour si.
|
||||
numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ArticleUne>(article =>
|
||||
{
|
||||
article.Property(a => a.Titre).IsRequired();
|
||||
article.Property(a => a.TitreNormalise).IsRequired();
|
||||
|
||||
// Cascade, comme les numéros sous leur revue : un article à la une n'existe que par
|
||||
// la parution qui le porte. Rien de comparable à ElementSerie, dont la place doit
|
||||
// survivre au livre.
|
||||
article.HasOne(a => a.NumeroRevue)
|
||||
.WithMany(n => n.Articles)
|
||||
.HasForeignKey(a => a.NumeroRevueId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Le même article ne s'annonce pas deux fois à la une du même numéro. L'unicité est
|
||||
// bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
|
||||
article.HasIndex(a => new { a.NumeroRevueId, a.TitreNormalise }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Pret>(pret =>
|
||||
{
|
||||
pret.Property(p => p.Emprunteur).IsRequired();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// <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("20260817195059_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Auteur")
|
||||
.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<int>("Statut")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Isbn");
|
||||
|
||||
b.ToTable("Livres");
|
||||
});
|
||||
|
||||
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("LivreId");
|
||||
|
||||
b.ToTable("Prets");
|
||||
});
|
||||
|
||||
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.Livre", b =>
|
||||
{
|
||||
b.Navigation("Prets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Livres",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Isbn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Auteur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Editeur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Format = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Statut = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Livres", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Prets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Emprunteur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DatePret = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DateRetour = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Prets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Prets_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Livres_Isbn",
|
||||
table: "Livres",
|
||||
column: "Isbn");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prets_LivreId",
|
||||
table: "Prets",
|
||||
column: "LivreId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Prets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Livres");
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
// <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("20260818000335_StatutPersonnelEtTableAuteurs")]
|
||||
partial class StatutPersonnelEtTableAuteurs
|
||||
{
|
||||
/// <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.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("LivreId");
|
||||
|
||||
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,278 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort le statut de lecture et l'auteur de la table <c>Livres</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Ce que deviennent les données existantes.</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <c>Livres.Statut</c> était commun à tout le foyer. Il est <b>rattaché à
|
||||
/// <c>AjoutePar</c></b>, la seule personne que la base associe au livre. Les statuts des
|
||||
/// livres sans <c>AjoutePar</c> (saisis sans identité) sont <b>perdus</b> : les attribuer à
|
||||
/// quelqu'un serait une invention, et les garder pour tout le monde serait exactement le
|
||||
/// défaut que cette migration corrige. Les trois valeurs sont reprises telles quelles, « À
|
||||
/// lire » compris : c'est ce que l'ancienne interface affichait, et le taire changerait ce
|
||||
/// que l'utilisateur voit.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>Livres.Auteur</c> devient une ligne de <c>Auteurs</c> par valeur distincte, plus un lien
|
||||
/// en position 0. Le regroupement se fait ici sur une simple mise en minuscules — SQLite ne
|
||||
/// sait pas retirer les accents. La normalisation complète, et la fusion des variantes qu'elle
|
||||
/// révèle, sont faites en C# au démarrage suivant (voir <c>ServiceRenormalisation</c>).
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// L'ordre des opérations compte : tout est recopié <b>avant</b> que les anciennes colonnes
|
||||
/// ne soient supprimées.
|
||||
/// </remarks>
|
||||
public partial class StatutPersonnelEtTableAuteurs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Mise à l'abri du contenu des deux colonnes condamnées. Supprimer une colonne sous
|
||||
// SQLite reconstruit la table : on le fait pendant que Livres n'a pas encore
|
||||
// d'enfants à préserver, et on recopie ensuite depuis cette table de transit.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
CREATE TABLE _RepriseLivres AS
|
||||
SELECT Id, Auteur, Statut FROM Livres;
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Auteur",
|
||||
table: "Livres");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Statut",
|
||||
table: "Livres");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TitreNormalise",
|
||||
table: "Livres",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Auteurs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Nom = table.Column<string>(type: "TEXT", nullable: false),
|
||||
NomNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CleRegroupement = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Auteurs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RapprochementsRefuses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AuteurAId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AuteurBId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StatutsLecture",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Statut = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DateMaj = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StatutsLecture", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StatutsLecture_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LivreAuteurs",
|
||||
columns: table => new
|
||||
{
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Position = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId });
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreAuteurs_Auteurs_AuteurId",
|
||||
column: x => x.AuteurId,
|
||||
principalTable: "Auteurs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreAuteurs_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Livres_TitreNormalise",
|
||||
table: "Livres",
|
||||
column: "TitreNormalise");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Auteurs_CleRegroupement",
|
||||
table: "Auteurs",
|
||||
column: "CleRegroupement",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Auteurs_NomNormalise",
|
||||
table: "Auteurs",
|
||||
column: "NomNormalise");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivreAuteurs_AuteurId",
|
||||
table: "LivreAuteurs",
|
||||
column: "AuteurId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId",
|
||||
table: "RapprochementsRefuses",
|
||||
columns: new[] { "AuteurAId", "AuteurBId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StatutsLecture_LivreId_Utilisateur",
|
||||
table: "StatutsLecture",
|
||||
columns: new[] { "LivreId", "Utilisateur" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StatutsLecture_Utilisateur",
|
||||
table: "StatutsLecture",
|
||||
column: "Utilisateur");
|
||||
|
||||
// ── Reprise des données depuis la table de transit ──────────────────────────────
|
||||
|
||||
// Le titre normalisé n'est qu'approché ici (lower() de SQLite ne touche pas aux
|
||||
// accents) ; le passage C# du démarrage le recalculera correctement.
|
||||
migrationBuilder.Sql("UPDATE Livres SET TitreNormalise = lower(trim(Titre));");
|
||||
|
||||
// Une fiche auteur par valeur distincte, à la casse près. Le nom d'affichage retenu
|
||||
// est le premier dans l'ordre alphabétique : arbitraire, mais déterministe, et
|
||||
// l'utilisateur peut le corriger depuis n'importe quel livre.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO Auteurs (Nom, NomNormalise, CleRegroupement)
|
||||
SELECT min(trim(Auteur)), lower(trim(Auteur)), lower(trim(Auteur))
|
||||
FROM _RepriseLivres
|
||||
WHERE Auteur IS NOT NULL AND trim(Auteur) <> ''
|
||||
GROUP BY lower(trim(Auteur));
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO LivreAuteurs (LivreId, AuteurId, Position)
|
||||
SELECT r.Id, a.Id, 0
|
||||
FROM _RepriseLivres r
|
||||
JOIN Auteurs a ON a.CleRegroupement = lower(trim(r.Auteur))
|
||||
JOIN Livres l ON l.Id = r.Id
|
||||
WHERE r.Auteur IS NOT NULL AND trim(r.Auteur) <> '';
|
||||
""");
|
||||
|
||||
// Le statut commun devient celui de la personne qui a saisi le livre. Sans AjoutePar,
|
||||
// il n'y a personne à qui l'attribuer : la ligne n'est pas créée.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO StatutsLecture (LivreId, Utilisateur, Statut, DateMaj)
|
||||
SELECT l.Id, trim(l.AjoutePar), r.Statut, l.DateAjout
|
||||
FROM Livres l
|
||||
JOIN _RepriseLivres r ON r.Id = l.Id
|
||||
WHERE l.AjoutePar IS NOT NULL AND trim(l.AjoutePar) <> '';
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("DROP TABLE _RepriseLivres;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Retour en arrière. <b>Une partie de l'information est perdue par nature</b> : l'ancien
|
||||
/// modèle n'a qu'une case pour un auteur et une pour un statut. On y remet le premier
|
||||
/// auteur et le statut de <c>AjoutePar</c> ; les co-auteurs et les statuts des autres
|
||||
/// membres du foyer disparaissent.
|
||||
/// </summary>
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Auteur",
|
||||
table: "Livres",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Statut",
|
||||
table: "Livres",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE Livres SET Auteur = (
|
||||
SELECT a.Nom FROM LivreAuteurs la
|
||||
JOIN Auteurs a ON a.Id = la.AuteurId
|
||||
WHERE la.LivreId = Livres.Id
|
||||
ORDER BY la.Position LIMIT 1);
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE Livres SET Statut = coalesce((
|
||||
SELECT s.Statut FROM StatutsLecture s
|
||||
WHERE s.LivreId = Livres.Id AND s.Utilisateur = trim(Livres.AjoutePar)
|
||||
LIMIT 1), 0);
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LivreAuteurs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RapprochementsRefuses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StatutsLecture");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Auteurs");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Livres_TitreNormalise",
|
||||
table: "Livres");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TitreNormalise",
|
||||
table: "Livres");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// <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("20260818003150_IndexPretEnCours")]
|
||||
partial class IndexPretEnCours
|
||||
{
|
||||
/// <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.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,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IndexPretEnCours : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prets_LivreId_EnCours",
|
||||
table: "Prets",
|
||||
column: "LivreId",
|
||||
unique: true,
|
||||
filter: "\"DateRetour\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Prets_LivreId_EnCours",
|
||||
table: "Prets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// <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("20260818052834_ListeDEnvies")]
|
||||
partial class ListeDEnvies
|
||||
{
|
||||
/// <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<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,56 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ListeDEnvies : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LivresSouhaites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Auteur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AuteurNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||
Editeur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Annee = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Isbn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LivresSouhaites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivresSouhaites_Utilisateur",
|
||||
table: "LivresSouhaites",
|
||||
column: "Utilisateur");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivresSouhaites_Utilisateur_Oeuvre",
|
||||
table: "LivresSouhaites",
|
||||
columns: new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LivresSouhaites");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
// <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("20260822104741_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
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.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
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<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.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.Property<int>("Role")
|
||||
.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.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
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.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RevuesSouhaitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
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.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NomNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("NumeroRevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NumeroRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
|
||||
.WithMany("Elements")
|
||||
.HasForeignKey("SerieId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Serie");
|
||||
});
|
||||
|
||||
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.LivreTheme", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany("Themes")
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
|
||||
.WithMany("Livres")
|
||||
.HasForeignKey("ThemeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
|
||||
.WithMany("Numeros")
|
||||
.HasForeignKey("RevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Revue");
|
||||
});
|
||||
|
||||
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.Serie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
|
||||
.WithMany("SousSeries")
|
||||
.HasForeignKey("SerieParenteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SerieParente");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
b.Navigation("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Navigation("Numeros");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Navigation("Elements");
|
||||
|
||||
b.Navigation("SousSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Navigation("Livres");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Auteurs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Nom = table.Column<string>(type: "TEXT", nullable: false),
|
||||
NomNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CleRegroupement = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Auteurs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BibliographiesMasquees",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BibliographiesMasquees", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Livres",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Isbn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Editeur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Format = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TypeDocument = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
NombrePages = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UrlNotice = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Livres", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LivresSouhaites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Auteur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AuteurNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||
Editeur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Annee = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Isbn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Rang = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LivresSouhaites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RapprochementsRefuses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
AuteurAId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AuteurBId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Revues",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Issn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Editeur = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Revues", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RevuesSouhaitees",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Numero = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||
Issn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Rang = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RevuesSouhaitees", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Series",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SerieParenteId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Position = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Series", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Series_Series_SerieParenteId",
|
||||
column: x => x.SerieParenteId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Themes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Nom = table.Column<string>(type: "TEXT", nullable: false),
|
||||
NomNormalise = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Themes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LivreAuteurs",
|
||||
columns: table => new
|
||||
{
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Position = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Role = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId });
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreAuteurs_Auteurs_AuteurId",
|
||||
column: x => x.AuteurId,
|
||||
principalTable: "Auteurs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreAuteurs_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Prets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Emprunteur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DatePret = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DateRetour = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Prets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Prets_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StatutsLecture",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Statut = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DateMaj = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StatutsLecture", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StatutsLecture_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NumerosRevue",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
RevueId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Numero = table.Column<string>(type: "TEXT", nullable: false),
|
||||
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DateParution = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CoverUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NumerosRevue", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NumerosRevue_Revues_RevueId",
|
||||
column: x => x.RevueId,
|
||||
principalTable: "Revues",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ElementsSerie",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SerieId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Position = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ElementsSerie", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ElementsSerie_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_ElementsSerie_Series_SerieId",
|
||||
column: x => x.SerieId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LivreThemes",
|
||||
columns: table => new
|
||||
{
|
||||
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ThemeId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LivreThemes", x => new { x.LivreId, x.ThemeId });
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreThemes_Livres_LivreId",
|
||||
column: x => x.LivreId,
|
||||
principalTable: "Livres",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_LivreThemes_Themes_ThemeId",
|
||||
column: x => x.ThemeId,
|
||||
principalTable: "Themes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ArticlesUne",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
NumeroRevueId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Position = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ArticlesUne", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ArticlesUne_NumerosRevue_NumeroRevueId",
|
||||
column: x => x.NumeroRevueId,
|
||||
principalTable: "NumerosRevue",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ArticlesUne_NumeroRevueId_TitreNormalise",
|
||||
table: "ArticlesUne",
|
||||
columns: new[] { "NumeroRevueId", "TitreNormalise" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Auteurs_CleRegroupement",
|
||||
table: "Auteurs",
|
||||
column: "CleRegroupement",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Auteurs_NomNormalise",
|
||||
table: "Auteurs",
|
||||
column: "NomNormalise");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre",
|
||||
table: "BibliographiesMasquees",
|
||||
columns: new[] { "AuteurId", "Utilisateur", "TitreNormalise" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BibliographiesMasquees_Utilisateur_AuteurId",
|
||||
table: "BibliographiesMasquees",
|
||||
columns: new[] { "Utilisateur", "AuteurId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ElementsSerie_LivreId",
|
||||
table: "ElementsSerie",
|
||||
column: "LivreId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ElementsSerie_SerieId",
|
||||
table: "ElementsSerie",
|
||||
column: "SerieId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ElementsSerie_SerieId_LivreId",
|
||||
table: "ElementsSerie",
|
||||
columns: new[] { "SerieId", "LivreId" },
|
||||
unique: true,
|
||||
filter: "\"LivreId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivreAuteurs_AuteurId",
|
||||
table: "LivreAuteurs",
|
||||
column: "AuteurId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Livres_Isbn",
|
||||
table: "Livres",
|
||||
column: "Isbn");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Livres_TitreNormalise",
|
||||
table: "Livres",
|
||||
column: "TitreNormalise");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivresSouhaites_Utilisateur",
|
||||
table: "LivresSouhaites",
|
||||
column: "Utilisateur");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivresSouhaites_Utilisateur_Oeuvre",
|
||||
table: "LivresSouhaites",
|
||||
columns: new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LivreThemes_ThemeId",
|
||||
table: "LivreThemes",
|
||||
column: "ThemeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NumerosRevue_RevueId_NumeroNormalise",
|
||||
table: "NumerosRevue",
|
||||
columns: new[] { "RevueId", "NumeroNormalise" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prets_LivreId",
|
||||
table: "Prets",
|
||||
column: "LivreId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prets_LivreId_EnCours",
|
||||
table: "Prets",
|
||||
column: "LivreId",
|
||||
unique: true,
|
||||
filter: "\"DateRetour\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId",
|
||||
table: "RapprochementsRefuses",
|
||||
columns: new[] { "AuteurAId", "AuteurBId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Revues_Issn",
|
||||
table: "Revues",
|
||||
column: "Issn",
|
||||
unique: true,
|
||||
filter: "\"Issn\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Revues_TitreNormalise",
|
||||
table: "Revues",
|
||||
column: "TitreNormalise",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RevuesSouhaitees_Utilisateur",
|
||||
table: "RevuesSouhaitees",
|
||||
column: "Utilisateur");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RevuesSouhaitees_Utilisateur_Revue",
|
||||
table: "RevuesSouhaitees",
|
||||
columns: new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Series_SerieParenteId",
|
||||
table: "Series",
|
||||
column: "SerieParenteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Series_TitreNormalise",
|
||||
table: "Series",
|
||||
column: "TitreNormalise",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StatutsLecture_LivreId_Utilisateur",
|
||||
table: "StatutsLecture",
|
||||
columns: new[] { "LivreId", "Utilisateur" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StatutsLecture_Utilisateur",
|
||||
table: "StatutsLecture",
|
||||
column: "Utilisateur");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Themes_NomNormalise",
|
||||
table: "Themes",
|
||||
column: "NomNormalise",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ArticlesUne");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BibliographiesMasquees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ElementsSerie");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LivreAuteurs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LivresSouhaites");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LivreThemes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Prets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RapprochementsRefuses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RevuesSouhaitees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StatutsLecture");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NumerosRevue");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Series");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Auteurs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Themes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Livres");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Revues");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
// <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("20260907234923_NumeroDeTome")]
|
||||
partial class NumeroDeTome
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
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.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
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<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.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.Property<int>("Role")
|
||||
.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.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
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.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RevuesSouhaitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
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.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NomNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("NumeroRevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NumeroRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
|
||||
.WithMany("Elements")
|
||||
.HasForeignKey("SerieId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Serie");
|
||||
});
|
||||
|
||||
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.LivreTheme", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany("Themes")
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
|
||||
.WithMany("Livres")
|
||||
.HasForeignKey("ThemeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
|
||||
.WithMany("Numeros")
|
||||
.HasForeignKey("RevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Revue");
|
||||
});
|
||||
|
||||
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.Serie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
|
||||
.WithMany("SousSeries")
|
||||
.HasForeignKey("SerieParenteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SerieParente");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
b.Navigation("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Navigation("Numeros");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Navigation("Elements");
|
||||
|
||||
b.Navigation("SousSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Navigation("Livres");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NumeroDeTome : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Numero",
|
||||
table: "ElementsSerie",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Numero",
|
||||
table: "ElementsSerie");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
// <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("20260910204753_AjouterEtatSerie")]
|
||||
partial class AjouterEtatSerie
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
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.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
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<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.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.Property<int>("Role")
|
||||
.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.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
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.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RevuesSouhaitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Etat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
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.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NomNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("NumeroRevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NumeroRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
|
||||
.WithMany("Elements")
|
||||
.HasForeignKey("SerieId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Serie");
|
||||
});
|
||||
|
||||
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.LivreTheme", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany("Themes")
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
|
||||
.WithMany("Livres")
|
||||
.HasForeignKey("ThemeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
|
||||
.WithMany("Numeros")
|
||||
.HasForeignKey("RevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Revue");
|
||||
});
|
||||
|
||||
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.Serie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
|
||||
.WithMany("SousSeries")
|
||||
.HasForeignKey("SerieParenteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SerieParente");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
b.Navigation("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Navigation("Numeros");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Navigation("Elements");
|
||||
|
||||
b.Navigation("SousSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Navigation("Livres");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AjouterEtatSerie : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Etat",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Etat",
|
||||
table: "Series");
|
||||
}
|
||||
}
|
||||
}
|
||||
-699
@@ -1,699 +0,0 @@
|
||||
// <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("20260910214613_AjouterTypesPratiquetConnaissance")]
|
||||
partial class AjouterTypesPratiquetConnaissance
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
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.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
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<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.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.Property<int>("Role")
|
||||
.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.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
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.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RevuesSouhaitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Etat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
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.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NomNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("NumeroRevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NumeroRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
|
||||
.WithMany("Elements")
|
||||
.HasForeignKey("SerieId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Serie");
|
||||
});
|
||||
|
||||
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.LivreTheme", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany("Themes")
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
|
||||
.WithMany("Livres")
|
||||
.HasForeignKey("ThemeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
|
||||
.WithMany("Numeros")
|
||||
.HasForeignKey("RevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Revue");
|
||||
});
|
||||
|
||||
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.Serie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
|
||||
.WithMany("SousSeries")
|
||||
.HasForeignKey("SerieParenteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SerieParente");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
b.Navigation("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Navigation("Numeros");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Navigation("Elements");
|
||||
|
||||
b.Navigation("SousSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Navigation("Livres");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AjouterTypesPratiquetConnaissance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,34 +17,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -73,68 +45,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.ToTable("Auteurs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -159,9 +69,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Property<string>("Isbn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -170,12 +77,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Isbn");
|
||||
@@ -196,9 +97,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "AuteurId");
|
||||
|
||||
b.HasIndex("AuteurId");
|
||||
@@ -264,58 +162,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.ToTable("LivresSouhaites");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -366,132 +212,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.ToTable("RapprochementsRefuses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RevuesSouhaitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Etat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -521,57 +241,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.ToTable("StatutsLecture");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NomNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("NumeroRevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NumeroRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany()
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
|
||||
.WithMany("Elements")
|
||||
.HasForeignKey("SerieId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Serie");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
|
||||
@@ -591,36 +260,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Navigation("Livre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
.WithMany("Themes")
|
||||
.HasForeignKey("LivreId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
|
||||
.WithMany("Livres")
|
||||
.HasForeignKey("ThemeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Livre");
|
||||
|
||||
b.Navigation("Theme");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
|
||||
.WithMany("Numeros")
|
||||
.HasForeignKey("RevueId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Revue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
@@ -632,16 +271,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Navigation("Livre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
|
||||
.WithMany("SousSeries")
|
||||
.HasForeignKey("SerieParenteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SerieParente");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
|
||||
{
|
||||
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
|
||||
@@ -665,30 +294,6 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Navigation("Prets");
|
||||
|
||||
b.Navigation("Statuts");
|
||||
|
||||
b.Navigation("Themes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Navigation("Numeros");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
|
||||
{
|
||||
b.Navigation("Elements");
|
||||
|
||||
b.Navigation("SousSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Navigation("Livres");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -26,228 +25,15 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
|
||||
{
|
||||
var titres = await RenormaliserTitresAsync(ct);
|
||||
var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct);
|
||||
var autres = await RenormaliserLesAutresTablesAsync(ct);
|
||||
|
||||
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
|
||||
if (titres + auteursCorriges + auteursFusionnes > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
|
||||
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
|
||||
titres, auteursCorriges, auteursFusionnes, autres);
|
||||
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).",
|
||||
titres, auteursCorriges, auteursFusionnes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remet à jour les colonnes normalisées des tables apparues après ce service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Envies, séries, revues et numéros calculent leurs formes <b>à l'écriture</b> : rien ne
|
||||
/// les rattraperait si la règle de normalisation changeait — et elle a changé le 2026-08-19,
|
||||
/// avec le développement des ligatures <c>œ</c> et <c>æ</c>. Sans ce passage, une envie
|
||||
/// enregistrée avant serait restée introuvable par sa propre clé.
|
||||
/// <para>
|
||||
/// ⚠️ Trois de ces colonnes portent une <b>unicité</b>. Une règle plus large peut donc
|
||||
/// rendre identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), et
|
||||
/// l'écriture échouerait <b>au démarrage</b> — l'application ne se lancerait plus. La
|
||||
/// collision est donc détectée avant d'écrire : la ligne la plus ancienne prend la nouvelle
|
||||
/// clé, les suivantes <b>gardent l'ancienne</b> et sont journalisées. Mal normalisée est
|
||||
/// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> RenormaliserLesAutresTablesAsync(CancellationToken ct)
|
||||
{
|
||||
var corriges = 0;
|
||||
|
||||
var souhaits = await db.LivresSouhaites.OrderBy(s => s.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
souhaits,
|
||||
s => Cle(s.Utilisateur, s.TitreNormalise, s.AuteurNormalise),
|
||||
s => Cle(s.Utilisateur, CleOeuvre.Cle(s.Titre), RapprochementAuteurs.Cle(s.Auteur)),
|
||||
s => s.RecalculerFormes(),
|
||||
s => $"l'envie « {s.Titre} »");
|
||||
|
||||
// ⚠️ La table sœur des envies suit exactement la même règle. L'oublier ne se verrait
|
||||
// pas tout de suite : une revue souhaitée resterait introuvable par sa propre clé le
|
||||
// jour où la normalisation changerait — c'est ce qui était arrivé aux envies avec les
|
||||
// ligatures.
|
||||
var revuesSouhaitees = await db.RevuesSouhaitees.OrderBy(e => e.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
revuesSouhaitees,
|
||||
e => Cle(e.Utilisateur, e.TitreNormalise, e.NumeroNormalise),
|
||||
e => Cle(
|
||||
e.Utilisateur,
|
||||
NormalisationTexte.Normaliser(e.Titre),
|
||||
e.Numero is null ? string.Empty : NormalisationTexte.Normaliser(e.Numero)),
|
||||
e => e.RecalculerFormes(),
|
||||
e => $"l'envie de revue « {e.Titre} »");
|
||||
|
||||
// ⚠️ Renormaliser() n'applique rien quand la CLÉ ne bouge pas : un ISSN mal formé sur
|
||||
// une ligne au titre inchangé y échapperait. Il se canonise donc à part — et sans le
|
||||
// moindre jeu de clés, cette colonne ne portant aucune unicité, contrairement à celle
|
||||
// de Revue.
|
||||
foreach (var envie in revuesSouhaitees)
|
||||
{
|
||||
var voulu = FormatageIssn.Canonique(envie.Issn);
|
||||
if (voulu != envie.Issn)
|
||||
{
|
||||
envie.Issn = voulu;
|
||||
corriges++;
|
||||
}
|
||||
}
|
||||
|
||||
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
series,
|
||||
s => s.TitreNormalise,
|
||||
s => NormalisationTexte.Normaliser(s.Titre),
|
||||
s => s.RecalculerFormes(),
|
||||
s => $"la série « {s.Titre} »");
|
||||
|
||||
var revues = await db.Revues.OrderBy(r => r.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
revues,
|
||||
r => r.TitreNormalise,
|
||||
r => NormalisationTexte.Normaliser(r.Titre),
|
||||
r => r.RecalculerFormes(),
|
||||
r => $"la revue « {r.Titre} »");
|
||||
|
||||
var numeros = await db.NumerosRevue.OrderBy(n => n.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
numeros,
|
||||
n => Cle(n.RevueId.ToString(), n.NumeroNormalise),
|
||||
n => Cle(n.RevueId.ToString(), NormalisationTexte.Normaliser(n.Numero)),
|
||||
n => n.RecalculerFormes(),
|
||||
n => $"le numéro {n.Numero}");
|
||||
|
||||
// ⚠️ L'ISSN ne peut PAS passer par Renormaliser : sa clé est nullable — la plupart des
|
||||
// revues n'en ont pas — et un jeu de clés confondrait tous ces NULL en une seule.
|
||||
// Son unicité est d'ailleurs un index PARTIEL, précisément pour cette raison.
|
||||
corriges += CanoniserLesIssn(revues);
|
||||
|
||||
var articles = await db.ArticlesUne.OrderBy(a => a.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
articles,
|
||||
a => Cle(a.NumeroRevueId.ToString(), a.TitreNormalise),
|
||||
a => Cle(a.NumeroRevueId.ToString(), NormalisationTexte.Normaliser(a.Titre)),
|
||||
a => a.RecalculerFormes(),
|
||||
a => $"l'article « {a.Titre} »");
|
||||
|
||||
var themes = await db.Themes.OrderBy(t => t.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
themes,
|
||||
t => t.NomNormalise,
|
||||
t => NormalisationTexte.Normaliser(t.Nom),
|
||||
t => t.RecalculerFormes(),
|
||||
t => $"le thème « {t.Nom} »");
|
||||
|
||||
if (corriges > 0)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return corriges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le jeu des clés occupées part de l'état <b>actuel</b> de la base, et chaque ligne libère
|
||||
/// la sienne avant de réserver la nouvelle. Sans cette libération, une ligne inchangée
|
||||
/// bloquerait sa propre mise à jour ; sans le jeu initial, une ligne traitée tôt prendrait
|
||||
/// la clé d'une ligne non encore traitée, et l'écriture échouerait.
|
||||
/// </remarks>
|
||||
private int Renormaliser<T>(
|
||||
IReadOnlyList<T> lignes,
|
||||
Func<T, string> cleActuelle,
|
||||
Func<T, string> cleVoulue,
|
||||
Action<T> appliquer,
|
||||
Func<T, string> nommer)
|
||||
{
|
||||
var occupees = new HashSet<string>(lignes.Select(cleActuelle), StringComparer.Ordinal);
|
||||
var corriges = 0;
|
||||
|
||||
foreach (var ligne in lignes)
|
||||
{
|
||||
var actuelle = cleActuelle(ligne);
|
||||
var voulue = cleVoulue(ligne);
|
||||
|
||||
if (actuelle == voulue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
occupees.Remove(actuelle);
|
||||
|
||||
if (!occupees.Add(voulue))
|
||||
{
|
||||
// Deux lignes que la nouvelle règle rend identiques. On garde les deux, en
|
||||
// laissant la seconde sur son ancienne clé : c'est visible, réparable à la main,
|
||||
// et surtout ça ne coûte pas le démarrage du serveur.
|
||||
occupees.Add(actuelle);
|
||||
logger.LogWarning(
|
||||
"Renormalisation : {Ligne} garde son ancienne forme, la nouvelle est déjà prise.",
|
||||
nommer(ligne));
|
||||
continue;
|
||||
}
|
||||
|
||||
appliquer(ligne);
|
||||
corriges++;
|
||||
}
|
||||
|
||||
return corriges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range les ISSN existants sous leur forme à tiret, sans jamais créer de collision.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rattrape les revues saisies à la main avant que la canonisation n'existe : « 24666718 »
|
||||
/// devient « 2466-6718 », donc rapprochable du code-barres et de <c>bib.issn</c>.
|
||||
/// <para>
|
||||
/// ⚠️ Une revue dont l'ISSN canonique est déjà celui d'une autre <b>garde le sien</b> : la
|
||||
/// fusion de deux fiches n'est pas une opération de démarrage, et l'unicité partielle
|
||||
/// ferait échouer le lancement du serveur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private int CanoniserLesIssn(IReadOnlyList<Revue> revues)
|
||||
{
|
||||
var porteuses = revues.Where(r => r.Issn is not null).ToList();
|
||||
var occupes = new HashSet<string>(porteuses.Select(r => r.Issn!), StringComparer.Ordinal);
|
||||
var corriges = 0;
|
||||
|
||||
foreach (var revue in porteuses)
|
||||
{
|
||||
var actuel = revue.Issn!;
|
||||
var voulu = FormatageIssn.Canonique(actuel);
|
||||
|
||||
if (voulu is null || voulu == actuel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
occupes.Remove(actuel);
|
||||
|
||||
if (!occupes.Add(voulu))
|
||||
{
|
||||
occupes.Add(actuel);
|
||||
logger.LogWarning(
|
||||
"Renormalisation : la revue « {Titre} » garde l'ISSN {Issn}, sa forme à tiret est déjà prise.",
|
||||
revue.Titre,
|
||||
actuel);
|
||||
continue;
|
||||
}
|
||||
|
||||
revue.Issn = voulu;
|
||||
corriges++;
|
||||
}
|
||||
|
||||
return corriges;
|
||||
}
|
||||
|
||||
/// <summary>Clé composite comparable, les composants ne pouvant pas contenir de NUL.</summary>
|
||||
private static string Cle(params string?[] composants) => string.Join('\0', composants);
|
||||
|
||||
private async Task<int> RenormaliserTitresAsync(CancellationToken ct)
|
||||
{
|
||||
var livres = await db.Livres.ToListAsync(ct);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using MaBibli.Api.Services.Couvertures;
|
||||
|
||||
namespace MaBibli.Api.Endpoints;
|
||||
|
||||
public static class CouverturesEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Relais d'images de couverture, pour la mise en cache hors-ligne.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Tout refus répond 404</b>, qu'il s'agisse d'une URL inconnue de la base, d'un schéma
|
||||
/// interdit, d'une adresse non routable ou d'un hôte injoignable. Distinguer ces cas ferait du
|
||||
/// point d'entrée un oracle : on y lirait quelles URL l'application connaît, et quelles
|
||||
/// adresses répondent depuis le serveur. Voir <see cref="ServiceCouvertures"/>.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapCouverturesEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/api/couvertures", async (
|
||||
string? url,
|
||||
IServiceCouvertures service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ObtenirAsync(url, ct);
|
||||
|
||||
return resultat.Trouvee
|
||||
? Results.File(resultat.Octets!, resultat.TypeContenu!)
|
||||
: Results.NotFound();
|
||||
})
|
||||
.WithName("RelayerCouverture")
|
||||
.WithTags("Couvertures")
|
||||
.WithSummary("Relaie une couverture déjà enregistrée, en même origine.")
|
||||
// ⚠️ Pas de `contentType` ici : il varie avec l'image (image/jpeg, image/png…), et
|
||||
// l'annotation OpenAPI REFUSE les jokers — « image/* » fait échouer le démarrage du
|
||||
// serveur, pas la compilation. Constaté en exécution.
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,8 @@ public static class LivresEndpoints
|
||||
groupe.MapGet("/", async (
|
||||
string? recherche,
|
||||
Format? format,
|
||||
TypeDocument? typeDocument,
|
||||
Statut? statut,
|
||||
int? auteurId,
|
||||
bool? prete,
|
||||
bool? sansCouverture,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
@@ -41,18 +38,15 @@ public static class LivresEndpoints
|
||||
{
|
||||
Recherche = recherche,
|
||||
Format = format,
|
||||
TypeDocument = typeDocument,
|
||||
Statut = statut,
|
||||
AuteurId = auteurId,
|
||||
Prete = prete,
|
||||
SansCouverture = sansCouverture,
|
||||
};
|
||||
|
||||
var utilisateur = utilisateurs.Obtenir().Identifiant;
|
||||
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
|
||||
})
|
||||
.WithName("ListerLivres")
|
||||
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; cherchable sur titre et auteurs.")
|
||||
.WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.")
|
||||
.WithDescription(
|
||||
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. "
|
||||
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. "
|
||||
@@ -75,36 +69,21 @@ public static class LivresEndpoints
|
||||
|
||||
groupe.MapPost("/", async (
|
||||
EnregistrementLivre saisie,
|
||||
bool? confirmerDoublon,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// AjoutePar vient du serveur, pas de la charge utile.
|
||||
var utilisateur = utilisateurs.Obtenir();
|
||||
var resultat = await service.CreerAsync(
|
||||
saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct);
|
||||
var resultat = await service.CreerAsync(saisie, utilisateur.Identifiant, ct);
|
||||
|
||||
if (resultat.Erreur is not null)
|
||||
{
|
||||
return Results.BadRequest(new { erreur = resultat.Erreur });
|
||||
}
|
||||
|
||||
// 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà
|
||||
// quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira.
|
||||
return resultat.Doublons is { } doublons
|
||||
? Results.Conflict(doublons)
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
|
||||
})
|
||||
.WithName("CreerLivre")
|
||||
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
|
||||
.WithDescription(
|
||||
"Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même "
|
||||
+ "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : "
|
||||
+ "posséder deux exemplaires est légitime, et « confirmerDoublon=true » "
|
||||
+ "enregistre la même saisie sans plus rien demander.")
|
||||
.Produces<LivreDto>(StatusCodes.Status201Created)
|
||||
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
@@ -190,31 +169,6 @@ public static class LivresEndpoints
|
||||
.Produces<AuteurDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id,
|
||||
RenommerAuteur demande,
|
||||
bool? confirmerFusion,
|
||||
IServiceAuteurs service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.RenommerAsync(
|
||||
id, demande.Nom, confirmerFusion ?? false, ct);
|
||||
|
||||
if (resultat.Auteur is null && resultat.Collision is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Collision is not null
|
||||
? Results.Conflict(resultat)
|
||||
: Results.Ok(resultat);
|
||||
})
|
||||
.WithName("RenommerAuteur")
|
||||
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
|
||||
.Produces<ResultatRenommageAuteur>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
|
||||
Results.Ok(await service.SuggestionsAsync(ct)))
|
||||
.WithName("ListerRapprochements")
|
||||
|
||||
@@ -53,20 +53,6 @@ public static class PretsEndpoints
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
prets.MapDelete("/{id:int}", async (int id, IServicePrets service, CancellationToken ct) =>
|
||||
{
|
||||
var supprime = await service.SupprimerAsync(id, ct);
|
||||
return supprime ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("SupprimerPret")
|
||||
.WithSummary("Efface un prêt saisi par erreur — ce n'est pas « rendre ».")
|
||||
.WithDescription(
|
||||
"Rendre pose une date de retour et conserve la ligne : c'est l'histoire du livre. "
|
||||
+ "Effacer défait une saisie fautive (mauvais emprunteur, mauvaise date, prêt noté "
|
||||
+ "deux fois). Un historique juste vaut mieux qu'un historique complet mais faux.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
var parLivre = routes.MapGroup("/api/livres/{livreId:int}/prets").WithTags("Prêts");
|
||||
|
||||
parLivre.MapGet("/", async (int livreId, IServicePrets service, CancellationToken ct) =>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
using MaBibli.Api.Services.Identite;
|
||||
using MaBibli.Api.Services.Revues;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Endpoints;
|
||||
|
||||
public static class RevuesEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Communes au foyer</b>, comme le catalogue : aucun point d'entrée ne prend d'identité,
|
||||
/// et « ajoutePar » n'est qu'une trace. La lecture est unique — <c>GET /api/revues</c> rend
|
||||
/// les revues avec leurs numéros, ce qui donne un seul instantané hors-ligne.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapRevuesEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var groupe = routes.MapGroup("/api/revues").WithTags("Revues");
|
||||
|
||||
groupe.MapGet("/", async (IServiceRevues service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListerAsync(ct)))
|
||||
.WithName("ListerRevues")
|
||||
.WithSummary("Toutes les revues, avec les numéros possédés, du plus récent au plus ancien.")
|
||||
.Produces<IReadOnlyList<RevueDto>>();
|
||||
|
||||
groupe.MapPost("/", async (
|
||||
EnregistrementRevue saisie,
|
||||
IServiceRevues service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.CreerOuRetrouverAsync(
|
||||
saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Revue);
|
||||
})
|
||||
.WithName("CreerRevue")
|
||||
.WithSummary("Crée la revue, ou rend celle qui existe déjà (même ISSN, ou même titre).")
|
||||
.WithDescription(
|
||||
"« Créer ou retrouver » et non « créer » : scanner le code-barres d'un second "
|
||||
+ "numéro retombe forcément sur la même revue, et le geste de l'utilisateur est "
|
||||
+ "toujours « voici un numéro de plus ».")
|
||||
.Produces<RevueDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id, EnregistrementRevue saisie, IServiceRevues service, CancellationToken ct) =>
|
||||
Repondre(await service.ModifierAsync(id, saisie, ct)))
|
||||
.WithName("ModifierRevue")
|
||||
.Produces<RevueDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/{id:int}", async (int id, IServiceRevues service, CancellationToken ct) =>
|
||||
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithName("SupprimerRevue")
|
||||
.WithSummary("Supprime la revue et les numéros qu'elle porte : ils n'existent que par elle.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPost("/{id:int}/numeros", async (
|
||||
int id, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
|
||||
Repondre(await service.AjouterNumeroAsync(id, saisie, ct)))
|
||||
.WithName("AjouterNumeroRevue")
|
||||
.WithSummary("Ajoute un numéro possédé. Le numéro est obligatoire et unique dans la revue.")
|
||||
.Produces<RevueDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/numeros/{numeroId:int}", async (
|
||||
int numeroId, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
|
||||
Repondre(await service.ModifierNumeroAsync(numeroId, saisie, ct)))
|
||||
.WithName("ModifierNumeroRevue")
|
||||
.WithSummary("Modifie un numéro : numéro, parution, note, couverture et articles à la une.")
|
||||
.WithDescription(
|
||||
"La couverture d'un numéro et sa une ne viennent d'aucune source — l'ISSN "
|
||||
+ "désigne la revue, pas la parution — et se saisissent donc après coup.")
|
||||
.Produces<RevueDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/numeros/{numeroId:int}", async (
|
||||
int numeroId, IServiceRevues service, CancellationToken ct) =>
|
||||
await service.RetirerNumeroAsync(numeroId, ct)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound())
|
||||
.WithName("RetirerNumeroRevue")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult Repondre(ResultatRevue resultat)
|
||||
{
|
||||
if (resultat.EstIntrouvable)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Revue);
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
using MaBibli.Api.Services.Identite;
|
||||
using MaBibli.Api.Services.Series;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Endpoints;
|
||||
|
||||
public static class SeriesEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Aucun point d'entrée n'accepte ni ne rend d'identité</b>, à une exception près :
|
||||
/// la mise en envies. Les séries sont <b>communes au foyer</b>, comme le catalogue et les
|
||||
/// prêts — l'ordre de lecture d'une saga ne dépend pas de qui regarde. La liste d'envies,
|
||||
/// elle, reste personnelle, et c'est le serveur qui sait à qui il parle.
|
||||
/// <para>
|
||||
/// La lecture est <b>unique</b> : <c>GET /api/series</c> rend tout l'arbre à plat. Le détail
|
||||
/// d'une série s'y lit sans second appel, ce qui donne un seul instantané hors-ligne à
|
||||
/// tenir à jour plutôt qu'un par série consultée.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapSeriesEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var groupe = routes.MapGroup("/api/series").WithTags("Séries");
|
||||
|
||||
groupe.MapGet("/", async (IServiceSeries service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListerAsync(ct)))
|
||||
.WithName("ListerSeries")
|
||||
.WithSummary("Toutes les séries, à plat, chacune avec ses tomes dans l'ordre de lecture.")
|
||||
.WithDescription(
|
||||
"Le client rebâtit l'arbre à partir de « serieParenteId ». Un tome sans "
|
||||
+ "« livreId » est un tome qu'on ne possède pas : c'est ce que l'écran montre.")
|
||||
.Produces<IReadOnlyList<SerieDto>>();
|
||||
|
||||
groupe.MapPost("/", async (
|
||||
EnregistrementSerie saisie,
|
||||
IServiceSeries service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.CreerAsync(saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Created($"/api/series/{resultat.Serie!.Id}", resultat.Serie);
|
||||
})
|
||||
.WithName("CreerSerie")
|
||||
.WithSummary("Crée une série. « AjoutePar » est déterminé par le serveur, et n'est qu'une trace.")
|
||||
.Produces<SerieDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id, EnregistrementSerie saisie, IServiceSeries service, CancellationToken ct) =>
|
||||
Repondre(await service.ModifierAsync(id, saisie, ct)))
|
||||
.WithName("ModifierSerie")
|
||||
.WithSummary("Renomme la série, ou la range dans un cycle.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/{id:int}", async (int id, IServiceSeries service, CancellationToken ct) =>
|
||||
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithName("SupprimerSerie")
|
||||
.WithSummary("Supprime la série. Ses sous-séries remontent à la racine, elles ne sont pas détruites.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPost("/{id:int}/elements", async (
|
||||
int id, AjoutElementSerie saisie, IServiceSeries service, CancellationToken ct) =>
|
||||
Repondre(await service.AjouterElementAsync(id, saisie, ct)))
|
||||
.WithName("AjouterElementSerie")
|
||||
.WithSummary("Ajoute un tome en fin de série : un livre du catalogue, ou un simple titre.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/{id:int}/ordre", async (
|
||||
int id, OrdreElementsSerie ordre, IServiceSeries service, CancellationToken ct) =>
|
||||
Repondre(await service.ReordonnerAsync(id, ordre.Ids, ct)))
|
||||
.WithName("ReordonnerSerie")
|
||||
.WithSummary("Fixe l'ordre de lecture — la liste entière des tomes, dans l'ordre voulu.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/{id:int}/sous-series/ordre", async (
|
||||
int id, OrdreSeries ordre, IServiceSeries service, CancellationToken ct) =>
|
||||
Repondre(await service.ReordonnerSousSeriesAsync(id, ordre.Ids, ct)))
|
||||
.WithName("ReordonnerSousSeries")
|
||||
.WithSummary("Fixe l'ordre de lecture des séries directement contenues dans un cycle.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/elements/{elementId:int}/livre", async (
|
||||
int elementId,
|
||||
RattachementLivre rattachement,
|
||||
IServiceSeries service,
|
||||
CancellationToken ct) =>
|
||||
Repondre(await service.RattacherLivreAsync(elementId, rattachement.LivreId, ct)))
|
||||
.WithName("RattacherLivreSerie")
|
||||
.WithSummary("Rattache un livre possédé à une place, ou l'en détache (« livreId » nul).")
|
||||
.WithDescription(
|
||||
"Détacher laisse un trou NOMMÉ : le titre du tome est conservé, il ne dépend "
|
||||
+ "pas de l'exemplaire.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPut("/elements/{elementId:int}", async (
|
||||
int elementId,
|
||||
AjoutElementSerie saisie,
|
||||
IServiceSeries service,
|
||||
CancellationToken ct) =>
|
||||
Repondre(await service.ModifierElementAsync(elementId, saisie, ct)))
|
||||
.WithName("ModifierElementSerie")
|
||||
.WithSummary("Corrige le titre et le numéro d'un tome, sans toucher au livre rattaché.")
|
||||
.WithDescription(
|
||||
"Le numéro est celui imprimé sur le livre (« 7 », « Hors-série »), à ne pas "
|
||||
+ "confondre avec la position, qui est le rang dans l'ordre de lecture.")
|
||||
.Produces<SerieDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/elements/{elementId:int}", async (
|
||||
int elementId, IServiceSeries service, CancellationToken ct) =>
|
||||
await service.RetirerElementAsync(elementId, ct)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound())
|
||||
.WithName("RetirerElementSerie")
|
||||
.WithSummary("Retire une place de la série. Le livre, lui, reste au catalogue.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPost("/elements/{elementId:int}/souhait", async (
|
||||
int elementId,
|
||||
IServiceSeries service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// ⚠️ Le seul endroit où une série (commune) touche à une liste d'envies
|
||||
// (personnelle). L'envie créée est celle de l'appelant, jamais celle du foyer.
|
||||
var resultat = await service.MettreEnEnviesAsync(
|
||||
elementId, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Souhait);
|
||||
})
|
||||
.WithName("SouhaiterTomeManquant")
|
||||
.WithSummary("Met un tome manquant dans la liste d'envies de l'appelant.")
|
||||
.Produces<SouhaitDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult Repondre(ResultatSerie resultat)
|
||||
{
|
||||
if (resultat.EstIntrouvable)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Serie);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Charge utile du rattachement d'un livre à une place. <c>null</c> détache.</summary>
|
||||
public record RattachementLivre
|
||||
{
|
||||
public int? LivreId { get; set; }
|
||||
}
|
||||
@@ -74,37 +74,6 @@ public static class SouhaitsEndpoints
|
||||
.Produces<SouhaitDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id,
|
||||
EnregistrementSouhait saisie,
|
||||
IServiceSouhaits service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(
|
||||
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
if (resultat.NExistePas)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Souhait);
|
||||
})
|
||||
.WithName("ModifierSouhait")
|
||||
.WithSummary("Corrige une envie de l'appelant.")
|
||||
.WithDescription(
|
||||
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
|
||||
+ "sont volontairement indiscernables. 400 — et non 409 — quand la correction "
|
||||
+ "ferait doublon avec une autre envie : l'unicité l'interdit, il n'y a rien à "
|
||||
+ "confirmer, contrairement au doublon du catalogue. Le rang n'est pas touché : "
|
||||
+ "l'ordre a son propre point d'entrée.")
|
||||
.Produces<SouhaitDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/{id:int}", async (
|
||||
int id,
|
||||
IServiceSouhaits service,
|
||||
@@ -124,108 +93,20 @@ public static class SouhaitsEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Envies de revues et de numéros.
|
||||
//
|
||||
// Table sœur, donc points d'entrée sœurs : mêmes règles de portée, même 404 sur ce qui
|
||||
// appartient à un autre. Le préfixe littéral « revues » ne peut pas être confondu avec
|
||||
// « /{id:int} », qui exige un entier.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
groupe.MapGet("/revues", async (
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
|
||||
.WithName("ListerRevuesSouhaitees")
|
||||
.WithSummary("Les revues et numéros souhaités par l'appelant.")
|
||||
.WithDescription(
|
||||
"Section distincte de la liste d'envies : les rangs des deux tables sont "
|
||||
+ "numérotés indépendamment, les entrelacer produirait un ordre que personne "
|
||||
+ "n'a choisi.")
|
||||
.Produces<IReadOnlyList<RevueSouhaiteeDto>>();
|
||||
|
||||
groupe.MapPost("/revues", async (
|
||||
EnregistrementRevueSouhaitee saisie,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.AjouterAsync(
|
||||
saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Created($"/api/souhaits/revues/{resultat.Envie!.Id}", resultat.Envie);
|
||||
})
|
||||
.WithName("AjouterRevueSouhaitee")
|
||||
.WithSummary("Ajoute une revue — ou un numéro précis — à la liste d'envies.")
|
||||
.WithDescription(
|
||||
"Le numéro est facultatif : on souhaite parfois un numéro vu en kiosque, "
|
||||
+ "parfois le titre entier. L'ISSN est canonisé avec son tiret.")
|
||||
.Produces<RevueSouhaiteeDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/revues/{id:int}", async (
|
||||
int id,
|
||||
EnregistrementRevueSouhaitee saisie,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(
|
||||
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
if (resultat.NExistePas)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Envie);
|
||||
})
|
||||
.WithName("ModifierRevueSouhaitee")
|
||||
.WithSummary("Corrige une envie de revue de l'appelant.")
|
||||
.Produces<RevueSouhaiteeDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/revues/{id:int}", async (
|
||||
int id,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var supprime = await service.SupprimerAsync(
|
||||
id, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return supprime ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("SupprimerRevueSouhaitee")
|
||||
.WithSummary("Retire une revue de la liste d'envies de l'appelant.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Deux formats parce que les deux usages décrits dans CLAUDE.md diffèrent : le .txt
|
||||
// Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt
|
||||
// s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se
|
||||
// répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les
|
||||
// produire, ni pour les lire.
|
||||
groupe.MapGet("/export.txt", (
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
|
||||
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct))
|
||||
.WithName("ExporterSouhaitsTexte")
|
||||
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
groupe.MapGet("/export.csv", (
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Csv, service, revues, utilisateurs, ct))
|
||||
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct))
|
||||
.WithName("ExporterSouhaitsCsv")
|
||||
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
|
||||
.ExcludeFromDescription();
|
||||
@@ -250,20 +131,15 @@ public static class SouhaitsEndpoints
|
||||
private static async Task<IResult> ExporterAsync(
|
||||
FormatExportSouhaits format,
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var utilisateur = utilisateurs.Obtenir();
|
||||
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
|
||||
|
||||
// ⚠️ Les deux tables partent ensemble, sans quoi les envies de revues n'existeraient
|
||||
// pas dans le fichier qu'on emporte — c'est-à-dire là où elles servent.
|
||||
var revuesSouhaitees = await revues.ListerAsync(utilisateur.Identifiant, ct);
|
||||
var maintenant = DateTime.Now;
|
||||
|
||||
var contenu = ExportSouhaits.Produire(
|
||||
format, souhaits, revuesSouhaitees, utilisateur.Affichage, maintenant);
|
||||
format, souhaits, utilisateur.Affichage, maintenant);
|
||||
|
||||
// Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le
|
||||
// fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait
|
||||
@@ -304,58 +180,6 @@ public static class SouhaitsEndpoints
|
||||
.Produces<BibliographieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
routes.MapGet("/api/auteurs/{id:int}/bibliographie/nouveautes", async (
|
||||
int id,
|
||||
IServiceBibliographie service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var bibliographie = await service.ObtenirNouveautesAsync(
|
||||
id, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
|
||||
})
|
||||
.WithName("NouveautesAuteur")
|
||||
.WithTags("Auteurs")
|
||||
.WithSummary("Les œuvres de cet auteur parues depuis le livre le plus récent possédé.")
|
||||
.WithDescription(
|
||||
"La date du représentant d'une œuvre est celle de l'édition la plus ancienne ; "
|
||||
+ "la sélection compare donc la date la plus récente des éditions connues. "
|
||||
+ "La recherche étendue est bornée à 1000 notices BnF.")
|
||||
.Produces<BibliographieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async (
|
||||
int id,
|
||||
MasquageBibliographie saisie,
|
||||
IServiceBibliographie service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var fait = await service.MasquerAsync(
|
||||
id, saisie.Titre, utilisateurs.Obtenir().Identifiant, ct);
|
||||
return fait ? Results.NoContent() : Results.BadRequest();
|
||||
})
|
||||
.WithName("MasquerOeuvreBibliographie")
|
||||
.WithSummary("Masque une œuvre de la bibliographie pour l'appelant.")
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
routes.MapDelete("/api/auteurs/{id:int}/bibliographie/masque", async (
|
||||
int id,
|
||||
string titre,
|
||||
IServiceBibliographie service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var fait = await service.DemasquerAsync(
|
||||
id, titre, utilisateurs.Obtenir().Identifiant, ct);
|
||||
return fait ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("DemasquerOeuvreBibliographie")
|
||||
.WithSummary("Réaffiche une œuvre masquée de la bibliographie.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using System.Reflection;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Ce que l'application sait d'elle-même : sa version publiée et sa date de build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est le <b>serveur</b> qui répond, et non le client Blazor, bien que les deux soient
|
||||
/// compilés du même coup : lire ses propres attributs depuis le WebAssembly dépendrait de ce que
|
||||
/// le trimmer veut bien conserver, alors qu'ici la lecture est certaine. La page « À propos »
|
||||
/// affiche donc la version du binaire réellement déployé — ce qu'on veut savoir en diagnostiquant
|
||||
/// un appareil.
|
||||
/// </remarks>
|
||||
public static class VersionEndpoints
|
||||
{
|
||||
// Lue une fois : les attributs d'un assembly ne changent pas en cours d'exécution.
|
||||
private static readonly VersionApplication Version = Lire(typeof(VersionEndpoints).Assembly);
|
||||
|
||||
public static IEndpointRouteBuilder MapVersionEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/api/version", () => Results.Ok(Version))
|
||||
.WithName("VersionApplication")
|
||||
.WithTags("Version")
|
||||
.WithSummary("Version publiée et date de build, ou « rien » pour un binaire non publié.")
|
||||
.Produces<VersionApplication>();
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/// <summary>Extrait les deux valeurs de l'assembly, sans rien interpréter lui-même.</summary>
|
||||
public static VersionApplication Lire(Assembly assembly) =>
|
||||
VersionApplication.Depuis(
|
||||
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
|
||||
assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.FirstOrDefault(a => a.Key == "MaBibliDateBuild")?.Value);
|
||||
}
|
||||
@@ -16,20 +16,6 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Version publiée et horodatage de build, posés par `build/publier-release.sh` (dépôt
|
||||
`mabibli_ynh`) : `-p:Version=X.Y.Z -p:MaBibliDateBuild=...`.
|
||||
|
||||
⚠️ L'horodatage est le TÉMOIN de l'injection, et c'est pour cela qu'il n'est jamais calculé
|
||||
ici : un `DateTime.UtcNow` d'MSBuild vaudrait pour toute compilation locale, y compris celles
|
||||
que le SDK numérote 1.0.0 — l'application afficherait alors une version inventée, ce qui est
|
||||
pire que pas de version du tout (voir VersionApplication). Il ferait de surcroît recompiler
|
||||
l'assembly à chaque build, l'attribut changeant à chaque fois.
|
||||
-->
|
||||
<ItemGroup Condition="'$(MaBibliDateBuild)' != ''">
|
||||
<AssemblyMetadata Include="MaBibliDateBuild" Value="$(MaBibliDateBuild)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaBibli.Client\MaBibli.Client.csproj" />
|
||||
<ProjectReference Include="..\MaBibli.Shared\MaBibli.Shared.csproj" />
|
||||
|
||||
+2
-69
@@ -1,12 +1,9 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Api.Endpoints;
|
||||
using MaBibli.Api.Services.Catalogue;
|
||||
using MaBibli.Api.Services.Couvertures;
|
||||
using MaBibli.Api.Services.Identite;
|
||||
using MaBibli.Api.Services.Isbn;
|
||||
using MaBibli.Api.Services.Prets;
|
||||
using MaBibli.Api.Services.Revues;
|
||||
using MaBibli.Api.Services.Series;
|
||||
using MaBibli.Api.Services.Souhaits;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -21,10 +18,7 @@ builder.Services.AddScoped<IServiceAuteurs, ServiceAuteurs>();
|
||||
builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>();
|
||||
builder.Services.AddScoped<IServicePrets, ServicePrets>();
|
||||
builder.Services.AddScoped<IServiceSouhaits, ServiceSouhaits>();
|
||||
builder.Services.AddScoped<IServiceRevuesSouhaitees, ServiceRevuesSouhaitees>();
|
||||
builder.Services.AddScoped<IServiceBibliographie, ServiceBibliographie>();
|
||||
builder.Services.AddScoped<IServiceSeries, ServiceSeries>();
|
||||
builder.Services.AddScoped<IServiceRevues, ServiceRevues>();
|
||||
|
||||
// Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé
|
||||
// configuré en développement (il n'y a pas de portail YunoHost en local).
|
||||
@@ -36,19 +30,6 @@ builder.Services.AddScoped<IFournisseurUtilisateur, FournisseurUtilisateurSsowat
|
||||
// Timeout court : une source lente ne doit pas bloquer la cascade, l'appelant bascule sur la suivante.
|
||||
var timeoutSources = TimeSpan.FromSeconds(10);
|
||||
|
||||
// ⚠️ Le relais de couvertures a son PROPRE délai, et bien plus long. Ce n'est pas une largesse :
|
||||
// les deux appels n'ont pas le même témoin. Un lookup ISBN fait patienter quelqu'un devant un
|
||||
// formulaire — d'où les 10 s ci-dessus, et la bascule sur la source suivante. Le relais, lui,
|
||||
// remplit un cache en tâche de fond : personne n'attend derrière, et abandonner ne fait pas
|
||||
// gagner du temps, cela perd la couverture.
|
||||
//
|
||||
// Mesuré le 2026-08-21 : les couvertures OpenLibrary aboutissent sur `archive.org`, qui les
|
||||
// EXTRAIT D'UNE ARCHIVE ZIP à la volée et met 5 à 15 s. À 10 s, le relais répondait 404 une fois
|
||||
// sur trois — indistinguable, pour l'appelant, d'une URL inconnue. L'image n'était alors jamais
|
||||
// mise en cache, et le client rejouait CORS puis relais à CHAQUE affichage : exactement le
|
||||
// défaut que ce relais existe pour corriger.
|
||||
var timeoutRelaisCouvertures = TimeSpan.FromSeconds(30);
|
||||
|
||||
builder.Services.AddHttpClient<IBnfClient, BnfClient>(http =>
|
||||
{
|
||||
http.BaseAddress = new Uri(BnfClient.UrlBase);
|
||||
@@ -65,31 +46,6 @@ builder.Services.AddHttpClient<IOpenLibraryClient, OpenLibraryClient>(http =>
|
||||
|
||||
builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>();
|
||||
|
||||
// Relais de couvertures : rend une image de MÊME ORIGINE, seule façon de la mettre en cache
|
||||
// hors-ligne quand son hébergeur n'envoie pas d'en-tête CORS (le formulaire livre accepte
|
||||
// n'importe quelle URL de couverture).
|
||||
//
|
||||
// ⚠️ Les deux réglages du handler ne sont PAS des détails de confort :
|
||||
// - ConnectCallback n'ouvre la socket que vers une adresse publiquement routable, ce qui
|
||||
// ferme le SSRF y compris par DNS rebinding (voir GardeAdresses) ;
|
||||
// - les redirections sont SUIVIES, mais bornées à 3. Elles avaient d'abord été coupées, à
|
||||
// tort : `covers.openlibrary.org` répond 302 (mesuré), et beaucoup de CDN font de même —
|
||||
// les couper refusait donc les couvertures les plus courantes. Les suivre reste sûr parce
|
||||
// que le garde agit à la CONNEXION : il s'applique à chaque saut, cible de redirection
|
||||
// comprise. C'est la position du garde, et non l'interdiction des redirections, qui ferme
|
||||
// le SSRF.
|
||||
builder.Services.AddHttpClient<IServiceCouvertures, ServiceCouvertures>(http =>
|
||||
{
|
||||
http.Timeout = timeoutRelaisCouvertures;
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 3,
|
||||
ConnectCallback = GardeAdresses.ConnecterAsync,
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost.
|
||||
@@ -113,40 +69,17 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
// Le client Blazor WebAssembly est servi comme fichiers statiques par cette API :
|
||||
// un seul processus tourne sur le serveur.
|
||||
//
|
||||
// ⚠️ Les empreintes WASM sont désactivées (voir CLAUDE.md) : tous ces fichiers portent des noms
|
||||
// stables. Sans en-tête Cache-Control, un navigateur peut garder une version périmée en cache
|
||||
// heuristique (par exemple MaBibli.Client.styles.css) même si le service worker, lui, revalide
|
||||
// déjà à l'installation — mais seulement quand il parvient à s'activer. `no-cache` force une
|
||||
// revalidation conditionnelle (ETag/Last-Modified, déjà posés par défaut) à chaque requête :
|
||||
// pas de re-téléchargement si le fichier n'a pas changé, mais jamais de contenu périmé servi
|
||||
// sans repasser par le serveur.
|
||||
var optionsFichiersStatiques = new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = ctx =>
|
||||
ctx.Context.Response.Headers.CacheControl = "no-cache, must-revalidate",
|
||||
};
|
||||
app.UseBlazorFrameworkFiles();
|
||||
app.UseStaticFiles(optionsFichiersStatiques);
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapIsbnEndpoints();
|
||||
app.MapLivresEndpoints();
|
||||
app.MapAuteursEndpoints();
|
||||
app.MapPretsEndpoints();
|
||||
app.MapSouhaitsEndpoints();
|
||||
app.MapSeriesEndpoints();
|
||||
app.MapRevuesEndpoints();
|
||||
app.MapBibliographieEndpoints();
|
||||
app.MapIdentiteEndpoints();
|
||||
app.MapCouverturesEndpoints();
|
||||
app.MapVersionEndpoints();
|
||||
|
||||
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées
|
||||
// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
|
||||
// la seule URL qu'ouvre l'application installée — repartait sans aucun Cache-Control, donc en
|
||||
// cache heuristique du navigateur. Mesuré : /index.html portait bien l'en-tête, « / » non.
|
||||
// C'est la coquille HTML qui décide de tout le reste : la servir périmée suffit à mélanger deux
|
||||
// versions sur l'appareil.
|
||||
app.MapFallbackToFile("index.html", optionsFichiersStatiques);
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -18,10 +18,6 @@ public interface IServiceAuteurs
|
||||
|
||||
Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Renomme une fiche, ou signale la fiche cible à fusionner.</summary>
|
||||
Task<ResultatRenommageAuteur> RenommerAsync(
|
||||
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
|
||||
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -143,62 +139,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task<ResultatRenommageAuteur> RenommerAsync(
|
||||
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
|
||||
{
|
||||
var auteur = await db.Auteurs.FirstOrDefaultAsync(a => a.Id == id, ct);
|
||||
if (auteur is null)
|
||||
{
|
||||
return new ResultatRenommageAuteur();
|
||||
}
|
||||
|
||||
nom = nom.Trim();
|
||||
var cle = RapprochementAuteurs.Cle(nom);
|
||||
if (cle.Length == 0)
|
||||
{
|
||||
return new ResultatRenommageAuteur
|
||||
{
|
||||
Auteur = new AuteurDto { Id = id, Nom = auteur.Nom },
|
||||
};
|
||||
}
|
||||
|
||||
var collision = await db.Auteurs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id != id && a.CleRegroupement == cle, ct);
|
||||
|
||||
if (collision is not null)
|
||||
{
|
||||
var dto = new AuteurDto
|
||||
{
|
||||
Id = collision.Id,
|
||||
Nom = collision.Nom,
|
||||
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == collision.Id, ct),
|
||||
};
|
||||
|
||||
if (!confirmerFusion)
|
||||
{
|
||||
return new ResultatRenommageAuteur { Collision = dto };
|
||||
}
|
||||
|
||||
await FusionnerAsync(id, collision.Id, ct);
|
||||
return new ResultatRenommageAuteur { Auteur = dto };
|
||||
}
|
||||
|
||||
auteur.Nom = nom;
|
||||
auteur.RecalculerFormes();
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new ResultatRenommageAuteur
|
||||
{
|
||||
Auteur = new AuteurDto
|
||||
{
|
||||
Id = auteur.Id,
|
||||
Nom = auteur.Nom,
|
||||
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == auteur.Id, ct),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -277,15 +217,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
|
||||
// la clé primaire du lien est (LivreId, AuteurId).
|
||||
if (!dejaLies.Add(lien.LivreId))
|
||||
{
|
||||
var existant = cible.Livres.FirstOrDefault(l => l.LivreId == lien.LivreId);
|
||||
if (existant is not null)
|
||||
{
|
||||
existant.Position = Math.Min(existant.Position, lien.Position);
|
||||
if (existant.Role == RoleAuteur.NonPrecise)
|
||||
{
|
||||
existant.Role = lien.Role;
|
||||
}
|
||||
}
|
||||
db.LivreAuteurs.Remove(lien);
|
||||
continue;
|
||||
}
|
||||
@@ -296,7 +227,6 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
|
||||
LivreId = lien.LivreId,
|
||||
AuteurId = cible.Id,
|
||||
Position = lien.Position,
|
||||
Role = lien.Role,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Api.Services.Isbn;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -15,13 +13,6 @@ public interface IServiceBibliographie
|
||||
/// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas.
|
||||
/// </summary>
|
||||
Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<BibliographieDto?> ObtenirNouveautesAsync(
|
||||
int auteurId, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -82,10 +73,9 @@ public sealed class ServiceBibliographie(
|
||||
}
|
||||
|
||||
var souhaits = await SouhaitsAsync(utilisateur, ct);
|
||||
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
|
||||
|
||||
var oeuvres = Regrouper(resultat)
|
||||
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
|
||||
.Select(o => Confronter(o, parTitre, parIsbn, souhaits))
|
||||
.OrderByDescending(o => AnneeTriable(o.Annee))
|
||||
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
@@ -105,122 +95,6 @@ public sealed class ServiceBibliographie(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
|
||||
int auteurId, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
var auteur = await auteurs.ObtenirAsync(auteurId, ct);
|
||||
if (auteur is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var recherche = bnf is IBnfClientNouveautes etendu
|
||||
? await etendu.RechercherParAuteurNouveautesAsync(auteur.Nom, ct)
|
||||
: await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
|
||||
var resultat = recherche.Resultat;
|
||||
|
||||
var possedes = await db.LivreAuteurs
|
||||
.AsNoTracking()
|
||||
.Where(la => la.AuteurId == auteurId)
|
||||
.Select(la => new { la.LivreId, la.Livre!.Titre, la.Livre.Isbn })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var parTitre = new Dictionary<string, int>();
|
||||
var parIsbn = new Dictionary<string, int>();
|
||||
foreach (var livre in possedes)
|
||||
{
|
||||
var cle = CleOeuvre.Cle(livre.Titre);
|
||||
if (cle.Length > 0)
|
||||
{
|
||||
parTitre.TryAdd(cle, livre.LivreId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(livre.Isbn))
|
||||
{
|
||||
parIsbn.TryAdd(livre.Isbn, livre.LivreId);
|
||||
}
|
||||
}
|
||||
|
||||
var souhaits = await SouhaitsAsync(utilisateur, ct);
|
||||
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
|
||||
var oeuvres = Regrouper(resultat)
|
||||
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
|
||||
.ToList();
|
||||
var seuil = AnneeSeuilNouveautes(
|
||||
possedes.Select(l => (l.Titre, l.Isbn)), resultat.Notices);
|
||||
|
||||
// Une notice d'édition originale ne date pas la dernière édition de l'œuvre. Le
|
||||
// regroupement conserve donc AnneeDerniereEdition, qui est la seule date pertinente
|
||||
// pour répondre à « paru depuis ».
|
||||
var nouveautes = FiltrerNouveautes(oeuvres, seuil);
|
||||
|
||||
return new BibliographieDto
|
||||
{
|
||||
Auteur = auteur,
|
||||
Oeuvres = nouveautes,
|
||||
NoticesAnnoncees = resultat.NombreAnnonce,
|
||||
NoticesLues = resultat.NombreLu,
|
||||
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
|
||||
Avertissement = recherche.Avertissement,
|
||||
Etat = resultat.Etat,
|
||||
EstNouveautes = true,
|
||||
AnneeSeuilNouveautes = seuil,
|
||||
SeuilNouveautesInconnu = seuil is null && possedes.Count > 0,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> MasquerAsync(
|
||||
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cle = CleOeuvre.Cle(titre);
|
||||
if (cle.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var existe = await db.BibliographiesMasquees.AnyAsync(
|
||||
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
|
||||
if (existe)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
db.BibliographiesMasquees.Add(new BibliographieMasquee
|
||||
{
|
||||
AuteurId = auteurId,
|
||||
Utilisateur = utilisateur,
|
||||
TitreNormalise = cle,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DemasquerAsync(
|
||||
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cle = CleOeuvre.Cle(titre);
|
||||
var ligne = await db.BibliographiesMasquees.FirstOrDefaultAsync(
|
||||
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
|
||||
if (ligne is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.BibliographiesMasquees.Remove(ligne);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary>
|
||||
private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct)
|
||||
{
|
||||
@@ -244,22 +118,6 @@ public sealed class ServiceBibliographie(
|
||||
return index;
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> MasqueesAsync(
|
||||
int auteurId, string? utilisateur, CancellationToken ct)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cles = await db.BibliographiesMasquees
|
||||
.AsNoTracking()
|
||||
.Where(m => m.AuteurId == auteurId && m.Utilisateur == utilisateur)
|
||||
.Select(m => m.TitreNormalise)
|
||||
.ToListAsync(ct);
|
||||
return cles.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Réunit les rééditions sous une seule œuvre.
|
||||
/// </summary>
|
||||
@@ -290,12 +148,6 @@ public sealed class ServiceBibliographie(
|
||||
{
|
||||
Titre = reference.Titre,
|
||||
Annee = reference.Annee,
|
||||
AnneeDerniereEdition = ordonnees
|
||||
.Select(n => AnneeTriable(n.Annee))
|
||||
.Where(a => a is not null)
|
||||
.Select(a => a!.Value)
|
||||
.DefaultIfEmpty()
|
||||
.Max() is var annee && annee > 0 ? annee : null,
|
||||
Editeur = reference.Editeur,
|
||||
Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
|
||||
NombreEditions = groupe.Count(),
|
||||
@@ -306,8 +158,7 @@ public sealed class ServiceBibliographie(
|
||||
OeuvreBibliographie oeuvre,
|
||||
IReadOnlyDictionary<string, int> parTitre,
|
||||
IReadOnlyDictionary<string, int> parIsbn,
|
||||
IReadOnlyDictionary<string, int> souhaits,
|
||||
IReadOnlySet<string> masquees)
|
||||
IReadOnlyDictionary<string, int> souhaits)
|
||||
{
|
||||
var cle = CleOeuvre.Cle(oeuvre.Titre);
|
||||
|
||||
@@ -328,65 +179,9 @@ public sealed class ServiceBibliographie(
|
||||
LivreId = livreId,
|
||||
Souhaite = souhaitId is not null,
|
||||
SouhaitId = souhaitId,
|
||||
Masquee = masquees.Contains(cle),
|
||||
};
|
||||
}
|
||||
|
||||
public static int? AnneeSeuilNouveautes(
|
||||
IEnumerable<(string Titre, string? Isbn)> possedes,
|
||||
IReadOnlyList<NoticeAuteur> notices)
|
||||
{
|
||||
var annees = new List<int>();
|
||||
foreach (var livre in possedes)
|
||||
{
|
||||
var correspondantes = !string.IsNullOrWhiteSpace(livre.Isbn)
|
||||
? notices.Where(n => MemesIsbn(n.Isbn, livre.Isbn))
|
||||
: notices.Where(n => n.CleTitre == CleOeuvre.Cle(livre.Titre));
|
||||
|
||||
annees.AddRange(correspondantes
|
||||
.Select(n => AnneeTriable(n.Annee))
|
||||
.Where(a => a is not null)
|
||||
.Select(a => a!.Value));
|
||||
}
|
||||
|
||||
return annees.Count == 0 ? null : annees.Max();
|
||||
}
|
||||
|
||||
private static bool MemesIsbn(string? gauche, string? droite)
|
||||
{
|
||||
var a = IsbnUtils.Normaliser(gauche);
|
||||
var b = IsbnUtils.Normaliser(droite);
|
||||
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsbnUtils.TryConvertirEnIsbn10(a, out var a10)
|
||||
&& string.Equals(a10, b, StringComparison.OrdinalIgnoreCase)
|
||||
|| IsbnUtils.TryConvertirEnIsbn10(b, out var b10)
|
||||
&& string.Equals(b10, a, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<OeuvreBibliographie> FiltrerNouveautes(
|
||||
IEnumerable<OeuvreBibliographie> oeuvres, int? seuil)
|
||||
{
|
||||
if (seuil is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return oeuvres
|
||||
.Where(o => o.AnneeDerniereEdition > seuil && o.ADecouvrir)
|
||||
.OrderByDescending(o => o.AnneeDerniereEdition)
|
||||
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Année exploitable pour le tri, extraite d'une date BnF.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,34 +3,21 @@ using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Catalogue;
|
||||
|
||||
/// <summary>
|
||||
/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
|
||||
/// </summary>
|
||||
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
|
||||
/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
|
||||
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
|
||||
{
|
||||
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null);
|
||||
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);
|
||||
|
||||
public static ResultatEcriture Invalide(string message) => new(null, message, null);
|
||||
public static ResultatEcriture Invalide(string message) => new(null, message);
|
||||
|
||||
/// <summary>
|
||||
/// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ce n'est <b>pas</b> une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
|
||||
/// D'où un troisième état plutôt qu'un message dans <c>Erreur</c> : l'écran doit proposer
|
||||
/// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
|
||||
/// </remarks>
|
||||
public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
|
||||
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
|
||||
public static readonly ResultatEcriture Introuvable = new(null, null);
|
||||
|
||||
/// <summary>Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.</summary>
|
||||
public static readonly ResultatEcriture Introuvable = new(null, null, null);
|
||||
|
||||
public bool EstIntrouvable => Livre is null && Erreur is null && Doublons is null;
|
||||
public bool EstIntrouvable => Livre is null && Erreur is null;
|
||||
}
|
||||
|
||||
public interface IServiceCatalogue
|
||||
@@ -41,10 +28,7 @@ public interface IServiceCatalogue
|
||||
Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatEcriture> CreerAsync(
|
||||
EnregistrementLivre saisie,
|
||||
string? utilisateur,
|
||||
bool confirmerDoublon = false,
|
||||
CancellationToken ct = default);
|
||||
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatEcriture> ModifierAsync(
|
||||
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
|
||||
@@ -72,9 +56,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
|
||||
// la bibliothèque est commune à tout le foyer (CLAUDE.md).
|
||||
var requete = FiltreLivres.Appliquer(
|
||||
db.Livres.AsNoTracking()
|
||||
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
||||
.Include(l => l.Themes).ThenInclude(lt => lt.Theme),
|
||||
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
|
||||
criteres,
|
||||
utilisateur);
|
||||
|
||||
@@ -94,7 +76,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
var livre = await db.Livres
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
||||
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
|
||||
if (livre is null)
|
||||
@@ -108,35 +89,20 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
}
|
||||
|
||||
public async Task<ResultatEcriture> CreerAsync(
|
||||
EnregistrementLivre saisie,
|
||||
string? utilisateur,
|
||||
bool confirmerDoublon = false,
|
||||
CancellationToken ct = default)
|
||||
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (Valider(saisie, out var isbn, out var erreur) is false)
|
||||
{
|
||||
return ResultatEcriture.Invalide(erreur!);
|
||||
}
|
||||
|
||||
if (!confirmerDoublon)
|
||||
{
|
||||
var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct);
|
||||
if (deja is not null)
|
||||
{
|
||||
return ResultatEcriture.Doublon(deja);
|
||||
}
|
||||
}
|
||||
|
||||
var livre = new Livre
|
||||
{
|
||||
Isbn = isbn,
|
||||
Titre = saisie.Titre,
|
||||
Editeur = Vide(saisie.Editeur),
|
||||
Format = saisie.Format,
|
||||
TypeDocument = saisie.TypeDocument,
|
||||
NombrePages = saisie.NombrePages,
|
||||
CoverUrl = Vide(saisie.CoverUrl),
|
||||
UrlNotice = Vide(saisie.UrlNotice),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
|
||||
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
|
||||
@@ -147,7 +113,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
db.Livres.Add(livre);
|
||||
|
||||
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
|
||||
await RattacherThemesAsync(livre, saisie.Themes, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
|
||||
@@ -160,10 +125,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
public async Task<ResultatEcriture> ModifierAsync(
|
||||
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
var livre = await db.Livres
|
||||
.Include(l => l.Auteurs)
|
||||
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
if (livre is null)
|
||||
{
|
||||
return ResultatEcriture.Introuvable;
|
||||
@@ -178,17 +140,13 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
livre.Titre = saisie.Titre;
|
||||
livre.Editeur = Vide(saisie.Editeur);
|
||||
livre.Format = saisie.Format;
|
||||
livre.TypeDocument = saisie.TypeDocument;
|
||||
livre.NombrePages = saisie.NombrePages;
|
||||
livre.CoverUrl = Vide(saisie.CoverUrl);
|
||||
livre.UrlNotice = Vide(saisie.UrlNotice);
|
||||
livre.RecalculerFormes();
|
||||
|
||||
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
|
||||
// d'origine, pas des champs éditables.
|
||||
|
||||
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
|
||||
await RattacherThemesAsync(livre, saisie.Themes, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
|
||||
@@ -226,102 +184,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cherche ce que le catalogue contient déjà de semblable, ou <c>null</c> s'il n'y a rien.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Deux critères, et aucun n'est une clé.</b> L'ISBN identique attrape le cas visé — le
|
||||
/// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est
|
||||
/// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le
|
||||
/// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le
|
||||
/// résultat est un <b>avertissement</b> et non un refus : un index unique interdirait le
|
||||
/// second exemplaire, qui est un cas parfaitement normal.
|
||||
/// <para>
|
||||
/// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
|
||||
/// <see cref="RapprochementAuteurs"/> — sinon deux « Nouvelles » sans rapport se
|
||||
/// signaleraient l'une l'autre.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<DoublonsLivre?> DoublonsAsync(
|
||||
string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct)
|
||||
{
|
||||
// Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit
|
||||
// apparaître qu'une fois.
|
||||
var candidats = new Dictionary<int, Livre>();
|
||||
|
||||
if (isbn is not null)
|
||||
{
|
||||
foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct))
|
||||
{
|
||||
candidats[livre.Id] = livre;
|
||||
}
|
||||
}
|
||||
|
||||
var cle = CleOeuvre.Cle(saisie.Titre);
|
||||
if (cle.Length > 0)
|
||||
{
|
||||
// La clé d'œuvre est un PRÉFIXE de TitreNormalise : la troncature du sous-titre
|
||||
// précède la normalisation, qui préserve l'ordre des caractères. SQLite dégrossit
|
||||
// donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire
|
||||
// — sans quoi il faudrait charger tout le catalogue à chaque ajout.
|
||||
var parTitre = await AvecAuteurs()
|
||||
.Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " "))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var livre in parTitre)
|
||||
{
|
||||
if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs))
|
||||
{
|
||||
candidats[livre.Id] = livre;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidats.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trouves = candidats.Values.OrderBy(l => l.Id).ToList();
|
||||
var ids = trouves.Select(l => l.Id).ToList();
|
||||
var statuts = await StatutsAsync(ids, utilisateur, ct);
|
||||
var prets = await PretsOuvertsAsync(ids, ct);
|
||||
|
||||
return new DoublonsLivre
|
||||
{
|
||||
Message = Avertir(trouves),
|
||||
Livres = trouves
|
||||
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private IQueryable<Livre> AvecAuteurs() =>
|
||||
db.Livres.AsNoTracking()
|
||||
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
|
||||
.Include(l => l.Themes).ThenInclude(lt => lt.Theme);
|
||||
|
||||
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
|
||||
/// <remarks>Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici.</remarks>
|
||||
private static bool MemeAuteur(Livre livre, IReadOnlyList<AuteurSaisi> saisis) =>
|
||||
RapprochementAuteurs.PartagentUnAuteur(
|
||||
livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom));
|
||||
|
||||
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
|
||||
/// <remarks>
|
||||
/// Elle nomme ce qu'on possède déjà et <b>invite à passer outre</b> : c'est un doute soumis à
|
||||
/// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le
|
||||
/// texte n'a pas à les décrire.
|
||||
/// </remarks>
|
||||
private static string Avertir(IReadOnlyList<Livre> trouves)
|
||||
{
|
||||
var debut = trouves.Count == 1
|
||||
? $"« {trouves[0].Titre} » est déjà au catalogue."
|
||||
: $"{trouves.Count} livres du catalogue ressemblent à celui-ci.";
|
||||
|
||||
return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition.";
|
||||
}
|
||||
|
||||
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
|
||||
private async Task<Dictionary<int, Statut?>> StatutsAsync(
|
||||
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
|
||||
@@ -415,18 +277,11 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre et leur rôle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le <b>rôle vit sur le lien</b>, pas sur la fiche de l'auteur : le même dessinateur
|
||||
/// peut avoir scénarisé un autre album, et il ne doit pas exister en deux fiches pour
|
||||
/// autant. C'est pour cela que <c>ResoudreAsync</c> ne reçoit que des noms.
|
||||
/// </remarks>
|
||||
/// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
|
||||
private async Task RattacherAuteursAsync(
|
||||
Livre livre, IReadOnlyList<AuteurSaisi> saisis, CancellationToken ct)
|
||||
Livre livre, IEnumerable<string> noms, CancellationToken ct)
|
||||
{
|
||||
var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), ct);
|
||||
var resolus = await auteurs.ResoudreAsync(noms, ct);
|
||||
|
||||
// Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
|
||||
// gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
|
||||
@@ -436,54 +291,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
livre.Auteurs.Clear();
|
||||
}
|
||||
|
||||
// ResoudreAsync écarte les noms vides et fusionne les variantes : sa liste peut être
|
||||
// plus courte que la saisie. Les rôles se retrouvent donc par NOM RÉSOLU, jamais par
|
||||
// position — sans quoi un nom vide au milieu décalerait tous les rôles suivants.
|
||||
var roles = new Dictionary<string, RoleAuteur>(StringComparer.Ordinal);
|
||||
foreach (var saisi in saisis)
|
||||
{
|
||||
roles.TryAdd(RapprochementAuteurs.Cle(saisi.Nom), saisi.Role);
|
||||
}
|
||||
|
||||
for (var i = 0; i < resolus.Count; i++)
|
||||
{
|
||||
livre.Auteurs.Add(new LivreAuteur
|
||||
{
|
||||
Auteur = resolus[i],
|
||||
Position = i,
|
||||
Role = roles.GetValueOrDefault(resolus[i].CleRegroupement, RoleAuteur.NonPrecise),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RattacherThemesAsync(
|
||||
Livre livre, IReadOnlyList<string> saisis, CancellationToken ct)
|
||||
{
|
||||
var themes = saisis
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Select(t => t.Trim())
|
||||
.GroupBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
if (livre.Themes.Count > 0)
|
||||
{
|
||||
db.LivreThemes.RemoveRange(livre.Themes);
|
||||
livre.Themes.Clear();
|
||||
}
|
||||
|
||||
foreach (var nom in themes)
|
||||
{
|
||||
var normalise = NormalisationTexte.Normaliser(nom);
|
||||
var theme = await db.Themes.FirstOrDefaultAsync(t => t.NomNormalise == normalise, ct);
|
||||
if (theme is null)
|
||||
{
|
||||
theme = new Theme { Nom = nom };
|
||||
theme.RecalculerFormes();
|
||||
db.Themes.Add(theme);
|
||||
}
|
||||
|
||||
livre.Themes.Add(new LivreTheme { Livre = livre, Theme = theme });
|
||||
livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,15 +328,6 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
isbn = normalise;
|
||||
}
|
||||
|
||||
// ⚠️ On refuse « 0 page » plutôt que de l'enregistrer : la colonne est nullable
|
||||
// précisément pour que l'absence ait sa propre valeur, et un zéro stocké se lirait
|
||||
// comme une donnée. Laisser le champ vide est la façon de dire « je ne sais pas ».
|
||||
if (saisie.NombrePages is { } pages && pages <= 0)
|
||||
{
|
||||
erreur = "Le nombre de pages doit être positif. Laissez le champ vide si vous ne l'avez pas.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -541,20 +342,12 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
Auteurs = livre.Auteurs
|
||||
.OrderBy(la => la.Position)
|
||||
.Where(la => la.Auteur is not null)
|
||||
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom, Role = la.Role })
|
||||
.ToList(),
|
||||
Themes = livre.Themes
|
||||
.Where(lt => lt.Theme is not null)
|
||||
.Select(lt => lt.Theme!.Nom)
|
||||
.OrderBy(nom => NormalisationTexte.Normaliser(nom), StringComparer.Ordinal)
|
||||
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom })
|
||||
.ToList(),
|
||||
Editeur = livre.Editeur,
|
||||
Format = livre.Format,
|
||||
TypeDocument = livre.TypeDocument,
|
||||
NombrePages = livre.NombrePages,
|
||||
Statut = statut,
|
||||
CoverUrl = livre.CoverUrl,
|
||||
UrlNotice = livre.UrlNotice,
|
||||
DateAjout = livre.DateAjout,
|
||||
AjoutePar = livre.AjoutePar,
|
||||
PreteA = pretOuvert?.Emprunteur,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MaBibli.Api.Services.Couvertures;
|
||||
|
||||
/// <summary>
|
||||
/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Ce garde est la raison pour laquelle le proxy de couvertures est acceptable.</b> Le
|
||||
/// service .NET écoute sur <c>127.0.0.1</c> et vit sur un serveur YunoHost qui héberge d'autres
|
||||
/// applications, souvent sur des ports locaux. Un proxy qui irait chercher n'importe quelle URL
|
||||
/// permettrait de faire lire au serveur ce que le client ne peut pas atteindre lui-même —
|
||||
/// c'est-à-dire exactement ce que la contrainte « écouter sur 127.0.0.1 uniquement » cherche à
|
||||
/// empêcher.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>La vérification est faite au moment de CONNECTER, pas avant la requête.</b> Résoudre le
|
||||
/// nom d'abord puis laisser <c>HttpClient</c> résoudre à nouveau laisserait passer un
|
||||
/// <i>DNS rebinding</i> : un nom qui répond une adresse publique à la vérification et
|
||||
/// <c>127.0.0.1</c> à la connexion. Ici, la socket se connecte <b>aux adresses déjà validées</b>,
|
||||
/// et à aucune autre.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class GardeAdresses
|
||||
{
|
||||
/// <summary>
|
||||
/// Rappel de connexion pour <see cref="SocketsHttpHandler.ConnectCallback"/>.
|
||||
/// </summary>
|
||||
public static async ValueTask<Stream> ConnecterAsync(
|
||||
SocketsHttpConnectionContext contexte, CancellationToken ct)
|
||||
{
|
||||
var adresses = await Dns.GetHostAddressesAsync(contexte.DnsEndPoint.Host, ct);
|
||||
var permises = adresses.Where(EstPubliquementRoutable).ToArray();
|
||||
|
||||
if (permises.Length == 0)
|
||||
{
|
||||
// Message volontairement muet sur ce qui a été résolu : l'appelant n'a pas à
|
||||
// apprendre la topologie du réseau du serveur.
|
||||
throw new HttpRequestException("Adresse non autorisée.");
|
||||
}
|
||||
|
||||
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(permises, contexte.DnsEndPoint.Port, ct);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si l'adresse est publiquement routable, c'est-à-dire hors de tout ce qui pourrait
|
||||
/// désigner le serveur lui-même ou son réseau local.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Liste <b>négative</b>, contrairement à la plupart des filtres du projet : ici, laisser
|
||||
/// passer une plage oubliée est une faille, alors qu'en écarter une de trop ne coûte qu'une
|
||||
/// couverture non mise en cache.
|
||||
/// </remarks>
|
||||
public static bool EstPubliquementRoutable(IPAddress adresse)
|
||||
{
|
||||
// Une adresse IPv4 encapsulée en IPv6 (::ffff:127.0.0.1) doit être jugée sur son IPv4,
|
||||
// sans quoi le bouclage passerait déguisé.
|
||||
if (adresse.IsIPv4MappedToIPv6)
|
||||
{
|
||||
adresse = adresse.MapToIPv4();
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(adresse))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (adresse.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var o = adresse.GetAddressBytes();
|
||||
|
||||
return o[0] switch
|
||||
{
|
||||
0 => false, // 0.0.0.0/8 « ce réseau »
|
||||
10 => false, // 10.0.0.0/8 privé
|
||||
127 => false, // bouclage (déjà couvert, par sûreté)
|
||||
169 when o[1] == 254 => false, // 169.254.0.0/16 lien-local (métadonnées cloud)
|
||||
172 when o[1] >= 16 && o[1] <= 31 => false, // 172.16.0.0/12 privé
|
||||
192 when o[1] == 168 => false, // 192.168.0.0/16 privé
|
||||
192 when o[1] == 0 && o[2] == 0 => false, // 192.0.0.0/24 usages protocolaires
|
||||
100 when o[1] >= 64 && o[1] <= 127 => false, // 100.64.0.0/10 CGNAT
|
||||
>= 224 => false, // multicast et réservé
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
if (adresse.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
if (adresse.IsIPv6LinkLocal || adresse.IsIPv6SiteLocal || adresse.IsIPv6Multicast
|
||||
|| adresse.Equals(IPAddress.IPv6Any))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// fc00::/7 : adresses locales uniques (l'équivalent IPv6 du privé).
|
||||
return (adresse.GetAddressBytes()[0] & 0xFE) != 0xFC;
|
||||
}
|
||||
|
||||
// Ni IPv4 ni IPv6 : on ne sait pas juger, donc on refuse.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
using MaBibli.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Couvertures;
|
||||
|
||||
/// <summary>Résultat d'une demande de couverture : les octets, ou rien.</summary>
|
||||
public sealed record ResultatCouverture(byte[]? Octets, string? TypeContenu)
|
||||
{
|
||||
public static readonly ResultatCouverture Refusee = new(null, null);
|
||||
|
||||
public bool Trouvee => Octets is not null && TypeContenu is not null;
|
||||
}
|
||||
|
||||
public interface IServiceCouvertures
|
||||
{
|
||||
Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relaie une image de couverture <b>en même origine</b>, pour qu'elle puisse être mise en cache
|
||||
/// hors-ligne quel que soit son hébergeur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Pourquoi ce service existe.</b> Le cache hors-ligne des couvertures (lot A5) lit les octets
|
||||
/// par <c>fetch()</c>, ce qui exige un en-tête <c>Access-Control-Allow-Origin</c>. OpenLibrary
|
||||
/// l'envoie, mais le formulaire livre accepte <b>n'importe quelle URL</b> de couverture : ces
|
||||
/// images s'affichaient bien (une balise <c><img></c> n'a que faire du CORS) sans jamais
|
||||
/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même
|
||||
/// origine, et la question du CORS disparaît.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
|
||||
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre, d'une envie ou d'un
|
||||
/// numéro de revue. Le proxy
|
||||
/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
|
||||
/// l'application détient déjà.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
|
||||
/// (<see cref="GardeAdresses"/>). C'est ce second verrou qui tient si quelqu'un enregistre
|
||||
/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait
|
||||
/// refusée quand même.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Tout refus est indistinct</b> côté appelant (voir l'endpoint) : distinguer « pas en
|
||||
/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
|
||||
{
|
||||
/// <summary>
|
||||
/// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce
|
||||
/// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros
|
||||
/// fichier ne puisse pas remplir la mémoire du serveur.
|
||||
/// </summary>
|
||||
public const int TailleMaximale = 5 * 1024 * 1024;
|
||||
|
||||
public async Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)
|
||||
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
|| uri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
// https uniquement : l'application est servie en https par YunoHost, une image en
|
||||
// clair y serait de toute façon bloquée par le navigateur (contenu mixte).
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
if (!await EstConnueAsync(url, ct))
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
// Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond
|
||||
// 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à
|
||||
// la connexion — c'est lui qui borne, pas l'interdiction de rediriger.
|
||||
if (!reponse.IsSuccessStatusCode)
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
var type = reponse.Content.Headers.ContentType?.MediaType;
|
||||
if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
if (reponse.Content.Headers.ContentLength > TailleMaximale)
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
var octets = await LireBorneAsync(reponse.Content, ct);
|
||||
|
||||
return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non
|
||||
// relayée reste un agrément perdu, jamais une erreur à propager.
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
|
||||
/// valeur stockée qui autorise, pas une ressemblance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les numéros de revue en font partie</b> depuis qu'ils portent une couverture. Les
|
||||
/// oublier ici aurait produit exactement le défaut que ce service existe pour corriger :
|
||||
/// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant
|
||||
/// refusé par le relais.
|
||||
/// </remarks>
|
||||
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
|
||||
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|
||||
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct)
|
||||
|| await db.NumerosRevue.AnyAsync(n => n.CoverUrl == url, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
|
||||
/// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
|
||||
/// </remarks>
|
||||
private static async Task<byte[]?> LireBorneAsync(HttpContent contenu, CancellationToken ct)
|
||||
{
|
||||
await using var flux = await contenu.ReadAsStreamAsync(ct);
|
||||
using var tampon = new MemoryStream();
|
||||
|
||||
var morceau = new byte[81920];
|
||||
int lus;
|
||||
|
||||
while ((lus = await flux.ReadAsync(morceau, ct)) > 0)
|
||||
{
|
||||
if (tampon.Length + lus > TailleMaximale)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
tampon.Write(morceau, 0, lus);
|
||||
}
|
||||
|
||||
return tampon.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -45,19 +45,10 @@ public interface IBnfClient
|
||||
string titre, string? auteur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
|
||||
/// </summary>
|
||||
public interface IBnfClientNouveautes
|
||||
{
|
||||
Task<(ResultatBibliographie Resultat, string? Avertissement)>
|
||||
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
|
||||
/// </summary>
|
||||
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
|
||||
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
|
||||
{
|
||||
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
|
||||
public const string NomHttpClient = "bnf";
|
||||
@@ -220,12 +211,6 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
|
||||
public const int NoticesParPage = 100;
|
||||
|
||||
public const int PagesMaximum = 2;
|
||||
public const int PagesMaximumNouveautes = 10;
|
||||
|
||||
public static int NombrePagesNouveautes(int nombreAnnonce) =>
|
||||
Math.Min(
|
||||
PagesMaximumNouveautes,
|
||||
Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage));
|
||||
|
||||
public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
|
||||
string auteur, CancellationToken ct = default)
|
||||
@@ -272,52 +257,6 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(ResultatBibliographie Resultat, string? Avertissement)>
|
||||
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(auteur))
|
||||
{
|
||||
return (new ResultatBibliographie(), null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var premiere = await LirePageAsync(auteur, 1, ct);
|
||||
if (premiere is null)
|
||||
{
|
||||
return Echec(EtatSourceBibliographie.Injoignable, auteur);
|
||||
}
|
||||
|
||||
var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce);
|
||||
|
||||
var pages = new List<ResultatBibliographie> { premiere };
|
||||
if (nombrePages > 1)
|
||||
{
|
||||
var suivantes = await Task.WhenAll(
|
||||
Enumerable.Range(1, nombrePages - 1)
|
||||
.Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));
|
||||
pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!));
|
||||
}
|
||||
|
||||
return (Fusionner(pages), null);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.Injoignable, auteur);
|
||||
}
|
||||
catch (System.Xml.XmlException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
|
||||
/// </summary>
|
||||
|
||||
@@ -62,8 +62,6 @@ public static class BnfSruParser
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
|
||||
|
||||
candidats.Add(new CandidatLivre
|
||||
{
|
||||
Titre = titre,
|
||||
@@ -71,29 +69,16 @@ public static class BnfSruParser
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Annee = Premier(data, "date"),
|
||||
Langue = Premier(data, "language"),
|
||||
|
||||
// ⚠️ Plusieurs dc:format cohabitent sur une notice (« 1 vol. (349 p.) », puis
|
||||
// « 30 cm ») : on prend le PREMIER qui donne une pagination, et non le premier
|
||||
// tout court, qui pourrait n'être qu'une dimension.
|
||||
NombrePages = Tous(data, "format")
|
||||
.Select(NettoyageIsbd.NombreDePages)
|
||||
.FirstOrDefault(pages => pages is not null),
|
||||
CoverUrl = urlCouverture,
|
||||
Source = SourceMetadonnees.Bnf,
|
||||
IsbnInterroge = isbnInterroge,
|
||||
IdentifiantSource = identifiant,
|
||||
UrlNotice = UrlNotice(identifiant),
|
||||
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return candidats;
|
||||
}
|
||||
|
||||
private static string? UrlNotice(string? identifiant) =>
|
||||
string.IsNullOrWhiteSpace(identifiant)
|
||||
? null
|
||||
: $"https://catalogue.bnf.fr/{identifiant}";
|
||||
|
||||
/// <summary>
|
||||
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -23,13 +23,6 @@ public static partial class NettoyageIsbd
|
||||
[GeneratedRegex(@"\s*\([^)]*\)\s*$")]
|
||||
private static partial Regex ParenthesesFinales();
|
||||
|
||||
/// <summary>
|
||||
/// Un nombre collé à une mention de pages. Le nombre doit <b>précéder</b> « p. » : dans
|
||||
/// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<![\d\]])(\d{1,6})\s*(?:p\.|pages?\b)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Pagination();
|
||||
|
||||
/// <summary>
|
||||
/// Titre : couper à la première mention de responsabilité (« / »).
|
||||
/// </summary>
|
||||
@@ -173,51 +166,6 @@ public static partial class NettoyageIsbd
|
||||
return s.Length == 0 ? null : s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de pages lu dans la description matérielle (<c>dc:format</c>), ou <c>null</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Ce champ n'est pas un nombre, c'est une phrase</b> : la BnF y décrit le support
|
||||
/// (« 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm »,
|
||||
/// mesuré sur les notices enregistrées sous <c>Fixtures/</c>). Les règles restent
|
||||
/// volontairement <b>étroites</b> : on ne reconnaît qu'un nombre suivi de « p. » ou de
|
||||
/// « page(s) », et <b>tout le reste rend <c>null</c></b> — le catalogue décrit aussi des
|
||||
/// disques compacts et des dimensions en centimètres.
|
||||
/// <para>
|
||||
/// ⚠️ Le sens de l'erreur n'est pas symétrique : un champ vide se remplit à la main en trois
|
||||
/// secondes, un chiffre faux s'enregistre sans que personne ne le remarque. C'est pourquoi
|
||||
/// « 1 vol. » ne rend pas 1, « 30 cm » ne rend pas 30, et « (p. 45-90) » — une pagination de
|
||||
/// contribution, pas une épaisseur — ne rend rien du tout.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// La valeur reste de toute façon <b>proposée</b> dans un champ modifiable, jamais imposée.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static int? NombreDePages(string? brut)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(brut))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trouve = Pagination().Match(brut);
|
||||
if (!trouve.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(
|
||||
trouve.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var pages))
|
||||
{
|
||||
// Un nombre à rallonge ne tient pas dans un int : ce n'est pas une pagination.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bornes de bon sens. Zéro page n'est pas une donnée, et au-delà de dix mille on a lu
|
||||
// autre chose que l'épaisseur d'un livre.
|
||||
return pages is > 0 and <= 10_000 ? pages : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Titre d'un <b>périodique</b> : couper la mention de responsabilité puis retirer le
|
||||
/// qualificatif entre parenthèses.
|
||||
|
||||
@@ -83,15 +83,9 @@ public static class OpenLibraryMapper
|
||||
Source = SourceMetadonnees.OpenLibrary,
|
||||
IsbnInterroge = isbnInterroge,
|
||||
IdentifiantSource = edition.Key,
|
||||
UrlNotice = UrlNotice(edition.Key),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? UrlNotice(string? cle) =>
|
||||
string.IsNullOrWhiteSpace(cle)
|
||||
? null
|
||||
: $"https://openlibrary.org/{cle.TrimStart('/')}";
|
||||
|
||||
private static IReadOnlyList<string> Liste(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];
|
||||
|
||||
|
||||
@@ -29,17 +29,6 @@ public interface IServicePrets
|
||||
Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Efface un prêt, en cours ou clos. <c>false</c> s'il n'existe pas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Ce n'est pas « rendre »</b>, et les deux ne doivent jamais être confondus. Rendre
|
||||
/// pose une date et conserve la ligne : c'est l'histoire du livre, et c'est la raison d'être
|
||||
/// de la table. Effacer <b>défait une saisie</b> — mauvais emprunteur, mauvaise date, prêt
|
||||
/// noté deux fois. Un historique juste vaut mieux qu'un historique complet mais faux.
|
||||
/// </remarks>
|
||||
Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -186,22 +175,6 @@ public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
|
||||
return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default)
|
||||
{
|
||||
var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
||||
if (pret is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Aucune identité n'entre ici, comme partout dans ce service : le prêt est commun au
|
||||
// foyer, et celui qui constate l'erreur n'est pas forcément celui qui l'a faite.
|
||||
db.Prets.Remove(pret);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
|
||||
{
|
||||
var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Revues;
|
||||
|
||||
/// <summary>Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.</summary>
|
||||
public readonly record struct ResultatRevue(RevueDto? Revue, string? Erreur)
|
||||
{
|
||||
public static ResultatRevue Ok(RevueDto revue) => new(revue, null);
|
||||
|
||||
public static ResultatRevue Invalide(string message) => new(null, message);
|
||||
|
||||
public static readonly ResultatRevue Introuvable = new(null, null);
|
||||
|
||||
public bool EstIntrouvable => Revue is null && Erreur is null;
|
||||
}
|
||||
|
||||
public interface IServiceRevues
|
||||
{
|
||||
Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Crée la revue, ou <b>rend celle qui existe déjà</b> quand l'ISSN ou le titre la désigne.
|
||||
/// </summary>
|
||||
Task<ResultatRevue> CreerOuRetrouverAsync(
|
||||
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevue> ModifierAsync(
|
||||
int id, EnregistrementRevue saisie, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevue> AjouterNumeroAsync(
|
||||
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Modifie un numéro déjà recensé : son numéro, sa parution, sa note, sa couverture et ses
|
||||
/// articles à la une.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sans ce point d'entrée, la couverture et les articles ne se seraient saisis qu'à la
|
||||
/// création — c'est-à-dire jamais, puisqu'on ajoute un numéro le jour où on l'achète et
|
||||
/// qu'on en recopie la une plus tard.
|
||||
/// </remarks>
|
||||
Task<ResultatRevue> ModifierNumeroAsync(
|
||||
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default);
|
||||
|
||||
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Communes au foyer</b>, comme le catalogue : aucune méthode ne filtre sur un
|
||||
/// utilisateur, <see cref="Revue.AjoutePar"/> n'étant qu'une trace.
|
||||
/// <para>
|
||||
/// Un numéro est <b>recensé, rien de plus</b> — ni prêt, ni statut de lecture (décidé le
|
||||
/// 2026-08-19). C'est ce qui garde cette table à l'écart de deux mécanismes attachés à
|
||||
/// <c>Livre</c> par clé étrangère.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
|
||||
{
|
||||
public async Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default)
|
||||
{
|
||||
var revues = await db.Revues
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
|
||||
.OrderBy(r => r.TitreNormalise)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return revues.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatRevue> CreerOuRetrouverAsync(
|
||||
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
var titre = saisie.Titre?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(titre))
|
||||
{
|
||||
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
// ⚠️ Canonisé AVANT la recherche, pas seulement à l'écriture : « 24666718 » et
|
||||
// « 2466-6718 » désignent la même revue, et sans cela le scan suivant en créerait une
|
||||
// seconde à côté de celle saisie à la main.
|
||||
var issn = FormatageIssn.Canonique(saisie.Issn);
|
||||
var normalise = NormalisationTexte.Normaliser(titre);
|
||||
|
||||
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
|
||||
// forcément la même revue à chaque numéro. Refuser le doublon obligerait l'utilisateur
|
||||
// à comprendre qu'il doit aller chercher la fiche lui-même — alors que le geste est
|
||||
// toujours le même : « voici un numéro de plus ».
|
||||
var existante = await db.Revues
|
||||
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
|
||||
.FirstOrDefaultAsync(
|
||||
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
|
||||
|
||||
if (existante is not null)
|
||||
{
|
||||
// Un ISSN appris après coup complète la fiche créée à la main : c'est ce qui
|
||||
// permettra de la retrouver au prochain scan.
|
||||
if (existante.Issn is null && issn is not null)
|
||||
{
|
||||
existante.Issn = issn;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return ResultatRevue.Ok(Projeter(existante));
|
||||
}
|
||||
|
||||
var revue = new Revue
|
||||
{
|
||||
Titre = titre,
|
||||
Issn = issn,
|
||||
Editeur = Vide(saisie.Editeur),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
AjoutePar = utilisateur,
|
||||
};
|
||||
|
||||
revue.RecalculerFormes();
|
||||
db.Revues.Add(revue);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatRevue.Ok(Projeter(revue));
|
||||
}
|
||||
|
||||
public async Task<ResultatRevue> ModifierAsync(
|
||||
int id, EnregistrementRevue saisie, CancellationToken ct = default)
|
||||
{
|
||||
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (revue is null)
|
||||
{
|
||||
return ResultatRevue.Introuvable;
|
||||
}
|
||||
|
||||
var titre = saisie.Titre?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(titre))
|
||||
{
|
||||
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
var normalise = NormalisationTexte.Normaliser(titre);
|
||||
if (await db.Revues.AnyAsync(r => r.Id != id && r.TitreNormalise == normalise, ct))
|
||||
{
|
||||
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
|
||||
}
|
||||
|
||||
var issn = FormatageIssn.Canonique(saisie.Issn);
|
||||
if (issn is not null && await db.Revues.AnyAsync(r => r.Id != id && r.Issn == issn, ct))
|
||||
{
|
||||
return ResultatRevue.Invalide($"L'ISSN {issn} est déjà celui d'une autre revue.");
|
||||
}
|
||||
|
||||
revue.Titre = titre;
|
||||
revue.Issn = issn;
|
||||
revue.Editeur = Vide(saisie.Editeur);
|
||||
revue.RecalculerFormes();
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return ResultatRevue.Ok(Projeter(revue));
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var revue = await db.Revues.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (revue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Les numéros partent en cascade : ils n'existent que par leur revue, contrairement aux
|
||||
// livres d'une série, qui vivent leur vie au catalogue.
|
||||
db.Revues.Remove(revue);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ResultatRevue> AjouterNumeroAsync(
|
||||
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default)
|
||||
{
|
||||
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == revueId, ct);
|
||||
if (revue is null)
|
||||
{
|
||||
return ResultatRevue.Introuvable;
|
||||
}
|
||||
|
||||
var numero = saisie.Numero?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(numero))
|
||||
{
|
||||
return ResultatRevue.Invalide("Le numéro est obligatoire.");
|
||||
}
|
||||
|
||||
var normalise = NormalisationTexte.Normaliser(numero);
|
||||
if (revue.Numeros.Any(n => n.NumeroNormalise == normalise))
|
||||
{
|
||||
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
|
||||
}
|
||||
|
||||
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
|
||||
{
|
||||
// Comme pour les prêts : une date future est une faute de frappe, pas une intention.
|
||||
// Un jour de marge, les revues datées du mois suivant étant courantes en kiosque.
|
||||
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
|
||||
}
|
||||
|
||||
var ligne = new NumeroRevue
|
||||
{
|
||||
RevueId = revueId,
|
||||
Numero = numero,
|
||||
DateParution = saisie.DateParution,
|
||||
Note = Vide(saisie.Note),
|
||||
CoverUrl = Vide(saisie.CoverUrl),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
ligne.RecalculerFormes();
|
||||
RemplacerArticles(ligne, saisie.Articles);
|
||||
db.NumerosRevue.Add(ligne);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
|
||||
.FirstAsync(r => r.Id == revueId, ct);
|
||||
|
||||
return ResultatRevue.Ok(Projeter(relue));
|
||||
}
|
||||
|
||||
public async Task<ResultatRevue> ModifierNumeroAsync(
|
||||
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default)
|
||||
{
|
||||
var ligne = await db.NumerosRevue
|
||||
.Include(n => n.Articles)
|
||||
.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
|
||||
|
||||
if (ligne is null)
|
||||
{
|
||||
return ResultatRevue.Introuvable;
|
||||
}
|
||||
|
||||
var numero = saisie.Numero?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(numero))
|
||||
{
|
||||
return ResultatRevue.Invalide("Le numéro est obligatoire.");
|
||||
}
|
||||
|
||||
var normalise = NormalisationTexte.Normaliser(numero);
|
||||
if (await db.NumerosRevue.AnyAsync(
|
||||
n => n.Id != numeroId && n.RevueId == ligne.RevueId && n.NumeroNormalise == normalise, ct))
|
||||
{
|
||||
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
|
||||
}
|
||||
|
||||
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
|
||||
{
|
||||
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
|
||||
}
|
||||
|
||||
ligne.Numero = numero;
|
||||
ligne.DateParution = saisie.DateParution;
|
||||
ligne.Note = Vide(saisie.Note);
|
||||
ligne.CoverUrl = Vide(saisie.CoverUrl);
|
||||
ligne.RecalculerFormes();
|
||||
RemplacerArticles(ligne, saisie.Articles);
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
|
||||
.FirstAsync(r => r.Id == ligne.RevueId, ct);
|
||||
|
||||
return ResultatRevue.Ok(Projeter(relue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les articles à la une du numéro par ceux de la saisie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
|
||||
/// du champ doit disparaître, comme pour les thèmes d'un livre.
|
||||
/// <para>
|
||||
/// Le dédoublonnage a déjà eu lieu à la saisie (<c>ListeSaisie</c>), mais il est refait ici
|
||||
/// sur la forme normalisée : c'est l'unicité <c>(NumeroRevueId, TitreNormalise)</c> qui est
|
||||
/// en jeu, et un appel direct à l'API n'est pas passé par le champ texte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RemplacerArticles(NumeroRevue numero, IReadOnlyList<string>? titres)
|
||||
{
|
||||
if (numero.Articles.Count > 0)
|
||||
{
|
||||
db.ArticlesUne.RemoveRange(numero.Articles);
|
||||
numero.Articles.Clear();
|
||||
}
|
||||
|
||||
var vus = new HashSet<string>(StringComparer.Ordinal);
|
||||
var position = 0;
|
||||
|
||||
foreach (var titre in titres ?? [])
|
||||
{
|
||||
var article = new ArticleUne { NumeroRevue = numero, Titre = titre ?? string.Empty };
|
||||
article.RecalculerFormes();
|
||||
|
||||
if (article.TitreNormalise.Length == 0 || !vus.Add(article.TitreNormalise))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
article.Position = position++;
|
||||
numero.Articles.Add(article);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
|
||||
{
|
||||
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
|
||||
if (numero is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.NumerosRevue.Remove(numero);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? Vide(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
||||
|
||||
private static RevueDto Projeter(Revue revue) => new()
|
||||
{
|
||||
Id = revue.Id,
|
||||
Titre = revue.Titre,
|
||||
Issn = revue.Issn,
|
||||
Editeur = revue.Editeur,
|
||||
AjoutePar = revue.AjoutePar,
|
||||
|
||||
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche,
|
||||
// à l'inverse d'une série dont on suit l'ordre de lecture. Les numéros sans date de
|
||||
// parution ferment la liste plutôt que de passer pour les plus vieux.
|
||||
Numeros = revue.Numeros
|
||||
.OrderByDescending(n => n.DateParution.HasValue)
|
||||
.ThenByDescending(n => n.DateParution)
|
||||
.ThenByDescending(n => n.NumeroNormalise, StringComparer.Ordinal)
|
||||
.Select(n => new NumeroRevueDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Numero = n.Numero,
|
||||
DateParution = n.DateParution,
|
||||
Note = n.Note,
|
||||
CoverUrl = n.CoverUrl,
|
||||
Articles = n.Articles
|
||||
.OrderBy(a => a.Position)
|
||||
.ThenBy(a => a.Id)
|
||||
.Select(a => a.Titre)
|
||||
.ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Api.Services.Souhaits;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using MaBibli.Shared.Textes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Series;
|
||||
|
||||
/// <summary>Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur.</summary>
|
||||
public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur)
|
||||
{
|
||||
public static ResultatSerie Ok(SerieDto serie) => new(serie, null);
|
||||
|
||||
public static ResultatSerie Invalide(string message) => new(null, message);
|
||||
|
||||
/// <summary>Ni série ni erreur : la ressource demandée n'existe pas.</summary>
|
||||
public static readonly ResultatSerie Introuvable = new(null, null);
|
||||
|
||||
public bool EstIntrouvable => Serie is null && Erreur is null;
|
||||
}
|
||||
|
||||
public interface IServiceSeries
|
||||
{
|
||||
Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> CreerAsync(
|
||||
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> ModifierAsync(
|
||||
int id, EnregistrementSerie saisie, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> AjouterElementAsync(
|
||||
int serieId, AjoutElementSerie saisie, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.</summary>
|
||||
Task<ResultatSerie> ModifierElementAsync(
|
||||
int elementId, AjoutElementSerie saisie, CancellationToken ct = default);
|
||||
|
||||
Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> ReordonnerAsync(
|
||||
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> ReordonnerSousSeriesAsync(
|
||||
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
|
||||
Task<ResultatSouhait> MettreEnEnviesAsync(
|
||||
int elementId, string? utilisateur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Portée COMMUNE au foyer</b>, comme le catalogue et les prêts. Aucune méthode ne prend
|
||||
/// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas
|
||||
/// selon qui regarde. La seule exception est <see cref="MettreEnEnviesAsync"/>, qui écrit dans
|
||||
/// une liste d'envies, et qui est donc <b>obligée</b> de savoir de qui elle parle : c'est là que
|
||||
/// les deux portées se rencontrent, et nulle part ailleurs.
|
||||
/// </remarks>
|
||||
public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries
|
||||
{
|
||||
public async Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default)
|
||||
{
|
||||
// AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue.
|
||||
var series = await db.Series
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
|
||||
.ThenInclude(la => la.Auteur)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var livreIds = series
|
||||
.SelectMany(s => s.Elements)
|
||||
.Select(e => e.LivreId)
|
||||
.OfType<int>()
|
||||
.ToList();
|
||||
|
||||
var prets = await PretsOuvertsAsync(livreIds, ct);
|
||||
|
||||
return series
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.TitreNormalise, StringComparer.Ordinal)
|
||||
.Select(s => Projeter(s, prets))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> CreerAsync(
|
||||
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
var titre = saisie.Titre?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(titre))
|
||||
{
|
||||
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
|
||||
}
|
||||
|
||||
var normalise = NormalisationTexte.Normaliser(titre);
|
||||
if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct))
|
||||
{
|
||||
// Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien
|
||||
// dire pour l'utilisateur. L'index reste là comme garantie de dernier recours.
|
||||
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
|
||||
}
|
||||
|
||||
if (saisie.SerieParenteId is { } parenteId
|
||||
&& !await db.Series.AnyAsync(s => s.Id == parenteId, ct))
|
||||
{
|
||||
return ResultatSerie.Invalide("La série parente n'existe pas.");
|
||||
}
|
||||
|
||||
var serie = new Serie
|
||||
{
|
||||
Titre = titre,
|
||||
SerieParenteId = saisie.SerieParenteId,
|
||||
DateAjout = DateTime.UtcNow,
|
||||
|
||||
// Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar.
|
||||
AjoutePar = utilisateur,
|
||||
};
|
||||
|
||||
serie.RecalculerFormes();
|
||||
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
|
||||
|
||||
db.Series.Add(serie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return await RelireAsync(serie.Id, ct);
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> ModifierAsync(
|
||||
int id, EnregistrementSerie saisie, CancellationToken ct = default)
|
||||
{
|
||||
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (serie is null)
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
var titre = saisie.Titre?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(titre))
|
||||
{
|
||||
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
|
||||
}
|
||||
|
||||
var normalise = NormalisationTexte.Normaliser(titre);
|
||||
if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct))
|
||||
{
|
||||
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
|
||||
}
|
||||
|
||||
if (saisie.SerieParenteId is { } parenteId)
|
||||
{
|
||||
if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct))
|
||||
{
|
||||
return ResultatSerie.Invalide("La série parente n'existe pas.");
|
||||
}
|
||||
|
||||
if (await EstDescendanteAsync(parenteId, id, ct))
|
||||
{
|
||||
// Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se
|
||||
// refermerait sur lui-même et l'affichage boucherait à l'infini.
|
||||
return ResultatSerie.Invalide(
|
||||
"Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes.");
|
||||
}
|
||||
}
|
||||
|
||||
if (serie.SerieParenteId != saisie.SerieParenteId)
|
||||
{
|
||||
serie.SerieParenteId = saisie.SerieParenteId;
|
||||
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
|
||||
}
|
||||
|
||||
serie.Titre = titre;
|
||||
serie.RecalculerFormes();
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await RelireAsync(id, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (serie is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles,
|
||||
// remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres.
|
||||
db.Series.Remove(serie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> AjouterElementAsync(
|
||||
int serieId, AjoutElementSerie saisie, CancellationToken ct = default)
|
||||
{
|
||||
var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct);
|
||||
if (serie is null)
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
var titre = saisie.Titre?.Trim();
|
||||
|
||||
if (saisie.LivreId is { } livreId)
|
||||
{
|
||||
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
|
||||
if (livre is null)
|
||||
{
|
||||
return ResultatSerie.Invalide("Ce livre n'existe plus.");
|
||||
}
|
||||
|
||||
if (serie.Elements.Any(e => e.LivreId == livreId))
|
||||
{
|
||||
return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série.");
|
||||
}
|
||||
|
||||
// Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de
|
||||
// quoi la saga se retrouverait avec une place muette.
|
||||
titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(titre))
|
||||
{
|
||||
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
|
||||
}
|
||||
|
||||
var element = new ElementSerie
|
||||
{
|
||||
SerieId = serieId,
|
||||
LivreId = saisie.LivreId,
|
||||
Titre = titre,
|
||||
Numero = saisie.Numero,
|
||||
Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1,
|
||||
};
|
||||
|
||||
element.RecalculerFormes();
|
||||
db.ElementsSerie.Add(element);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return await RelireAsync(serieId, ct);
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> RattacherLivreAsync(
|
||||
int elementId, int? livreId, CancellationToken ct = default)
|
||||
{
|
||||
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
|
||||
if (element is null)
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
if (livreId is { } id)
|
||||
{
|
||||
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct);
|
||||
if (livre is null)
|
||||
{
|
||||
return ResultatSerie.Invalide("Ce livre n'existe plus.");
|
||||
}
|
||||
|
||||
var deja = await db.ElementsSerie.AnyAsync(
|
||||
e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct);
|
||||
|
||||
if (deja)
|
||||
{
|
||||
return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série.");
|
||||
}
|
||||
}
|
||||
|
||||
// Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher
|
||||
// un livre laisse donc un trou nommé, et non une ligne vide.
|
||||
element.LivreId = livreId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return await RelireAsync(element.SerieId, ct);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Ce point d'entrée existe pour que le <b>numéro de tome</b> puisse se saisir après coup.
|
||||
/// Sans lui, il ne serait renseignable qu'à la création — c'est-à-dire jamais en pratique,
|
||||
/// puisqu'on découvre souvent le numéro en ayant le livre en main. Même défaut que celui
|
||||
/// rencontré sur les numéros de revue, et corrigé de la même façon.
|
||||
/// <para>
|
||||
/// Le livre rattaché n'est <b>pas</b> touché : c'est l'affaire de
|
||||
/// <see cref="RattacherLivreAsync"/>. Un point d'entrée, un geste.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ResultatSerie> ModifierElementAsync(
|
||||
int elementId, AjoutElementSerie saisie, CancellationToken ct = default)
|
||||
{
|
||||
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
|
||||
if (element is null)
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
var titre = (saisie.Titre ?? string.Empty).Trim();
|
||||
if (titre.Length == 0)
|
||||
{
|
||||
// Le titre survit à la suppression du livre : une place sans titre serait un trou
|
||||
// anonyme, que plus rien ne permettrait d'identifier.
|
||||
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
|
||||
}
|
||||
|
||||
element.Titre = titre;
|
||||
element.Numero = saisie.Numero;
|
||||
element.RecalculerFormes();
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await RelireAsync(element.SerieId, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default)
|
||||
{
|
||||
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
|
||||
if (element is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.ElementsSerie.Remove(element);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> ReordonnerAsync(
|
||||
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
|
||||
{
|
||||
var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct);
|
||||
if (elements.Count == 0)
|
||||
{
|
||||
return await db.Series.AnyAsync(s => s.Id == serieId, ct)
|
||||
? await RelireAsync(serieId, ct)
|
||||
: ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
// Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on
|
||||
// ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un
|
||||
// tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être
|
||||
// périmée, et mal classé vaut infiniment mieux que disparu.
|
||||
var parId = elements.ToDictionary(e => e.Id);
|
||||
var position = 0;
|
||||
|
||||
foreach (var id in idsOrdonnes.Distinct())
|
||||
{
|
||||
if (parId.Remove(id, out var element))
|
||||
{
|
||||
element.Position = position++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id))
|
||||
{
|
||||
oublie.Position = position++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await RelireAsync(serieId, ct);
|
||||
}
|
||||
|
||||
public async Task<ResultatSerie> ReordonnerSousSeriesAsync(
|
||||
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
var enfants = await db.Series
|
||||
.Where(s => s.SerieParenteId == serieId)
|
||||
.ToListAsync(ct);
|
||||
var parId = enfants.ToDictionary(s => s.Id);
|
||||
var position = 0;
|
||||
|
||||
foreach (var id in idsOrdonnes.Distinct())
|
||||
{
|
||||
if (parId.Remove(id, out var enfant))
|
||||
{
|
||||
enfant.Position = position++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
|
||||
{
|
||||
oublie.Position = position++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await RelireAsync(serieId, ct);
|
||||
}
|
||||
|
||||
public async Task<ResultatSouhait> MettreEnEnviesAsync(
|
||||
int elementId, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
var element = await db.ElementsSerie
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == elementId, ct);
|
||||
|
||||
if (element is null)
|
||||
{
|
||||
return ResultatSouhait.Invalide("Ce tome n'existe plus.");
|
||||
}
|
||||
|
||||
if (element.LivreId is not null)
|
||||
{
|
||||
// Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait
|
||||
// aussitôt comme « déjà au catalogue ».
|
||||
return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque.");
|
||||
}
|
||||
|
||||
// ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
|
||||
// (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
|
||||
// membres peuvent vouloir le même tome manquant, chacun dans sa liste.
|
||||
return await souhaits.AjouterAsync(
|
||||
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
|
||||
}
|
||||
|
||||
/// <summary>Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs.</summary>
|
||||
private async Task<Dictionary<int, string>> PretsOuvertsAsync(
|
||||
IReadOnlyList<int> livreIds, CancellationToken ct)
|
||||
{
|
||||
if (livreIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await db.Prets
|
||||
.AsNoTracking()
|
||||
.Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId))
|
||||
.ToDictionaryAsync(p => p.LivreId, p => p.Emprunteur, ct);
|
||||
}
|
||||
|
||||
private async Task<int> ProchainePositionAsync(int? parenteId, CancellationToken ct)
|
||||
{
|
||||
var derniere = await db.Series
|
||||
.Where(s => s.SerieParenteId == parenteId)
|
||||
.Select(s => (int?)s.Position)
|
||||
.MaxAsync(ct);
|
||||
|
||||
return (derniere ?? -1) + 1;
|
||||
}
|
||||
|
||||
/// <summary><paramref name="candidate"/> est-elle <paramref name="serieId"/> ou l'une de ses filles ?</summary>
|
||||
private async Task<bool> EstDescendanteAsync(int candidate, int serieId, CancellationToken ct)
|
||||
{
|
||||
var courante = (int?)candidate;
|
||||
|
||||
// Remontée bornée par le nombre de séries : même si une boucle existait déjà en base,
|
||||
// cette fonction s'arrête au lieu de tourner indéfiniment.
|
||||
var garde = await db.Series.CountAsync(ct) + 1;
|
||||
|
||||
while (courante is { } id && garde-- > 0)
|
||||
{
|
||||
if (id == serieId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
courante = await db.Series
|
||||
.Where(s => s.Id == id)
|
||||
.Select(s => s.SerieParenteId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<ResultatSerie> RelireAsync(int serieId, CancellationToken ct)
|
||||
{
|
||||
var serie = await db.Series
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
|
||||
.ThenInclude(la => la.Auteur)
|
||||
.FirstOrDefaultAsync(s => s.Id == serieId, ct);
|
||||
|
||||
if (serie is null)
|
||||
{
|
||||
return ResultatSerie.Introuvable;
|
||||
}
|
||||
|
||||
var prets = await PretsOuvertsAsync(
|
||||
serie.Elements.Select(e => e.LivreId).OfType<int>().ToList(), ct);
|
||||
|
||||
return ResultatSerie.Ok(Projeter(serie, prets));
|
||||
}
|
||||
|
||||
private static SerieDto Projeter(Serie serie, IReadOnlyDictionary<int, string> prets) => new()
|
||||
{
|
||||
Id = serie.Id,
|
||||
Titre = serie.Titre,
|
||||
SerieParenteId = serie.SerieParenteId,
|
||||
Position = serie.Position,
|
||||
AjoutePar = serie.AjoutePar,
|
||||
Elements = serie.Elements
|
||||
.OrderBy(e => e.Position)
|
||||
.ThenBy(e => e.Id)
|
||||
.Select(e => new ElementSerieDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Position = e.Position,
|
||||
|
||||
// Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé
|
||||
// depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression.
|
||||
Titre = e.Livre?.Titre ?? e.Titre,
|
||||
Numero = e.Numero,
|
||||
LivreId = e.LivreId,
|
||||
Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0
|
||||
? null
|
||||
: string.Join(", ", e.Livre.Auteurs
|
||||
.OrderBy(la => la.Position)
|
||||
.Select(la => la.Auteur?.Nom)
|
||||
.Where(nom => !string.IsNullOrWhiteSpace(nom))),
|
||||
PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace MaBibli.Api.Services.Souhaits;
|
||||
/// C'est ce qui permet de vérifier le contenu exact du fichier en test plutôt que de se fier à
|
||||
/// une inspection visuelle.
|
||||
/// <para>
|
||||
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> CLAUDE.md demandait un format
|
||||
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> IDEES.md demandait un format
|
||||
/// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -48,29 +48,12 @@ public static class ExportSouhaits
|
||||
public static string TypeMime(FormatExportSouhaits format) =>
|
||||
format == FormatExportSouhaits.Csv ? "text/csv; charset=utf-8" : "text/plain; charset=utf-8";
|
||||
|
||||
/// <summary>
|
||||
/// Produit le fichier, <b>les deux tables réunies</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Les revues souhaitées viennent d'une table sœur et doivent traverser l'export : c'est
|
||||
/// le fichier qu'on emporte, donc précisément l'endroit où les oublier ne se verrait pas.
|
||||
/// <para>
|
||||
/// Elles forment une <b>section à part</b> plutôt que d'être entrelacées : les deux tables
|
||||
/// numérotent leur rang indépendamment, et mélanger deux suites sans rapport produirait un
|
||||
/// ordre que personne n'a choisi. Une revue n'a d'ailleurs pas d'auteur, donc rien à faire
|
||||
/// dans un fichier groupé par auteur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string Produire(
|
||||
FormatExportSouhaits format,
|
||||
IReadOnlyList<SouhaitDto> souhaits,
|
||||
IReadOnlyList<RevueSouhaiteeDto> revues,
|
||||
string? proprietaire,
|
||||
DateTime maintenant) =>
|
||||
FormatExportSouhaits format, IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant) =>
|
||||
format switch
|
||||
{
|
||||
FormatExportSouhaits.Csv => Csv(souhaits, revues),
|
||||
_ => Texte(souhaits, revues, proprietaire, maintenant),
|
||||
FormatExportSouhaits.Csv => Csv(souhaits),
|
||||
_ => Texte(souhaits, proprietaire, maintenant),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -80,11 +63,7 @@ public static class ExportSouhaits
|
||||
/// Le groupement par auteur n'est pas cosmétique : c'est ainsi qu'une librairie est rangée.
|
||||
/// Une liste à plat obligerait à parcourir tout le fichier pour chaque rayon.
|
||||
/// </remarks>
|
||||
public static string Texte(
|
||||
IReadOnlyList<SouhaitDto> souhaits,
|
||||
IReadOnlyList<RevueSouhaiteeDto> revues,
|
||||
string? proprietaire,
|
||||
DateTime maintenant)
|
||||
public static string Texte(IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant)
|
||||
{
|
||||
var sortie = new StringBuilder();
|
||||
|
||||
@@ -97,32 +76,13 @@ public static class ExportSouhaits
|
||||
sortie.Append(" — ").Append(maintenant.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)).Append('\n');
|
||||
sortie.Append(new string('=', 40)).Append('\n');
|
||||
|
||||
if (souhaits.Count == 0 && revues.Count == 0)
|
||||
if (souhaits.Count == 0)
|
||||
{
|
||||
sortie.Append("\n(aucune envie dans la liste)\n");
|
||||
sortie.Append("\n(aucun livre dans la liste)\n");
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
if (souhaits.Count > 0)
|
||||
{
|
||||
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
|
||||
Livres(sortie, souhaits);
|
||||
}
|
||||
|
||||
Revues(sortie, revues);
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Les livres souhaités, groupés par auteur.</summary>
|
||||
private static void Livres(StringBuilder sortie, IReadOnlyList<SouhaitDto> souhaits)
|
||||
{
|
||||
// ⚠️ Le groupement par auteur DÉTRUIT l'ordre d'envie : c'est le prix, assumé, d'une
|
||||
// liste rangée comme une librairie. Le rang est donc réinscrit devant chaque titre,
|
||||
// faute de quoi le fichier emporté ne dit plus par quoi commencer — précisément
|
||||
// l'information que l'écran de réordonnancement sert à établir.
|
||||
var rangs = Rangs(souhaits);
|
||||
sortie.Append("(le chiffre entre crochets est votre ordre d'envie, 1 = le plus désiré)\n");
|
||||
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
|
||||
|
||||
// « Auteur inconnu » est rejeté en fin de liste : ce sont les entrées les moins
|
||||
// exploitables en rayon, elles ne doivent pas ouvrir le fichier.
|
||||
@@ -137,7 +97,7 @@ public static class ExportSouhaits
|
||||
|
||||
foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
sortie.Append(" - [").Append(rangs[souhait.Id]).Append("] ").Append(souhait.Titre);
|
||||
sortie.Append(" - ").Append(souhait.Titre);
|
||||
|
||||
// Éditeur, année et ISBN sur la même ligne, entre parenthèses : c'est ce qu'on
|
||||
// épelle à un libraire, et ça reste lisible sur un écran de téléphone.
|
||||
@@ -158,74 +118,21 @@ public static class ExportSouhaits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les revues souhaitées, en <b>section à part</b>, à la fin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Elles ne peuvent pas rejoindre les groupes ci-dessus : une revue n'a pas d'auteur, et
|
||||
/// « Auteur non précisé » réunirait des magazines et des livres dont l'auteur est simplement
|
||||
/// inconnu. Au kiosque comme en librairie, ce ne sont pas les mêmes rayons.
|
||||
/// </remarks>
|
||||
private static void Revues(StringBuilder sortie, IReadOnlyList<RevueSouhaiteeDto> revues)
|
||||
{
|
||||
if (revues.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sortie.Append('\n').Append(new string('-', 40)).Append('\n');
|
||||
sortie.Append(revues.Count).Append(revues.Count > 1 ? " revues" : " revue").Append('\n');
|
||||
|
||||
// La numérotation repart de 1 : le rang d'une revue est celui de sa propre section, pas
|
||||
// une place dans un classement commun qui n'existe pas.
|
||||
var rang = 0;
|
||||
|
||||
foreach (var revue in revues)
|
||||
{
|
||||
sortie.Append(" - [").Append(++rang).Append("] ").Append(revue.TitreComplet);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(revue.Issn))
|
||||
{
|
||||
sortie.Append(" (ISSN ").Append(revue.Issn).Append(')');
|
||||
}
|
||||
|
||||
sortie.Append('\n');
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(revue.Note))
|
||||
{
|
||||
sortie.Append(" ").Append(revue.Note.Trim()).Append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tableau ouvrable dans un tableur, une envie par ligne, <b>les deux tables réunies</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Une colonne « Type » ouvre le tableau à côté du rang : sans elle, une revue et un
|
||||
/// livre seraient indiscernables une fois le fichier trié par titre, et la colonne
|
||||
/// « ISBN / ISSN » deviendrait illisible — deux codes de nature différente y cohabitent.
|
||||
/// Les revues suivent les livres, chaque table gardant sa propre numérotation.
|
||||
/// </remarks>
|
||||
public static string Csv(IReadOnlyList<SouhaitDto> souhaits, IReadOnlyList<RevueSouhaiteeDto> revues)
|
||||
/// <summary>Tableau ouvrable dans un tableur, une envie par ligne.</summary>
|
||||
public static string Csv(IReadOnlyList<SouhaitDto> souhaits)
|
||||
{
|
||||
var sortie = new StringBuilder();
|
||||
|
||||
// Le rang ouvre le tableau : c'est la colonne sur laquelle on trie dans un tableur, et
|
||||
// celle qui répond à « par quoi commence-t-on ? » quand on se répartit des achats.
|
||||
// Le CSV suit déjà cet ordre, mais un ordre implicite se perd au premier tri par titre.
|
||||
Ligne(sortie, "Rang", "Type", "Titre", "Auteur", "Éditeur", "Année", "ISBN / ISSN", "Note", "Ajouté le");
|
||||
|
||||
var rangs = Rangs(souhaits);
|
||||
Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le");
|
||||
|
||||
foreach (var souhait in souhaits)
|
||||
{
|
||||
Ligne(
|
||||
sortie,
|
||||
rangs[souhait.Id].ToString(CultureInfo.InvariantCulture),
|
||||
"Livre",
|
||||
souhait.Titre,
|
||||
souhait.Auteur,
|
||||
souhait.Editeur,
|
||||
@@ -235,40 +142,9 @@ public static class ExportSouhaits
|
||||
souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var rangRevue = 0;
|
||||
|
||||
foreach (var revue in revues)
|
||||
{
|
||||
// Ni auteur, ni éditeur, ni année : une revue n'en a pas au sens de cette liste, et
|
||||
// inventer des colonnes remplies au hasard serait pire que des cases vides.
|
||||
Ligne(
|
||||
sortie,
|
||||
(++rangRevue).ToString(CultureInfo.InvariantCulture),
|
||||
"Revue",
|
||||
revue.TitreComplet,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
revue.Issn,
|
||||
revue.Note,
|
||||
revue.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rang affiché de chaque envie, à partir de 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Déduit de la <b>position dans la liste reçue</b>, et non lu sur une propriété du DTO :
|
||||
/// <c>ListerAsync</c> trie déjà par <c>Rang</c>, et la colonne en base peut comporter des
|
||||
/// trous (une suppression ne renumérote pas). Ce qu'on veut imprimer est « troisième de ma
|
||||
/// liste », pas la valeur brute stockée.
|
||||
/// </remarks>
|
||||
private static Dictionary<int, int> Rangs(IReadOnlyList<SouhaitDto> souhaits) =>
|
||||
souhaits.Select((s, i) => (s.Id, Rang: i + 1)).ToDictionary(x => x.Id, x => x.Rang);
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Souhaits;
|
||||
|
||||
/// <summary>Issue d'une écriture d'envie de revue : la ligne, un message, ou rien du tout.</summary>
|
||||
public readonly record struct ResultatRevueSouhaitee(
|
||||
RevueSouhaiteeDto? Envie, string? Erreur, bool NExistePas = false)
|
||||
{
|
||||
public static readonly ResultatRevueSouhaitee Introuvable = new(null, null, true);
|
||||
|
||||
public static ResultatRevueSouhaitee Ok(RevueSouhaiteeDto envie) => new(envie, null);
|
||||
|
||||
public static ResultatRevueSouhaitee Invalide(string message) => new(null, message);
|
||||
}
|
||||
|
||||
public interface IServiceRevuesSouhaitees
|
||||
{
|
||||
Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
|
||||
string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevueSouhaitee> AjouterAsync(
|
||||
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevueSouhaitee> ModifierAsync(
|
||||
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les envies de revues et de numéros, <b>personnelles</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mêmes règles que <see cref="ServiceSouhaits"/>, sans exception : toute méthode filtre sur
|
||||
/// l'appelant, aucun point d'entrée n'accepte de nom d'utilisateur, et l'envie d'un autre est
|
||||
/// simplement introuvable.
|
||||
/// <para>
|
||||
/// ⚠️ Il n'y a <b>pas</b> de rapprochement avec les revues possédées, contrairement aux envies
|
||||
/// de livres. Ce n'est pas un oubli : « je possède la revue mais pas ce numéro » et « je possède
|
||||
/// ce numéro » demandent deux réponses différentes, et un signalement approximatif sur une liste
|
||||
/// d'envies coûte plus qu'il ne rapporte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceRevuesSouhaitees(MaBibliDbContext db) : IServiceRevuesSouhaitees
|
||||
{
|
||||
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
|
||||
string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var envies = await db.RevuesSouhaitees
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Utilisateur == utilisateur)
|
||||
.OrderBy(e => e.Rang)
|
||||
.ThenBy(e => e.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return envies.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatRevueSouhaitee> AjouterAsync(
|
||||
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(
|
||||
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
var envie = new RevueSouhaitee
|
||||
{
|
||||
Utilisateur = utilisateur,
|
||||
Titre = saisie.Titre,
|
||||
Numero = saisie.Numero,
|
||||
Issn = saisie.Issn,
|
||||
Note = Vide(saisie.Note),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// ⚠️ Canonise l'ISSN au passage : « 24666718 » devient « 2466-6718 », la seule forme
|
||||
// rangée en base et la seule que la BnF interroge.
|
||||
envie.RecalculerFormes();
|
||||
|
||||
if (await ExisteDejaAsync(envie, null, ct))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(Deja(envie));
|
||||
}
|
||||
|
||||
// En fin de section, comme une envie de livre : on vient de la noter, on n'a pas dit
|
||||
// qu'on la voulait plus que les autres.
|
||||
var dernierRang = await db.RevuesSouhaitees
|
||||
.Where(e => e.Utilisateur == utilisateur)
|
||||
.Select(e => (int?)e.Rang)
|
||||
.MaxAsync(ct);
|
||||
|
||||
envie.Rang = (dernierRang ?? -1) + 1;
|
||||
|
||||
db.RevuesSouhaitees.Add(envie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatRevueSouhaitee.Ok(Projeter(envie));
|
||||
}
|
||||
|
||||
public async Task<ResultatRevueSouhaitee> ModifierAsync(
|
||||
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Introuvable;
|
||||
}
|
||||
|
||||
// Le filtre fait partie de la clé de recherche : l'envie d'un autre n'est pas refusée,
|
||||
// elle n'est pas trouvée.
|
||||
var envie = await db.RevuesSouhaitees
|
||||
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Introuvable;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
envie.Titre = saisie.Titre;
|
||||
envie.Numero = saisie.Numero;
|
||||
envie.Issn = saisie.Issn;
|
||||
envie.Note = Vide(saisie.Note);
|
||||
envie.RecalculerFormes();
|
||||
|
||||
if (await ExisteDejaAsync(envie, envie.Id, ct))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(Deja(envie));
|
||||
}
|
||||
|
||||
// Rang inchangé : corriger un numéro ne dit rien de l'ordre voulu.
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatRevueSouhaitee.Ok(Projeter(envie));
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var envie = await db.RevuesSouhaitees
|
||||
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.RevuesSouhaitees.Remove(envie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le doublon est refusé <b>avec un message</b>, jamais laissé à l'index unique.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ L'entité est déjà rattachée au contexte lors d'une modification : d'où l'exclusion
|
||||
/// explicite de sa propre ligne, sans quoi toute édition se dirait doublon d'elle-même.
|
||||
/// </remarks>
|
||||
private Task<bool> ExisteDejaAsync(RevueSouhaitee envie, int? saufId, CancellationToken ct) =>
|
||||
db.RevuesSouhaitees.AnyAsync(
|
||||
e => e.Utilisateur == envie.Utilisateur
|
||||
&& e.TitreNormalise == envie.TitreNormalise
|
||||
&& e.NumeroNormalise == envie.NumeroNormalise
|
||||
&& (saufId == null || e.Id != saufId),
|
||||
ct);
|
||||
|
||||
private static string Deja(RevueSouhaitee envie) =>
|
||||
envie.Numero is null
|
||||
? $"« {envie.Titre} » est déjà dans votre liste d'envies."
|
||||
: $"« {envie.Titre} n° {envie.Numero} » est déjà dans votre liste d'envies.";
|
||||
|
||||
private static string? Vide(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
||||
|
||||
/// <summary>Projection vers le DTO, <b>sans</b> le champ <c>Utilisateur</c>.</summary>
|
||||
private static RevueSouhaiteeDto Projeter(RevueSouhaitee envie) => new()
|
||||
{
|
||||
Id = envie.Id,
|
||||
Titre = envie.Titre,
|
||||
Numero = envie.Numero,
|
||||
Issn = envie.Issn,
|
||||
Note = envie.Note,
|
||||
DateAjout = envie.DateAjout,
|
||||
};
|
||||
}
|
||||
@@ -7,16 +7,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Souhaits;
|
||||
|
||||
/// <summary>Issue d'une écriture d'envie : la ligne, un message, ou rien du tout.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ « Introuvable » est un <b>troisième état</b>, et pas un message d'erreur de plus : c'est
|
||||
/// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par
|
||||
/// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables.
|
||||
/// </remarks>
|
||||
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false)
|
||||
/// <summary>Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur.</summary>
|
||||
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur)
|
||||
{
|
||||
public static readonly ResultatSouhait Introuvable = new(null, null, true);
|
||||
|
||||
public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);
|
||||
|
||||
public static ResultatSouhait Invalide(string message) => new(null, message);
|
||||
@@ -29,16 +22,6 @@ public interface IServiceSouhaits
|
||||
Task<ResultatSouhait> AjouterAsync(
|
||||
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Corrige une envie existante de l'appelant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <c>Rang</c> n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un
|
||||
/// titre n'est pas dire qu'on le veut davantage.
|
||||
/// </remarks>
|
||||
Task<ResultatSouhait> ModifierAsync(
|
||||
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -89,9 +72,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
.ThenBy(s => s.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var possedes = await CorrespondancesAsync(souhaits, ct);
|
||||
|
||||
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
|
||||
return souhaits.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatSouhait> AjouterAsync(
|
||||
@@ -165,98 +146,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
db.LivresSouhaites.Add(souhait);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var possede = await CorrespondancesAsync([souhait], ct);
|
||||
|
||||
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
||||
}
|
||||
|
||||
public async Task<ResultatSouhait> ModifierAsync(
|
||||
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatSouhait.Introuvable;
|
||||
}
|
||||
|
||||
// Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne
|
||||
// vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un
|
||||
// autre. C'est ce qui rend le 404 exact plutôt que poli.
|
||||
var souhait = await db.LivresSouhaites
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (souhait is null)
|
||||
{
|
||||
return ResultatSouhait.Introuvable;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatSouhait.Invalide("Le titre est obligatoire.");
|
||||
}
|
||||
|
||||
string? isbn = null;
|
||||
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
|
||||
{
|
||||
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
|
||||
if (!IsbnUtils.EstValide(normalise))
|
||||
{
|
||||
return ResultatSouhait.Invalide(
|
||||
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
|
||||
}
|
||||
|
||||
isbn = normalise;
|
||||
}
|
||||
|
||||
souhait.Titre = saisie.Titre;
|
||||
souhait.Auteur = saisie.Auteur;
|
||||
souhait.Editeur = Vide(saisie.Editeur);
|
||||
souhait.Annee = Vide(saisie.Annee);
|
||||
souhait.Isbn = isbn;
|
||||
souhait.Note = Vide(saisie.Note);
|
||||
|
||||
// ⚠️ Écrite SEULEMENT quand le geste de correction a été utilisé (CoverUrlModifiee) :
|
||||
// « Modifier une envie » enregistre aussi le titre, l'ISBN, etc. sans qu'on ait touché à
|
||||
// la couverture, et cette valeur affichée peut être une DÉDUCTION à la lecture (formule
|
||||
// OpenLibrary à ISBN connu, voir Projeter) plutôt que ce qu'il y a réellement en base.
|
||||
// La matérialiser à chaque enregistrement figerait cette déduction, et une correction
|
||||
// d'ISBN qui suit ne la referait plus jouer. Le drapeau distingue « je n'y ai pas
|
||||
// touché » de « je viens de vider le champ », ce qu'un simple null ne pourrait pas dire.
|
||||
if (saisie.CoverUrlModifiee)
|
||||
{
|
||||
souhait.CoverUrl = Vide(saisie.CoverUrl);
|
||||
}
|
||||
|
||||
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
|
||||
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
|
||||
// se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise.
|
||||
souhait.RecalculerFormes();
|
||||
|
||||
// ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise),
|
||||
// contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en
|
||||
// une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message
|
||||
// plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire.
|
||||
//
|
||||
// 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux
|
||||
// exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a
|
||||
// rien à confirmer. C'est une saisie à corriger, pas un choix à trancher.
|
||||
var deja = await db.LivresSouhaites.AnyAsync(
|
||||
s => s.Id != souhait.Id
|
||||
&& s.Utilisateur == utilisateur
|
||||
&& s.TitreNormalise == souhait.TitreNormalise
|
||||
&& s.AuteurNormalise == souhait.AuteurNormalise,
|
||||
ct);
|
||||
|
||||
if (deja)
|
||||
{
|
||||
return ResultatSouhait.Invalide(
|
||||
$"« {souhait.Titre} » est déjà dans votre liste d'envies.");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var possede = await CorrespondancesAsync([souhait], ct);
|
||||
|
||||
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
||||
return ResultatSouhait.Ok(Projeter(souhait));
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
|
||||
@@ -330,97 +220,6 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
|
||||
/// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
|
||||
/// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
|
||||
/// d'acheter à nouveau.
|
||||
/// <para>
|
||||
/// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
|
||||
/// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
|
||||
/// commun. Le titre seul confondrait deux recueils homonymes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle
|
||||
/// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans
|
||||
/// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici
|
||||
/// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<Dictionary<int, int>> CorrespondancesAsync(
|
||||
IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
|
||||
{
|
||||
if (souhaits.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var livres = await db.Livres
|
||||
.AsNoTracking()
|
||||
.Select(l => new
|
||||
{
|
||||
l.Id,
|
||||
l.Titre,
|
||||
l.Isbn,
|
||||
Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var parCle = livres
|
||||
.Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
|
||||
.Where(x => x.Cle.Length > 0)
|
||||
.GroupBy(x => x.Cle)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());
|
||||
|
||||
var parIsbn = new Dictionary<string, int>();
|
||||
foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
|
||||
{
|
||||
parIsbn.TryAdd(livre.Isbn!, livre.Id);
|
||||
}
|
||||
|
||||
var correspondances = new Dictionary<int, int>();
|
||||
|
||||
foreach (var souhait in souhaits)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
|
||||
{
|
||||
correspondances[souhait.Id] = idIsbn;
|
||||
continue;
|
||||
}
|
||||
|
||||
// TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
|
||||
// recalculer de ce côté-là.
|
||||
if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trouve = homonymes.FirstOrDefault(
|
||||
l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));
|
||||
|
||||
if (trouve is not null)
|
||||
{
|
||||
correspondances[souhait.Id] = trouve.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return correspondances;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant du livre correspondant, ou <c>null</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
|
||||
/// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
|
||||
/// été pris par les tests, pas par le compilateur.
|
||||
/// </remarks>
|
||||
private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
|
||||
correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;
|
||||
|
||||
private static string? Vide(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
||||
|
||||
@@ -428,7 +227,7 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
/// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
|
||||
/// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
|
||||
/// </summary>
|
||||
internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
|
||||
internal static SouhaitDto Projeter(LivreSouhaite souhait) => new()
|
||||
{
|
||||
Id = souhait.Id,
|
||||
Titre = souhait.Titre,
|
||||
@@ -436,24 +235,8 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
Editeur = souhait.Editeur,
|
||||
Annee = souhait.Annee,
|
||||
Isbn = souhait.Isbn,
|
||||
|
||||
// ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie.
|
||||
//
|
||||
// Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le
|
||||
// composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie »
|
||||
// remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie
|
||||
// d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais
|
||||
// aucune image.
|
||||
//
|
||||
// Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour
|
||||
// les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN,
|
||||
// pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on
|
||||
// applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.
|
||||
CoverUrl = souhait.CoverUrl
|
||||
?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)),
|
||||
CoverUrl = souhait.CoverUrl,
|
||||
Note = souhait.Note,
|
||||
DateAjout = souhait.DateAjout,
|
||||
Possede = livreId is not null,
|
||||
LivreId = livreId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
@using MaBibli.Shared.Dtos
|
||||
|
||||
@*
|
||||
L'arbre des séries : rien d'autre que la descendance.
|
||||
|
||||
⚠️ Il dépliait auparavant la liste complète des tomes de chaque série, carte de livre
|
||||
comprise. Un cycle de quatre séries occupait alors plusieurs écrans, et l'on ne voyait
|
||||
justement plus ce qu'un arbre sert à voir — qui contient quoi. Les tomes ont leur écran,
|
||||
celui de la série ; ici on ne montre que le titre, l'avancement, et le lien qui y mène.
|
||||
|
||||
Corollaire : tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. L'inverse
|
||||
(tout replié) obligeait à ouvrir chaque nœud pour découvrir s'il contenait quelque chose,
|
||||
c'est-à-dire à faire à la main le travail que l'arbre est censé faire.
|
||||
*@
|
||||
<li class="arbre-noeud" @key="Serie.Id">
|
||||
<div class="arbre-ligne">
|
||||
@if (Enfants.Count > 0)
|
||||
{
|
||||
<button type="button" class="arbre-plier" aria-expanded="@Aria.Etat(Deplie)"
|
||||
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Serie.Titre} »")"
|
||||
@onclick="Basculer">
|
||||
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Une puce à la place de la bascule : sans elle, les titres sans descendance ne
|
||||
s'aligneraient pas sur ceux qui en ont. *@
|
||||
<span class="arbre-puce" aria-hidden="true">•</span>
|
||||
}
|
||||
|
||||
<a class="arbre-titre" href="@($"series/{Serie.Id}")">@Serie.Titre</a>
|
||||
<span class="arbre-compte">@Avancement()</span>
|
||||
|
||||
@if (ActionsActives)
|
||||
{
|
||||
<span class="arbre-actions">
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_index <= 0)" title="@MotifBlocage"
|
||||
aria-label="@($"Monter « {Serie.Titre} »")"
|
||||
@onclick="Monter">▲</button>
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_index < 0 || _index == Soeurs.Count - 1)" title="@MotifBlocage"
|
||||
aria-label="@($"Descendre « {Serie.Titre} »")"
|
||||
@onclick="Descendre">▼</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Le chemin déjà rendu coupe la récursion : une boucle résiduelle en base ferait sinon
|
||||
boucler le rendu Razor à l'infini. Le garde-fou serveur, lui, reste borné par le
|
||||
nombre de séries. *@
|
||||
@if (Deplie && Enfants.Count > 0)
|
||||
{
|
||||
<ul class="arbre-enfants">
|
||||
@foreach (var enfant in Enfants.Where(e => !CheminAvecSerie.Contains(e.Id)))
|
||||
{
|
||||
<ArbreSerie Serie="enfant" Toutes="Toutes"
|
||||
Totaux="Totaux" Chemin="@CheminAvecSerie"
|
||||
ActionsActives="ActionsActives"
|
||||
MotifBlocage="MotifBlocage"
|
||||
Replies="Replies"
|
||||
OnDeplacer="OnDeplacer" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public SerieDto Serie { get; set; } = default!;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<SerieDto> Toutes { get; set; } = [];
|
||||
[Parameter, EditorRequired] public IReadOnlyDictionary<int, (int Possedes, int Total)> Totaux { get; set; } = new Dictionary<int, (int, int)>();
|
||||
[Parameter] public IReadOnlySet<int> Chemin { get; set; } = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Nœuds REPLIÉS, et non dépliés : le défaut est de tout montrer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture,
|
||||
/// pas une propriété de la série.
|
||||
/// </remarks>
|
||||
[Parameter] public ISet<int> Replies { get; set; } = new HashSet<int>();
|
||||
|
||||
[Parameter] public bool ActionsActives { get; set; }
|
||||
[Parameter] public string? MotifBlocage { get; set; }
|
||||
[Parameter] public EventCallback<DeplacementSerie> OnDeplacer { get; set; }
|
||||
|
||||
private List<SerieDto> Soeurs => Toutes
|
||||
.Where(s => s.SerieParenteId == Serie.SerieParenteId)
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
private List<SerieDto> Enfants => Toutes
|
||||
.Where(s => s.SerieParenteId == Serie.Id)
|
||||
.OrderBy(s => s.Position)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
private int _index => Soeurs.FindIndex(s => s.Id == Serie.Id);
|
||||
private bool Deplie => !Replies.Contains(Serie.Id);
|
||||
private IReadOnlySet<int> CheminAvecSerie => new HashSet<int>(Chemin) { Serie.Id };
|
||||
|
||||
/// <summary>
|
||||
/// « 3 sur 7 tomes », en comptant les descendants — c'est ce qu'on vient chercher.
|
||||
/// </summary>
|
||||
private string Avancement()
|
||||
{
|
||||
var total = Totaux.TryGetValue(Serie.Id, out var valeur)
|
||||
? valeur
|
||||
: (Possedes: Serie.NombrePossedes, Total: Serie.Elements.Count);
|
||||
|
||||
return total.Total == 0
|
||||
? "aucun tome recensé"
|
||||
: $"{total.Possedes} sur {total.Total} tome{(total.Total > 1 ? "s" : "")}";
|
||||
}
|
||||
|
||||
private void Basculer()
|
||||
{
|
||||
if (!Replies.Add(Serie.Id))
|
||||
{
|
||||
Replies.Remove(Serie.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Monter() => Deplacer(-1);
|
||||
private Task Descendre() => Deplacer(1);
|
||||
|
||||
private Task Deplacer(int delta) =>
|
||||
Serie.SerieParenteId is { } parent
|
||||
? OnDeplacer.InvokeAsync(new DeplacementSerie(Serie.Id, parent, delta))
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
@*
|
||||
Le catalogue contient déjà quelque chose de semblable.
|
||||
|
||||
⚠️ C'est un AVERTISSEMENT, jamais un refus : posséder deux exemplaires est légitime — on
|
||||
garde le sien et on prête l'autre — et le rapprochement se trompe dans les deux sens (il
|
||||
confond le poche et le grand format, il rate une réédition retitrée). L'écran montre donc
|
||||
les fiches en cause plutôt que de les décrire, et laisse la décision à l'utilisateur.
|
||||
|
||||
Les fiches sont des LIENS : la question qui se pose vraiment est « est-ce bien le même
|
||||
livre ? », et on n'y répond qu'en regardant celui qui est déjà là.
|
||||
*@
|
||||
<div class="message-avertissement" role="alert">@Doublons.Message</div>
|
||||
|
||||
<ul class="liste-livres">
|
||||
@foreach (var livre in Doublons.Livres)
|
||||
{
|
||||
<li class="carte-livre" @key="livre.Id">
|
||||
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
|
||||
|
||||
@if (livre.Auteurs.Count > 0)
|
||||
{
|
||||
<p class="carte-auteur">@string.Join(", ", livre.Auteurs.Select(a => a.Nom))</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (livre.Format == Format.Numerique)
|
||||
{
|
||||
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
||||
}
|
||||
|
||||
@* Un exemplaire dehors est justement une raison d'en vouloir un second. *@
|
||||
@if (livre.PreteA is { } emprunteur)
|
||||
{
|
||||
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
|
||||
{
|
||||
<span>@livre.Editeur</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(livre.Isbn))
|
||||
{
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(livre.Isbn)</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@EnCours" @onclick="OnConfirmer">
|
||||
@(EnCours ? "Ajout…" : "Ajouter quand même")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@EnCours" @onclick="OnRenoncer">
|
||||
Ne pas ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!;
|
||||
|
||||
/// <summary>Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté.</summary>
|
||||
[Parameter] public EventCallback OnConfirmer { get; set; }
|
||||
|
||||
/// <summary>Retour en arrière : le livre était bien déjà là.</summary>
|
||||
[Parameter] public EventCallback OnRenoncer { get; set; }
|
||||
|
||||
[Parameter] public bool EnCours { get; set; }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
@*
|
||||
Les deux autres façons de regarder le même auteur, posées à côté du filtre.
|
||||
|
||||
Elles étaient auparavant dans une barre flottante en bas d'écran. Sur une bibliographie
|
||||
de cinquante lignes, cela revenait à les cacher : « Nouveautés » est justement ce qu'on
|
||||
veut au moment où l'on ouvre l'écran, pas après l'avoir parcouru.
|
||||
|
||||
Composant plutôt que balisage recopié : la bibliographie les affiche à deux endroits
|
||||
(liste garnie, et liste vide où le filtre n'a pas lieu d'être), et deux copies auraient
|
||||
divergé.
|
||||
*@
|
||||
@if (Biblio.EstNouveautes)
|
||||
{
|
||||
<a class="bouton bouton-discret" href="@($"auteurs/{Biblio.Auteur.Id}/bibliographie")">
|
||||
Toute la bibliographie
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="bouton bouton-discret"
|
||||
href="@($"auteurs/{Biblio.Auteur.Id}/bibliographie?nouveautes=true")">
|
||||
Nouveautés
|
||||
</a>
|
||||
}
|
||||
|
||||
<a class="bouton bouton-discret" href="@($"?auteur={Biblio.Auteur.Id}")">Ses livres chez vous</a>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public BibliographieDto Biblio { get; set; } = default!;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<div class="carte-livre">
|
||||
<Couverture Url="@Livre.CoverUrl" Titre="@Livre.Titre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{Livre.Id}")">@Livre.Titre</a>
|
||||
|
||||
@if (Livre.Auteurs.Count > 0)
|
||||
{
|
||||
<p class="carte-auteur">
|
||||
@foreach (var (auteur, index) in Livre.Auteurs.Select((a, i) => (a, i)))
|
||||
{
|
||||
@(index > 0 ? ", " : "")
|
||||
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
||||
@if (auteur.Role != RoleAuteur.NonPrecise)
|
||||
{
|
||||
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (Livre.Format == Format.Numerique)
|
||||
{
|
||||
<span class="etiquette">@Libelles.Format(Livre.Format)</span>
|
||||
}
|
||||
@if (Livre.TypeDocument != TypeDocument.NonPrecise)
|
||||
{
|
||||
<span class="etiquette etiquette-type @Libelles.ClasseTypeDocument(Livre.TypeDocument)">
|
||||
@Libelles.TypeDocument(Livre.TypeDocument)
|
||||
</span>
|
||||
}
|
||||
@foreach (var theme in Livre.Themes)
|
||||
{
|
||||
<span class="etiquette">@theme</span>
|
||||
}
|
||||
@if (Livre.Statut is { } statut)
|
||||
{
|
||||
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
|
||||
@Libelles.Statut(statut)
|
||||
</span>
|
||||
}
|
||||
@if (Livre.PreteA is { } emprunteur)
|
||||
{
|
||||
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Livre.Editeur))
|
||||
{
|
||||
<span>@Livre.Editeur</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@Actions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public LivreDto Livre { get; set; } = default!;
|
||||
[Parameter] public RenderFragment? Actions { get; set; }
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
@*
|
||||
Les champs d'un numéro de revue, partagés par l'ajout et la modification.
|
||||
|
||||
Un seul exemplaire, et pas deux blocs jumeaux : le formulaire d'ajout et celui de
|
||||
modification doivent proposer exactement les mêmes champs, sinon une couverture ou une une
|
||||
saisies à la création ne seraient plus modifiables ensuite — ce qui est précisément le cas
|
||||
d'usage (on ajoute un numéro le jour où on l'achète, on en recopie la une plus tard).
|
||||
|
||||
⚠️ Aucun champ ne se lie DIRECTEMENT à « Saisie » : chacun passe par une propriété qui
|
||||
prévient le parent (OnChangement). Blazor ne redessine que le composant qui a traité
|
||||
l'événement — une frappe ici ne re-rendait donc pas l'écran qui porte le bouton
|
||||
« Ajouter ce numéro », et celui-ci restait grisé jusqu'à ce qu'autre chose redessine le
|
||||
parent (enregistrer la revue, par exemple). Voir CLAUDE.md.
|
||||
*@
|
||||
<div class="champs-numero">
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Numéro</span>
|
||||
<input class="champ-saisie" type="text" placeholder="43" @bind="Numero"
|
||||
@bind:event="oninput" @onkeydown="SurTouche" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Parution <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="date" @bind="Parution" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="Note" @bind:event="oninput"
|
||||
placeholder="dossier sur l'eau" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@*
|
||||
⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules :
|
||||
voir SaisieListe, qui porte le pourquoi. Une virgule dans un titre (« Ukraine, deux ans
|
||||
après ») n'a plus rien à couper, puisque plus rien ne découpe.
|
||||
*@
|
||||
<SaisieListe Valeurs="Saisie.Articles" Libelle="À la une"
|
||||
Aide=" (un titre à la fois : tapez-le, puis Entrée)"
|
||||
Placeholder="Ukraine, deux ans après"
|
||||
MessageDoublon="Ce titre est déjà à la une de ce numéro."
|
||||
OnChangement="Notifier" />
|
||||
|
||||
@*
|
||||
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
|
||||
la revue, pas la parution. Et le projet ne stocke aucun fichier — « ebooks : fiches
|
||||
uniquement » vaut ici aussi.
|
||||
*@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Couverture <span class="champ-aide">(adresse d'une image, facultatif)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="url" @bind="CoverUrl" @bind:event="oninput"
|
||||
placeholder="https://…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Signale au parent que la saisie a changé, pour qu'il se redessine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Sans cela, un bouton du parent qui dépend de la saisie reste figé.</b> Blazor ne
|
||||
/// redessine que le composant ayant traité l'événement : « Ajouter ce numéro » vit dans
|
||||
/// l'écran de la revue, le champ « Numéro » vit ici, et le bouton restait donc désactivé
|
||||
/// tant que rien d'autre ne re-rendait le parent — constaté en usage, où il fallait passer
|
||||
/// par « Modifier la revue » puis « Enregistrer » pour le débloquer.
|
||||
/// <para>
|
||||
/// Tous les champs préviennent, pas seulement le numéro : aucun autre bouton n'en dépend
|
||||
/// aujourd'hui, mais faire dépendre la correction de « lequel commande quoi » la referait
|
||||
/// oublier au premier champ ajouté.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Parameter] public EventCallback OnChangement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Les champs du numéro, servis par des propriétés plutôt que liés à <see cref="Saisie"/> :
|
||||
/// c'est le seul endroit d'où prévenir le parent à coup sûr.
|
||||
/// </summary>
|
||||
private string Numero
|
||||
{
|
||||
get => Saisie.Numero;
|
||||
set { Saisie.Numero = value; Notifier(); }
|
||||
}
|
||||
|
||||
private string? Note
|
||||
{
|
||||
get => Saisie.Note;
|
||||
set { Saisie.Note = value; Notifier(); }
|
||||
}
|
||||
|
||||
private string? CoverUrl
|
||||
{
|
||||
get => Saisie.CoverUrl;
|
||||
set { Saisie.CoverUrl = value; Notifier(); }
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Le parent est prévenu sans être attendu : <c>InvokeAsync</c> ne fait que demander un
|
||||
/// nouveau rendu, et un setter de propriété ne peut de toute façon rien attendre.
|
||||
/// </remarks>
|
||||
private void Notifier() => _ = OnChangement.InvokeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Date affichée par le champ, en <b>local</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le <c><input type="date"></c> produit une date sans fuseau. Elle est déclarée
|
||||
/// locale ici et convertie en UTC, comme toutes les dates du projet : sans cela la parution
|
||||
/// se décale d'un jour pour la moitié du globe. La conversion se fait dans le composant
|
||||
/// pour que les deux écrans qui s'en servent ne puissent pas l'oublier chacun de leur côté.
|
||||
/// </remarks>
|
||||
private DateTime? Parution
|
||||
{
|
||||
get => Saisie.DateParution?.ToLocalTime().Date;
|
||||
set
|
||||
{
|
||||
Saisie.DateParution = value is { } date
|
||||
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
|
||||
: null;
|
||||
|
||||
Notifier();
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter] public EventCallback<KeyboardEventArgs> OnEntree { get; set; }
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await OnEntree.InvokeAsync(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace MaBibli.Client.Composants;
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'un coup de scanner a produit : le code du livre ou de la revue, et son add-on éventuel.
|
||||
/// </summary>
|
||||
/// <param name="Code">EAN-13 normalisé, seul code qui identifie l'ouvrage.</param>
|
||||
/// <param name="Parution">
|
||||
/// Contenu de l'add-on <b>EAN-2</b>, imprimé à côté du code principal sur les périodiques.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>L'EAN-2 est une indication, jamais une donnée acquise.</b> Il porte le numéro de
|
||||
/// parution d'une revue — c'est sa raison d'être normative — mais rien n'a pu être vérifié sur
|
||||
/// des magazines réels : la vérification demande un exemplaire devant la caméra. Il est donc
|
||||
/// <b>proposé</b> à l'utilisateur dans un champ qu'il corrige, jamais enregistré tel quel.
|
||||
/// <para>
|
||||
/// L'add-on <b>EAN-5</b> n'est pas remonté : il porte un <i>prix</i>, et le confondre avec un
|
||||
/// numéro créerait des numéros « 00595 » que personne ne comprendrait.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public readonly record struct CodeScanne(string Code, string? Parution);
|
||||
|
||||
/// <summary>Forme brute renvoyée par <c>scanner-camera.js</c>, avant validation de l'ISBN.</summary>
|
||||
internal sealed record LectureCodeBarres(string? Code, string? Parution);
|
||||
@@ -1,7 +1,3 @@
|
||||
@using MaBibli.Client.Services
|
||||
@inject CacheHorsLigne Cache
|
||||
@inject EtatReseau Reseau
|
||||
@implements IAsyncDisposable
|
||||
@*
|
||||
Couverture d'un livre, avec substitut.
|
||||
|
||||
@@ -13,93 +9,39 @@
|
||||
@key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que
|
||||
le onerror a marqué comme cassé.
|
||||
|
||||
Agrandissement (CLAUDE.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
|
||||
à agrandir - le substitut à initiale reste un simple décor, jamais un bouton.
|
||||
|
||||
⚠️ Image cassée après coup (404/502 intermittent d'OpenLibrary) : le onerror désactive aussi
|
||||
le bouton déclencheur (this.closest('.couverture-declencheur').disabled = true), pas
|
||||
seulement l'image. Sans ça, cliquer une vignette cassée agrandissait quand même une image
|
||||
cassée (A4, CLAUDE.md).
|
||||
|
||||
Cache des couvertures : le cache IndexedDB est consulté D'ABORD, en ligne comme hors ligne.
|
||||
Une image déjà rangée sur l'appareil s'affiche sans aucune requête réseau — c'est ce qui rend
|
||||
un rechargement instantané sur téléphone, là où covers.openlibrary.org impose deux
|
||||
redirections avant d'aboutir sur archive.org. Le réseau n'est sollicité QUE si rien n'est en
|
||||
cache, et la mise en cache démarre alors en tâche de fond.
|
||||
|
||||
⚠️ Ceci REMPLACE la règle d'origine (A5) « en ligne, rien ne doit ralentir la voie rapide »,
|
||||
qui réservait le cache au hors-ligne. La lecture IndexedDB précède désormais l'affichage :
|
||||
elle est locale et sans réseau, donc largement plus rapide que ce qu'elle remplace.
|
||||
|
||||
⚠️ Corollaire : l'URL d'objet doit être LIBÉRÉE quand le composant disparaît ou change
|
||||
d'image (Dispose / changement d'URL). Un blob: retient son Blob en mémoire jusque-là, et il y
|
||||
a maintenant une vignette par carte sur tous les écrans.
|
||||
⚠️ L'allègement (redimensionner, convertir en WebP) n'est PAS fait ici : à mesurer avant
|
||||
de s'y engager, comme pour l'AOT — voir CLAUDE.md.
|
||||
Agrandissement (IDEES.md) : seulement quand il y a une URL. Sans couverture, il n'y a rien
|
||||
à agrandir — le substitut à initiale reste un simple décor, jamais un bouton.
|
||||
*@
|
||||
<div class="couverture @Classe">
|
||||
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
|
||||
|
||||
@if (ADesPixels)
|
||||
{
|
||||
@* ⚠️ L'image cassée est désormais un état C# (`_cassee`), posé par @onerror, et non plus
|
||||
une classe ajoutée par du JavaScript en ligne. C'est ce qui permet d'en faire quelque
|
||||
chose : proposer de corriger l'URL, là où l'on constate qu'elle est morte.
|
||||
|
||||
`onerror` ne remonte pas dans le DOM, mais Blazor le connaît comme événement
|
||||
non propagatif et pose son écouteur sur l'élément même : @onerror fonctionne bien
|
||||
sur une <img>. *@
|
||||
@if (Corrigeable)
|
||||
{
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Corriger l'adresse de la couverture de {Titre}")"
|
||||
title="Cette image ne se charge pas — cliquez pour corriger son adresse"
|
||||
@onclick="OnCorriger">
|
||||
<img @key="UrlAffichee" class="couverture-image image-cassee"
|
||||
src="@UrlAffichee" alt="Couverture de @Titre" loading="lazy" />
|
||||
</button>
|
||||
}
|
||||
else if (Agrandissable && !_cassee)
|
||||
@if (Agrandissable)
|
||||
{
|
||||
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
|
||||
et activation à l'Entrée sans une ligne de code. *@
|
||||
<button type="button" class="couverture-declencheur"
|
||||
aria-label="Agrandir la couverture de @Titre"
|
||||
@onclick="Ouvrir">
|
||||
<img @key="UrlAffichee"
|
||||
<img @key="Url"
|
||||
class="couverture-image"
|
||||
src="@UrlAffichee"
|
||||
src="@Url"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
@onerror="() => _cassee = true" />
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img @key="UrlAffichee"
|
||||
class="couverture-image @(_cassee ? "image-cassee" : null)"
|
||||
src="@UrlAffichee"
|
||||
<img @key="Url"
|
||||
class="couverture-image"
|
||||
src="@Url"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
@onerror="() => _cassee = true" />
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
}
|
||||
}
|
||||
else if (OnCorriger.HasDelegate)
|
||||
{
|
||||
@*
|
||||
⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle
|
||||
« le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic
|
||||
servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et
|
||||
c'est précisément le livre qui n'en a pas qui en a besoin.
|
||||
|
||||
Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte :
|
||||
le paramètre n'est pas passé, et le substitut y reste un décor inerte.
|
||||
*@
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Ajouter une couverture à {Titre}")"
|
||||
title="Aucune couverture — cliquez pour en indiquer l'adresse"
|
||||
@onclick="OnCorriger"></button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_agrandie)
|
||||
@@ -110,7 +52,7 @@
|
||||
aria-label="Couverture de @Titre"
|
||||
tabindex="-1" @ref="_calque"
|
||||
@onclick="Fermer" @onkeydown="SurToucheAsync">
|
||||
<img class="calque-image" src="@UrlAffichee" alt="Couverture de @Titre"
|
||||
<img class="calque-image" src="@Url" alt="Couverture de @Titre"
|
||||
@onclick:stopPropagation="true"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
|
||||
@@ -128,37 +70,12 @@
|
||||
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
|
||||
[Parameter] public bool Agrandissable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Renseigné, il <b>remplace</b> l'agrandissement sur une image cassée, et rend cliquable
|
||||
/// le substitut à initiale d'un livre <b>sans</b> couverture. Agrandir une image qui ne
|
||||
/// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque
|
||||
/// est exactement ce qu'on veut faire à cet instant.
|
||||
/// </remarks>
|
||||
[Parameter] public EventCallback OnCorriger { get; set; }
|
||||
|
||||
/// <summary>L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager.</summary>
|
||||
private bool _cassee;
|
||||
|
||||
private bool Corrigeable => _cassee && OnCorriger.HasDelegate;
|
||||
|
||||
private bool _agrandie;
|
||||
private bool _aFocaliser;
|
||||
private ElementReference _calque;
|
||||
private string? _urlCachee;
|
||||
private (string? Url, bool EnLigne)? _urlResoluePour;
|
||||
|
||||
private bool ADesPixels => !string.IsNullOrWhiteSpace(Url);
|
||||
|
||||
/// <summary>
|
||||
/// URL réellement posée en <c>src</c> : le blob mis en cache dès qu'il existe — en ligne
|
||||
/// comme hors ligne —, sinon l'URL réseau telle quelle (qui échoue simplement hors-ligne,
|
||||
/// exactement comme avant cette fonctionnalité).
|
||||
/// </summary>
|
||||
private string? UrlAffichee => _urlCachee ?? Url;
|
||||
|
||||
private string Initiale =>
|
||||
string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant();
|
||||
|
||||
@@ -178,53 +95,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// ⚠️ Le jeton compare aussi l'état réseau, pas seulement l'URL : sans quoi, passer
|
||||
// en ligne → hors ligne sans que l'URL change (le cas le plus courant) ne relirait
|
||||
// jamais le cache, et la couverture resterait invisible hors-ligne.
|
||||
var jeton = (Url, Reseau.EnLigne);
|
||||
if (!ADesPixels || _urlResoluePour == jeton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_urlResoluePour = jeton;
|
||||
|
||||
// Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci.
|
||||
_cassee = false;
|
||||
await LibererAsync();
|
||||
|
||||
// Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche
|
||||
// sans requête, sans redirection, et sans réveiller la radio du téléphone.
|
||||
_urlCachee = await Cache.LireCouvertureCacheeAsync(Url!);
|
||||
|
||||
if (_urlCachee is null && Reseau.EnLigne)
|
||||
{
|
||||
// Rien en cache : le réseau sert l'image (UrlAffichee retombe sur Url), et la mise
|
||||
// en cache démarre en tâche de fond pour que le prochain chargement soit local.
|
||||
// ⚠️ Jamais attendue : elle ne doit pas retarder un affichage qui va avoir lieu.
|
||||
_ = Cache.MettreEnCacheCouvertureAsync(Url!);
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Révoque l'URL d'objet en cours, s'il y en a une.</summary>
|
||||
private async Task LibererAsync()
|
||||
{
|
||||
if (_urlCachee is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var aLiberer = _urlCachee;
|
||||
_urlCachee = null;
|
||||
await Cache.LibererCouvertureAsync(aLiberer);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await LibererAsync();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_aFocaliser)
|
||||
@@ -234,4 +104,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@inject ServiceLivresApi Api
|
||||
|
||||
@*
|
||||
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
|
||||
|
||||
@@ -21,81 +19,29 @@
|
||||
</label>
|
||||
|
||||
@*
|
||||
⚠️ L'ORDRE DES CHAMPS EST UNE DÉCISION (lot Q, 2026-08-21), pas une mise en page.
|
||||
|
||||
Type de document, puis thèmes, puis auteurs (chacun avec son rôle) : c'est l'ordre dans
|
||||
lequel on regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de
|
||||
la saisie — les rôles ne se posent que là. Relégué en bas, il obligeait à descendre tout le formulaire
|
||||
pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend.
|
||||
|
||||
⚠️ Remonter le champ le rend visible ; le PRÉREMPLIR écrirait quelque chose de faux. Le
|
||||
défaut reste « non précisé », qui ne prétend rien (décision actée) — c'est aussi ce qui
|
||||
rend l'étiquette honnête dans les listes : elle ne s'affiche que si un type a été choisi.
|
||||
Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà
|
||||
prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle.
|
||||
Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu.
|
||||
*@
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
Type<span class="champ-aide"> (facultatif)</span>
|
||||
</legend>
|
||||
<div class="segments">
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
|
||||
@onclick="() => Saisie.TypeDocument = type">
|
||||
@Libelles.TypeDocument(type)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Auteur<span class="champ-aide"> (plusieurs : séparez par « ; »)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
|
||||
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
⚠️ Thèmes et auteurs se saisissent UN PAR UN, et non plus sur une ligne à séparateurs.
|
||||
Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis
|
||||
recomposait la liste : le point-virgule tapé y était élagué (entrée vide) et l'espace
|
||||
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
|
||||
Voir CLAUDE.md.
|
||||
*@
|
||||
@*
|
||||
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
|
||||
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
|
||||
filtre par thème n'y verrait que deux étiquettes sans rapport.
|
||||
*@
|
||||
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
|
||||
Suggestions="_themesConnus"
|
||||
OnChangement="StateHasChanged" />
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur</span>
|
||||
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
Un auteur par ligne, et son RÔLE avec lui.
|
||||
|
||||
⚠️ Le rôle vit sur le LIEN, pas sur la personne : le même auteur scénarise un album et
|
||||
dessine le suivant. C'est aussi pourquoi renommer une ligne conserve son rôle
|
||||
(NomsAuteurs) — on corrige un nom, on ne désigne pas quelqu'un d'autre.
|
||||
|
||||
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
|
||||
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
|
||||
*@
|
||||
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="Prénom Nom, ou Nom, Prénom"
|
||||
MessageDoublon="Cet auteur est déjà sur ce livre."
|
||||
OnChangement="StateHasChanged">
|
||||
<Complement Context="index">
|
||||
@if (Saisie.Auteurs.Count > 1)
|
||||
{
|
||||
<div class="segments segments-role" role="group"
|
||||
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
|
||||
@foreach (var role in Roles)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
|
||||
@onclick="() => PoserRole(index, role)">
|
||||
@Libelles.RoleAuteur(role)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Complement>
|
||||
</SaisieListe>
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
|
||||
placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc
|
||||
@@ -120,32 +66,6 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur</span>
|
||||
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
⚠️ Le champ reste VIDE quand on ne sait pas : « 0 » se lirait comme une donnée. C'est
|
||||
aussi pourquoi la valeur proposée par la BnF arrive ici et non en base directement —
|
||||
une pagination lue dans une phrase (« 1 vol. (349 p.) ») se corrige d'un geste.
|
||||
*@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Nombre de pages <span class="champ-aide">(facultatif)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="number" inputmode="numeric" min="1" step="1"
|
||||
@bind="Saisie.NombrePages" @bind:event="oninput"
|
||||
placeholder="349" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
|
||||
placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
Statut de lecture<span class="champ-aide"> (le vôtre)</span>
|
||||
@@ -192,85 +112,6 @@
|
||||
@code {
|
||||
private static readonly Format[] Formats = Enum.GetValues<Format>();
|
||||
|
||||
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
|
||||
|
||||
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
|
||||
|
||||
/// <summary>
|
||||
/// Pose le rôle d'un auteur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AuteurSaisi"/> est un record : on remplace l'entrée plutôt que de la muter,
|
||||
/// et la liste reste celle que la propriété <c>Auteur</c> saura relire pour préserver les
|
||||
/// rôles à la frappe suivante.
|
||||
/// </remarks>
|
||||
private void PoserRole(int index, RoleAuteur role) =>
|
||||
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
|
||||
|
||||
/// <summary>
|
||||
/// Les noms des auteurs, vus comme une liste de chaînes par <c>SaisieListe</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
|
||||
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
|
||||
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
|
||||
/// par <c>OnParametersSet</c> et par elle seule.
|
||||
/// <para>
|
||||
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
|
||||
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
|
||||
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
|
||||
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
|
||||
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private NomsAuteurs? _noms;
|
||||
|
||||
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
|
||||
|
||||
private EnregistrementLivre? _saisiePrecedente;
|
||||
|
||||
/// <summary>
|
||||
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
|
||||
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
|
||||
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
|
||||
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
|
||||
/// pris de la détection de doublons.
|
||||
/// <para>
|
||||
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IReadOnlyList<string> _themesConnus = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_themesConnus =
|
||||
[
|
||||
.. (await Api.ListerAsync(new CritereLivres()))
|
||||
.SelectMany(l => l.Themes)
|
||||
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_themesConnus = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
||||
{
|
||||
_saisiePrecedente = Saisie;
|
||||
_noms = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
|
||||
|
||||
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
@inject ServiceLivresApi Api
|
||||
|
||||
@*
|
||||
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
|
||||
|
||||
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
|
||||
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
|
||||
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
|
||||
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
|
||||
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
|
||||
*@
|
||||
<div class="formulaire">
|
||||
<p class="message-avertissement" role="status">
|
||||
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
|
||||
sélectionnés — pour les auteurs, une liste vide les retire tous.
|
||||
</p>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierType" />
|
||||
<span>Type</span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierType)
|
||||
{
|
||||
<div class="segments">
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
|
||||
@onclick="() => Saisie.TypeDocument = type">
|
||||
@Libelles.TypeDocument(type)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierThemes" />
|
||||
<span>Thèmes</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierThemes)
|
||||
{
|
||||
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
|
||||
Suggestions="_themesConnus"
|
||||
OnChangement="StateHasChanged" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
|
||||
<span>Auteurs</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierAuteurs)
|
||||
{
|
||||
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="Prénom Nom, ou Nom, Prénom"
|
||||
MessageDoublon="Cet auteur est déjà dans la liste."
|
||||
OnChangement="StateHasChanged">
|
||||
<Complement Context="index">
|
||||
@if (Saisie.Auteurs.Count > 1)
|
||||
{
|
||||
<div class="segments segments-role" role="group"
|
||||
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
|
||||
@foreach (var role in Roles)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
|
||||
@onclick="() => PoserRole(index, role)">
|
||||
@Libelles.RoleAuteur(role)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Complement>
|
||||
</SaisieListe>
|
||||
}
|
||||
</div>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierFormat" />
|
||||
<span>Format</span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierFormat)
|
||||
{
|
||||
<div class="segments">
|
||||
@foreach (var format in Formats)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
|
||||
@onclick="() => Saisie.Format = format">
|
||||
@Libelles.Format(format)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
|
||||
<span>Éditeur</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierEditeur)
|
||||
{
|
||||
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierStatut" />
|
||||
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierStatut)
|
||||
{
|
||||
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Erreur))
|
||||
{
|
||||
<p class="message-erreur" role="alert">@Erreur</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(MessageBlocage))
|
||||
{
|
||||
<p class="message-avertissement" role="status">@MessageBlocage</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
|
||||
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
|
||||
@onclick="Valider">
|
||||
@(EnCours ? "Enregistrement…" : LibelleValidation)
|
||||
</button>
|
||||
|
||||
@if (OnAnnuler.HasDelegate)
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
|
||||
@onclick="OnAnnuler">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly Format[] Formats = Enum.GetValues<Format>();
|
||||
|
||||
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
|
||||
|
||||
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
|
||||
|
||||
private void PoserRole(int index, RoleAuteur role) =>
|
||||
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
|
||||
|
||||
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
|
||||
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
|
||||
private NomsAuteurs? _noms;
|
||||
|
||||
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
|
||||
|
||||
private SaisieLot? _saisiePrecedente;
|
||||
|
||||
private IReadOnlyList<string> _themesConnus = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_themesConnus =
|
||||
[
|
||||
.. (await Api.ListerAsync(new CritereLivres()))
|
||||
.SelectMany(l => l.Themes)
|
||||
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_themesConnus = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
||||
{
|
||||
_saisiePrecedente = Saisie;
|
||||
_noms = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
|
||||
|
||||
[Parameter] public int NombreLivres { get; set; }
|
||||
|
||||
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
|
||||
|
||||
[Parameter] public string? Erreur { get; set; }
|
||||
|
||||
[Parameter] public bool EnCours { get; set; }
|
||||
|
||||
[Parameter] public string? MessageBlocage { get; set; }
|
||||
|
||||
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
|
||||
|
||||
[Parameter] public EventCallback OnValider { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnAnnuler { get; set; }
|
||||
|
||||
private Task Valider() => OnValider.InvokeAsync();
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
@using MaBibli.Client.Services
|
||||
|
||||
@*
|
||||
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
|
||||
série de séries : chaque niveau porte sa propre bascule.
|
||||
|
||||
⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
|
||||
catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
|
||||
alors que l'écran des séries EST la liste des séries, où les masquer toutes viderait l'écran.
|
||||
|
||||
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
|
||||
du catalogue paraîtrait mentir.
|
||||
|
||||
⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce
|
||||
qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le
|
||||
filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a
|
||||
qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ».
|
||||
*@
|
||||
<li class="groupe-serie" @key="Entree.Serie!.Id">
|
||||
<div class="groupe-serie-entete">
|
||||
@* ⚠️ Aria.Etat, et non le booléen nu : voir la mise en garde qu'il porte. *@
|
||||
<button type="button" class="groupe-serie-plier"
|
||||
aria-expanded="@Aria.Etat(Deplie)"
|
||||
aria-label="@($"{(Deplie ? "Replier" : "Déplier")} « {Entree.Serie!.Titre} »")"
|
||||
@onclick="Basculer">
|
||||
<span aria-hidden="true">@(Deplie ? "▾" : "▸")</span>
|
||||
</button>
|
||||
|
||||
@* Le nom mène à la SÉRIE : c'est le seul écran qui montre aussi les tomes manquants,
|
||||
ce que le catalogue ne peut pas faire. *@
|
||||
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
|
||||
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
|
||||
<span class="groupe-serie-compte">
|
||||
@Libelles.CompteBloc(Entree.NombreLivres, Possedes, Total)
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (Deplie)
|
||||
{
|
||||
@if (Entree.SousEntrees.Count > 0)
|
||||
{
|
||||
<ul class="liste-livres">
|
||||
@foreach (var sous in Entree.SousEntrees)
|
||||
{
|
||||
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux"
|
||||
LotActif="LotActif" SelectionLot="SelectionLot" OnSelectionner="OnSelectionner" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (Entree.Livres.Count > 0)
|
||||
{
|
||||
<ul class="liste-livres">
|
||||
@foreach (var livre in Entree.Livres)
|
||||
{
|
||||
@if (LotActif)
|
||||
{
|
||||
<li class="carte-oeuvre" @key="livre.Id">
|
||||
<label class="oeuvre-case">
|
||||
<input type="checkbox"
|
||||
checked="@(SelectionLot?.Contains(livre.Id) ?? false)"
|
||||
@onchange="e => OnSelectionner.InvokeAsync((livre.Id, e.Value is true))" />
|
||||
<span class="lecture-ecran">Sélectionner « @livre.Titre »</span>
|
||||
</label>
|
||||
<CarteLivre Livre="livre" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li @key="livre.Id">
|
||||
<CarteLivre Livre="livre" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Blocs OUVERTS, par identifiant de série — et non repliés : le défaut est de tout replier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture, pas
|
||||
/// une propriété de la série. Il survit en revanche aux rechargements du catalogue (frappe
|
||||
/// dans la recherche, filtre), le jeu appartenant à l'écran et non au bloc.
|
||||
/// </remarks>
|
||||
[Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Calculés une fois par l'écran (<c>TotauxSeries</c>), et non ici : le catalogue se relit
|
||||
/// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre.
|
||||
/// C'est aussi ce qui garantit le <b>même</b> compte que la fiche de la série.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
|
||||
|
||||
/// <summary>Modification en lot du catalogue : voir la remarque équivalente sur Catalogue.razor.</summary>
|
||||
[Parameter] public bool LotActif { get; set; }
|
||||
|
||||
[Parameter] public ISet<int>? SelectionLot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ EventCallback, et non un Action brut : coché depuis un tome niché dans ce bloc, l'écran
|
||||
/// du catalogue (le VRAI propriétaire de la sélection) ne serait sinon jamais prévenu du
|
||||
/// changement, et sa barre de sélection resterait figée sur l'état d'avant — le second piège
|
||||
/// Blazor de CLAUDE.md.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<(int LivreId, bool Coche)> OnSelectionner { get; set; }
|
||||
|
||||
private (int Possedes, int Total) Compte =>
|
||||
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
|
||||
? connu
|
||||
: (Entree.NombreLivres, Entree.NombreLivres);
|
||||
|
||||
private int Possedes => Compte.Possedes;
|
||||
|
||||
private int Total => Compte.Total;
|
||||
|
||||
private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
|
||||
|
||||
private void Basculer()
|
||||
{
|
||||
if (!Ouverts.Add(Entree.Serie!.Id))
|
||||
{
|
||||
Ouverts.Remove(Entree.Serie!.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,34 +12,6 @@ public static class Libelles
|
||||
_ => format.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Libellé du type de document. <b>« Non précisé » ne s'affiche pas sur une fiche</b> :
|
||||
/// c'est l'absence de choix, et l'interface ne montre que ce qui a été choisi. Le libellé
|
||||
/// existe pour les boutons de saisie et de filtre, où il faut bien nommer l'option.
|
||||
/// </summary>
|
||||
public static string TypeDocument(TypeDocument type) => type switch
|
||||
{
|
||||
MaBibli.Shared.Entites.TypeDocument.NonPrecise => "Non précisé",
|
||||
MaBibli.Shared.Entites.TypeDocument.Roman => "Roman",
|
||||
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "BD",
|
||||
MaBibli.Shared.Entites.TypeDocument.Pratique => "Pratique",
|
||||
MaBibli.Shared.Entites.TypeDocument.Connaissance => "Connaissance",
|
||||
_ => type.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Libellé d'un rôle d'auteur. « Non précisé » ne s'affiche jamais sur une fiche : c'est
|
||||
/// l'absence de choix, pas une information.
|
||||
/// </summary>
|
||||
public static string RoleAuteur(RoleAuteur role) => role switch
|
||||
{
|
||||
MaBibli.Shared.Entites.RoleAuteur.NonPrecise => "Non précisé",
|
||||
MaBibli.Shared.Entites.RoleAuteur.Scenario => "Scénario",
|
||||
MaBibli.Shared.Entites.RoleAuteur.Dessin => "Dessin",
|
||||
MaBibli.Shared.Entites.RoleAuteur.Couleurs => "Couleurs",
|
||||
_ => role.ToString(),
|
||||
};
|
||||
|
||||
public static string Statut(Statut statut) => statut switch
|
||||
{
|
||||
MaBibli.Shared.Entites.Statut.ALire => "À lire",
|
||||
@@ -56,53 +28,4 @@ public static class Libelles
|
||||
MaBibli.Shared.Entites.Statut.Lu => "statut-lu",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Classe CSS associée au type de document, pour la pastille de couleur (A2, CLAUDE.md) :
|
||||
/// le filtre doit reprendre le même vocabulaire visuel que la carte, pas des segments neutres.
|
||||
/// « Non précisé » n'a pas de couleur : ce n'est pas une valeur choisie.
|
||||
/// </summary>
|
||||
public static string ClasseTypeDocument(TypeDocument type) => type switch
|
||||
{
|
||||
MaBibli.Shared.Entites.TypeDocument.Roman => "type-roman",
|
||||
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
|
||||
MaBibli.Shared.Entites.TypeDocument.Pratique => "type-pratique",
|
||||
MaBibli.Shared.Entites.TypeDocument.Connaissance => "type-connaissance",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et
|
||||
/// ce que la série compte de tomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le compte d'un bloc changeait avec le filtre</b> : « 3 tomes » devenait « 1 tome »
|
||||
/// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un
|
||||
/// tome ». Le nombre affiché ne disait pas de quoi il parlait.
|
||||
/// <para>
|
||||
/// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc.
|
||||
/// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="affiches">Tomes de ce bloc actuellement à l'écran, filtre appliqué.</param>
|
||||
/// <param name="possedes">Tomes de cette série présents au catalogue, descendance comprise.</param>
|
||||
/// <param name="total">Tomes recensés dans la série, ceux qui manquent compris.</param>
|
||||
public static string CompteBloc(int affiches, int possedes, int total)
|
||||
{
|
||||
var tomes = total > 1 ? "tomes" : "tome";
|
||||
|
||||
if (affiches == possedes && possedes == total)
|
||||
{
|
||||
return $"{total} {tomes}";
|
||||
}
|
||||
|
||||
if (affiches == possedes)
|
||||
{
|
||||
return $"{possedes} sur {total} {tomes}";
|
||||
}
|
||||
|
||||
return possedes == total
|
||||
? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}"
|
||||
: $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Collections;
|
||||
using MaBibli.Shared.Dtos;
|
||||
|
||||
namespace MaBibli.Client.Composants;
|
||||
|
||||
/// <summary>
|
||||
/// Vue « liste de noms » des auteurs d'une saisie, pour que <c>SaisieListe</c> les remplisse
|
||||
/// comme il remplit les thèmes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Un auteur n'est pas un nom : il porte aussi son rôle sur ce livre</b> (scénario,
|
||||
/// dessin…), qui vit sur le lien et non sur la personne. Renommer par l'indexeur <b>conserve
|
||||
/// donc le rôle</b> — c'est ce qui remplace la reprise des rôles que faisait
|
||||
/// <c>EnregistrementLivre.Decouper</c> quand le champ des noms se reconstruisait à chaque
|
||||
/// frappe : ici la ligne n'est pas reconstruite, elle est corrigée.
|
||||
/// <para>
|
||||
/// La liste sous-jacente est celle de la saisie, modifiée en place : cette vue n'en garde
|
||||
/// aucune copie qui pourrait diverger.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class NomsAuteurs(IList<AuteurSaisi> auteurs) : IList<string>
|
||||
{
|
||||
public int Count => auteurs.Count;
|
||||
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
public string this[int index]
|
||||
{
|
||||
get => auteurs[index].Nom;
|
||||
set => auteurs[index] = auteurs[index] with { Nom = value };
|
||||
}
|
||||
|
||||
public void Add(string nom) => auteurs.Add(new AuteurSaisi(nom));
|
||||
|
||||
public void Insert(int index, string nom) => auteurs.Insert(index, new AuteurSaisi(nom));
|
||||
|
||||
public void RemoveAt(int index) => auteurs.RemoveAt(index);
|
||||
|
||||
public void Clear() => auteurs.Clear();
|
||||
|
||||
public bool Contains(string nom) => IndexOf(nom) >= 0;
|
||||
|
||||
public int IndexOf(string nom)
|
||||
{
|
||||
for (var i = 0; i < auteurs.Count; i++)
|
||||
{
|
||||
if (auteurs[i].Nom == nom)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
public bool Remove(string nom)
|
||||
{
|
||||
var index = IndexOf(nom);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auteurs.RemoveAt(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void CopyTo(string[] tableau, int index)
|
||||
{
|
||||
foreach (var auteur in auteurs)
|
||||
{
|
||||
tableau[index++] = auteur.Nom;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator<string> GetEnumerator() => auteurs.Select(a => a.Nom).GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
@*
|
||||
Indicateur d'attente pour les appels lents (lot L1).
|
||||
|
||||
⚠️ Le besoin n'est pas décoratif. Une recherche BnF coûte environ une seconde par page, et
|
||||
jusqu'à dix pages pour les nouveautés : sans rien à l'écran, l'application se lit comme
|
||||
plantée, et l'utilisateur reclique — donc relance l'appel. C'est exactement ce que le lot H
|
||||
avait constaté sur la bibliographie.
|
||||
|
||||
⚠️ Un indicateur NE REMPLACE PAS un message d'échec. Une source muette garde ses états
|
||||
EtatSourceBibliographie et son motif : ici on dit « ça travaille », jamais « ça a marché ».
|
||||
|
||||
role="status" et non role="alert" : l'attente s'annonce sans interrompre le lecteur d'écran.
|
||||
L'animation est neutralisée sous prefers-reduced-motion (voir app.css).
|
||||
*@
|
||||
<p class="attente @Classe" role="status">
|
||||
<span class="attente-rondelle" aria-hidden="true"></span>
|
||||
<span>@Message</span>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
/// <summary>Ce qu'on attend, dit à l'utilisateur. Jamais un libellé générique si l'on sait mieux.</summary>
|
||||
[Parameter, EditorRequired] public string Message { get; set; } = default!;
|
||||
|
||||
[Parameter] public string? Classe { get; set; }
|
||||
}
|
||||
@@ -69,12 +69,8 @@
|
||||
à <strong>@pret.Emprunteur</strong>
|
||||
depuis le @Jour(pret.DatePret)
|
||||
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
|
||||
|
||||
@Croix(pret)
|
||||
</p>
|
||||
|
||||
@Confirmation(pret)
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
|
||||
@onclick="RendreAsync">
|
||||
@@ -131,21 +127,11 @@
|
||||
@foreach (var passe in Historique)
|
||||
{
|
||||
<li @key="passe.Id">
|
||||
@* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
|
||||
reléguée dessous elle se lisait comme une action de la liste entière. *@
|
||||
<div class="historique-ligne">
|
||||
<span class="historique-qui">
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@Croix(passe)
|
||||
</div>
|
||||
|
||||
@Confirmation(passe)
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -157,44 +143,7 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
@*
|
||||
⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
|
||||
pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
|
||||
La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
|
||||
historique juste vaut mieux qu'un historique complet mais faux.
|
||||
|
||||
⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
|
||||
écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
|
||||
aucun.
|
||||
*@
|
||||
@code {
|
||||
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
|
||||
private RenderFragment Croix(PretDto pret) =>
|
||||
@<button type="button" class="bouton-croix" disabled="@_occupe"
|
||||
title="Effacer ce prêt (saisie erronée)"
|
||||
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
|
||||
@onclick="() => _aEffacer = pret.Id">✕</button>;
|
||||
|
||||
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
|
||||
private RenderFragment Confirmation(PretDto pret) => @<text>
|
||||
@if (_aEffacer == pret.Id)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
|
||||
</p>
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
|
||||
@onclick="() => EffacerAsync(pret)">
|
||||
@(_occupe ? "Suppression…" : "Oui, effacer")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
|
||||
@onclick="() => _aEffacer = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</text>;
|
||||
|
||||
[Parameter, EditorRequired] public int LivreId { get; set; }
|
||||
|
||||
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
|
||||
@@ -220,9 +169,6 @@
|
||||
private string? _erreur;
|
||||
private int _livreCharge;
|
||||
|
||||
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
|
||||
private int? _aEffacer;
|
||||
|
||||
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
|
||||
|
||||
private IReadOnlyList<PretDto> Historique =>
|
||||
@@ -333,29 +279,6 @@
|
||||
await OnChangement.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task EffacerAsync(PretDto pret)
|
||||
{
|
||||
_occupe = true;
|
||||
_erreur = null;
|
||||
|
||||
var erreur = await Api.SupprimerPretAsync(pret.Id);
|
||||
|
||||
_occupe = false;
|
||||
_aEffacer = null;
|
||||
|
||||
if (erreur is not null)
|
||||
{
|
||||
_erreur = erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
await ChargerAsync();
|
||||
|
||||
// Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle
|
||||
// doit donc se relire, comme après un retour.
|
||||
await OnChangement.InvokeAsync();
|
||||
}
|
||||
|
||||
private static string Jour(DateTime instant) =>
|
||||
instant.ToLocalTime().ToString("dd/MM/yyyy");
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
@*
|
||||
Un champ qui remplit une LISTE : on écrit une valeur, Entrée la range, et chaque ligne se
|
||||
modifie ou se retire ensuite.
|
||||
|
||||
⚠️ Il remplace partout le « champ unique à séparateur » (thèmes, articles à la une), qui
|
||||
était intapable : relié à chaque frappe sur une propriété qui découpait puis recomposait la
|
||||
ligne, il élaguait le point-virgule tapé (entrée vide) et rognait l'espace qui le suit. Le
|
||||
seul caractère qu'on ne pouvait pas y taper était son propre séparateur. Voir CLAUDE.md.
|
||||
|
||||
⚠️ Le composant écrit dans une liste PRÊTÉE par son parent : il doit donc le prévenir
|
||||
(OnChangement), sinon tout ce que le parent calcule à partir d'elle reste figé.
|
||||
*@
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="@_idChamp">
|
||||
@Libelle
|
||||
@if (!string.IsNullOrWhiteSpace(Aide))
|
||||
{
|
||||
<span class="champ-aide">@Aide</span>
|
||||
}
|
||||
</label>
|
||||
|
||||
<div class="ligne-creation">
|
||||
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
|
||||
@bind:event="oninput" @onkeydown="SurTouche"
|
||||
@onfocus="() => _suggestionsOuvertes = true"
|
||||
placeholder="@Placeholder" autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(string.IsNullOrWhiteSpace(_saisie))" @onclick="Valider">
|
||||
@(_indexModifie is null ? "Ajouter" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
@if (_indexModifie is not null)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
@onclick="Annuler">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_message is not null)
|
||||
{
|
||||
<p class="message-discret" role="status">@_message</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Ce qui existe déjà ailleurs, proposé dès le focus — un thème est un VOCABULAIRE, il ne
|
||||
vaut que réutilisé : « fantasy » et « Fantaisie » saisis séparément ne regroupent rien.
|
||||
|
||||
⚠️ La liste ne se referme PAS au blur, et c'est délibéré : le blur précède le clic sur
|
||||
une proposition, et la faire disparaître à ce moment-là rend les propositions
|
||||
incliquables — le piège classique de l'autocomplétion. Elle se referme quand on a choisi.
|
||||
*@
|
||||
@if (Proposees.Count > 0)
|
||||
{
|
||||
<div class="suggestions">
|
||||
@foreach (var suggestion in Proposees)
|
||||
{
|
||||
<button type="button" class="segment" @key="suggestion"
|
||||
@onclick="() => Choisir(suggestion)">
|
||||
@suggestion
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Valeurs.Count > 0)
|
||||
{
|
||||
<ul class="liste-saisie">
|
||||
@for (var rang = 0; rang < Valeurs.Count; rang++)
|
||||
{
|
||||
@* La copie locale est indispensable : sans elle, les fermetures des boutons
|
||||
captureraient la variable de boucle, donc son état final. *@
|
||||
var index = rang;
|
||||
var valeur = Valeurs[rang];
|
||||
|
||||
<li class="ligne-saisie" @key="index">
|
||||
<span class="ligne-saisie-valeur">@valeur</span>
|
||||
|
||||
@* Ce que le parent veut poser sur la ligne — les rôles d'un auteur, par
|
||||
exemple. Rien par défaut. *@
|
||||
@Complement?.Invoke(index)
|
||||
|
||||
<div class="actions-tome">
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
aria-label="@($"Modifier « {valeur} »")"
|
||||
@onclick="() => Modifier(index)">
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
aria-label="@($"Supprimer « {valeur} »")"
|
||||
@onclick="() => Supprimer(index)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>La liste remplie, modifiée <b>en place</b> : c'est celle du parent.</summary>
|
||||
[Parameter, EditorRequired] public IList<string> Valeurs { get; set; } = default!;
|
||||
|
||||
[Parameter, EditorRequired] public string Libelle { get; set; } = default!;
|
||||
|
||||
[Parameter] public string? Aide { get; set; }
|
||||
|
||||
[Parameter] public string? Placeholder { get; set; }
|
||||
|
||||
/// <summary>Message affiché quand la valeur tapée est déjà dans la liste.</summary>
|
||||
[Parameter] public string MessageDoublon { get; set; } = "C'est déjà dans la liste.";
|
||||
|
||||
/// <summary>
|
||||
/// Ce que le parent ajoute à chaque ligne — les rôles d'un auteur, par exemple.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment<int>? Complement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Valeurs déjà employées ailleurs, proposées sous le champ. <c>null</c> = aucune proposition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ N'a de sens que pour un <b>vocabulaire</b> — les thèmes, qui ne valent que réutilisés.
|
||||
/// Elle n'en a aucun pour des valeurs uniques à leur fiche (titres d'articles à la une, noms
|
||||
/// d'auteurs d'un livre donné), où elle ne ferait que du bruit.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyList<string>? Suggestions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prévient le parent que la liste a changé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans cela, un bouton du parent qui dépend de la liste reste figé : Blazor ne redessine
|
||||
/// que le composant qui a traité l'événement.
|
||||
/// </remarks>
|
||||
[Parameter] public EventCallback OnChangement { get; set; }
|
||||
|
||||
private readonly string _idChamp = $"liste-{Guid.NewGuid():N}";
|
||||
|
||||
/// <summary>Valeur en cours de frappe. <b>Elle n'est reliée à rien d'autre</b>.</summary>
|
||||
private string? _saisie;
|
||||
|
||||
/// <summary>Rang de la valeur qu'on modifie — <c>null</c> = on en ajoute une nouvelle.</summary>
|
||||
private int? _indexModifie;
|
||||
|
||||
private string? _message;
|
||||
|
||||
/// <summary>Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse.</summary>
|
||||
private bool _suggestionsOuvertes;
|
||||
|
||||
/// <summary>Nombre de propositions affichées : au-delà, la liste cache le formulaire.</summary>
|
||||
private const int MaximumPropose = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à
|
||||
/// ce qu'on tape.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> Proposees
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_suggestionsOuvertes || Suggestions is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var deja = Valeurs.Select(NormalisationTexte.Normaliser).ToHashSet(StringComparer.Ordinal);
|
||||
var terme = NormalisationTexte.Normaliser(_saisie);
|
||||
|
||||
return
|
||||
[
|
||||
.. Suggestions
|
||||
.Where(s => !deja.Contains(NormalisationTexte.Normaliser(s)))
|
||||
.Where(s => terme.Length == 0
|
||||
|| NormalisationTexte.Normaliser(s).Contains(terme, StringComparison.Ordinal))
|
||||
.Take(MaximumPropose),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private void Choisir(string suggestion)
|
||||
{
|
||||
_saisie = suggestion;
|
||||
Valider();
|
||||
}
|
||||
|
||||
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
|
||||
private IList<string>? _precedente;
|
||||
|
||||
/// <summary>
|
||||
/// Le parent a changé de liste (autre fiche, formulaire vidé après enregistrement) : ce qui
|
||||
/// restait dans le champ appartenait à la précédente.
|
||||
/// </summary>
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_precedente, Valeurs))
|
||||
{
|
||||
_precedente = Valeurs;
|
||||
RemettreAZero();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemettreAZero()
|
||||
{
|
||||
_saisie = null;
|
||||
_indexModifie = null;
|
||||
_message = null;
|
||||
_suggestionsOuvertes = false;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Cette touche ne remonte pas au parent : celui-ci enregistre la fiche entière, et l'on
|
||||
/// serait bien en peine de saisir deux valeurs.
|
||||
/// </remarks>
|
||||
private void SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
Valider();
|
||||
}
|
||||
}
|
||||
|
||||
private void Valider()
|
||||
{
|
||||
var valeur = _saisie?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(valeur))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Doublon au sens de la normalisation, comme partout ailleurs : le libellé déjà rangé
|
||||
// est celui qui reste.
|
||||
var cle = NormalisationTexte.Normaliser(valeur);
|
||||
var deja = Valeurs.ToList().FindIndex(v => NormalisationTexte.Normaliser(v) == cle);
|
||||
|
||||
if (deja >= 0 && deja != _indexModifie)
|
||||
{
|
||||
_message = MessageDoublon;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexModifie is { } index)
|
||||
{
|
||||
Valeurs[index] = valeur;
|
||||
}
|
||||
else
|
||||
{
|
||||
Valeurs.Add(valeur);
|
||||
}
|
||||
|
||||
RemettreAZero();
|
||||
Notifier();
|
||||
}
|
||||
|
||||
/// <summary>Reprend une valeur dans le champ : on la corrige là où on l'a écrite.</summary>
|
||||
private void Modifier(int index)
|
||||
{
|
||||
_message = null;
|
||||
_indexModifie = index;
|
||||
_saisie = Valeurs[index];
|
||||
}
|
||||
|
||||
private void Annuler() => RemettreAZero();
|
||||
|
||||
private void Supprimer(int index)
|
||||
{
|
||||
Valeurs.RemoveAt(index);
|
||||
|
||||
// La valeur qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont
|
||||
// glissé : reprendre la modification écrirait sur la mauvaise.
|
||||
RemettreAZero();
|
||||
Notifier();
|
||||
}
|
||||
|
||||
private void Notifier() => _ = OnChangement.InvokeAsync();
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Client.Composants;
|
||||
|
||||
/// <summary>
|
||||
/// Saisie du panneau « Modifier en lot » (fiche série).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
|
||||
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
|
||||
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
|
||||
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
|
||||
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
|
||||
/// </remarks>
|
||||
public sealed class SaisieLot
|
||||
{
|
||||
public bool ModifierType { get; set; }
|
||||
|
||||
public TypeDocument TypeDocument { get; set; }
|
||||
|
||||
public bool ModifierThemes { get; set; }
|
||||
|
||||
public List<string> Themes { get; set; } = [];
|
||||
|
||||
public bool ModifierAuteurs { get; set; }
|
||||
|
||||
public List<AuteurSaisi> Auteurs { get; set; } = [];
|
||||
|
||||
public bool ModifierFormat { get; set; }
|
||||
|
||||
public Format Format { get; set; }
|
||||
|
||||
public bool ModifierEditeur { get; set; }
|
||||
|
||||
public string? Editeur { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
|
||||
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
|
||||
/// </summary>
|
||||
public bool ModifierStatut { get; set; }
|
||||
|
||||
public Statut? Statut { get; set; }
|
||||
|
||||
public bool AucunChamp =>
|
||||
!ModifierType && !ModifierThemes && !ModifierAuteurs
|
||||
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
@*
|
||||
Scan du code-barres ISBN par la caméra.
|
||||
|
||||
Le décodage se fait dans wwwroot/js/scanner-camera.js, par zbar compilé en WebAssembly.
|
||||
⚠️ Il était en C# (ZXing.Net) jusqu'au 2026-08-19 : voir CLAUDE.md, ZXing.Net ne lisait pas
|
||||
des codes-barres que zbar lit sur le même livre. Ce composant ne voit plus passer un seul
|
||||
pixel — seulement la valeur décodée.
|
||||
Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage
|
||||
est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md.
|
||||
|
||||
On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée
|
||||
(voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la
|
||||
main, et cela ménage la batterie du téléphone.
|
||||
*@
|
||||
@using System.Diagnostics
|
||||
@using MaBibli.Client.Scan
|
||||
@using MaBibli.Shared.Isbn
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
@@ -45,9 +44,9 @@
|
||||
|
||||
@if (_framesDecodees > 0)
|
||||
{
|
||||
@* Mesure sur l'appareil de l'utilisateur. ⚠️ Elle porte maintenant sur la capture,
|
||||
le décodage zbar ET l'aller-retour d'interopérabilité : elle n'est pas comparable
|
||||
aux chiffres ZXing.Net de CLAUDE.md, qui ne mesuraient que le décodage C#. *@
|
||||
@* Mesure réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation
|
||||
(~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté
|
||||
sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@
|
||||
<p class="message-discret scanner-mesure">
|
||||
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
|
||||
</p>
|
||||
@@ -61,7 +60,7 @@
|
||||
|
||||
@code {
|
||||
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
|
||||
[Parameter] public EventCallback<CodeScanne> OnCodeDetecte { get; set; }
|
||||
[Parameter] public EventCallback<string> OnCodeDetecte { get; set; }
|
||||
|
||||
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
|
||||
[Parameter] public EventCallback OnAnnuler { get; set; }
|
||||
@@ -77,13 +76,12 @@
|
||||
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
|
||||
private const int IntervalleMs = 80;
|
||||
|
||||
// L'image est réduite avant décodage. Elle ne traverse plus le pont JS→C# — c'est zbar,
|
||||
// côté JS, qui la lit — mais 960 px de large restent un bon compromis entre le détail
|
||||
// nécessaire à un EAN-13 dense et le temps de décodage.
|
||||
//
|
||||
// ⚠️ Plus large qu'avec ZXing.Net (640 px), et surtout l'image ENTIÈRE est analysée : le
|
||||
// recadrage sur la bande centrale n'existait que pour alléger le transfert de pixels.
|
||||
private const int LargeurMax = 960;
|
||||
// La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser
|
||||
// le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13.
|
||||
private const int LargeurMax = 640;
|
||||
|
||||
// Seule la bande centrale est décodée : c'est là que l'utilisateur vise.
|
||||
private const double PartHauteur = 0.45;
|
||||
|
||||
private ElementReference _video;
|
||||
private IJSObjectReference? _module;
|
||||
@@ -167,9 +165,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var dimensions = await _module.InvokeAsync<int[]?>("capturer", LargeurMax, PartHauteur);
|
||||
if (dimensions is null)
|
||||
{
|
||||
continue; // vidéo pas encore prête
|
||||
}
|
||||
|
||||
var pixels = await _module.InvokeAsync<byte[]>("pixels");
|
||||
|
||||
var depart = Stopwatch.GetTimestamp();
|
||||
var lecture = await _module.InvokeAsync<LectureCodeBarres?>("decoder", LargeurMax);
|
||||
var code = lecture?.Code;
|
||||
var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]);
|
||||
_totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds;
|
||||
_framesDecodees++;
|
||||
|
||||
@@ -193,7 +198,7 @@
|
||||
}
|
||||
|
||||
await ArreterCameraAsync();
|
||||
await OnCodeDetecte.InvokeAsync(new CodeScanne(isbn, lecture?.Parution));
|
||||
await OnCodeDetecte.InvokeAsync(isbn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,50 +2,11 @@
|
||||
@implements IDisposable
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@inject CacheHorsLigne Cache
|
||||
|
||||
@*
|
||||
Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
|
||||
Le PC hérite de la même colonne, simplement centrée et limitée en largeur.
|
||||
|
||||
⚠️ La navigation est passée EN HAUT le 2026-08-20, à la demande de l'utilisateur, après
|
||||
que la barre d'onglets du bas se soit affichée « toujours très mal » sur son téléphone.
|
||||
Cela renverse la décision du 2026-08-18 (« en bas, le pouce atteint le bas de l'écran ») :
|
||||
le raisonnement sur le pouce reste juste, mais il ne vaut rien face à une barre qui ne
|
||||
s'affiche pas correctement. Le repli retenu est celui du gabarit Blazor par défaut — un
|
||||
bouton bascule et une liste qui se déploie — parce qu'il ne dépend d'AUCUNE mise en page
|
||||
exotique : sans la moindre feuille de style, il reste une suite de liens lisibles les uns
|
||||
sous les autres, au lieu d'une rangée écrasée.
|
||||
*@
|
||||
@*
|
||||
⚠️ Bandeau et menu sont dans un MÊME conteneur collant, et ce n'est pas cosmétique : sur PC
|
||||
la rangée du menu doit rester atteignable au défilement (lot R), et deux éléments collants
|
||||
superposés obligeraient à écrire en dur la hauteur du bandeau dans le `top` du second.
|
||||
*@
|
||||
<div class="entete">
|
||||
@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
|
||||
Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
|
||||
<header class="bandeau">
|
||||
@* Le déplacement entre écrans était jugé « foireux » : d'un écran profond (bibliographie,
|
||||
fiche de tome, ajout d'envie) il fallait deviner quel onglet ramenait en arrière. Un
|
||||
retour explicite, au même endroit sur tous les écrans, répond à la question sans
|
||||
obliger à connaître l'arborescence. *@
|
||||
<button type="button" class="bandeau-icone" @onclick="Retour"
|
||||
title="Revenir à l'écran précédent" aria-label="Revenir à l'écran précédent">
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
|
||||
<a class="marque" href="">
|
||||
@* Pictogramme dessiné en ligne plutôt que chargé depuis /logo-bandeau.svg : un fichier
|
||||
séparé se sert (ou non) indépendamment de l'application, et c'est précisément ce qui
|
||||
produisait l'icône cassée en haut à gauche — un appareil dont le cache datait d'avant
|
||||
l'ajout du fichier recevait un 404. Un SVG en ligne ne peut pas manquer. *@
|
||||
<svg class="marque-logo" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||
<polygon points="32,15 10,20 10,47 32,44" fill="currentColor" />
|
||||
<polygon points="32,15 54,20 54,47 32,44" fill="currentColor" />
|
||||
<line x1="32" y1="15" x2="32" y2="44" stroke="#1b3a5c" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span class="marque-nom">MaBibli</span>
|
||||
</a>
|
||||
<a class="marque" href="">MaBibli</a>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
@@ -60,72 +21,8 @@
|
||||
@_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "")
|
||||
</span>
|
||||
}
|
||||
|
||||
<button type="button" class="bandeau-icone bandeau-bascule" @onclick="BasculerMenu"
|
||||
aria-expanded="@Aria.Etat(_menuOuvert)" aria-controls="menu-principal"
|
||||
aria-label="@(_menuOuvert ? "Fermer le menu" : "Ouvrir le menu")">
|
||||
<span aria-hidden="true">@(_menuOuvert ? "\u2715" : "\u2630")</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@*
|
||||
Les six destinations de l'application. Sur PC elles tiennent en une rangée sous le
|
||||
bandeau ; sur téléphone elles occupent l'écran ENTIER au clic sur la bascule (lot R) —
|
||||
déployé sous le bandeau, le menu partageait l'écran avec la liste qu'on quittait, et l'on
|
||||
choisissait sa destination au milieu d'autre chose.
|
||||
|
||||
⚠️ Un calque plein écran sans porte de sortie est un piège : trois en sont offertes, en plus
|
||||
de la fermeture déjà en place sur LocationChanged — la croix, la touche Échap (le calque est
|
||||
focalisé à l'ouverture, sans quoi aucun keydown ne lui parviendrait, comme celui
|
||||
d'agrandissement des couvertures) et le clic hors des liens.
|
||||
|
||||
Les entrées restent actives hors-ligne : les six écrans se consultent depuis leurs
|
||||
instantanés. Ce sont les écritures qui se désactivent, jamais la navigation.
|
||||
*@
|
||||
<nav id="menu-principal" class="menu @(_menuOuvert ? "menu-ouvert" : null)" aria-label="Navigation principale"
|
||||
tabindex="-1" @ref="_menu" @onclick="FermerMenu" @onkeydown="SurToucheMenu">
|
||||
@if (_menuOuvert)
|
||||
{
|
||||
<div class="menu-entete">
|
||||
<span class="menu-titre">Aller à</span>
|
||||
<button type="button" class="menu-fermer" @onclick="FermerMenu" aria-label="Fermer le menu">
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Une rafale non vidée reste consultable ailleurs sur le site — sans quoi y revenir
|
||||
depuis un autre écran demande de refaire tout le chemin d'« Ajouter ». L'entrée
|
||||
disparaît d'elle-même une fois la file effacée (bouton « Nouvelle rafale »).
|
||||
*@
|
||||
@if (_rafale is { Total: > 0 })
|
||||
{
|
||||
<NavLink class="menu-lien menu-lien-rafale" href="ajout/rafale">@LibelleRafale</NavLink>
|
||||
}
|
||||
|
||||
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
|
||||
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
|
||||
<NavLink class="menu-lien" href="series">Séries</NavLink>
|
||||
<NavLink class="menu-lien" href="revues">Revues</NavLink>
|
||||
<NavLink class="menu-lien" href="prets">Prêts</NavLink>
|
||||
<NavLink class="menu-lien" href="souhaits">Envies</NavLink>
|
||||
|
||||
@*
|
||||
« À propos » n'est PAS une septième destination : c'est une annexe, et elle est
|
||||
visuellement détachée pour cela. Les six entrées ci-dessus sont la bibliothèque ; celle-ci
|
||||
porte le manuel, le contact, la version et la licence — on y va une fois, pas tous les
|
||||
jours. Mise sur le même rang, elle diluerait une navigation qu'on venait justement de
|
||||
reprendre. Elle reste dans le menu plutôt qu'en pied de page : sur téléphone, un pied de
|
||||
page vit sous une liste de trois cents livres.
|
||||
|
||||
⚠️ Elle reste active hors-ligne, comme les six autres : la version qu'on vient y lire est
|
||||
justement ce qu'on cherche quand quelque chose ne va pas.
|
||||
*@
|
||||
<NavLink class="menu-lien menu-lien-annexe" href="a-propos">À propos</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
@*
|
||||
@@ -146,37 +43,36 @@
|
||||
@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;
|
||||
private bool _synchroEnCours;
|
||||
|
||||
/// <summary>Menu déployé (téléphone). Sur PC la rangée est visible en permanence.</summary>
|
||||
private bool _menuOuvert;
|
||||
|
||||
/// <summary>Le calque du menu doit prendre le focus au prochain rendu — sinon pas d'Échap.</summary>
|
||||
private bool _menuAFocaliser;
|
||||
|
||||
private ElementReference _menu;
|
||||
|
||||
/// <summary>Rafale non vidée, s'il y en a une. Relue à chaque navigation (lot AB).</summary>
|
||||
private Rafale? _rafale;
|
||||
|
||||
private string LibelleRafale =>
|
||||
_rafale is { Crees: > 0 } r ? $"Compte rendu ({r.Crees})" : "Rafale à reprendre";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
Reseau.SynchroChange += SurSynchro;
|
||||
|
||||
// ⚠️ Sans cela, le menu resterait déployé par-dessus l'écran qu'on vient d'atteindre :
|
||||
// NavLink ne referme rien de lui-même, et un clic sur « Auteurs » laisserait les six
|
||||
// entrées empilées au-dessus de la liste des auteurs.
|
||||
Navigation.LocationChanged += SurNavigation;
|
||||
|
||||
_rafale = await Cache.LireRafaleAsync();
|
||||
|
||||
// Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit
|
||||
// aller directement au cache, sans attendre l'échec d'une requête.
|
||||
await Reseau.DemarrerAsync();
|
||||
@@ -243,76 +139,9 @@
|
||||
/// <summary>La date affichée vient de changer : rien à recharger, juste à redessiner.</summary>
|
||||
private void SurSynchro() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
private void SurNavigation(object? _, LocationChangedEventArgs __)
|
||||
{
|
||||
if (_menuOuvert)
|
||||
{
|
||||
_menuOuvert = false;
|
||||
}
|
||||
|
||||
// ⚠️ Relu à CHAQUE navigation, pas seulement au quitter de l'écran de rafale : c'est ce
|
||||
// qui fait apparaître l'entrée du menu dès le premier code scanné, et disparaître dès
|
||||
// la file effacée, où qu'on soit rendu entre-temps.
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
_rafale = await Cache.LireRafaleAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remonte d'un cran dans l'arborescence des écrans, en repliant le menu au passage.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Renverse la décision du 2026-08-20</b> (« le retour passe par l'historique du
|
||||
/// navigateur, jamais par une destination calculée »). L'historique remonte aussi les
|
||||
/// allers-retours — filtre, ordre, édition — et l'on cliquait cinq fois sans quitter le même
|
||||
/// écran. La parenté est désormais explicite et testable : voir
|
||||
/// <see cref="RemonteeRoutes"/>, qui porte aussi le garde-fou « ne jamais sortir de
|
||||
/// l'application » que <c>history.length</c> tenait mal dans une PWA <c>standalone</c>.
|
||||
/// </remarks>
|
||||
private void Retour()
|
||||
{
|
||||
FermerMenu();
|
||||
Navigation.NavigateTo(RemonteeRoutes.Parent(Navigation.ToBaseRelativePath(Navigation.Uri)));
|
||||
}
|
||||
|
||||
private void BasculerMenu()
|
||||
{
|
||||
_menuOuvert = !_menuOuvert;
|
||||
|
||||
// Le focus n'est demandé qu'à l'ouverture : c'est lui qui rend Échap opérant sur un
|
||||
// calque, et il n'y a rien à focaliser une fois le menu refermé.
|
||||
_menuAFocaliser = _menuOuvert;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Referme le menu. Posé sur le calque lui-même : un clic hors des liens ferme, et un clic
|
||||
/// SUR un lien ferme aussi — la navigation qui suit s'en chargerait de toute façon.
|
||||
/// </summary>
|
||||
private void FermerMenu() => _menuOuvert = false;
|
||||
|
||||
private void SurToucheMenu(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
FermerMenu();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_menuAFocaliser)
|
||||
{
|
||||
_menuAFocaliser = false;
|
||||
await _menu.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
Reseau.SynchroChange -= SurSynchro;
|
||||
Navigation.LocationChanged -= SurNavigation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* ⚠️ Ce n'est plus le bandeau qui colle en haut, mais le conteneur `.entete` qui le réunit au
|
||||
menu (feuille globale, lot R) : les deux doivent rester solidaires au défilement. */
|
||||
.bandeau {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1b3a5c;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -14,62 +16,15 @@
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
/* La marque prend la place restante : c'est elle qui repousse la bascule à droite,
|
||||
sans dépendre d'un justify-content que le nombre d'éléments du bandeau ferait varier
|
||||
(la pastille hors-ligne et le nom d'utilisateur vont et viennent). */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.marque-nom {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marque-logo {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
flex: 0 0 auto;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Boutons du bandeau (retour, bascule du menu). Cibles tactiles de 2.25rem : on les touche
|
||||
au pouce, souvent en marchant. */
|
||||
.bandeau-icone {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bandeau-icone:hover,
|
||||
.bandeau-icone:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.utilisateur {
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 40%;
|
||||
max-width: 55%;
|
||||
}
|
||||
|
||||
/* L'état du réseau change ce que l'application permet : il se voit dans le bandeau, en
|
||||
@@ -103,34 +58,51 @@
|
||||
width: 100%;
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
/* Marge basse généreuse : la barre d'actions flottantes ne doit pas masquer le dernier
|
||||
livre de la liste. */
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
⚠️ Les styles du MENU ne sont PAS ici, mais dans `wwwroot/css/app.css`.
|
||||
/* --- Onglets de navigation --- */
|
||||
|
||||
Deux raisons, la première étant un vrai bug corrigé le 2026-08-20 :
|
||||
.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);
|
||||
|
||||
1. `NavLink` est un COMPOSANT. L'isolation CSS ne pose son attribut de portée (`b-xxxx`)
|
||||
que sur les éléments écrits dans le balisage du composant courant — jamais sur ce que
|
||||
rend un composant enfant. `.menu-lien` déclaré ici ne pouvait donc atteindre AUCUN des
|
||||
six liens : le `<nav>` recevait bien son fond sombre, et les liens restaient en bleu
|
||||
souligné par défaut. C'est exactement ce que l'utilisateur voyait (« toujours les liens
|
||||
trop moches et pas ressemblant à un menu »). Le gabarit Blazor par défaut contourne cela
|
||||
avec `::deep`.
|
||||
2. Une feuille globale ne dépend pas du bundle `MaBibli.Client.styles.css`, qui est
|
||||
précisément le fichier qu'un cache a déjà servi périmé ou pas du tout (voir « lot A1 »
|
||||
et « L'icône cassée du bandeau » dans CLAUDE.md). La navigation est ce qui doit le moins
|
||||
pouvoir tomber.
|
||||
*/
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
/* La rangée du menu est visible en permanence à partir de cette largeur : la bascule
|
||||
n'a plus rien à déployer. Le point de rupture est répété dans `app.css` — les deux
|
||||
doivent rester identiques. */
|
||||
.bandeau-bascule {
|
||||
display: none;
|
||||
}
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -18,20 +18,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all" />
|
||||
<!--
|
||||
Décodage des codes-barres : moteur zbar (LGPL-2.1) compilé en WebAssembly.
|
||||
|
||||
⚠️ Le paquet est référencé pour ses ASSETS STATIQUES (_content/ZBar.Blazor/zbar.js et
|
||||
zbar.wasm), pas pour ses composants Blazor. Son composant ZBarCamera ouvre lui-même la
|
||||
caméra et AVALE les erreurs (« catch { console.log(error) } ») : on perdrait les messages
|
||||
qui distinguent permission refusée, absence de caméra, caméra occupée et contexte non
|
||||
sécurisé — précisément ce que CLAUDE.md documente comme durement acquis. On garde donc
|
||||
notre scanner-camera.js et on n'emprunte que le décodeur.
|
||||
|
||||
Remplace ZXing.Net, qui ne lisait pas certains codes-barres pourtant lus par zbar sur le
|
||||
même livre et le même appareil (voir CLAUDE.md).
|
||||
-->
|
||||
<PackageReference Include="ZBar.Blazor" Version="1.1.0" />
|
||||
<!-- Décodage EAN-13 en C# (Apache 2.0). Le JS ne fournit que les pixels de la caméra. -->
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
@page "/a-propos"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Ce que l'application dit d'elle-même : à qui écrire, où trouver de l'aide, quelle version
|
||||
tourne réellement, et sous quelle licence elle est distribuée.
|
||||
|
||||
⚠️ Ce n'est pas une page d'ornement. Elle porte la version, c'est-à-dire la première chose
|
||||
qu'on demande à quelqu'un dont l'application se comporte bizarrement — le projet a déjà connu
|
||||
un appareil au cache dépareillé, où seule une version affichée aurait tranché. Et elle porte
|
||||
le lien vers la source, que l'AGPL v3 attend qu'un service en réseau offre à ses utilisateurs.
|
||||
*@
|
||||
|
||||
<PageTitle>MaBibli — à propos</PageTitle>
|
||||
|
||||
<h1 class="titre-page">À propos</h1>
|
||||
|
||||
<section class="bloc-apropos">
|
||||
<h2 class="titre-section">Manuel</h2>
|
||||
@*
|
||||
Le manuel vit hors de l'application, sur le site de l'auteur : il se corrige et s'illustre
|
||||
sans qu'il faille republier une version. Même règle que les autres liens sortants — un
|
||||
<a> ne se désactivant pas, hors-ligne il devient un bouton inerte qui porte sa raison,
|
||||
plutôt qu'une navigation qui abandonnerait l'utilisateur sur la page d'erreur du navigateur.
|
||||
*@
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<p>
|
||||
<a href="@Manuel" target="_blank" rel="noopener">Comment utiliser MaBibli</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Comment utiliser MaBibli</button>
|
||||
</p>
|
||||
}
|
||||
<p class="message-discret">
|
||||
Chaque écran porte par ailleurs ses explications à l'endroit où elles servent.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="bloc-apropos">
|
||||
<h2 class="titre-section">Contact</h2>
|
||||
@*
|
||||
⚠️ Le « mailto: » reste un lien même hors-ligne, et c'est délibéré : il ne charge aucune
|
||||
page, il passe la main au client de messagerie de l'appareil, lequel sait mettre un
|
||||
message en attente d'envoi. Le désactiver empêcherait précisément d'écrire « ça ne marche
|
||||
pas, je n'ai plus de réseau ».
|
||||
*@
|
||||
<p><a href="mailto:info@limonier.be">info@limonier.be</a></p>
|
||||
|
||||
@*
|
||||
Le site, lui, est une vraie navigation vers l'extérieur : hors-ligne elle finirait sur la
|
||||
page d'erreur du navigateur, hors de l'application. Un <a> ne se désactivant pas, il
|
||||
bascule en bouton inerte portant sa raison — même règle que les liens d'export des envies.
|
||||
*@
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<p>
|
||||
<a href="@Site" target="_blank" rel="noopener">www.limonier.be</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">www.limonier.be</button>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bloc-apropos">
|
||||
<h2 class="titre-section">Version</h2>
|
||||
@if (_version is null)
|
||||
{
|
||||
<p class="message-discret">Chargement…</p>
|
||||
}
|
||||
else if (_version.Publiee)
|
||||
{
|
||||
<p>
|
||||
<strong>@_version.Numero</strong>
|
||||
<span class="message-discret"> — compilée @Quand(_version.DateBuild!.Value)</span>
|
||||
</p>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Hors ligne : c'est la dernière version annoncée par le serveur, pas forcément
|
||||
celle qui y tourne en ce moment.
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Ce numéro vient du SERVEUR (voir plus haut) : il ne prouve pas que le navigateur a
|
||||
lui-même récupéré la nouvelle version. Le service worker n'active un nouveau cache
|
||||
qu'une fois TOUS les onglets fermés — un appareil qui reste ouvert en arrière-plan
|
||||
(téléphone surtout) peut rester bloqué sur l'ancienne version indéfiniment. Ce bouton
|
||||
court-circuite cette attente : désinscrire le worker et vider son cache, recharger.
|
||||
Hors-ligne il est désactivé — le déclencher sans réseau couperait l'accès hors-ligne
|
||||
en cours, sans rien pouvoir retélécharger à la place.
|
||||
*@
|
||||
<p>
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_forcageEnCours"
|
||||
@onclick="ForcerMiseAJourAsync">
|
||||
@(_forcageEnCours ? "Mise à jour…" : "Forcer la mise à jour")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Forcer la mise à jour</button>
|
||||
}
|
||||
</p>
|
||||
<p class="message-discret">
|
||||
Vide le cache de l'application et la recharge entièrement — à utiliser si une nouvelle
|
||||
version tarde à apparaître malgré la fermeture de tous les onglets.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@*
|
||||
⚠️ Ne JAMAIS afficher ici le « 1.0.0 » que le SDK .NET pose par défaut : il se lirait
|
||||
comme une vraie version. Mieux vaut ne rien annoncer que d'annoncer un faux numéro,
|
||||
surtout sur la valeur qui sert à diagnostiquer.
|
||||
*@
|
||||
<p class="message-discret">
|
||||
Version de développement : ce binaire n'a pas été produit par la chaîne de
|
||||
publication, il n'a donc pas de numéro de version à annoncer.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="bloc-apropos">
|
||||
<h2 class="titre-section">Licence</h2>
|
||||
<p>
|
||||
MaBibli est distribuée sous licence <strong>GNU AGPL v3</strong>.
|
||||
</p>
|
||||
@*
|
||||
Le lien vers le dépôt n'est pas un ornement : l'AGPL demande que les utilisateurs d'un
|
||||
service accessible par le réseau puissent en obtenir le code source.
|
||||
*@
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<p>
|
||||
Son code source est disponible :
|
||||
<a href="@Depot" target="_blank" rel="noopener">@Depot</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
Son code source est disponible à l'adresse <code>@Depot</code>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ouvrir le dépôt</button>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>Site de l'auteur, ouvert dans une autre fenêtre.</summary>
|
||||
private const string Site = "https://www.limonier.be";
|
||||
|
||||
/// <summary>Manuel d'utilisation, hébergé avec les autres tutoriels du site.</summary>
|
||||
private const string Manuel = "https://limonier.be/tuto/utiliser_mabibli/";
|
||||
|
||||
/// <summary>Dépôt du code — ce que l'AGPL attend qu'on rende accessible.</summary>
|
||||
private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
|
||||
|
||||
private VersionApplication? _version;
|
||||
private bool _forcageEnCours;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
_version = await Api.ObtenirVersionAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Au retour du réseau, la version se redemande : hors-ligne on affichait la dernière connue.
|
||||
/// </summary>
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
_version = await Api.ObtenirVersionAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Désinscrit le service worker et vide son cache avant de recharger — voir js/forcer-maj.js.
|
||||
/// Le bouton reste désactivé pendant l'opération : un double clic déclencherait deux
|
||||
/// désinscriptions concurrentes pour un seul rechargement.
|
||||
/// </summary>
|
||||
private async Task ForcerMiseAJourAsync()
|
||||
{
|
||||
_forcageEnCours = true;
|
||||
try
|
||||
{
|
||||
await using var module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/forcer-maj.js");
|
||||
await module.InvokeVoidAsync("forcerMiseAJour");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Le rechargement forcé qui suit normalement l'appel JS ne survient pas ici : on
|
||||
// rend la main pour que l'utilisateur puisse réessayer plutôt que de rester sur un
|
||||
// bouton grisé pour rien.
|
||||
_forcageEnCours = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Quand(DateTimeOffset instant)
|
||||
{
|
||||
var local = instant.ToLocalTime();
|
||||
return $"le {local:dd/MM/yyyy} à {local:HH:mm}";
|
||||
}
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
@page "/ajout"
|
||||
@page "/ajout/isbn"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — ajouter un ouvrage</PageTitle>
|
||||
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
|
||||
|
||||
@*
|
||||
Entrée UNIQUE d'ajout, atteinte depuis le catalogue comme depuis les revues.
|
||||
|
||||
Le geste réel est « j'ai un truc avec un code-barres », pas « je vais cataloguer un livre » :
|
||||
obliger à savoir d'avance ce qu'on tient était un détour, d'autant que le décodage sait
|
||||
déjà distinguer un 978/979 (livre) d'un 977 (publication en série). Le modèle, lui, ne
|
||||
bouge pas : une revue reste une Revue, jamais un Livre — voir CLAUDE.md.
|
||||
*@
|
||||
<h1 class="titre-page">Ajouter un ouvrage</h1>
|
||||
<h1 class="titre-page">Ajouter par ISBN</h1>
|
||||
|
||||
@if (_etape == Etape.Saisie)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Scannez le code-barres, ou saisissez le code imprimé sur l'ouvrage — livre ou
|
||||
magazine, l'application reconnaît lequel. Une douchette USB fonctionne telle quelle :
|
||||
le champ est déjà actif.
|
||||
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>
|
||||
|
||||
@*
|
||||
@@ -67,15 +57,7 @@
|
||||
@onclick="OuvrirScanner">
|
||||
Scanner
|
||||
</button>
|
||||
@* Deux saisies manuelles, parce qu'il faut bien trancher soi-même quand aucun code
|
||||
n'est là pour le faire : c'est le seul endroit où l'entrée unique doit poser la
|
||||
question. *@
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
|
||||
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
|
||||
|
||||
@* Une pile de livres et une douchette : on collecte d'abord, on traite ensuite.
|
||||
Reste ici, et non dans le menu : c'est une façon d'ajouter, pas une destination. *@
|
||||
<a class="bouton bouton-discret" href="ajout/rafale">Cataloguer en rafale</a>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -86,10 +68,9 @@
|
||||
*@
|
||||
@if (_etape == Etape.Recherche)
|
||||
{
|
||||
@* L1 : l'étape existait déjà pour que l'enchaînement du scan SE VOIE ; la rondelle dit en
|
||||
plus que ça travaille encore, ce qu'un texte figé ne distingue pas d'un écran bloqué. *@
|
||||
<Patience Classe="attente-recherche"
|
||||
Message="@($"Recherche de {FormatageIsbn.Afficher(_isbn)} à la BnF, puis chez OpenLibrary…")" />
|
||||
<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)
|
||||
@@ -105,20 +86,6 @@
|
||||
@if (_messagePeriodique is not null)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@_messagePeriodique</p>
|
||||
|
||||
@* Une revue a désormais sa place : sa fiche, où l'on ajoute le numéro qu'on vient de
|
||||
scanner. Avant, ce code retombait sur la saisie manuelle d'un LIVRE — ce qu'un magazine
|
||||
n'est pas. *@
|
||||
@if (_periodique is not null)
|
||||
{
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="OuvrirLaRevueAsync">
|
||||
@(_enregistrement ? "Ouverture…" : "Ouvrir la fiche de cette revue")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@foreach (var avertissement in _avertissements)
|
||||
@@ -178,30 +145,17 @@
|
||||
|
||||
@if (_etape == Etape.Formulaire)
|
||||
{
|
||||
@* Le doublon prend toute la place tant qu'il n'est pas tranché : la fiche revient intacte
|
||||
si l'on renonce. C'est ici que le cas se présente le plus — scanner deux fois le même
|
||||
livre est précisément ce qui crée les fiches en double. *@
|
||||
@if (_doublons is { } doublons)
|
||||
{
|
||||
<AvertissementDoublon Doublons="doublons"
|
||||
EnCours="_enregistrement"
|
||||
OnConfirmer="ConfirmerAsync"
|
||||
OnRenoncer="() => _doublons = null" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="message-discret">
|
||||
Vérifiez et complétez la fiche : tout reste modifiable.
|
||||
</p>
|
||||
<p class="message-discret">
|
||||
Vérifiez et complétez la fiche : tout reste modifiable.
|
||||
</p>
|
||||
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreurFormulaire"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@MotifBlocage"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Recommencer" />
|
||||
}
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreurFormulaire"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@MotifBlocage"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Recommencer" />
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -216,10 +170,6 @@
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
private string? _messagePeriodique;
|
||||
private PeriodiqueDetecte? _periodique;
|
||||
|
||||
/// <summary>Add-on EAN-2 du dernier scan, quand il y en avait un. Voir <see cref="CodeScanne"/>.</summary>
|
||||
private string? _parutionLue;
|
||||
private bool _chargement;
|
||||
private bool _enregistrement;
|
||||
private string? _erreur;
|
||||
@@ -227,31 +177,8 @@
|
||||
private IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private EnregistrementLivre _saisie = new();
|
||||
private DoublonsLivre? _doublons;
|
||||
|
||||
/// <summary>
|
||||
/// Code transmis par un autre écran — un résidu de rafale à trancher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ La recherche s'enchaîne d'elle-même, comme après un scan : arriver ici depuis un
|
||||
/// compte rendu de rafale signifie qu'on a déjà décidé de reprendre ce code. Demander un
|
||||
/// clic de plus sur « Chercher » serait le détour que cet écran documente déjà comme un
|
||||
/// défaut (voir l'étape « Recherche »).
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "code")]
|
||||
public string? CodePropose { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CodePropose))
|
||||
{
|
||||
_isbn = CodePropose.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _repriseFaite;
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
/// <summary>
|
||||
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
|
||||
@@ -263,29 +190,6 @@
|
||||
/// </remarks>
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
// ⚠️ AVANT le garde de focus ci-dessous, qui rend la main dès que le focus est posé :
|
||||
// greffée après, la reprise ne serait jouée qu'une fois sur deux, selon l'étape.
|
||||
//
|
||||
// ⚠️ Et surtout PAS conditionnée à `premierRendu` : au premier rendu, l'état réseau
|
||||
// n'est pas encore établi (il s'initialise de façon asynchrone), donc `EnLigne` est
|
||||
// faux et la reprise ne partait jamais. Constaté à l'écran — le champ se préremplissait
|
||||
// bien, la recherche ne s'enchaînait pas. C'est `_repriseFaite` qui garantit l'unicité,
|
||||
// pas le premier rendu ; l'événement réseau provoque le rendu où la condition passe.
|
||||
if (!_repriseFaite && !string.IsNullOrWhiteSpace(CodePropose) && Reseau.EnLigne)
|
||||
{
|
||||
_repriseFaite = true;
|
||||
await ChercherAsync();
|
||||
|
||||
// ⚠️ `StateHasChanged` est INDISPENSABLE ici, et son absence ne se voit pas :
|
||||
// `OnAfterRenderAsync` n'est pas un gestionnaire d'événement, donc Blazor ne
|
||||
// redessine rien de lui-même. La recherche partait bien — sonde à l'appui — et
|
||||
// remplissait `_candidats`, mais l'écran restait sur l'étape de saisie.
|
||||
// Même famille que « un composant qui écrit dans un objet prêté doit prévenir son
|
||||
// parent » : le symptôme trompe, on cherche un appel qui n'a pas eu lieu là où il
|
||||
// n'y a qu'un rendu manquant.
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (_etape != Etape.Saisie || !_focusAFaire)
|
||||
{
|
||||
return;
|
||||
@@ -314,8 +218,6 @@
|
||||
{
|
||||
_erreur = null;
|
||||
_messagePeriodique = null;
|
||||
_periodique = null;
|
||||
_parutionLue = null;
|
||||
_avertissements = [];
|
||||
_etape = Etape.Scan;
|
||||
}
|
||||
@@ -323,15 +225,9 @@
|
||||
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
|
||||
// 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(CodeScanne lecture)
|
||||
private async Task SurCodeDetecteAsync(string isbn)
|
||||
{
|
||||
_isbn = lecture.Code;
|
||||
|
||||
// Gardé de côté pour la seule chose qui sache quoi en faire : la fiche d'une revue.
|
||||
// Sur un livre, l'add-on EAN-5 (le prix) n'est même pas remonté, et l'EAN-2 n'a pas
|
||||
// de sens.
|
||||
_parutionLue = lecture.Parution;
|
||||
|
||||
_isbn = isbn;
|
||||
await ChercherAsync();
|
||||
}
|
||||
|
||||
@@ -339,9 +235,6 @@
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn))
|
||||
{
|
||||
// Saisi à la main ou à la douchette : aucun add-on, et surtout pas celui d'un
|
||||
// scan précédent, qui se retrouverait proposé pour un autre magazine.
|
||||
_parutionLue = null;
|
||||
await ChercherAsync();
|
||||
}
|
||||
}
|
||||
@@ -351,7 +244,6 @@
|
||||
_chargement = true;
|
||||
_erreur = null;
|
||||
_messagePeriodique = null;
|
||||
_periodique = null;
|
||||
_avertissements = [];
|
||||
_etape = Etape.Recherche;
|
||||
|
||||
@@ -408,75 +300,33 @@
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
|
||||
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Ne remplit surtout pas le formulaire d'un livre</b>, ce qu'il faisait tant que les
|
||||
/// revues n'avaient pas de modèle : un magazine n'est pas un livre, et ranger un code
|
||||
/// <c>977</c> dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur la fiche.
|
||||
/// <para>
|
||||
/// Le <b>numéro</b> se saisit ensuite à la main : les deux chiffres de parution du
|
||||
/// code-barres ne sont pas un numéro fiable, et l'add-on EAN-2 qui le porterait vraiment
|
||||
/// n'a pas pu être vérifié sur un magazine réel.
|
||||
/// </para>
|
||||
/// 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} »";
|
||||
|
||||
_periodique = revue;
|
||||
_messagePeriodique =
|
||||
$"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : "
|
||||
+ "il commence par 977, réservé aux publications en série. "
|
||||
+ "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner.";
|
||||
+ "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, "
|
||||
+ "sans ISBN.";
|
||||
|
||||
_erreur = null;
|
||||
RevenirALaSaisie();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le serveur fait « créer ou retrouver » : scanner le numéro suivant du même magazine
|
||||
/// retombe sur la même fiche, ce qui est exactement le geste attendu.
|
||||
/// <para>
|
||||
/// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que
|
||||
/// l'utilisateur corrigera sur la fiche.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OuvrirLaRevueAsync()
|
||||
{
|
||||
if (_periodique is not { } revue)
|
||||
_saisie = new EnregistrementLivre
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enregistrement = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
|
||||
{
|
||||
Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}",
|
||||
Issn = revue.Issn,
|
||||
Titre = revue.Titre ?? string.Empty,
|
||||
Editeur = revue.Editeur,
|
||||
});
|
||||
Format = Format.Physique,
|
||||
Statut = Statut.ALire,
|
||||
};
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
// Le numéro lu sur l'add-on EAN-2 suit la navigation : la fiche le proposera dans un
|
||||
// champ modifiable. On ne l'enregistre pas soi-même — voir CodeScanne.
|
||||
var numero = string.IsNullOrWhiteSpace(_parutionLue)
|
||||
? string.Empty
|
||||
: $"?numero={Uri.EscapeDataString(_parutionLue)}";
|
||||
|
||||
Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}{numero}");
|
||||
_erreur = null;
|
||||
_etape = Etape.Formulaire;
|
||||
}
|
||||
|
||||
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
|
||||
@@ -494,12 +344,7 @@
|
||||
Titre = candidat.Titre,
|
||||
Auteur = candidat.Auteur,
|
||||
Editeur = candidat.Editeur,
|
||||
|
||||
// Proposé, jamais imposé : la notice donne une phrase (« 1 vol. (349 p.) »), le
|
||||
// champ reste modifiable et se vide d'un geste si elle a mal été lue.
|
||||
NombrePages = candidat.NombrePages,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
UrlNotice = candidat.UrlNotice,
|
||||
Format = Format.Physique,
|
||||
Statut = Statut.ALire,
|
||||
};
|
||||
@@ -512,37 +357,20 @@
|
||||
{
|
||||
_erreur = null;
|
||||
_messagePeriodique = null;
|
||||
_periodique = null;
|
||||
_erreurFormulaire = null;
|
||||
_doublons = null;
|
||||
_candidats = [];
|
||||
RevenirALaSaisie();
|
||||
}
|
||||
|
||||
private Task EnregistrerAsync() => AjouterAsync(false);
|
||||
|
||||
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
|
||||
private Task ConfirmerAsync() => AjouterAsync(true);
|
||||
|
||||
private async Task AjouterAsync(bool confirmerDoublon)
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
_enregistrement = true;
|
||||
_erreurFormulaire = null;
|
||||
|
||||
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
|
||||
var resultat = await Api.CreerAsync(_saisie);
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
// Rien n'a été écrit : le catalogue contient déjà quelque chose de semblable, et c'est
|
||||
// à l'utilisateur de dire s'il s'agit du même livre. Voir AvertissementDoublon.
|
||||
if (resultat.Doublons is { } doublons)
|
||||
{
|
||||
_doublons = doublons;
|
||||
return;
|
||||
}
|
||||
|
||||
_doublons = null;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreurFormulaire = resultat.Erreur;
|
||||
|
||||
@@ -12,52 +12,18 @@
|
||||
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
|
||||
</p>
|
||||
|
||||
@*
|
||||
Le formulaire s'efface pendant qu'on tranche le doublon : la saisie n'est pas perdue pour
|
||||
autant, elle attend dans « _saisie » et revient telle quelle si l'on renonce. Laisser les
|
||||
deux à l'écran donnerait deux boutons d'ajout, dont un seul confirme.
|
||||
*@
|
||||
@if (_doublons is { } doublons)
|
||||
{
|
||||
<AvertissementDoublon Doublons="doublons"
|
||||
EnCours="_enregistrement"
|
||||
OnConfirmer="ConfirmerAsync"
|
||||
OnRenoncer="() => _doublons = null" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreur"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Retour" />
|
||||
}
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreur"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Retour" />
|
||||
|
||||
@code {
|
||||
private readonly EnregistrementLivre _saisie = new();
|
||||
|
||||
/// <summary>
|
||||
/// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire
|
||||
/// <b>vide</b> : il fallait retaper le code qu'on venait de scanner, c'est-à-dire refaire
|
||||
/// exactement ce que la rafale existe pour éviter. Le lien compilait et ne servait à rien.
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "isbn")]
|
||||
public string? IsbnPropose { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(IsbnPropose))
|
||||
{
|
||||
_saisie.Isbn = IsbnPropose.Trim();
|
||||
}
|
||||
}
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
@@ -65,32 +31,16 @@ else
|
||||
|
||||
private bool _enregistrement;
|
||||
private string? _erreur;
|
||||
private DoublonsLivre? _doublons;
|
||||
|
||||
private Task EnregistrerAsync() => AjouterAsync(false);
|
||||
|
||||
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
|
||||
private Task ConfirmerAsync() => AjouterAsync(true);
|
||||
|
||||
private async Task AjouterAsync(bool confirmerDoublon)
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
_enregistrement = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
|
||||
var resultat = await Api.CreerAsync(_saisie);
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
// Un doublon signalé n'est pas un échec : rien n'a été écrit, et la même saisie
|
||||
// confirmée s'enregistrera telle quelle.
|
||||
if (resultat.Doublons is { } doublons)
|
||||
{
|
||||
_doublons = doublons;
|
||||
return;
|
||||
}
|
||||
|
||||
_doublons = null;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
|
||||
@@ -1,741 +0,0 @@
|
||||
@page "/ajout/rafale"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject CacheHorsLigne Cache
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — cataloguer en rafale</PageTitle>
|
||||
|
||||
<h1>Cataloguer en rafale</h1>
|
||||
|
||||
@*
|
||||
Lancée depuis une saga, la rafale rattache chaque livre créé à la série. ⚠️ La cible vit
|
||||
dans la FILE (Rafale.SerieId), pas dans cet écran : une rafale se reprend plus tard, et la
|
||||
reprise doit rattacher au même endroit.
|
||||
*@
|
||||
@if (SerieVisee is { } visee)
|
||||
{
|
||||
<p class="aide">
|
||||
Les livres créés seront ajoutés à la série
|
||||
<a href="@($"series/{visee.Id}")"><strong>@visee.Titre</strong></a>.
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Le geste visé : une pile de livres et une douchette. Celle-ci se présente au système comme
|
||||
un CLAVIER — elle tape les chiffres puis « Entrée ». Or dans une zone de texte multiligne,
|
||||
« Entrée » fait un saut de ligne : la rafale se collecte donc toute seule, sans mécanique
|
||||
dédiée. C'est ce qui rend cet écran bien moins coûteux qu'une file interactive.
|
||||
*@
|
||||
|
||||
@if (_erreur is { } erreur)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@erreur</p>
|
||||
}
|
||||
|
||||
@if (_repriseProposee)
|
||||
{
|
||||
@*
|
||||
⚠️ Sans cette proposition, la file existerait en base et personne ne la retrouverait :
|
||||
on rescannerait tout. C'est la contrepartie directe de « reprendre plus tard ».
|
||||
*@
|
||||
<div class="encart-reprise">
|
||||
@*
|
||||
⚠️ La file est proposée MÊME TERMINÉE (2026-09-08). Elle ne l'était que s'il restait
|
||||
à traiter ou des résidus : une rafale entièrement réussie devenait donc inatteignable
|
||||
alors qu'elle dormait dans IndexedDB, et la liste des livres créés — avec ses liens
|
||||
vers chaque fiche — était perdue au premier changement d'écran.
|
||||
*@
|
||||
@if (_rafale.ResteATraiter || _rafale.Residus.Count > 0)
|
||||
{
|
||||
<p>
|
||||
Une rafale de <strong>@_rafale.Total code@(_rafale.Total > 1 ? "s" : "")</strong>
|
||||
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
Votre dernière rafale est terminée :
|
||||
<strong>@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</strong>.
|
||||
Son compte rendu, et les liens vers chaque fiche, sont encore là.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" @onclick="Reprendre">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0
|
||||
? "Abandonner cette rafale"
|
||||
: "Commencer une nouvelle rafale")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_phase == Phase.Collecte)
|
||||
{
|
||||
<p class="aide">
|
||||
Scannez les codes-barres à la suite : chaque lecture ajoute une ligne. Vous pouvez aussi
|
||||
les taper ou les coller, un par ligne. Rien n'est envoyé avant que vous ne lanciez le
|
||||
traitement.
|
||||
</p>
|
||||
|
||||
@*
|
||||
⚠️ La zone est focalisée à l'ouverture, comme le champ ISBN de l'écran d'ajout : une
|
||||
douchette « tape » dès qu'on scanne, et sans le focus le premier code partait dans le
|
||||
vide. C'est une étape de moins, et c'est celle qu'on oublie en ayant les mains prises.
|
||||
|
||||
⚠️ Le focus ne se reprend pas à chaque rendu (`_focusAFaire`) : il serait arraché à
|
||||
chaque frappe, et la zone se réécrit à chaque caractère.
|
||||
*@
|
||||
<div class="champ">
|
||||
<label for="codes">Codes scannés</label>
|
||||
<textarea id="codes" class="champ-saisie zone-codes" rows="10"
|
||||
@ref="_zoneCodes"
|
||||
placeholder="9782070612758 9782253004226 …"
|
||||
@bind="_saisie" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="aide" aria-live="polite">
|
||||
@{
|
||||
var codes = SaisieRafale.Decouper(_saisie).Count;
|
||||
}
|
||||
@if (codes == 0)
|
||||
{
|
||||
<span>Aucun code pour l'instant.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span><strong>@codes</strong> code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@*
|
||||
Cochée par défaut : cataloguer cinquante livres ne doit pas être interrompu cinquante
|
||||
fois. ⚠️ Mais le compte rendu DIT combien ont été passés — posséder deux exemplaires est
|
||||
légitime, et passer en silence contredirait cet esprit.
|
||||
*@
|
||||
<label class="case-a-cocher">
|
||||
<input type="checkbox" @bind="_passerLesDoublons" />
|
||||
Passer les livres déjà au catalogue
|
||||
</label>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne || SaisieRafale.Decouper(_saisie).Count == 0)"
|
||||
title="@MotifBlocage"
|
||||
@onclick="LancerAsync">
|
||||
Traiter @(SaisieRafale.Decouper(_saisie).Count > 0 ? $"({SaisieRafale.Decouper(_saisie).Count})" : "")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
@*
|
||||
La COLLECTE marche hors-ligne, pas le traitement : c'est le seul endroit du projet
|
||||
où l'on accumule quelque chose sans réseau. ⚠️ Ce ne sont pas des écritures en
|
||||
attente, ce sont des codes à interroger — rien n'est promis à la base.
|
||||
*@
|
||||
<p class="aide">
|
||||
Vous pouvez scanner sans réseau : la liste est conservée sur cet appareil. Le
|
||||
traitement, lui, interroge la BnF et attendra le retour du réseau.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
else if (_phase == Phase.Traitement)
|
||||
{
|
||||
<h2 class="titre-section">Traitement en cours</h2>
|
||||
|
||||
<div class="avancement" role="progressbar"
|
||||
aria-valuenow="@_rafale.Pourcentage" aria-valuemin="0" aria-valuemax="100"
|
||||
aria-label="Avancement du traitement">
|
||||
<div class="avancement-barre" style="width: @(_rafale.Pourcentage)%"></div>
|
||||
</div>
|
||||
|
||||
<p aria-live="polite">
|
||||
<strong>@_rafale.Pourcentage %</strong> — @_rafale.Traites sur @_rafale.Total
|
||||
@if (_codeEnCours is { } encours)
|
||||
{
|
||||
<span class="aide"> · @FormatageIsbn.Afficher(encours)</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton" @onclick="() => _interrompu = true">
|
||||
Interrompre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="aide">
|
||||
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="titre-section">Compte rendu</h2>
|
||||
|
||||
<ul class="bilan">
|
||||
<li><strong>@_rafale.Crees</strong> livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</li>
|
||||
@if (_rafale.Passes > 0)
|
||||
{
|
||||
@*
|
||||
⚠️ Se COMPTE, ne se tait pas : sans cette ligne on croirait avoir ajouté
|
||||
cinquante livres alors qu'on en a ajouté quarante-deux.
|
||||
*@
|
||||
<li><strong>@_rafale.Passes</strong> déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")</li>
|
||||
}
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<li><strong>@_rafale.Residus.Count</strong> à regarder ci-dessous</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@*
|
||||
⚠️ La liste des livres ajoutés est la moitié utile du compte rendu : sans elle, on sait
|
||||
qu'on a créé vingt fiches et l'on n'a aucun moyen de les retrouver autrement qu'en
|
||||
fouillant le catalogue à la main. Or c'est juste après la rafale qu'on veut les
|
||||
compléter — un scan ne donne ni type de document, ni thèmes, ni série.
|
||||
|
||||
Chaque ligne mène donc à sa fiche, et un bouton mène droit à son écran d'édition.
|
||||
*@
|
||||
@if (Ajoutes.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">Livres ajoutés</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in Ajoutes)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<a class="carte-titre" href="@($"livres/{ligne.LivreId}")">
|
||||
@(string.IsNullOrWhiteSpace(ligne.Titre) ? "Fiche du livre" : ligne.Titre)
|
||||
</a>
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
<a class="bouton bouton-compact" href="@($"livres/{ligne.LivreId}/edition")">
|
||||
Compléter la fiche
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (_rafale.ResteATraiter)
|
||||
{
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="TraiterAsync">
|
||||
Continuer (@(_rafale.Total - _rafale.Traites) restant@(_rafale.Total - _rafale.Traites > 1 ? "s" : ""))
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">À reprendre</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in _rafale.Residus)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
<span class="etiquette @ClasseEtat(ligne.Etat)">@Libelle(ligne.Etat)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Titre))
|
||||
{
|
||||
<p class="carte-titre">@ligne.Titre</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
@*
|
||||
Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que
|
||||
de réimplémenter ici le choix d'édition ou la fiche revue.
|
||||
*@
|
||||
<a class="bouton bouton-compact" href="@LienDeReprise(ligne)">
|
||||
@(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre")
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@onclick="() => RetirerAsync(ligne)">
|
||||
Retirer de la liste
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<p class="aide">
|
||||
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
|
||||
vidée.
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
|
||||
@if (SerieVisee is { } retour)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="@($"series/{retour.Id}")">
|
||||
Revenir à @retour.Titre
|
||||
</a>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
Terminer et vider la liste
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Phase { Collecte, Traitement, Bilan }
|
||||
|
||||
/// <summary>
|
||||
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Ce paramètre ne sert qu'à <b>amorcer</b> la file : une fois lancée, c'est
|
||||
/// <c>Rafale.SerieId</c> qui commande, sans quoi une reprise ouverte depuis une autre
|
||||
/// adresse rattacherait ailleurs.
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "serie")]
|
||||
public int? SerieId { get; set; }
|
||||
|
||||
private SerieDto? _serie;
|
||||
|
||||
private Phase _phase = Phase.Collecte;
|
||||
private Rafale _rafale = new();
|
||||
private string _saisie = string.Empty;
|
||||
private bool _passerLesDoublons = true;
|
||||
private bool _repriseProposee;
|
||||
private bool _interrompu;
|
||||
private string? _codeEnCours;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// ISBN déjà au catalogue, sous leurs DEUX formes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Lu une seule fois par traitement, et volontairement : le catalogue ne bouge que par
|
||||
/// nos propres créations, qu'on y ajoute au fil de l'eau.
|
||||
/// </remarks>
|
||||
private HashSet<string> _isbnDuCatalogue = new(StringComparer.Ordinal);
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.</summary>
|
||||
private SerieDto? SerieVisee => _serie;
|
||||
|
||||
private int? CibleSerie => _rafale.SerieId ?? SerieId;
|
||||
|
||||
/// <summary>
|
||||
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lu depuis l'instantané des séries, donc sans coût ni dépendance au réseau. Une lecture
|
||||
/// ratée ne retire rien : le rattachement, lui, ne dépend pas de ce nom.
|
||||
/// </remarks>
|
||||
private async Task ChargerSerieAsync()
|
||||
{
|
||||
if (CibleSerie is not { } id)
|
||||
{
|
||||
_serie = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_serie = (await Api.ListerSeriesAsync()).FirstOrDefault(s => s.Id == id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_serie = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.</summary>
|
||||
private ElementReference _zoneCodes;
|
||||
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
/// <summary>Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.</summary>
|
||||
private IReadOnlyList<CodeRafale> Ajoutes =>
|
||||
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Une seule fois par entrée dans l'étape de collecte : reprendre le focus à chaque rendu
|
||||
/// arracherait le curseur en pleine frappe, et la zone se réécrit à chaque caractère.
|
||||
/// </remarks>
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusAFaire && _phase == Phase.Collecte && !_repriseProposee)
|
||||
{
|
||||
_focusAFaire = false;
|
||||
await _zoneCodes.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurReseau;
|
||||
|
||||
// ⚠️ Une rafale inachevée doit se proposer d'elle-même : sans cela, elle existe dans
|
||||
// IndexedDB et personne ne la retrouve.
|
||||
// ⚠️ Toute file non vidée est proposée, terminée ou non : c'est le seul chemin de retour
|
||||
// vers la liste des livres qu'une rafale vient de créer.
|
||||
var reprise = await Cache.LireRafaleAsync();
|
||||
if (reprise is { Total: > 0 })
|
||||
{
|
||||
_rafale = reprise;
|
||||
_passerLesDoublons = reprise.PasserLesDoublons;
|
||||
_repriseProposee = true;
|
||||
}
|
||||
|
||||
await ChargerSerieAsync();
|
||||
}
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurReseau;
|
||||
|
||||
private void SurReseau() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void Reprendre()
|
||||
{
|
||||
_repriseProposee = false;
|
||||
_phase = Phase.Bilan;
|
||||
}
|
||||
|
||||
private async Task AbandonnerAsync()
|
||||
{
|
||||
await Cache.EffacerRafaleAsync();
|
||||
_rafale = new Rafale();
|
||||
_saisie = string.Empty;
|
||||
_repriseProposee = false;
|
||||
_phase = Phase.Collecte;
|
||||
|
||||
// On revient à une zone vide : c'est une nouvelle rafale, elle mérite le même focus
|
||||
// que la première.
|
||||
_focusAFaire = true;
|
||||
|
||||
// ⚠️ La file effacée emportait sa série : sans cette relecture, le bandeau continuait
|
||||
// d'annoncer « les livres seront ajoutés à la série X » pour une rafale neuve qui,
|
||||
// ouverte sans paramètre d'adresse, ne rattachera nulle part.
|
||||
await ChargerSerieAsync();
|
||||
}
|
||||
|
||||
private async Task LancerAsync()
|
||||
{
|
||||
var codes = SaisieRafale.Decouper(_saisie);
|
||||
if (codes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rafale = new Rafale
|
||||
{
|
||||
Codes = [.. codes.Select(c => new CodeRafale { Code = c })],
|
||||
PasserLesDoublons = _passerLesDoublons,
|
||||
SerieId = SerieId,
|
||||
};
|
||||
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
await TraiterAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traite la file, un code à la fois.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Un par un, délibérément.</b> Cinquante livres font jusqu'à cent requêtes — chaque
|
||||
/// ISBN est cherché en 13 puis en 10 — et c'est le SERVEUR qui appelle la BnF. Les lancer
|
||||
/// ensemble lui ferait ouvrir cent connexions vers <c>catalogue.bnf.fr</c>. Séquentiel, la
|
||||
/// progression se voit et une coupure ne perd que le code en cours.
|
||||
/// <para>
|
||||
/// La file est réécrite <b>après chaque code</b> : c'est ce qui rend la reprise exacte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task TraiterAsync()
|
||||
{
|
||||
_phase = Phase.Traitement;
|
||||
_interrompu = false;
|
||||
_erreur = null;
|
||||
|
||||
await ChargerIsbnDuCatalogueAsync();
|
||||
|
||||
for (var i = 0; i < _rafale.Codes.Count; i++)
|
||||
{
|
||||
if (_interrompu || !Reseau.EnLigne)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (_rafale.Codes[i].Etat != EtatCodeRafale.ATraiter)
|
||||
{
|
||||
continue; // Déjà traité : une reprise ne refait jamais ce qui est fait.
|
||||
}
|
||||
|
||||
_codeEnCours = _rafale.Codes[i].Code;
|
||||
StateHasChanged();
|
||||
|
||||
_rafale.Codes[i] = await TraiterUnAsync(_rafale.Codes[i]);
|
||||
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_codeEnCours = null;
|
||||
_phase = Phase.Bilan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les deux formes sont indispensables</b> : un livre saisi avant 2007 porte un
|
||||
/// ISBN-10 en base, alors qu'un scanner lit toujours un EAN-13. Ne comparer qu'une forme
|
||||
/// ferait rescanner tout le fonds ancien comme s'il était neuf — c'est le même piège que
|
||||
/// celui de la recherche BnF, à l'autre bout de la chaîne.
|
||||
/// </remarks>
|
||||
private async Task ChargerIsbnDuCatalogueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var livres = await Api.ListerAsync(new CritereLivres());
|
||||
_isbnDuCatalogue = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType<string>())
|
||||
{
|
||||
_isbnDuCatalogue.Add(isbn);
|
||||
|
||||
if (IsbnUtils.TryConvertirEnIsbn10(isbn, out var court) && court is not null)
|
||||
{
|
||||
_isbnDuCatalogue.Add(court);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Sans cette liste, on retombe simplement sur la détection du serveur à la
|
||||
// création : plus lente, mais jamais fausse.
|
||||
_isbnDuCatalogue = [];
|
||||
}
|
||||
}
|
||||
|
||||
private bool DejaAuCatalogue(string code)
|
||||
{
|
||||
if (_isbnDuCatalogue.Contains(code))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsbnUtils.TryConvertirEnIsbn10(code, out var court)
|
||||
&& court is not null
|
||||
&& _isbnDuCatalogue.Contains(court);
|
||||
}
|
||||
|
||||
private async Task<CodeRafale> TraiterUnAsync(CodeRafale code)
|
||||
{
|
||||
// ⚠️ AVANT le lookup, et avant le choix d'édition. Constaté à l'écran : sans ce test,
|
||||
// un livre déjà possédé dont la BnF rend trois notices ressortait « à choisir » — on
|
||||
// demandait de trancher l'édition d'un livre qu'on allait de toute façon passer.
|
||||
// Épargne au passage une requête BnF par livre déjà catalogué.
|
||||
if (_rafale.PasserLesDoublons && DejaAuCatalogue(code.Code))
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Passe,
|
||||
Motif = "Déjà au catalogue.",
|
||||
};
|
||||
}
|
||||
|
||||
ResultatLookupIsbn? lookup;
|
||||
try
|
||||
{
|
||||
lookup = await Api.ChercherIsbnAsync(code.Code);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup is null)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Motif = "La recherche n'a pas abouti. Réessayez plus tard.",
|
||||
};
|
||||
}
|
||||
|
||||
// Un préfixe 977 est un périodique : il porte un ISSN, donc un titre de revue, et son
|
||||
// numéro se saisit à la main. Il ne peut pas suivre le chemin des livres.
|
||||
if (lookup.Periodique is { } revue)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Revue,
|
||||
Titre = revue.Titre,
|
||||
Motif = "Magazine : le numéro de parution se saisit à la main.",
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup.Candidats.Count == 0)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Introuvable,
|
||||
Motif = "Aucune source ne connaît ce code. À saisir à la main.",
|
||||
};
|
||||
}
|
||||
|
||||
// ⚠️ Plusieurs notices : on NE choisit PAS à la place de l'utilisateur. C'est la règle
|
||||
// actée « notices multiples : demander systématiquement », et elle vaut ici aussi —
|
||||
// sauf qu'on demande à la fin, pas au milieu de la rafale.
|
||||
if (lookup.Candidats.Count > 1)
|
||||
{
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.AChoisir,
|
||||
Titre = lookup.Candidats[0].Titre,
|
||||
Motif = $"{lookup.Candidats.Count} éditions possibles : à choisir.",
|
||||
};
|
||||
}
|
||||
|
||||
var candidat = lookup.Candidats[0];
|
||||
|
||||
// Même construction que l'écran d'ajout unitaire : `Format.Physique` parce qu'un livre
|
||||
// scanné est un objet qu'on tient, et `Statut.ALire` parce qu'on vient de l'acquérir.
|
||||
var saisie = new EnregistrementLivre
|
||||
{
|
||||
Isbn = code.Code,
|
||||
Titre = candidat.Titre,
|
||||
Auteur = candidat.Auteur,
|
||||
Editeur = candidat.Editeur,
|
||||
NombrePages = candidat.NombrePages,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
UrlNotice = candidat.UrlNotice,
|
||||
Format = Format.Physique,
|
||||
Statut = Statut.ALire,
|
||||
};
|
||||
|
||||
var resultat = await Api.CreerAsync(saisie);
|
||||
|
||||
if (resultat.Doublons is { } doublons)
|
||||
{
|
||||
// Passer est le défaut, mais cela se compte : voir le compte rendu.
|
||||
return _rafale.PasserLesDoublons
|
||||
? code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Passe,
|
||||
Titre = candidat.Titre,
|
||||
Motif = doublons.Message,
|
||||
}
|
||||
: code with
|
||||
{
|
||||
Etat = EtatCodeRafale.AChoisir,
|
||||
Titre = candidat.Titre,
|
||||
Motif = doublons.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (resultat.Livre is { } livre)
|
||||
{
|
||||
// Un code scanné deux fois dans la MÊME rafale est déjà écarté par la découpe ;
|
||||
// ceci couvre le cas de deux codes différents qui désignent le même livre.
|
||||
_isbnDuCatalogue.Add(code.Code);
|
||||
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Cree,
|
||||
Titre = livre.Titre,
|
||||
LivreId = livre.Id,
|
||||
Motif = await RattacherAsync(livre),
|
||||
};
|
||||
}
|
||||
|
||||
return code with
|
||||
{
|
||||
Etat = EtatCodeRafale.Echec,
|
||||
Titre = candidat.Titre,
|
||||
Motif = resultat.Erreur ?? "L'enregistrement n'a pas abouti.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range le livre créé dans la série visée, s'il y en a une.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : <b>le livre existe</b>, et le
|
||||
/// redire « à traiter » le recréerait à la reprise. On le dit dans le compte rendu, où la
|
||||
/// ligne mène déjà à sa fiche — c'est là qu'on répare, en un geste.
|
||||
/// </remarks>
|
||||
private async Task<string?> RattacherAsync(LivreDto livre)
|
||||
{
|
||||
if (_rafale.SerieId is not { } serieId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resultat = await Api.AjouterElementSerieAsync(
|
||||
serieId, new AjoutElementSerie { LivreId = livre.Id });
|
||||
|
||||
return resultat.EstOk
|
||||
? null
|
||||
: "Livre enregistré, mais il n'a pas pu être ajouté à la série.";
|
||||
}
|
||||
|
||||
private async Task RetirerAsync(CodeRafale code)
|
||||
{
|
||||
_rafale.Codes.RemoveAll(c => c.Code == code.Code);
|
||||
await Cache.EcrireRafaleAsync(_rafale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ On ne réimplémente ici ni le choix d'édition, ni la fiche revue, ni la saisie
|
||||
/// manuelle : trois copies d'un écran existant divergeraient.
|
||||
/// </remarks>
|
||||
private static string LienDeReprise(CodeRafale code) => code.Etat switch
|
||||
{
|
||||
EtatCodeRafale.Introuvable => $"/ajout/manuel?isbn={code.Code}",
|
||||
_ => $"/ajout/isbn?code={code.Code}",
|
||||
};
|
||||
|
||||
private static string Libelle(EtatCodeRafale etat) => etat switch
|
||||
{
|
||||
EtatCodeRafale.AChoisir => "Plusieurs éditions",
|
||||
EtatCodeRafale.Introuvable => "Introuvable",
|
||||
EtatCodeRafale.Revue => "Magazine",
|
||||
EtatCodeRafale.Echec => "Échec",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string ClasseEtat(EtatCodeRafale etat) => etat switch
|
||||
{
|
||||
EtatCodeRafale.Revue => "etiquette-type",
|
||||
EtatCodeRafale.Echec => "etiquette-manquant",
|
||||
_ => "etiquette-prete",
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
@page "/revues/ajout"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@inject EtatReseau Reseau
|
||||
|
||||
<PageTitle>MaBibli — saisir une revue</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Saisir une revue</h1>
|
||||
|
||||
@*
|
||||
Le pendant de « Saisir un livre à la main » : ce qu'on fait quand le code-barres est absent,
|
||||
abîmé, ou que la BnF ne connaît pas le titre. L'entrée normale reste « Ajouter un ouvrage »,
|
||||
qui scanne et reconnaît un code 977 tout seul.
|
||||
*@
|
||||
<p class="message-discret">
|
||||
L'entrée habituelle est <a href="ajout">Ajouter un ouvrage</a> : le code-barres d'un
|
||||
magazine commence par <span class="code-isbn">977</span> et l'application le reconnaît
|
||||
seule. Ce formulaire sert quand il n'y a pas de code à lire.
|
||||
</p>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Hors ligne : l'enregistrement passe par le serveur. Il redeviendra possible au retour
|
||||
du réseau.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Titre<span class="obligatoire"> *</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput"
|
||||
placeholder="Médor" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
ISSN<span class="champ-aide"> (facultatif — huit chiffres, imprimé près du code-barres ; le tiret est posé à l'enregistrement)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
|
||||
placeholder="2466-6718" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur<span class="champ-aide"> (facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<p class="champ-aide">
|
||||
Les numéros s'ajoutent ensuite sur la fiche de la revue : une fiche par revue, les numéros
|
||||
à l'intérieur.
|
||||
</p>
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="EnregistrerAsync">
|
||||
@(_enCours ? "Enregistrement…" : "Créer la revue")
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private string _titre = string.Empty;
|
||||
private string _issn = string.Empty;
|
||||
private string _editeur = string.Empty;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// Crée la revue — ou retrouve celle qui existe déjà — puis ouvre sa fiche.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le serveur fait « créer ou retrouver » sur l'ISSN <b>ou</b> le titre : saisir deux fois
|
||||
/// la même revue mène à la même fiche au lieu d'un refus. C'est aussi ce qui permet à une
|
||||
/// revue saisie ici d'apprendre son ISSN au premier scan.
|
||||
/// <para>
|
||||
/// On enchaîne sur la fiche plutôt que de revenir à la liste : le geste n'est jamais
|
||||
/// « créer une revue », c'est toujours « ranger un numéro ».
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
|
||||
{
|
||||
Titre = _titre,
|
||||
Issn = string.IsNullOrWhiteSpace(_issn) ? null : _issn,
|
||||
Editeur = string.IsNullOrWhiteSpace(_editeur) ? null : _editeur,
|
||||
});
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk || resultat.Valeur is not { } revue)
|
||||
{
|
||||
_erreur = resultat.Erreur ?? "La revue n'a pas pu être créée.";
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo($"revues/{revue.Id}");
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,12 @@
|
||||
}
|
||||
|
||||
<div class="onglets-secondaires" role="group" aria-label="Manière de chercher">
|
||||
<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.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>
|
||||
<button type="button" class="segment @(_mode == Mode.Revue ? "segment-actif" : null)"
|
||||
@onclick="() => Basculer(Mode.Revue)">Une revue</button>
|
||||
</div>
|
||||
|
||||
@if (_mode == Mode.Titre)
|
||||
@@ -78,32 +76,9 @@
|
||||
title="@MotifBlocage" @onclick="ChercherParIsbnAsync">
|
||||
@(_recherche ? "Recherche…" : "Chercher")
|
||||
</button>
|
||||
|
||||
@*
|
||||
J2 : le cas d'usage est direct — le livre est en main, en librairie. C'est le même
|
||||
composant de scan que l'écran d'ajout au catalogue, sans création de Livre au bout :
|
||||
un souhait n'est pas une possession.
|
||||
*@
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@(_scan || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="OuvrirScanner">
|
||||
Scanner
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_scan)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="() => _scan = false" />
|
||||
}
|
||||
|
||||
@* L1 : la BnF met environ une seconde à répondre. Sans indicateur, l'écran ne dit rien et
|
||||
l'on reclique — donc on relance l'appel. *@
|
||||
@if (_recherche)
|
||||
{
|
||||
<Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
|
||||
}
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
@@ -123,16 +98,6 @@
|
||||
Choisissez celle qui correspond, ou complétez à la main.
|
||||
</p>
|
||||
|
||||
<div class="champ champ-tri">
|
||||
<label class="champ-libelle" for="tri-resultats">Trier par</label>
|
||||
<select id="tri-resultats" class="champ-saisie" value="@_tri"
|
||||
@onchange="e => ChangerTri(e.Value?.ToString())">
|
||||
<option value="@TriResultats.Date">Date (récent en premier)</option>
|
||||
<option value="@TriResultats.Titre">Titre</option>
|
||||
<option value="@TriResultats.Editeur">Éditeur</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul class="liste-candidats">
|
||||
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
|
||||
{
|
||||
@@ -200,59 +165,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_mode == Mode.Revue)
|
||||
{
|
||||
@*
|
||||
⚠️ Aucune recherche ici, et ce n'est pas un manque : l'ISSN désigne un TITRE de
|
||||
périodique, jamais une parution. Interroger la BnF ne dirait donc rien du numéro, qui est
|
||||
justement ce qu'on souhaite. On note ce qu'on a vu en kiosque.
|
||||
*@
|
||||
<p class="message-discret">
|
||||
Une revue entière, ou un numéro précis. Le numéro peut rester vide : « je voudrais
|
||||
m'abonner à Médor » est une envie comme une autre.
|
||||
</p>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="titre-revue">Titre de la revue <span class="obligatoire">*</span></label>
|
||||
<input id="titre-revue" class="champ-saisie" type="text" @ref="_premierChamp"
|
||||
@bind="_saisieRevue.Titre" @bind:event="oninput" placeholder="Médor" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="numero-revue">Numéro</label>
|
||||
<input id="numero-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Numero" @bind:event="oninput" placeholder="43, hors-série 7…" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="issn-revue">ISSN</label>
|
||||
<input id="issn-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Issn" @bind:event="oninput" placeholder="2466-6718" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="note-revue">Note</label>
|
||||
<input id="note-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Note" @bind:event="oninput" placeholder="« vu chez le dentiste »…" />
|
||||
</div>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieRevue.Titre))"
|
||||
title="@MotifBlocage" @onclick="AjouterRevueAsync">
|
||||
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, Revue }
|
||||
private enum Mode { Titre, Isbn, Manuel }
|
||||
|
||||
private Mode _mode = Mode.Isbn;
|
||||
private Mode _mode = Mode.Titre;
|
||||
private ElementReference _premierChamp;
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
@@ -261,15 +181,10 @@
|
||||
private string _isbn = string.Empty;
|
||||
|
||||
private EnregistrementSouhait _saisie = new();
|
||||
private EnregistrementRevueSouhaitee _saisieRevue = new();
|
||||
private IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private TriResultats _tri = TriResultats.Date;
|
||||
private bool _recherche;
|
||||
private bool _ajout;
|
||||
|
||||
/// <summary>Le scanner est ouvert. Il ne l'est que depuis la voie « par ISBN ».</summary>
|
||||
private bool _scan;
|
||||
private string? _erreur;
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
@@ -280,13 +195,6 @@
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private enum TriResultats
|
||||
{
|
||||
Date,
|
||||
Titre,
|
||||
Editeur,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
|
||||
/// </summary>
|
||||
@@ -313,43 +221,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void OuvrirScanner()
|
||||
{
|
||||
_erreur = null;
|
||||
_candidats = [];
|
||||
_avertissements = [];
|
||||
_scan = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enchaîne la recherche sur le code lu, sans bouton à confirmer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le code reste dans le champ : un décodage erroné se corrige et se relance. C'est la
|
||||
/// décision déjà actée pour l'écran d'ajout au catalogue, et la seule cohérente avec une
|
||||
/// douchette, qui envoie <c>Entrée</c> d'elle-même.
|
||||
/// <para>
|
||||
/// ⚠️ L'add-on EAN-2 (numéro de parution) est ignoré ici, et ce n'est pas un oubli : il ne
|
||||
/// concerne que les revues, qui ne se souhaitent pas — cet écran alimente une liste
|
||||
/// d'<i>œuvres</i>. Un code de périodique est d'ailleurs intercepté plus bas et nommé.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task SurCodeDetecteAsync(CodeScanne lecture)
|
||||
{
|
||||
_scan = false;
|
||||
_isbn = lecture.Code;
|
||||
await ChercherParIsbnAsync();
|
||||
}
|
||||
|
||||
private void Basculer(Mode mode)
|
||||
{
|
||||
_scan = false;
|
||||
_mode = mode;
|
||||
_focusAFaire = true;
|
||||
_erreur = null;
|
||||
_candidats = [];
|
||||
_avertissements = [];
|
||||
_tri = TriResultats.Date;
|
||||
}
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
@@ -406,7 +284,6 @@
|
||||
}
|
||||
|
||||
_candidats = resultat.Candidats;
|
||||
_candidats = Trier(_candidats);
|
||||
|
||||
if (_candidats.Count == 0)
|
||||
{
|
||||
@@ -419,52 +296,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void ChangerTri(string? valeur)
|
||||
{
|
||||
if (!Enum.TryParse<TriResultats>(valeur, out var tri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_tri = tri;
|
||||
_candidats = Trier(_candidats);
|
||||
}
|
||||
|
||||
private IReadOnlyList<CandidatLivre> Trier(IEnumerable<CandidatLivre> candidats) =>
|
||||
_tri switch
|
||||
{
|
||||
TriResultats.Titre => candidats
|
||||
.OrderBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(c => c.Editeur, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
TriResultats.Editeur => candidats
|
||||
.OrderBy(c => c.Editeur ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
_ => candidats
|
||||
.OrderByDescending(c => AnneeTriable(c.Annee))
|
||||
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
private static int AnneeTriable(string? date)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(date))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i + 4 <= date.Length; i++)
|
||||
{
|
||||
if (int.TryParse(date.AsSpan(i, 4), out var annee) && annee is >= 1000 and <= 2999)
|
||||
{
|
||||
return annee;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
|
||||
/// </summary>
|
||||
@@ -502,22 +333,4 @@
|
||||
// Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
|
||||
private async Task AjouterRevueAsync()
|
||||
{
|
||||
_ajout = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.AjouterRevueSouhaiteeAsync(_saisieRevue);
|
||||
|
||||
_ajout = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,136 +67,27 @@ else if (_auteurs.Count == 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
@*
|
||||
Recherche et tri d'une seule pièce : la question qu'on se pose devant une liste
|
||||
d'auteurs est soit « où est untel ? » (recherche), soit « qui ai-je le plus ? » (tri).
|
||||
|
||||
⚠️ Tout se fait en mémoire, sans nouvel appel : la liste des auteurs est déjà
|
||||
entièrement chargée — c'est elle qui sert l'instantané hors-ligne — et la filtrer
|
||||
côté serveur aurait rendu la page inutilisable sans réseau. La normalisation passe
|
||||
par NormalisationTexte, comme partout : « emile » retrouve « Émile ».
|
||||
*@
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="search" placeholder="Nom d'auteur…"
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un auteur" />
|
||||
</div>
|
||||
|
||||
<div class="segments" role="group" aria-label="Tri">
|
||||
<button type="button" class="segment @(_tri == TriAuteurs.Nom ? "segment-actif" : null)"
|
||||
@onclick="() => _tri = TriAuteurs.Nom">Par nom</button>
|
||||
<button type="button" class="segment @(_tri == TriAuteurs.NombreDeLivres ? "segment-actif" : null)"
|
||||
@onclick="() => _tri = TriAuteurs.NombreDeLivres">Les plus présents</button>
|
||||
</div>
|
||||
|
||||
@* Le compteur dit ce qu'on regarde : sur une liste filtrée, afficher le total ferait
|
||||
croire que la recherche n'a rien fait. *@
|
||||
<p class="compteur">
|
||||
@Affiches.Count auteur@(Affiches.Count > 1 ? "s" : "")
|
||||
@if (Affiches.Count != _auteurs.Count)
|
||||
{
|
||||
<text> sur @_auteurs.Count</text>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (Affiches.Count == 0)
|
||||
{
|
||||
<p class="message-discret">Aucun auteur ne correspond à « @_recherche ».</p>
|
||||
}
|
||||
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p>
|
||||
|
||||
<ul class="liste-auteurs">
|
||||
@foreach (var auteur in Affiches)
|
||||
@foreach (var auteur in _auteurs)
|
||||
{
|
||||
@*
|
||||
Trois colonnes, dont une seule est élastique.
|
||||
|
||||
Le nombre de livres passe à GAUCHE du nom, sur une largeur figée de trois
|
||||
chiffres : les compteurs s'alignent alors les uns sous les autres et se
|
||||
comparent d'un coup d'œil. Trois chiffres suffisent — personne ne possède
|
||||
mille livres d'un même auteur. Les actions se rangent tout à droite, elles
|
||||
aussi sur une largeur constante, pour que seul le nom absorbe la place
|
||||
restante au lieu de décaler les boutons ligne après ligne.
|
||||
*@
|
||||
<li class="carte-auteur-ligne" @key="auteur.Id">
|
||||
<span class="auteur-compte" title="@Livres(auteur.NombreDeLivres)">
|
||||
@auteur.NombreDeLivres
|
||||
<span class="lecture-ecran">@Livres(auteur.NombreDeLivres)</span>
|
||||
</span>
|
||||
|
||||
<a class="lien-auteur auteur-nom" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
||||
|
||||
<span class="auteur-actions">
|
||||
@* Le declencheur decrit dans CLAUDE.md : depuis un auteur qu'on a deja, voir
|
||||
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
||||
<span class="etiquette">@Livres(auteur.NombreDeLivres)</span>
|
||||
@* Le declencheur decrit dans IDEES.md : depuis un auteur qu'on a deja, voir
|
||||
tout ce qu'il a ecrit et marquer ce qui manque. *@
|
||||
<a class="bouton bouton-compact bouton-discret"
|
||||
href="@($"auteurs/{auteur.Id}/bibliographie")">Bibliographie</a>
|
||||
@if (_auteurRenomme == auteur.Id)
|
||||
{
|
||||
<input class="champ-saisie champ-auteur-renomme" @bind="_nomRenomme" />
|
||||
<button type="button" class="bouton bouton-compact bouton-principal"
|
||||
disabled="@_renommageEnCours" @onclick="RenommerAsync">
|
||||
Enregistrer
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@MotifBlocage"
|
||||
@onclick="() => CommencerRenommage(auteur)">
|
||||
Renommer
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (_collisionRenommage is not null)
|
||||
{
|
||||
<section class="message-avertissement" role="alert">
|
||||
<p>
|
||||
Un auteur existe déjà sous le nom « @_collisionRenommage.Nom ».
|
||||
Les livres seront regroupés sous cette fiche.
|
||||
Confirmer la fusion ?
|
||||
</p>
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@_renommageEnCours" @onclick="ConfirmerFusionAsync">
|
||||
Oui, fusionner
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_renommageEnCours" @onclick="AnnulerRenommage">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Les deux façons de trier une liste d'auteurs, et il n'y en a pas de troisième.</summary>
|
||||
private enum TriAuteurs
|
||||
{
|
||||
/// <summary>Ordre alphabétique : « où est untel ? ».</summary>
|
||||
Nom = 0,
|
||||
|
||||
/// <summary>Du plus présent au moins présent : « qui ai-je le plus ? ».</summary>
|
||||
NombreDeLivres = 1,
|
||||
}
|
||||
|
||||
private IReadOnlyList<AuteurDto>? _auteurs;
|
||||
private string _recherche = string.Empty;
|
||||
private TriAuteurs _tri = TriAuteurs.Nom;
|
||||
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
|
||||
private bool _enCours;
|
||||
private bool _renommageEnCours;
|
||||
private int? _auteurRenomme;
|
||||
private string _nomRenomme = string.Empty;
|
||||
private AuteurDto? _collisionRenommage;
|
||||
private string? _erreur;
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
@@ -216,47 +107,6 @@ else
|
||||
|
||||
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
|
||||
|
||||
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// La liste telle qu'elle s'affiche : filtrée par la recherche, puis triée.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La recherche est une <b>sous-chaîne du nom normalisé</b>, comme celle du catalogue :
|
||||
/// « zola » retrouve « Émile Zola », et « emile » aussi. Elle ne cherche pas mot à mot,
|
||||
/// contrairement au rapprochement d'auteurs, qui répond à une autre question — « ces deux
|
||||
/// noms désignent-ils la même personne ? » — et n'a rien à faire ici.
|
||||
/// <para>
|
||||
/// Le tri par nom est <b>ordinal sur la forme normalisée</b>, pour ne pas dépendre de la
|
||||
/// culture du navigateur : c'est la même règle que <c>FiltreLivresLocal</c>, et elle évite
|
||||
/// qu'un appareil classe « Éluard » avant « Duras » et un autre après.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IReadOnlyList<AuteurDto> Affiches
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_auteurs is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var terme = NormalisationTexte.Normaliser(_recherche);
|
||||
var retenus = string.IsNullOrEmpty(terme)
|
||||
? _auteurs
|
||||
: _auteurs.Where(a => NormalisationTexte.Normaliser(a.Nom).Contains(terme)).ToList();
|
||||
|
||||
return _tri == TriAuteurs.NombreDeLivres
|
||||
? retenus
|
||||
.OrderByDescending(a => a.NombreDeLivres)
|
||||
.ThenBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: retenus
|
||||
.OrderBy(a => NormalisationTexte.Normaliser(a.Nom), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_erreur = null;
|
||||
@@ -278,55 +128,6 @@ else
|
||||
private Task RefuserAsync(SuggestionRapprochement suggestion) =>
|
||||
DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
|
||||
|
||||
private void CommencerRenommage(AuteurDto auteur)
|
||||
{
|
||||
_auteurRenomme = auteur.Id;
|
||||
_nomRenomme = auteur.Nom;
|
||||
_collisionRenommage = null;
|
||||
_erreur = null;
|
||||
}
|
||||
|
||||
private void AnnulerRenommage()
|
||||
{
|
||||
_auteurRenomme = null;
|
||||
_collisionRenommage = null;
|
||||
_nomRenomme = string.Empty;
|
||||
}
|
||||
|
||||
private Task RenommerAsync() => EnvoyerRenommageAsync(false);
|
||||
|
||||
private Task ConfirmerFusionAsync() => EnvoyerRenommageAsync(true);
|
||||
|
||||
private async Task EnvoyerRenommageAsync(bool confirmerFusion)
|
||||
{
|
||||
if (_auteurRenomme is null || string.IsNullOrWhiteSpace(_nomRenomme))
|
||||
{
|
||||
_erreur = "Le nom de l'auteur est obligatoire.";
|
||||
return;
|
||||
}
|
||||
|
||||
_renommageEnCours = true;
|
||||
_erreur = null;
|
||||
var resultat = await Api.RenommerAuteurAsync(
|
||||
_auteurRenomme.Value, _nomRenomme, confirmerFusion);
|
||||
_renommageEnCours = false;
|
||||
|
||||
if (resultat is null)
|
||||
{
|
||||
_erreur = "Le renommage n'a pas pu être enregistré.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultat.Collision is not null)
|
||||
{
|
||||
_collisionRenommage = resultat.Collision;
|
||||
return;
|
||||
}
|
||||
|
||||
AnnulerRenommage();
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private async Task DeciderAsync(Func<Task<string?>> action)
|
||||
{
|
||||
_enCours = true;
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
@if (_bibliographie is null && _erreur is null)
|
||||
{
|
||||
<h1 class="titre-page">Bibliographie</h1>
|
||||
@* L1 : une bibliographie coûte ~1 s par page, et jusqu'à dix pages pour les nouveautés.
|
||||
Sans indicateur, l'écran se lit comme planté — et l'on reclique, donc on relance. *@
|
||||
<Patience Message="@(Nouveautes ? "Recherche des nouveautés à la BnF…" : "Interrogation de la BnF…")" />
|
||||
<p class="message-discret">Interrogation de la BnF…</p>
|
||||
}
|
||||
else if (_erreur is not null)
|
||||
{
|
||||
@@ -32,16 +30,7 @@ else if (_erreur is not null)
|
||||
}
|
||||
else if (_bibliographie is { } biblio)
|
||||
{
|
||||
<h1 class="titre-page">
|
||||
@if (biblio.EstNouveautes)
|
||||
{
|
||||
<text>Nouveautés de @biblio.Auteur.Nom</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
@biblio.Auteur.Nom
|
||||
}
|
||||
</h1>
|
||||
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
|
||||
|
||||
@*
|
||||
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
|
||||
@@ -76,29 +65,13 @@ else if (_bibliographie is { } biblio)
|
||||
}
|
||||
else if (biblio.Oeuvres.Count == 0)
|
||||
{
|
||||
@if (biblio.EstNouveautes && biblio.SeuilNouveautesInconnu)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Impossible de dater le livre le plus récent que vous possédez dans les notices
|
||||
reçues. La BnF a répondu, mais aucune nouveauté fiable ne peut être déduite.
|
||||
</p>
|
||||
}
|
||||
else if (biblio.EstNouveautes)
|
||||
{
|
||||
<p class="message-discret">Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
|
||||
l'expliquer. *@
|
||||
<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, et un
|
||||
auteur traduit n'y a souvent pas de notice à son nom.
|
||||
</p>
|
||||
}
|
||||
|
||||
<BasculeBibliographie Biblio="biblio" />
|
||||
@* 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, et un
|
||||
auteur traduit n'y a souvent pas de notice à son nom.
|
||||
</p>
|
||||
}
|
||||
else if (biblio.Avertissement is not null)
|
||||
{
|
||||
@@ -113,19 +86,8 @@ else if (_bibliographie is { } biblio)
|
||||
<p class="compteur">
|
||||
@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "")
|
||||
— @_possedees déjà chez vous
|
||||
@if (_masquees > 0)
|
||||
{
|
||||
<text> — @_masquees masquée@(_masquees > 1 ? "s" : "")</text>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (biblio.EstNouveautes)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
|
||||
conditionnels dans une même phrase produit une ponctuation bancale dès qu'un des
|
||||
fragments manque (« exister. .34 notices ont été écartées »). *@
|
||||
@@ -162,231 +124,67 @@ else if (_bibliographie is { } biblio)
|
||||
<p class="message-erreur" role="alert">@_message</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)">
|
||||
Filtrer (@NombreFiltres)
|
||||
</button>
|
||||
|
||||
@* Les deux bascules d'écran vivent ICI, à côté du filtre, et non plus dans une barre
|
||||
flottante en bas : « toute la bibliographie / les nouveautés » et « ses livres chez
|
||||
vous » sont trois façons de restreindre la même liste. Les chercher en bas d'un
|
||||
écran qui défile sur cinquante lignes n'avait pas de sens. *@
|
||||
<BasculeBibliographie Biblio="biblio" />
|
||||
|
||||
@*
|
||||
I1 : « Tout cocher » vit ICI et non dans la barre de sélection, qui n'apparaît
|
||||
qu'une fois une case cochée — c'est-à-dire trop tard pour rendre service.
|
||||
|
||||
⚠️ « Tout » signifie ce qui est ACTUELLEMENT VISIBLE, donc après filtre et selon
|
||||
que les masquées sont affichées ou non. Cocher les 200 notices remontées, dont
|
||||
l'écran n'en montre que trente, serait exactement ce que les compteurs par
|
||||
bouton cherchent à éviter.
|
||||
*@
|
||||
@if (ACocher(biblio).Count is var aCocher and > 0)
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => CocherTout(biblio)">
|
||||
Tout cocher (@aCocher)
|
||||
</button>
|
||||
}
|
||||
@* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne
|
||||
sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@
|
||||
<div class="filtres">
|
||||
<div class="segments" role="group" aria-label="Filtrer la bibliographie">
|
||||
<button type="button" class="segment @(_seulementManquants ? null : "segment-actif")"
|
||||
@onclick="() => _seulementManquants = false">Tout</button>
|
||||
<button type="button" class="segment @(_seulementManquants ? "segment-actif" : null)"
|
||||
@onclick="() => _seulementManquants = true">Ce qui me manque</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_filtresOuverts)
|
||||
{
|
||||
<div class="filtres">
|
||||
<div class="segments" role="group" aria-label="Possession">
|
||||
<button type="button" class="segment @(_modePossession == 0 ? "segment-actif" : null)"
|
||||
@onclick="() => _modePossession = 0">Tout</button>
|
||||
<button type="button" class="segment @(_modePossession == 1 ? "segment-actif" : null)"
|
||||
@onclick="() => _modePossession = 1">Ceux que j'ai déjà</button>
|
||||
<button type="button" class="segment @(_modePossession == 2 ? "segment-actif" : null)"
|
||||
@onclick="() => _modePossession = 2">Ce qui me manque</button>
|
||||
</div>
|
||||
@if (_masquees > 0)
|
||||
{
|
||||
<div class="segments">
|
||||
<button type="button" class="segment @(_afficherMasquees ? "segment-actif" : null)"
|
||||
@onclick="() => _afficherMasquees = true">
|
||||
Afficher les masquées (@_masquees)
|
||||
</button>
|
||||
<button type="button" class="segment @(!_afficherMasquees ? "segment-actif" : null)"
|
||||
@onclick="() => _afficherMasquees = false">Masquer les masquées</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
La barre de sélection est EN HAUT, au-dessus de la liste, et non plus flottante en
|
||||
bas : on coche en descendant, et il fallait auparavant redescendre jusqu'au bas de
|
||||
l'écran pour valider — ou pire, la barre recouvrait les dernières lignes qu'on
|
||||
venait justement de vouloir cocher.
|
||||
*@
|
||||
@if (_selection.Count > 0)
|
||||
{
|
||||
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
|
||||
<span class="selection-compte">@_selection.Count sélectionnée@(_selection.Count > 1 ? "s" : "")</span>
|
||||
|
||||
@if (SelectionADecouvrir(biblio).Count is var aSouhaiter and > 0)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="AjouterSelectionAsync">
|
||||
Ajouter aux envies (@aSouhaiter)
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (SelectionParMasquage(biblio, false).Count is var aMasquer and > 0)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="() => MasquerSelectionAsync(true)">
|
||||
Masquer (@aMasquer)
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (SelectionParMasquage(biblio, true).Count is var aReafficher and > 0)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="() => MasquerSelectionAsync(false)">
|
||||
Réafficher (@aReafficher)
|
||||
</button>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@_enCours" @onclick="() => _selection.Clear()">
|
||||
Tout décocher
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="liste-bibliographie">
|
||||
@foreach (var oeuvre in Affichees(biblio))
|
||||
{
|
||||
@* La classe « oeuvre-possedee » grise la ligne : le livre est déjà sur l'étagère. *@
|
||||
<li class="carte-oeuvre @(oeuvre.Possede ? "oeuvre-possedee" : null)" @key="oeuvre.Titre">
|
||||
@*
|
||||
La case est à GAUCHE du titre, et la ligne est centrée verticalement :
|
||||
c'est la disposition d'une liste qu'on parcourt en cochant. Elle est
|
||||
offerte sur TOUTE œuvre visible, pas seulement sur celles à découvrir —
|
||||
masquer en lot suppose de pouvoir cocher ce qu'on possède déjà.
|
||||
*@
|
||||
<label class="oeuvre-case">
|
||||
<input type="checkbox"
|
||||
checked="@_selection.Contains(oeuvre.Titre)"
|
||||
@onchange="e => Selectionner(oeuvre.Titre, e.Value is true)" />
|
||||
<span class="lecture-ecran">Sélectionner « @oeuvre.Titre »</span>
|
||||
</label>
|
||||
|
||||
@if (oeuvre is { Possede: true, LivreId: { } livreId }
|
||||
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
|
||||
{
|
||||
<CarteLivre Livre="livre" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">
|
||||
@if (oeuvre is { Possede: true, LivreId: { } livreIdPossede })
|
||||
{
|
||||
<a class="lien-auteur" href="@($"livres/{livreIdPossede}")">@oeuvre.Titre</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="carte-titre oeuvre-titre-bouton"
|
||||
@onclick="() => BasculerOeuvre(oeuvre.Titre)"
|
||||
aria-expanded="@Aria.Etat(_oeuvresOuvertes.Contains(oeuvre.Titre))">
|
||||
@oeuvre.Titre
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
|
||||
<p class="carte-details">
|
||||
@if (!string.IsNullOrWhiteSpace(oeuvre.Annee))
|
||||
{
|
||||
<span>@oeuvre.Annee</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(oeuvre.Editeur))
|
||||
{
|
||||
<span>@oeuvre.Editeur</span>
|
||||
}
|
||||
@if (oeuvre.NombreEditions > 1)
|
||||
{
|
||||
<span class="etiquette">@oeuvre.NombreEditions éditions</span>
|
||||
}
|
||||
|
||||
@if (oeuvre.Possede)
|
||||
{
|
||||
<span class="etiquette etiquette-possede">Déjà chez vous</span>
|
||||
}
|
||||
else if (oeuvre.Souhaite)
|
||||
{
|
||||
<span class="etiquette etiquette-souhaite">Dans vos envies</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (_oeuvresOuvertes.Contains(oeuvre.Titre))
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">
|
||||
@if (oeuvre is { Possede: true, LivreId: { } livreId })
|
||||
{
|
||||
@*
|
||||
I2 : la vignette n'est rendue qu'ICI, au dépliage. Une
|
||||
bibliographie compte des dizaines de lignes : les charger
|
||||
toutes d'avance ferait payer des images que personne ne
|
||||
regarde. Les œuvres possédées, elles, ont déjà la leur par
|
||||
CarteLivre.
|
||||
|
||||
⚠️ « Sans ISBN, pas de couverture, et on n'en invente pas » :
|
||||
la formule OpenLibrary s'applique à l'ISBN repris du
|
||||
dc:identifier de la notice, et le substitut à initiale sert
|
||||
de repli quand la notice n'en porte aucun.
|
||||
*@
|
||||
<div class="detail-oeuvre">
|
||||
<Couverture Url="@CouvertureDe(oeuvre)" Titre="@oeuvre.Titre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
<p class="message-discret">
|
||||
@if (!string.IsNullOrWhiteSpace(oeuvre.Isbn))
|
||||
{
|
||||
<span>ISBN : @FormatageIsbn.Afficher(oeuvre.Isbn)</span>
|
||||
}
|
||||
@if (oeuvre.NombreEditions > 1)
|
||||
{
|
||||
<span>@oeuvre.NombreEditions éditions recensées</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<a class="lien-auteur" href="@($"livres/{livreId}")">@oeuvre.Titre</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@oeuvre.Titre
|
||||
}
|
||||
</p>
|
||||
|
||||
@* Les boutons sont groupés à droite, centrés sur la même ligne que le titre :
|
||||
une colonne d'actions alignée se balaie du regard, des boutons empilés au fil
|
||||
du texte non. *@
|
||||
<div class="oeuvre-actions">
|
||||
@if (oeuvre.ADecouvrir)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="() => SouhaiterAsync(oeuvre)"
|
||||
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
|
||||
Je le veux
|
||||
</button>
|
||||
}
|
||||
<p class="carte-details">
|
||||
@if (!string.IsNullOrWhiteSpace(oeuvre.Annee))
|
||||
{
|
||||
<span>@oeuvre.Annee</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(oeuvre.Editeur))
|
||||
{
|
||||
<span>@oeuvre.Editeur</span>
|
||||
}
|
||||
@if (oeuvre.NombreEditions > 1)
|
||||
{
|
||||
<span class="etiquette">@oeuvre.NombreEditions éditions</span>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="() => ModifierMasquageAsync(oeuvre, !oeuvre.Masquee)">
|
||||
@(oeuvre.Masquee ? "Réafficher" : "Masquer")
|
||||
</button>
|
||||
@if (oeuvre.Possede)
|
||||
{
|
||||
<span class="etiquette etiquette-possede">Déjà chez vous</span>
|
||||
}
|
||||
else if (oeuvre.Souhaite)
|
||||
{
|
||||
<span class="etiquette etiquette-souhaite">Dans vos envies</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (oeuvre.ADecouvrir)
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-principal"
|
||||
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
|
||||
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
|
||||
Je le veux
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -402,17 +200,21 @@ else if (_bibliographie is { } biblio)
|
||||
}
|
||||
}
|
||||
|
||||
@* « 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>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int AuteurId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "nouveautes")]
|
||||
public bool Nouveautes { get; set; }
|
||||
|
||||
private BibliographieDto? _bibliographie;
|
||||
private int _modePossession;
|
||||
private bool _filtresOuverts;
|
||||
private bool _afficherMasquees;
|
||||
private bool _seulementManquants;
|
||||
private bool _enCours;
|
||||
private bool _chargement;
|
||||
|
||||
@@ -423,63 +225,21 @@ else if (_bibliographie is { } biblio)
|
||||
private string? _message;
|
||||
|
||||
private int _possedees;
|
||||
private int _masquees;
|
||||
private IReadOnlyList<LivreDto> _livresCatalogue = [];
|
||||
private readonly HashSet<string> _oeuvresOuvertes = [];
|
||||
private readonly HashSet<string> _selection = [];
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ChargerAsync();
|
||||
|
||||
private IEnumerable<OeuvreBibliographie> Affichees(BibliographieDto biblio) =>
|
||||
biblio.Oeuvres.Where(o =>
|
||||
(_afficherMasquees || !o.Masquee)
|
||||
&& (_modePossession == 0
|
||||
|| (_modePossession == 1 && o.Possede)
|
||||
|| (_modePossession == 2 && o.ADecouvrir)));
|
||||
|
||||
private int NombreFiltres =>
|
||||
(_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0);
|
||||
|
||||
/// <summary>
|
||||
/// URL de couverture d'une œuvre non possédée, ou <c>null</c> quand la notice n'a pas
|
||||
/// d'ISBN — auquel cas la vignette retombe sur son substitut à initiale.
|
||||
/// </summary>
|
||||
private static string? CouvertureDe(OeuvreBibliographie oeuvre) =>
|
||||
string.IsNullOrWhiteSpace(oeuvre.Isbn) ? null : IsbnUtils.UrlCouverture(oeuvre.Isbn);
|
||||
|
||||
/// <summary>Les œuvres visibles qui ne sont pas encore cochées.</summary>
|
||||
private List<OeuvreBibliographie> ACocher(BibliographieDto biblio) =>
|
||||
Affichees(biblio).Where(o => !_selection.Contains(o.Titre)).ToList();
|
||||
|
||||
/// <summary>Coche tout ce qui est visible — jamais ce que le filtre écarte.</summary>
|
||||
private void CocherTout(BibliographieDto biblio)
|
||||
{
|
||||
foreach (var oeuvre in ACocher(biblio))
|
||||
{
|
||||
_selection.Add(oeuvre.Titre);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Les œuvres cochées qui peuvent effectivement rejoindre la liste d'envies.</summary>
|
||||
private List<OeuvreBibliographie> SelectionADecouvrir(BibliographieDto biblio) =>
|
||||
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.ADecouvrir).ToList();
|
||||
|
||||
private List<OeuvreBibliographie> SelectionParMasquage(BibliographieDto biblio, bool masquees) =>
|
||||
biblio.Oeuvres.Where(o => _selection.Contains(o.Titre) && o.Masquee == masquees).ToList();
|
||||
_seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_erreur = null;
|
||||
_reessayable = false;
|
||||
_chargement = true;
|
||||
_selection.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
_bibliographie = Nouveautes
|
||||
? await Api.ObtenirNouveautesAsync(AuteurId)
|
||||
: await Api.ObtenirBibliographieAsync(AuteurId);
|
||||
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
|
||||
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
|
||||
|
||||
if (_bibliographie is null)
|
||||
{
|
||||
@@ -489,7 +249,7 @@ else if (_bibliographie is { } biblio)
|
||||
return;
|
||||
}
|
||||
|
||||
RecompterAsync();
|
||||
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -512,164 +272,18 @@ else if (_bibliographie is { } biblio)
|
||||
}
|
||||
}
|
||||
|
||||
private void RecompterAsync()
|
||||
{
|
||||
_possedees = _bibliographie?.Oeuvres.Count(o => o.Possede) ?? 0;
|
||||
_masquees = _bibliographie?.Oeuvres.Count(o => o.Masquee) ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applique en mémoire ce que l'écriture vient de changer, au lieu de tout recharger.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ C'est LA correction de lenteur signalée en usage : chaque « Je le veux » et chaque
|
||||
/// « Masquer » relançaient un <c>ChargerAsync</c> complet, donc une interrogation de la BnF
|
||||
/// (une seconde par page, jusqu'à dix pages pour les nouveautés, qui ne sont pas mises en
|
||||
/// cache) suivie d'une relecture de tout le catalogue. L'écran attendait une source
|
||||
/// distante pour apprendre un drapeau <b>personnel</b> qu'il connaissait déjà.
|
||||
/// <para>
|
||||
/// Le cache partagé de <see cref="ServiceLivresApi"/> est corrigé de la même façon de son
|
||||
/// côté ; ici on corrige la copie que cet écran tient en main, celle des nouveautés n'étant
|
||||
/// justement pas dans ce cache.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void MettreAJourOeuvre(string titre, Func<OeuvreBibliographie, OeuvreBibliographie> correction)
|
||||
{
|
||||
if (_bibliographie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bibliographie = _bibliographie with
|
||||
{
|
||||
Oeuvres = _bibliographie.Oeuvres
|
||||
.Select(o => o.Titre == titre ? correction(o) : o)
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
RecompterAsync();
|
||||
}
|
||||
|
||||
private void Selectionner(string titre, bool selectionnee)
|
||||
{
|
||||
if (selectionnee)
|
||||
{
|
||||
_selection.Add(titre);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selection.Remove(titre);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AjouterSelectionAsync()
|
||||
{
|
||||
if (_bibliographie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oeuvres = SelectionADecouvrir(_bibliographie);
|
||||
var saisies = oeuvres.Select(Saisie).ToList();
|
||||
|
||||
_enCours = true;
|
||||
_message = null;
|
||||
var resultats = await Api.AjouterSouhaitsAsync(saisies);
|
||||
_enCours = false;
|
||||
|
||||
// Chaque envie réussie bascule sa ligne ; les échecs gardent la leur intacte et sont
|
||||
// rapportés. Rien n'est rechargé — voir MettreAJourOeuvre.
|
||||
for (var i = 0; i < oeuvres.Count; i++)
|
||||
{
|
||||
if (resultats[i].EstOk)
|
||||
{
|
||||
var id = resultats[i].Valeur?.Id;
|
||||
MettreAJourOeuvre(oeuvres[i].Titre, o => o with { Souhaite = true, SouhaitId = id });
|
||||
}
|
||||
}
|
||||
|
||||
var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList();
|
||||
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs);
|
||||
_selection.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Masque (ou réaffiche) toutes les œuvres cochées qui sont dans l'autre état.</summary>
|
||||
private async Task MasquerSelectionAsync(bool masquer)
|
||||
{
|
||||
if (_bibliographie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oeuvres = SelectionParMasquage(_bibliographie, !masquer);
|
||||
|
||||
_enCours = true;
|
||||
_message = null;
|
||||
|
||||
var erreurs = new List<string>();
|
||||
foreach (var oeuvre in oeuvres)
|
||||
{
|
||||
var motif = masquer
|
||||
? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
|
||||
: await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
|
||||
|
||||
if (motif is null)
|
||||
{
|
||||
MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
|
||||
}
|
||||
else
|
||||
{
|
||||
erreurs.Add(motif);
|
||||
}
|
||||
}
|
||||
|
||||
_enCours = false;
|
||||
_message = erreurs.Count == 0 ? null : string.Join(" ", erreurs.Distinct());
|
||||
_selection.Clear();
|
||||
}
|
||||
|
||||
private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool masquer)
|
||||
{
|
||||
_enCours = true;
|
||||
_message = masquer
|
||||
? await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre)
|
||||
: await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre);
|
||||
_enCours = false;
|
||||
|
||||
if (_message is null)
|
||||
{
|
||||
MettreAJourOeuvre(oeuvre.Titre, o => o with { Masquee = masquer });
|
||||
}
|
||||
}
|
||||
|
||||
private void BasculerOeuvre(string titre)
|
||||
{
|
||||
if (!_oeuvresOuvertes.Add(titre))
|
||||
{
|
||||
_oeuvresOuvertes.Remove(titre);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ce que la BnF sait de l'œuvre, mis en forme pour la liste d'envies.
|
||||
/// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// L'auteur enregistré est celui de l'écran, pas celui de la notice : c'est lui que
|
||||
/// l'utilisateur a en tête, et c'est sous ce nom qu'il retrouvera l'envie dans l'export.
|
||||
/// <para>
|
||||
/// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
|
||||
/// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
|
||||
/// Après l'ajout, la bibliographie est <b>relue</b> plutôt que corrigée en mémoire : c'est
|
||||
/// le serveur qui sait rapprocher l'envie de l'œuvre, et refaire ce calcul ici le
|
||||
/// dupliquerait — avec le risque qu'il diverge.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private EnregistrementSouhait Saisie(OeuvreBibliographie oeuvre) => new()
|
||||
{
|
||||
Titre = oeuvre.Titre,
|
||||
Auteur = _bibliographie!.Auteur.Nom,
|
||||
Editeur = oeuvre.Editeur,
|
||||
Annee = oeuvre.Annee,
|
||||
Isbn = oeuvre.Isbn,
|
||||
};
|
||||
|
||||
private async Task SouhaiterAsync(OeuvreBibliographie oeuvre)
|
||||
{
|
||||
if (_bibliographie is null)
|
||||
@@ -680,7 +294,17 @@ else if (_bibliographie is { } biblio)
|
||||
_enCours = true;
|
||||
_message = null;
|
||||
|
||||
var resultat = await Api.AjouterSouhaitAsync(Saisie(oeuvre));
|
||||
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
|
||||
{
|
||||
Titre = oeuvre.Titre,
|
||||
Auteur = _bibliographie.Auteur.Nom,
|
||||
Editeur = oeuvre.Editeur,
|
||||
Annee = oeuvre.Annee,
|
||||
|
||||
// L'ISBN de la BnF est celui d'UNE édition relevée, souvent à 10 chiffres et parfois
|
||||
// épuisée. Il est repris tel quel : il aide à commander, sans engager le choix.
|
||||
Isbn = oeuvre.Isbn,
|
||||
});
|
||||
|
||||
_enCours = false;
|
||||
|
||||
@@ -690,8 +314,6 @@ else if (_bibliographie is { } biblio)
|
||||
return;
|
||||
}
|
||||
|
||||
MettreAJourOeuvre(
|
||||
oeuvre.Titre,
|
||||
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@page "/"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — catalogue</PageTitle>
|
||||
@@ -12,91 +11,30 @@
|
||||
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.
|
||||
*@
|
||||
@*
|
||||
⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre
|
||||
flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons
|
||||
du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même
|
||||
écran seraient du bruit, et le projet désencombre par le regroupement, jamais par
|
||||
l'effacement.
|
||||
|
||||
⚠️ Hors-ligne il 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.
|
||||
*@
|
||||
<div class="entete-page">
|
||||
<h1 class="titre-page">Catalogue</h1>
|
||||
|
||||
<div class="entete-page-actions">
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
|
||||
@if (_grouper && Entrees.Any(e => e.EstGroupe))
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
|
||||
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
|
||||
</button>
|
||||
}
|
||||
|
||||
@*
|
||||
« Modifier en lot » n'apparaît qu'à partir de DEUX livres — même seuil que sur la fiche
|
||||
série. Reste possible hors-ligne : l'écriture, elle, est bloquée par livre comme
|
||||
partout ailleurs (⚠️ MotifBlocage).
|
||||
*@
|
||||
@if (Reseau.EnLigne && _livres is { Count: > 1 })
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-catalogue"
|
||||
@onclick="BasculerLot">
|
||||
Modifier en lot
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal bouton-compact" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</div>
|
||||
<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)
|
||||
{
|
||||
@* Le « cliquer sur un auteur » d'CLAUDE.md : le catalogue est ici restreint à ses livres. *@
|
||||
@* Le « cliquer sur un auteur » d'IDEES.md : le catalogue est ici restreint à ses livres. *@
|
||||
<p class="message-discret">
|
||||
Livres de <strong>@_auteur.Nom</strong>.
|
||||
<a href="">Revenir à toute la bibliothèque</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
|
||||
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
|
||||
|
||||
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
|
||||
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
|
||||
*@
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
@onclick="() => _scanOuvert = !_scanOuvert">
|
||||
@(_scanOuvert ? "Fermer" : "Scanner")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_scanOuvert)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
|
||||
}
|
||||
|
||||
@if (_filtresOuverts)
|
||||
{
|
||||
<div class="filtres" id="filtres-catalogue">
|
||||
@@ -116,24 +54,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne
|
||||
n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée.
|
||||
⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai
|
||||
pas encore rangé ? » est justement la question qui se pose après coup. *@
|
||||
@if (PlusieursTypes)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Filtrer par type de document">
|
||||
<button type="button" class="segment @(_type is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerType(null)">Tous</button>
|
||||
@foreach (var type in Enum.GetValues<TypeDocument>())
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @Libelles.ClasseTypeDocument(type) @(_type == type ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerType(type)">@Libelles.TypeDocument(type)</button>
|
||||
}
|
||||
</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 ». L'écran
|
||||
l'assume au lieu de laisser croire à une vue commune. *@
|
||||
@@ -142,81 +62,14 @@
|
||||
@onclick="() => FiltrerStatut(null)">Tous</button>
|
||||
@foreach (var statut in Enum.GetValues<Statut>())
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @Libelles.ClasseStatut(statut) @(_statut == statut ? "segment-actif" : null)"
|
||||
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@*
|
||||
Le prêt, lui, est COMMUN au foyer : ce filtre ne dépend de personne. L'écran
|
||||
« Prêts » répond déjà à « qu'est-ce qui est dehors ? » ; celui-ci sert l'autre
|
||||
moitié de la question — « qu'est-ce que j'ai réellement sous la main ? » —, celle
|
||||
qu'on se pose avant de promettre un livre à quelqu'un.
|
||||
|
||||
⚠️ Même règle que pour les formats et les types : la ligne disparaît tant que rien
|
||||
n'est prêté, où elle n'offrirait qu'un choix utile. Et comme pour eux, elle ne se
|
||||
déduit QUE d'un chargement sans critère, sinon une recherche qui ne remonte que des
|
||||
livres à la maison escamoterait le filtre sans qu'on puisse l'élargir.
|
||||
*@
|
||||
@if (QuelqueChoseEstPrete)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Filtrer par prêt en cours">
|
||||
<button type="button" class="segment @(_prete is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerPret(null)">Tous</button>
|
||||
<button type="button" class="segment @(_prete == false ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerPret(false)">À la maison</button>
|
||||
<button type="button"
|
||||
class="segment segment-prete @(_prete == true ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerPret(true)">Prêtés</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
« Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce
|
||||
filtre répond, et la raison pour laquelle il existe.
|
||||
|
||||
⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne
|
||||
se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que
|
||||
CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre
|
||||
entrerait et sortirait du filtre d'un rafraîchissement à l'autre.
|
||||
|
||||
⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien.
|
||||
Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre
|
||||
n'a de couverture, la liste complète EST déjà celle des livres à illustrer.
|
||||
Et comme partout, cela ne se déduit QUE d'un chargement sans critère.
|
||||
*@
|
||||
@if (CouverturesInegales)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Filtrer par couverture">
|
||||
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(null)">Tous</button>
|
||||
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
|
||||
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur
|
||||
du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle
|
||||
se voit d'ailleurs dans la liste elle-même : avec ou sans blocs de série.
|
||||
|
||||
Décochée, on retrouve exactement le catalogue d'avant : tous les livres à plat.
|
||||
*@
|
||||
@if (_series is { Count: > 0 })
|
||||
{
|
||||
<label class="bascule">
|
||||
<input type="checkbox" checked="@_grouper"
|
||||
@onchange="e => _grouper = (bool?)e.Value ?? true" />
|
||||
<span>Grouper les livres d'une même série</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
<p class="champ-aide">
|
||||
Le statut de lecture est <strong>le vôtre</strong> : filtrer dessus montre votre
|
||||
lecture, pas celle du foyer. Le prêt, lui, est commun.
|
||||
lecture, pas celle du foyer.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -228,7 +81,7 @@
|
||||
|
||||
@if (_chargement && _livres is null)
|
||||
{
|
||||
<Patience Message="Chargement du catalogue…" />
|
||||
<p class="message-discret">Chargement…</p>
|
||||
}
|
||||
else if (_livres is { Count: 0 })
|
||||
{
|
||||
@@ -240,195 +93,102 @@ else if (_livres is not null)
|
||||
{
|
||||
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
|
||||
|
||||
@*
|
||||
Modification en lot : même patron que la fiche série (barre collante, formulaire affiché
|
||||
sous elle) — sauf que la sélection porte ici sur le catalogue ENTIER, groupé ou non, d'où
|
||||
le bouton « Tout sélectionner » qui coche d'un coup tous les livres actuellement chargés
|
||||
(donc déjà filtrés : cocher les 400 livres du fonds derrière un filtre à trois résultats
|
||||
n'aurait aucun sens).
|
||||
*@
|
||||
@if (_lotOuvert)
|
||||
{
|
||||
<div id="lot-catalogue">
|
||||
@if (_selectionLot.Count > 0)
|
||||
{
|
||||
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
|
||||
<span class="selection-compte">
|
||||
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
|
||||
</span>
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@_enCoursLot" @onclick="ToutSelectionnerLot">
|
||||
Tout sélectionner (@_livres!.Count)
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@_enCoursLot" @onclick="() => _selectionLot.Clear()">
|
||||
Tout décocher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
|
||||
Erreur="@_erreurLot" EnCours="_enCoursLot" MessageBlocage="@MotifBlocageLot"
|
||||
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="message-discret">
|
||||
Cochez les livres à modifier ensemble, ou
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
@onclick="ToutSelectionnerLot">tout sélectionner (@_livres!.Count)</button>.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
|
||||
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
|
||||
cherchait à sa lettre. Le nom du bloc MÈNE À LA SÉRIE, où figurent aussi les tomes qu'on
|
||||
ne possède pas — ce que le catalogue, lui, ne peut pas montrer.
|
||||
|
||||
Chaque bloc se REPLIE, et un cycle replie chacun de ses niveaux : un cycle de vingt
|
||||
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
|
||||
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
|
||||
toujours ce qu'il contient.
|
||||
|
||||
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
|
||||
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
|
||||
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
|
||||
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
|
||||
*@
|
||||
<ul class="liste-livres">
|
||||
@foreach (var entree in Entrees)
|
||||
@foreach (var livre in _livres)
|
||||
{
|
||||
@if (entree.EstGroupe)
|
||||
{
|
||||
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries"
|
||||
LotActif="_lotOuvert" SelectionLot="_selectionLot"
|
||||
OnSelectionner="args => SelectionnerLot(args.LivreId, args.Coche)" />
|
||||
}
|
||||
else if (_lotOuvert)
|
||||
{
|
||||
<li class="carte-oeuvre" @key="entree.Livres[0].Id">
|
||||
<label class="oeuvre-case">
|
||||
<input type="checkbox"
|
||||
checked="@_selectionLot.Contains(entree.Livres[0].Id)"
|
||||
@onchange="e => SelectionnerLot(entree.Livres[0].Id, e.Value is true)" />
|
||||
<span class="lecture-ecran">Sélectionner « @entree.Livres[0].Titre »</span>
|
||||
</label>
|
||||
<CarteLivre Livre="entree.Livres[0]" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li @key="entree.Livres[0].Id">
|
||||
<CarteLivre Livre="entree.Livres[0]" />
|
||||
</li>
|
||||
}
|
||||
<li class="carte-livre" @key="livre.Id">
|
||||
@* La vignette s'agrandit au clic ; le titre, lui, ouvre la fiche. *@
|
||||
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
|
||||
|
||||
@if (livre.Auteurs.Count > 0)
|
||||
{
|
||||
<p class="carte-auteur">
|
||||
@foreach (var (auteur, index) in livre.Auteurs.Select((a, i) => (a, i)))
|
||||
{
|
||||
@(index > 0 ? ", " : "")
|
||||
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Une seule ligne d'étiquettes, en lecture seule (IDEES.md) : la liste se
|
||||
parcourt, elle ne se manipule pas. Tout se modifie depuis la fiche. *@
|
||||
<p class="carte-details">
|
||||
@* Un livre est physique par défaut : ne l'afficher que pour les ebooks,
|
||||
sinon l'étiquette est sur toutes les lignes et n'apprend rien. *@
|
||||
@if (livre.Format == Format.Numerique)
|
||||
{
|
||||
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
||||
}
|
||||
|
||||
@* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer.
|
||||
Pas de pastille = non commencé, l'absence de ligne en base. *@
|
||||
@if (livre.Statut is { } statut)
|
||||
{
|
||||
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
|
||||
@Libelles.Statut(statut)
|
||||
</span>
|
||||
}
|
||||
|
||||
@* Un livre dehors l'est pour tout le foyer, contrairement au statut. *@
|
||||
@if (livre.PreteA is { } emprunteur)
|
||||
{
|
||||
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
|
||||
{
|
||||
<span>@livre.Editeur</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@*
|
||||
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 un livre</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un livre</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
|
||||
[SupplyParameterFromQuery(Name = "auteur")]
|
||||
public int? AuteurId { get; set; }
|
||||
|
||||
private IReadOnlyList<LivreDto>? _livres;
|
||||
|
||||
/// <summary>
|
||||
/// Les séries du foyer, pour regrouper leurs tomes. <c>null</c> = pas encore lues.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Elles ne sont lues <b>qu'une fois</b> par visite (et à chaque bascule du réseau), pas à
|
||||
/// chaque chargement du catalogue : celui-ci se relit à chaque frappe dans la recherche, et
|
||||
/// une requête de séries par caractère serait du gâchis pour une liste qui ne bouge pas
|
||||
/// pendant qu'on tape. Le prix est qu'un tome rattaché depuis un autre écran n'apparaît
|
||||
/// groupé qu'au prochain passage ici.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<SerieDto>? _series;
|
||||
|
||||
/// <summary>Regroupement actif — l'utilisateur peut le défaire, rien ne disparaît alors.</summary>
|
||||
private bool _grouper = true;
|
||||
|
||||
/// <summary>
|
||||
/// Blocs ouverts à la main, par identifiant de série. Tout est replié par défaut.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
|
||||
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
|
||||
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
|
||||
/// lecture, pas une propriété de la série.
|
||||
/// </remarks>
|
||||
private readonly HashSet<int> _ouverts = [];
|
||||
|
||||
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
|
||||
|
||||
private void ToutReplier() => _ouverts.Clear();
|
||||
|
||||
private void ToutDeplier()
|
||||
{
|
||||
foreach (var id in IdentifiantsSeries(Entrees))
|
||||
{
|
||||
_ouverts.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
|
||||
{
|
||||
foreach (var entree in entrees)
|
||||
{
|
||||
if (!entree.EstGroupe)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return entree.Serie!.Id;
|
||||
|
||||
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
|
||||
{
|
||||
yield return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ce que chaque série possède et recense, descendance comprise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
|
||||
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
|
||||
/// </remarks>
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
|
||||
new Dictionary<int, (int, int)>();
|
||||
|
||||
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
|
||||
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
||||
GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null);
|
||||
private AuteurDto? _auteur;
|
||||
private int? _auteurCharge;
|
||||
private string _recherche = string.Empty;
|
||||
private Format? _format;
|
||||
private TypeDocument? _type;
|
||||
private Statut? _statut;
|
||||
private bool? _prete;
|
||||
|
||||
private bool? _sansCouverture;
|
||||
private bool _chargement;
|
||||
private bool _filtresOuverts;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>Le panneau de modification en lot est déplié. Même patron que la fiche série.</summary>
|
||||
private bool _lotOuvert;
|
||||
|
||||
/// <summary>Identifiants des livres cochés pour la modification en lot.</summary>
|
||||
private readonly HashSet<int> _selectionLot = [];
|
||||
|
||||
private SaisieLot _saisieLot = new();
|
||||
private bool _enCoursLot;
|
||||
private string? _erreurLot;
|
||||
|
||||
private string? MotifBlocageLot => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>
|
||||
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
|
||||
/// </summary>
|
||||
@@ -443,32 +203,6 @@ else if (_livres is not null)
|
||||
|
||||
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
|
||||
|
||||
/// <summary>Types réellement présents dans le fonds. Mêmes précautions que les formats.</summary>
|
||||
private IReadOnlySet<TypeDocument>? _typesDuFonds;
|
||||
|
||||
private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si au moins un livre du fonds est actuellement dehors — ou qu'on ne le sait pas.
|
||||
/// </summary>
|
||||
/// <remarks>Mêmes précautions que les formats : dans le doute, la ligne reste affichée.</remarks>
|
||||
private bool? _fondsAvecPret;
|
||||
|
||||
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
|
||||
/// sait pas encore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours :
|
||||
/// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste
|
||||
/// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée.
|
||||
/// </remarks>
|
||||
private bool? _fondsCouverturesInegales;
|
||||
|
||||
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
||||
/// </summary>
|
||||
@@ -477,16 +211,13 @@ else if (_livres is not null)
|
||||
/// 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) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
|
||||
+ (_prete is null ? 0 : 1) + (_sansCouverture is null ? 0 : 1);
|
||||
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();
|
||||
|
||||
private bool EstFiltre =>
|
||||
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|
||||
|| _statut is not null || _prete is not null || _sansCouverture is not null
|
||||
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null
|
||||
|| AuteurId is not null;
|
||||
|
||||
/// <summary>
|
||||
@@ -495,42 +226,14 @@ else if (_livres is not null)
|
||||
/// </summary>
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
/// <summary>
|
||||
/// Lit les séries, et se contente de ne pas grouper si elles manquent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Une lecture ratée ne doit rien coûter au catalogue : sans série, la liste s'affiche à
|
||||
/// plat, ce qui est exactement l'écran d'avant. Hors-ligne, la lecture retombe d'elle-même
|
||||
/// sur l'instantané « series ».
|
||||
/// </remarks>
|
||||
private async Task ChargerSeriesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_series = await Api.ListerSeriesAsync();
|
||||
_totauxSeries = TotauxSeries.Calculer(_series);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_series = null;
|
||||
_totauxSeries = new Dictionary<int, (int, int)>();
|
||||
}
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerSeriesAsync();
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_series is null)
|
||||
{
|
||||
await ChargerSeriesAsync();
|
||||
}
|
||||
|
||||
if (_auteurCharge != AuteurId)
|
||||
{
|
||||
_auteurCharge = AuteurId;
|
||||
@@ -546,86 +249,23 @@ else if (_livres is not null)
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
|
||||
private bool _scanOuvert;
|
||||
|
||||
/// <summary>
|
||||
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
|
||||
/// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne
|
||||
/// sont pas au catalogue.
|
||||
/// </remarks>
|
||||
private async Task SurCodeScanneAsync(CodeScanne lecture)
|
||||
{
|
||||
_scanOuvert = false;
|
||||
_recherche = lecture.Code;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un
|
||||
/// élément à cliquer.
|
||||
/// <para>
|
||||
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
|
||||
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
|
||||
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
|
||||
/// est un hasard, et l'écran sauterait sous les doigts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool OuvrirSiUnique()
|
||||
{
|
||||
if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo($"livres/{_livres[0].Id}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private Task FiltrerFormat(Format? format)
|
||||
{
|
||||
_format = format;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerType(TypeDocument? type)
|
||||
{
|
||||
_type = type;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerStatut(Statut? statut)
|
||||
{
|
||||
_statut = statut;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerPret(bool? prete)
|
||||
{
|
||||
_prete = prete;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerCouverture(bool? sansCouverture)
|
||||
{
|
||||
_sansCouverture = sansCouverture;
|
||||
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.TypeDocument is null
|
||||
&& criteres.Statut is null
|
||||
&& criteres.Prete is null
|
||||
&& criteres.SansCouverture is null
|
||||
&& criteres.AuteurId is null;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
@@ -644,28 +284,15 @@ else if (_livres is not null)
|
||||
{
|
||||
Recherche = _recherche,
|
||||
Format = _format,
|
||||
TypeDocument = _type,
|
||||
Statut = _statut,
|
||||
Prete = _prete,
|
||||
SansCouverture = _sansCouverture,
|
||||
AuteurId = AuteurId,
|
||||
};
|
||||
|
||||
_livres = await Api.ListerAsync(criteres, jeton);
|
||||
|
||||
if (OuvrirSiUnique())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EstSansCritere(criteres))
|
||||
{
|
||||
_formatsDuFonds = _livres.Select(l => l.Format).ToHashSet();
|
||||
_typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet();
|
||||
_fondsAvecPret = _livres.Any(l => l.PreteA is not null);
|
||||
_fondsCouverturesInegales =
|
||||
_livres.Any(l => string.IsNullOrEmpty(l.CoverUrl))
|
||||
&& _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -687,110 +314,6 @@ else if (_livres is not null)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
|
||||
/// la saisie en cours — même règle que sur la fiche série : un panneau qui se rouvrirait sur
|
||||
/// l'état d'avant laisserait croire à une saisie encore active.
|
||||
/// </summary>
|
||||
private void BasculerLot()
|
||||
{
|
||||
_lotOuvert = !_lotOuvert;
|
||||
_erreurLot = null;
|
||||
_selectionLot.Clear();
|
||||
_saisieLot = new SaisieLot();
|
||||
}
|
||||
|
||||
private void SelectionnerLot(int livreId, bool selectionne)
|
||||
{
|
||||
if (selectionne)
|
||||
{
|
||||
_selectionLot.Add(livreId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectionLot.Remove(livreId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coche d'un coup tous les livres actuellement CHARGÉS — donc déjà filtrés. Un groupe replié
|
||||
/// n'y échappe pas : la sélection porte sur <c>_livres</c>, pas sur ce que l'écran affiche.
|
||||
/// </summary>
|
||||
private void ToutSelectionnerLot()
|
||||
{
|
||||
foreach (var livre in _livres ?? [])
|
||||
{
|
||||
_selectionLot.Add(livre.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private void AnnulerLot()
|
||||
{
|
||||
_selectionLot.Clear();
|
||||
_saisieLot = new SaisieLot();
|
||||
_erreurLot = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
|
||||
/// la fiche série : pas de point d'entrée dédié, la boucle réutilise
|
||||
/// <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs cochés et en
|
||||
/// reprenant tels quels ceux qui ne le sont pas.
|
||||
/// </summary>
|
||||
private async Task EnregistrerLotAsync()
|
||||
{
|
||||
_enCoursLot = true;
|
||||
_erreurLot = null;
|
||||
|
||||
var erreurs = new List<string>();
|
||||
|
||||
foreach (var livreId in _selectionLot)
|
||||
{
|
||||
var livre = (_livres ?? []).FirstOrDefault(l => l.Id == livreId);
|
||||
if (livre is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var saisie = new EnregistrementLivre
|
||||
{
|
||||
Isbn = livre.Isbn,
|
||||
Titre = livre.Titre,
|
||||
Auteurs = _saisieLot.ModifierAuteurs
|
||||
? _saisieLot.Auteurs
|
||||
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
|
||||
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
|
||||
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
|
||||
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
|
||||
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
|
||||
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
|
||||
NombrePages = livre.NombrePages,
|
||||
CoverUrl = livre.CoverUrl,
|
||||
UrlNotice = livre.UrlNotice,
|
||||
};
|
||||
|
||||
var resultat = await Api.ModifierAsync(livre.Id, saisie);
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
erreurs.Add(resultat.Erreur!);
|
||||
}
|
||||
}
|
||||
|
||||
_enCoursLot = false;
|
||||
|
||||
if (erreurs.Count > 0)
|
||||
{
|
||||
_erreurLot = string.Join(" ", erreurs.Distinct());
|
||||
await ChargerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_lotOuvert = false;
|
||||
_selectionLot.Clear();
|
||||
_saisieLot = new SaisieLot();
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
@page "/souhaits/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Écran dédié plutôt qu'un formulaire déplié dans la liste : convention du projet depuis la
|
||||
fiche livre — le mode est dans l'ADRESSE, jamais dans un booléen interne. C'est ce qui rend
|
||||
le bouton « retour » du navigateur une sortie d'édition naturelle, et ce qui empêche de
|
||||
modifier une envie en faisant défiler sa liste.
|
||||
|
||||
⚠️ Cette page ne partage aucun paramètre avec une autre route : contrairement à la fiche
|
||||
livre ou à la fiche série, elle n'a pas besoin de s'abonner à LocationChanged. L'abonnement
|
||||
ci-dessous ne sert qu'à suivre l'état du réseau.
|
||||
*@
|
||||
|
||||
<PageTitle>MaBibli — modifier une envie</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Modifier une envie</h1>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (_introuvable)
|
||||
{
|
||||
@* Hors-ligne, « supprimée » et « pas encore enregistrée ici » sont indiscernables : on ne
|
||||
tranche pas à la place de l'utilisateur. Même règle que pour une fiche livre absente. *@
|
||||
<p class="message-discret">
|
||||
@(Reseau.EnLigne
|
||||
? "Cette envie n'existe plus dans votre liste."
|
||||
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
|
||||
</p>
|
||||
}
|
||||
else if (_saisie is null)
|
||||
{
|
||||
<Patience Message="Ouverture de l'envie…" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="formulaire">
|
||||
@* Même ligne que dans le formulaire d'un ouvrage (FormulaireLivre) : un aperçu quand il y
|
||||
a quelque chose à montrer, puis un champ d'adresse ordinaire, au même titre que les
|
||||
autres. *@
|
||||
@if (!string.IsNullOrEmpty(_saisie.CoverUrl) || !string.IsNullOrWhiteSpace(_saisie.Titre))
|
||||
{
|
||||
<div class="formulaire-apercu">
|
||||
<Couverture Url="@_saisie.CoverUrl" Titre="@_saisie.Titre" Classe="couverture-moyenne" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Auteur</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Année</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Annee" @bind:event="oninput"
|
||||
placeholder="2012" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@* Le champ de saisie garde la valeur nue : découper à la frappe se battrait avec le
|
||||
curseur. Les tirets sont une affaire d'affichage, pas de saisie. *@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" inputmode="numeric"
|
||||
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">URL de couverture</span>
|
||||
<input class="champ-saisie" type="url" @bind="_saisie.CoverUrl" @bind:event="oninput"
|
||||
placeholder="https://…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
|
||||
placeholder="« demandé à Noël », « en poche seulement »…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
|
||||
title="@MotifBlocage" @onclick="EnregistrerAsync">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private EnregistrementSouhait? _saisie;
|
||||
private bool _introuvable;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// La couverture telle que chargée — éventuellement déduite de l'ISBN (voir <c>Projeter</c>
|
||||
/// côté serveur), pas forcément ce qu'il y a réellement en base. Sert uniquement à détecter,
|
||||
/// à l'enregistrement, si l'utilisateur a touché au champ.
|
||||
/// </summary>
|
||||
private string? _couvertureChargee;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>
|
||||
/// Reprend l'envie depuis la <b>liste</b>, et non depuis un point d'entrée dédié.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// L'API n'expose pas <c>GET /api/souhaits/{id}</c>, et il n'y a pas lieu de l'ajouter : la
|
||||
/// liste entière est déjà chargée, déjà mise en instantané, et c'est ce qui rend cet écran
|
||||
/// consultable hors-ligne — même si l'enregistrement, lui, y est refusé.
|
||||
/// </remarks>
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
var souhaits = await Api.ListerSouhaitsAsync();
|
||||
var souhait = souhaits.FirstOrDefault(s => s.Id == Id);
|
||||
|
||||
if (souhait is null)
|
||||
{
|
||||
_introuvable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_couvertureChargee = souhait.CoverUrl;
|
||||
|
||||
_saisie = new EnregistrementSouhait
|
||||
{
|
||||
Titre = souhait.Titre,
|
||||
Auteur = souhait.Auteur,
|
||||
Editeur = souhait.Editeur,
|
||||
Annee = souhait.Annee,
|
||||
Isbn = souhait.Isbn,
|
||||
CoverUrl = souhait.CoverUrl,
|
||||
Note = souhait.Note,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ Envoyée seulement si elle a changé depuis le chargement, et non à chaque
|
||||
// enregistrement : la valeur affichée peut être une DÉDUCTION à la lecture (formule
|
||||
// OpenLibrary à ISBN connu, voir `Projeter`) plutôt que ce qu'il y a réellement en base.
|
||||
// La renvoyer telle quelle à chaque correction de titre la figerait en base, et une
|
||||
// correction d'ISBN qui suit ne la referait plus jouer.
|
||||
_saisie.CoverUrlModifiee = !string.Equals(
|
||||
_saisie.CoverUrl?.Trim() ?? string.Empty,
|
||||
_couvertureChargee?.Trim() ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierSouhaitAsync(Id, _saisie);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
// Le message vient de l'API : doublon d'une autre envie, ISBN invalide, ou hors
|
||||
// ligne. Aucun code HTTP brut n'arrive jusqu'ici.
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
@page "/souhaits/revues/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Le même geste que pour une envie de livre, sur la table sœur : écran dédié, mode dans
|
||||
l'adresse. Les deux formulaires ne se fondent pas en un seul — une revue n'a ni auteur, ni
|
||||
éditeur, ni ISBN, et un formulaire à moitié grisé se lit plus mal que deux formulaires courts.
|
||||
*@
|
||||
|
||||
<PageTitle>MaBibli — modifier une envie de revue</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Modifier une envie de revue</h1>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (_introuvable)
|
||||
{
|
||||
<p class="message-discret">
|
||||
@(Reseau.EnLigne
|
||||
? "Cette envie n'existe plus dans votre liste."
|
||||
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
|
||||
</p>
|
||||
}
|
||||
else if (_saisie is null)
|
||||
{
|
||||
<Patience Message="Ouverture de l'envie…" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="formulaire">
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Titre de la revue <span class="obligatoire" aria-hidden="true">*</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
|
||||
placeholder="Médor" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Numéro <span class="champ-aide">(vide = la revue entière)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Numero" @bind:event="oninput"
|
||||
placeholder="43, hors-série 7…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@* La saisie garde ce qui est tapé : le tiret est posé à l'enregistrement, jamais à la
|
||||
frappe — sinon il se battrait avec le curseur. *@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISSN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Issn" @bind:event="oninput"
|
||||
placeholder="2466-6718" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
|
||||
placeholder="« vu chez le dentiste »…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
|
||||
title="@MotifBlocage" @onclick="EnregistrerAsync">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private EnregistrementRevueSouhaitee? _saisie;
|
||||
private bool _introuvable;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
var envies = await Api.ListerRevuesSouhaiteesAsync();
|
||||
var envie = envies.FirstOrDefault(e => e.Id == Id);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
_introuvable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_saisie = new EnregistrementRevueSouhaitee
|
||||
{
|
||||
Titre = envie.Titre,
|
||||
Numero = envie.Numero,
|
||||
Issn = envie.Issn,
|
||||
Note = envie.Note,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierRevueSouhaiteeAsync(Id, _saisie);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Une seule page, deux modes (CLAUDE.md) :
|
||||
Une seule page, deux modes (IDEES.md) :
|
||||
|
||||
— consultation par défaut. Ouvrir une fiche depuis le catalogue ne doit rien pouvoir casser,
|
||||
surtout au doigt sur un téléphone.
|
||||
@@ -91,17 +91,8 @@ else
|
||||
}
|
||||
|
||||
<div class="fiche-entete">
|
||||
@*
|
||||
⚠️ Une couverture CASSÉE devient un point de correction : cliquer dessus ouvre son
|
||||
adresse, focalisée, et « Entrée » enregistre. C'est là qu'on constate le défaut, et
|
||||
c'est donc là qu'il faut pouvoir le réparer — passer par « Éditer » puis descendre
|
||||
au dernier champ du formulaire pour corriger un lien mort était un détour.
|
||||
|
||||
Une couverture qui s'affiche, elle, s'agrandit comme avant.
|
||||
*@
|
||||
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
|
||||
Classe="couverture-grande" Agrandissable="true"
|
||||
OnCorriger="OuvrirCorrectionCouverture" />
|
||||
Classe="couverture-grande" Agrandissable="true" />
|
||||
|
||||
<div class="fiche-infos">
|
||||
<h2 class="fiche-titre">@_livre.Titre</h2>
|
||||
@@ -113,27 +104,11 @@ else
|
||||
@foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
|
||||
{
|
||||
@(index > 0 ? ", " : "")
|
||||
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>@if (auteur.Role != RoleAuteur.NonPrecise)
|
||||
{
|
||||
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
|
||||
}
|
||||
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
Les séries auxquelles ce livre appartient. Il peut y en avoir plusieurs — une
|
||||
trilogie, une intégrale, un cycle — d'où une liste et non un champ.
|
||||
⚠️ Lu depuis l'instantané des séries, donc disponible hors-ligne comme le reste.
|
||||
*@
|
||||
@foreach (var appartenance in _appartenances)
|
||||
{
|
||||
<p class="fiche-serie">
|
||||
Tome @appartenance.Rang de
|
||||
<a href="@($"series/{appartenance.Serie.Id}")">@appartenance.Serie.Titre</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
<dl class="fiche-champs">
|
||||
@if (!string.IsNullOrWhiteSpace(_livre.Editeur))
|
||||
{
|
||||
@@ -152,70 +127,10 @@ else
|
||||
{
|
||||
<div><dt>Format</dt><dd>@Libelles.Format(_livre.Format)</dd></div>
|
||||
}
|
||||
|
||||
@* Idem : « non précisé » est l'absence de réponse, pas une réponse. *@
|
||||
@if (_livre.TypeDocument != TypeDocument.NonPrecise)
|
||||
{
|
||||
<div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div>
|
||||
}
|
||||
|
||||
@if (_livre.Themes.Count > 0)
|
||||
{
|
||||
<div>
|
||||
<dt>Thèmes</dt>
|
||||
<dd class="etiquettes">
|
||||
@foreach (var theme in _livre.Themes)
|
||||
{
|
||||
<span class="etiquette">@theme</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Même règle que le format et le type de document : la ligne ne s'affiche que
|
||||
lorsqu'elle apprend quelque chose. « Nombre de pages : inconnu » sur presque
|
||||
chaque fiche n'apprendrait rien et allongerait toutes les autres.
|
||||
*@
|
||||
@if (_livre.NombrePages is { } pages)
|
||||
{
|
||||
<div><dt>Pages</dt><dd>@pages</dd></div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
|
||||
{
|
||||
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></dd></div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_correctionCouverture)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="SurToucheCouverture"
|
||||
placeholder="https://…" aria-label="Adresse de la couverture"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="EnregistrerCouvertureAsync">
|
||||
@(_enregistrement ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_enregistrement"
|
||||
@onclick="() => _correctionCouverture = false">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente
|
||||
de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
|
||||
<section class="bloc-statut">
|
||||
@@ -259,35 +174,6 @@ else
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private LivreDto? _livre;
|
||||
|
||||
/// <summary>Places de ce livre dans des séries : la série, et son rang de lecture.</summary>
|
||||
private IReadOnlyList<AppartenanceSerie> _appartenances = [];
|
||||
|
||||
private sealed record AppartenanceSerie(SerieDto Serie, int Rang);
|
||||
|
||||
/// <summary>
|
||||
/// Cherche ce livre dans les séries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Une seule lecture rend tout l'arbre, et elle est déjà en cache : chercher côté client
|
||||
/// évite un point d'entrée « séries d'un livre » qui n'aurait servi qu'ici, et qui aurait
|
||||
/// eu son propre instantané à tenir.
|
||||
/// <para>
|
||||
/// Le rang affiché est la <b>position dans l'ordre de lecture</b>, à partir de 1 — pas un
|
||||
/// numéro de tome imprimé sur la couverture, que le modèle ne connaît pas.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ChargerAppartenancesAsync()
|
||||
{
|
||||
var series = await Api.ListerSeriesAsync();
|
||||
|
||||
_appartenances = series
|
||||
.SelectMany(serie => serie.Elements
|
||||
.Select((element, index) => (serie, element, index))
|
||||
.Where(x => x.element.LivreId == Id)
|
||||
.Select(x => new AppartenanceSerie(x.serie, x.index + 1)))
|
||||
.ToList();
|
||||
}
|
||||
private EnregistrementLivre? _saisie;
|
||||
private bool _introuvable;
|
||||
private bool _enregistrement;
|
||||
@@ -360,7 +246,6 @@ else
|
||||
try
|
||||
{
|
||||
_livre = await Api.ObtenirAsync(Id);
|
||||
await ChargerAppartenancesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -386,15 +271,11 @@ else
|
||||
{
|
||||
Isbn = livre.Isbn,
|
||||
Titre = livre.Titre,
|
||||
Auteurs = livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
|
||||
Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
|
||||
Editeur = livre.Editeur,
|
||||
Format = livre.Format,
|
||||
TypeDocument = livre.TypeDocument,
|
||||
NombrePages = livre.NombrePages,
|
||||
Themes = livre.Themes.ToList(),
|
||||
Statut = livre.Statut,
|
||||
CoverUrl = livre.CoverUrl,
|
||||
UrlNotice = livre.UrlNotice,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -403,83 +284,6 @@ else
|
||||
/// </summary>
|
||||
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
|
||||
|
||||
/// <summary>Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même.</summary>
|
||||
private bool _correctionCouverture;
|
||||
|
||||
private string _urlCouverture = string.Empty;
|
||||
|
||||
private ElementReference _champUrlCouverture;
|
||||
|
||||
private bool _focusCouverture;
|
||||
|
||||
private void OuvrirCorrectionCouverture()
|
||||
{
|
||||
_erreur = null;
|
||||
_urlCouverture = _livre?.CoverUrl ?? string.Empty;
|
||||
_correctionCouverture = true;
|
||||
|
||||
// ⚠️ Le focus est demandé UNE fois, au prochain rendu : le reprendre à chaque rendu
|
||||
// l'arracherait pendant qu'on colle ou qu'on tape.
|
||||
_focusCouverture = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusCouverture)
|
||||
{
|
||||
_focusCouverture = false;
|
||||
await _champUrlCouverture.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SurToucheCouverture(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await EnregistrerCouvertureAsync();
|
||||
}
|
||||
else if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
_correctionCouverture = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Même exception que le statut de lecture et les prêts : ce n'est pas « modifier la fiche »,
|
||||
/// c'est réparer ce qu'on a sous les yeux. La saisie du formulaire d'édition est mise à jour
|
||||
/// dans la foulée, sinon un passage ultérieur en édition réécrirait l'ancienne adresse.
|
||||
/// </remarks>
|
||||
private async Task EnregistrerCouvertureAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var url = _urlCouverture.Trim();
|
||||
_saisie.CoverUrl = string.IsNullOrEmpty(url) ? null : url;
|
||||
|
||||
_enregistrement = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierAsync(Id, _saisie);
|
||||
|
||||
_enregistrement = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
_livre = resultat.Valeur;
|
||||
_saisie = Recopier(_livre!);
|
||||
_correctionCouverture = false;
|
||||
}
|
||||
|
||||
private async Task ChangerStatutAsync(Statut? statut)
|
||||
{
|
||||
_statutEnCours = true;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/prets"
|
||||
@using MaBibli.Shared.Textes
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
@@ -39,24 +38,8 @@ else
|
||||
{
|
||||
<p class="compteur">@_prets.Count livre@(_prets.Count > 1 ? "s" : "") dehors</p>
|
||||
|
||||
@* Filtre en mémoire : la liste des prêts en cours est courte, une forme normalisée en
|
||||
base serait disproportionnée pour ce seul besoin (voir CLAUDE.md, lot G). La recherche
|
||||
reste insensible aux accents et à la casse, comme partout ailleurs dans l'application. *@
|
||||
@if (_prets.Count > 1)
|
||||
{
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="search" placeholder="Filtrer par emprunteur…"
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Filtrer par emprunteur" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (PretsAffiches().Count == 0)
|
||||
{
|
||||
<p class="message-discret">Aucun prêt ne correspond à « @_recherche ».</p>
|
||||
}
|
||||
|
||||
<ul class="liste-livres">
|
||||
@foreach (var pret in PretsAffiches())
|
||||
@foreach (var pret in _prets)
|
||||
{
|
||||
<li class="carte-livre" @key="pret.Id">
|
||||
<Couverture Url="@pret.LivreCoverUrl" Titre="@pret.LivreTitre"
|
||||
@@ -94,22 +77,6 @@ else
|
||||
private IReadOnlyList<PretDto>? _prets;
|
||||
private string? _erreur;
|
||||
private int? _enCours;
|
||||
private string _recherche = string.Empty;
|
||||
|
||||
private void SurRecherche(ChangeEventArgs e) => _recherche = e.Value?.ToString() ?? string.Empty;
|
||||
|
||||
private IReadOnlyList<PretDto> PretsAffiches()
|
||||
{
|
||||
if (_prets is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var terme = NormalisationTexte.Normaliser(_recherche);
|
||||
return terme.Length == 0
|
||||
? _prets
|
||||
: _prets.Where(p => NormalisationTexte.Normaliser(p.Emprunteur).Contains(terme)).ToList();
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
@page "/revues/{Id:int}"
|
||||
@page "/revues/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — @(_revue?.Titre ?? "revue")</PageTitle>
|
||||
|
||||
@if (_revue is null)
|
||||
{
|
||||
<p class="message-discret" role="status">
|
||||
@(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.")
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@*
|
||||
Titre, identité de la revue et actions sur la MÊME ligne (lot W) : disposition
|
||||
symétrique de celle de la fiche d'une série, et déjà tenue par la bibliographie et la
|
||||
liste des auteurs. Elle rend les deux actions visibles sans descendre au bas d'une
|
||||
collection de trente numéros.
|
||||
*@
|
||||
<div class="entete-fiche">
|
||||
<div class="entete-fiche-texte">
|
||||
<h1 class="titre-page">@_revue.Titre</h1>
|
||||
|
||||
<p class="carte-details">
|
||||
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
|
||||
{
|
||||
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches
|
||||
d'un ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
|
||||
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
|
||||
{
|
||||
<span>@_revue.Editeur</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!ModeEdition)
|
||||
{
|
||||
<div class="entete-fiche-actions">
|
||||
@* Ajouter un numéro reste en CONSULTATION, et ce n'est pas une entorse à
|
||||
« consulter d'abord, modifier ensuite » : le geste ne touche pas à la fiche de
|
||||
la revue, il range un objet de plus — comme noter un prêt depuis la fiche d'un
|
||||
livre. Ce sont la modification et le retrait d'un numéro qui vivent, elles,
|
||||
uniquement dans l'écran d'édition. *@
|
||||
<button type="button" class="bouton bouton-compact bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="BasculerAjout">
|
||||
@(_ajout ? "Fermer" : "Ajouter un numéro")
|
||||
</button>
|
||||
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-compact bouton-principal"
|
||||
href="@($"revues/{Id}/edition")">Modifier la revue</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-compact bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Modifier la revue</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (!ModeEdition && _ajout)
|
||||
{
|
||||
<h2 class="titre-section">Ajouter un numéro</h2>
|
||||
|
||||
@*
|
||||
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
|
||||
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2
|
||||
qui le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
|
||||
*@
|
||||
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
|
||||
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
|
||||
avant d'enregistrer.
|
||||
</p>
|
||||
}
|
||||
|
||||
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" OnChangement="StateHasChanged" />
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="AjouterAsync">
|
||||
Ajouter ce numéro
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 class="titre-section">Numéros possédés</h2>
|
||||
|
||||
@if (_revue.Numeros.Count == 0)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Aucun numéro recensé pour l'instant.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
|
||||
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
|
||||
son ordre de lecture. L'ordre est celui rendu par l'API — les numéros sans date
|
||||
ferment la liste, une date inconnue n'étant pas une date ancienne. *@
|
||||
<ul class="liste-numeros">
|
||||
@foreach (var numero in _revue.Numeros)
|
||||
{
|
||||
<li class="carte-numero" @key="numero.Id">
|
||||
@if (ModeEdition && _edition == numero.Id)
|
||||
{
|
||||
<div class="carte-corps">
|
||||
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync"
|
||||
OnChangement="StateHasChanged" />
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieEdition.Numero))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="EnregistrerNumeroAsync">
|
||||
Enregistrer ce numéro
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours" @onclick="FermerEdition">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@*
|
||||
La couverture d'un numéro ne vient d'AUCUNE source — l'ISSN désigne
|
||||
la revue, pas la parution : elle se colle à la main, ou il n'y en a
|
||||
pas. C'est donc ici que le clic pour la poser sert le plus.
|
||||
|
||||
⚠️ Même principe que la fiche livre : cliquer une couverture absente
|
||||
ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce
|
||||
n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les
|
||||
yeux, et c'est pourquoi ce geste-là existe aussi en consultation,
|
||||
contrairement à « Modifier » et « Retirer ».
|
||||
*@
|
||||
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
|
||||
Classe="couverture-petite" Agrandissable="true"
|
||||
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">N° @numero.Numero</p>
|
||||
|
||||
@if (_correctionCouverture == numero.Id)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="@(e => SurToucheCouverture(e, numero))"
|
||||
placeholder="https://…"
|
||||
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => EnregistrerCouvertureAsync(numero)">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => _correctionCouverture = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ
|
||||
retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (numero.DateParution is { } parution)
|
||||
{
|
||||
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Date de parution inconnue</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@* Les articles à la une décrivent CE numéro, pas la revue : ils
|
||||
changent à chaque parution. D'où une étiquette par titre, sur la
|
||||
fiche du numéro et nulle part ailleurs. *@
|
||||
@if (numero.Articles.Count > 0)
|
||||
{
|
||||
<p class="carte-details">
|
||||
@foreach (var article in numero.Articles)
|
||||
{
|
||||
<span class="etiquette etiquette-une">@article</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(numero.Note))
|
||||
{
|
||||
<p class="souhait-note">@numero.Note</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@*
|
||||
⚠️ Modifier et retirer un numéro n'existent QUE dans l'écran
|
||||
d'édition : en consultation, ces deux boutons se déclenchaient sous
|
||||
le pouce en faisant défiler la collection — même raison que les
|
||||
flèches d'ordre d'une série, reléguées sur leur propre écran.
|
||||
*@
|
||||
@if (ModeEdition)
|
||||
{
|
||||
<div class="actions-tome">
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
aria-label="@($"Modifier le numéro {numero.Numero}")"
|
||||
@onclick="() => OuvrirEdition(numero)">
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
@* Un retrait est destructeur et ne se défait pas : il se
|
||||
confirme, comme la suppression d'un livre ou d'une revue. *@
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
aria-label="@($"Retirer le numéro {numero.Numero}")"
|
||||
@onclick="() => RetirerAsync(numero)">
|
||||
@(_confirmeRetrait == numero.Id ? "Confirmer le retrait" : "Retirer")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (ModeEdition)
|
||||
{
|
||||
<h2 class="titre-section">La revue</h2>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Nom</span>
|
||||
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
|
||||
placeholder="2466-6718" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur</span>
|
||||
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
|
||||
</label>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="EnregistrerAsync">
|
||||
Enregistrer
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="SupprimerAsync">
|
||||
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_confirmeSuppression)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils
|
||||
n'existent que par leur revue.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Numéro lu sur l'add-on <b>EAN-2</b> du code-barres, transmis par l'écran de scan.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Proposé, pas acquis.</b> L'EAN-2 est normativement le numéro de parution, mais
|
||||
/// rien ne l'a confirmé sur des magazines réels : il atterrit donc dans le champ, où
|
||||
/// l'utilisateur le compare à la couverture et le corrige. C'est aussi pourquoi l'écran le
|
||||
/// dit explicitement au lieu de le glisser en douce.
|
||||
/// </remarks>
|
||||
[SupplyParameterFromQuery(Name = "numero")]
|
||||
public string? NumeroScanne { get; set; }
|
||||
|
||||
/// <summary>Le numéro scanné n'est proposé qu'une fois : ensuite, le champ appartient à l'utilisateur.</summary>
|
||||
private bool _numeroPropose;
|
||||
|
||||
private RevueDto? _revue;
|
||||
private bool _chargement = true;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
private bool _confirmeSuppression;
|
||||
|
||||
private string _titre = string.Empty;
|
||||
private string? _issn;
|
||||
private string? _editeur;
|
||||
|
||||
/// <summary>Saisie du numéro qu'on ajoute, et repli du formulaire qui la porte.</summary>
|
||||
private AjoutNumeroRevue _saisie = new();
|
||||
private bool _ajout;
|
||||
|
||||
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
|
||||
private int? _edition;
|
||||
private AjoutNumeroRevue _saisieEdition = new();
|
||||
|
||||
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
|
||||
private int? _confirmeRetrait;
|
||||
|
||||
/// <summary>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
|
||||
private int? _correctionCouverture;
|
||||
|
||||
private string _urlCouverture = string.Empty;
|
||||
|
||||
private ElementReference _champUrlCouverture;
|
||||
|
||||
private bool _focusCouverture;
|
||||
|
||||
/// <summary>
|
||||
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
|
||||
/// numéro se modifie ou se retire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : convention du projet depuis
|
||||
/// la fiche livre. Comme les deux routes partagent le même paramètre <c>Id</c>, le routeur
|
||||
/// ne déclenche <b>aucun</b> nouveau rendu en passant de l'une à l'autre — d'où l'abonnement
|
||||
/// à <c>LocationChanged</c>, sans lequel l'URL changerait et l'écran resterait le même.
|
||||
/// </remarks>
|
||||
private bool ModeEdition =>
|
||||
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
||||
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
Navigation.LocationChanged += SurNavigation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quitter l'édition referme ce qui n'a de sens que là : un formulaire de numéro resté
|
||||
/// ouvert, et une confirmation de retrait en attente.
|
||||
/// </summary>
|
||||
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(() =>
|
||||
{
|
||||
if (!ModeEdition)
|
||||
{
|
||||
_edition = null;
|
||||
_confirmeRetrait = null;
|
||||
_confirmeSuppression = false;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
Navigation.LocationChanged -= SurNavigation;
|
||||
}
|
||||
|
||||
protected override Task OnParametersSetAsync() => ChargerAsync();
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
_chargement = true;
|
||||
_revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id);
|
||||
_chargement = false;
|
||||
|
||||
if (_revue is not null)
|
||||
{
|
||||
_titre = _revue.Titre;
|
||||
_issn = _revue.Issn;
|
||||
_editeur = _revue.Editeur;
|
||||
}
|
||||
|
||||
// Un numéro lu au code-barres ouvre le formulaire de lui-même : on vient de scanner,
|
||||
// demander un clic de plus pour saisir ce qu'on tient en main serait un détour.
|
||||
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
||||
{
|
||||
_numeroPropose = true;
|
||||
_saisie.Numero = NumeroScanne.Trim();
|
||||
_ajout = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void BasculerAjout()
|
||||
{
|
||||
_erreur = null;
|
||||
_ajout = !_ajout;
|
||||
}
|
||||
|
||||
private void Appliquer(RevueDto revue)
|
||||
{
|
||||
_revue = revue;
|
||||
_titre = revue.Titre;
|
||||
_issn = revue.Issn;
|
||||
_editeur = revue.Editeur;
|
||||
}
|
||||
|
||||
private async Task AjouterAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_saisie.Numero) || !Reseau.EnLigne || _enCours)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
// La conversion de la date en UTC vit dans ChampsNumeroRevue : les deux formulaires qui
|
||||
// s'en servent ne peuvent donc pas l'oublier chacun de leur côté.
|
||||
var resultat = await Api.AjouterNumeroAsync(Id, _saisie);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Appliquer(resultat.Valeur!);
|
||||
_saisie = new AjoutNumeroRevue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la modification d'un numéro, en recopiant ce qu'il porte déjà.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La couverture et les articles à la une <b>ne peuvent pas</b> être connus à l'achat : on
|
||||
/// note un numéro quand on le range, et on en recopie la une plus tard. Sans cet écran, ces
|
||||
/// deux champs n'auraient existé qu'à la création, c'est-à-dire jamais.
|
||||
/// </remarks>
|
||||
private void OuvrirEdition(NumeroRevueDto numero)
|
||||
{
|
||||
_erreur = null;
|
||||
_confirmeRetrait = null;
|
||||
_edition = numero.Id;
|
||||
_saisieEdition = new AjoutNumeroRevue
|
||||
{
|
||||
Numero = numero.Numero,
|
||||
DateParution = numero.DateParution,
|
||||
Note = numero.Note,
|
||||
CoverUrl = numero.CoverUrl,
|
||||
Articles = [.. numero.Articles],
|
||||
};
|
||||
}
|
||||
|
||||
private void FermerEdition() => _edition = null;
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
|
||||
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
|
||||
/// </remarks>
|
||||
private void OuvrirCorrectionCouverture(NumeroRevueDto numero)
|
||||
{
|
||||
_erreur = null;
|
||||
_confirmeRetrait = null;
|
||||
_correctionCouverture = numero.Id;
|
||||
_urlCouverture = numero.CoverUrl ?? string.Empty;
|
||||
_focusCouverture = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool premierRendu)
|
||||
{
|
||||
if (_focusCouverture)
|
||||
{
|
||||
_focusCouverture = false;
|
||||
await _champUrlCouverture.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await EnregistrerCouvertureAsync(numero);
|
||||
}
|
||||
else if (e.Key is "Escape" or "Esc")
|
||||
{
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, articles à la une compris : tout ce
|
||||
/// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une
|
||||
/// couverture effacerait la une. C'est la même précaution que dans <c>OuvrirEdition</c>, et
|
||||
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
|
||||
/// </remarks>
|
||||
private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero)
|
||||
{
|
||||
if (!Reseau.EnLigne || _enCours)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var url = _urlCouverture.Trim();
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierNumeroAsync(
|
||||
numero.Id,
|
||||
new AjoutNumeroRevue
|
||||
{
|
||||
Numero = numero.Numero,
|
||||
DateParution = numero.DateParution,
|
||||
Note = numero.Note,
|
||||
CoverUrl = string.IsNullOrEmpty(url) ? null : url,
|
||||
Articles = [.. numero.Articles],
|
||||
});
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Appliquer(resultat.Valeur!);
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
|
||||
private async Task EnregistrerNumeroAsync()
|
||||
{
|
||||
if (_edition is not { } numeroId
|
||||
|| string.IsNullOrWhiteSpace(_saisieEdition.Numero)
|
||||
|| !Reseau.EnLigne
|
||||
|| _enCours)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierNumeroAsync(numeroId, _saisieEdition);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Appliquer(resultat.Valeur!);
|
||||
_edition = null;
|
||||
}
|
||||
|
||||
private async Task RetirerAsync(NumeroRevueDto numero)
|
||||
{
|
||||
// Premier clic : on demande confirmation. Un numéro retiré ne se récupère pas, et la
|
||||
// liste s'égrène sous le pouce.
|
||||
if (_confirmeRetrait != numero.Id)
|
||||
{
|
||||
_erreur = null;
|
||||
_confirmeRetrait = numero.Id;
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = await Api.RetirerNumeroAsync(numero.Id);
|
||||
_enCours = false;
|
||||
_confirmeRetrait = null;
|
||||
|
||||
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
|
||||
// ligne disparue n'aurait plus rien à enregistrer.
|
||||
if (_edition == numero.Id)
|
||||
{
|
||||
_edition = null;
|
||||
}
|
||||
|
||||
// Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible.
|
||||
if (_correctionCouverture == numero.Id)
|
||||
{
|
||||
_correctionCouverture = null;
|
||||
}
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierRevueAsync(
|
||||
Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur });
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Appliquer(resultat.Valeur!);
|
||||
|
||||
// La fiche est à jour : on retombe sur la consultation, comme après l'édition d'un
|
||||
// livre ou d'une série. Rester en édition inviterait à réenregistrer.
|
||||
Navigation.NavigateTo($"revues/{Id}");
|
||||
}
|
||||
|
||||
private async Task SupprimerAsync()
|
||||
{
|
||||
if (!_confirmeSuppression)
|
||||
{
|
||||
_confirmeSuppression = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = await Api.SupprimerRevueAsync(Id);
|
||||
_enCours = false;
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
Navigation.NavigateTo("revues");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
@page "/revues"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — revues</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Revues</h1>
|
||||
|
||||
<p class="message-discret">
|
||||
Une fiche par revue, les numéros à l'intérieur — douze numéros d'un magazine ne font pas
|
||||
douze fiches. Les revues ne sont pas au catalogue : ce ne sont pas des livres.
|
||||
</p>
|
||||
|
||||
@if (_revues is null)
|
||||
{
|
||||
<p class="message-discret" role="status">Chargement…</p>
|
||||
}
|
||||
else if (_revues.Count == 0)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Aucune revue. Scannez le code-barres d'un magazine depuis « Ajouter un ouvrage » :
|
||||
le code d'une revue commence par 977 et porte un ISSN, pas un ISBN. Sans code à lire,
|
||||
<a href="revues/ajout">saisissez-la à la main</a>.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="liste-series">
|
||||
@foreach (var revue in _revues)
|
||||
{
|
||||
@*
|
||||
⚠️ Toute la LIGNE ouvre la fiche (lot V), et pas seulement le titre : c'est ce que
|
||||
le projet tient déjà au catalogue, où la carte entière y mène. Viser un titre de
|
||||
trois mots au pouce était le seul point d'entrée.
|
||||
|
||||
Le lien reste un vrai lien : il porte l'adresse, le clavier et le clic-milieu.
|
||||
Son clic ne remonte donc PAS jusqu'à la ligne, qui navigerait une seconde fois.
|
||||
Toute action posée un jour sur cette ligne doit faire de même
|
||||
(@@onclick:stopPropagation), sans quoi elle ouvrirait la fiche en s'exécutant.
|
||||
*@
|
||||
<li class="carte-serie carte-cliquable" @key="revue.Id"
|
||||
@onclick="() => Ouvrir(revue.Id)">
|
||||
<a class="carte-titre" href="@($"revues/{revue.Id}")"
|
||||
@onclick:stopPropagation="true">@revue.Titre</a>
|
||||
|
||||
<p class="carte-details">
|
||||
<span>
|
||||
@(revue.Numeros.Count == 0
|
||||
? "Aucun numéro"
|
||||
: $"{revue.Numeros.Count} numéro{(revue.Numeros.Count > 1 ? "s" : "")}")
|
||||
</span>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Issn))
|
||||
{
|
||||
<span class="code-isbn">ISSN @FormatageIssn.Afficher(revue.Issn)</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Editeur))
|
||||
{
|
||||
<span>@revue.Editeur</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@* Le dernier numéro reçu répond seul à « est-ce que je l'ai déjà ? », qui est la
|
||||
question qu'on se pose devant un kiosque. *@
|
||||
@if (revue.Numeros.FirstOrDefault() is { } dernier)
|
||||
{
|
||||
<p class="carte-details">Dernier : n° @dernier.Numero @Quand(dernier)</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Le champ « nom d'une nouvelle revue » a quitté cet écran : l'ajout passe par la MÊME
|
||||
entrée que celle du catalogue, « Ajouter un ouvrage », qui scanne et reconnaît seule un
|
||||
code 977. Deux points de départ selon ce qu'on croit tenir obligeaient à trancher avant
|
||||
de regarder le code-barres.
|
||||
*@
|
||||
<nav class="actions-flottantes">
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
|
||||
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<RevueDto>? _revues;
|
||||
|
||||
private void Ouvrir(int id) => Navigation.NavigateTo($"revues/{id}");
|
||||
|
||||
private static string Quand(NumeroRevueDto numero) =>
|
||||
numero.DateParution is { } date ? $"({date.ToLocalTime():MM/yyyy})" : string.Empty;
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
protected override Task OnInitializedAsync() => ChargerAsync();
|
||||
|
||||
private async Task ChargerAsync() => _revues = await Api.ListerRevuesAsync();
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
@page "/series"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — séries</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Séries</h1>
|
||||
|
||||
<p class="message-discret">
|
||||
Sagas, cycles et trilogies, dans leur ordre de <strong>lecture</strong> — qui n'est pas
|
||||
toujours celui de publication. Les séries sont communes au foyer, comme le catalogue.
|
||||
</p>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@* Créer une série depuis la liste : c'est le seul endroit d'où l'on part, et un écran de plus
|
||||
pour un unique champ ne se justifierait pas. *@
|
||||
@* M1 : le champ et son bouton sur UNE ligne. Le champ prend la place restante, le bouton
|
||||
garde la sienne et son libellé ne se tronque pas — vérifié jusqu'à 320 px. *@
|
||||
<div class="ligne-creation">
|
||||
<input class="champ-saisie" type="text" placeholder="Nom d'une nouvelle série…"
|
||||
@bind="_nouveau" @bind:event="oninput" @onkeydown="SurTouche"
|
||||
aria-label="Nom de la nouvelle série" autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveau))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="CreerAsync">
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_series is null)
|
||||
{
|
||||
<p class="message-discret" role="status">Chargement…</p>
|
||||
}
|
||||
else if (_series.Count == 0)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Aucune série pour l'instant. Créez-en une, puis ajoutez-y vos livres et les tomes
|
||||
qui vous manquent : c'est ce qui permet de voir les trous.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="liste-series arbre">
|
||||
@foreach (var serie in Racines)
|
||||
{
|
||||
<ArbreSerie Serie="serie" Toutes="_series" Totaux="_totaux" Replies="_replies" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<SerieDto>? _series;
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
|
||||
new Dictionary<int, (int, int)>();
|
||||
|
||||
/// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
|
||||
private readonly HashSet<int> _replies = [];
|
||||
private string _nouveau = string.Empty;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>Séries de premier niveau. Celles rangées dans un cycle s'affichent sous lui.</summary>
|
||||
private IEnumerable<SerieDto> Racines =>
|
||||
(_series?.Where(s => s.SerieParenteId is null) ?? []).OrderBy(s => s.Titre);
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
protected override Task OnInitializedAsync() => ChargerAsync();
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
// ⚠️ Le catalogue n'est plus chargé ici : l'arbre ne montre que la descendance des
|
||||
// séries, plus leurs tomes. C'était une lecture complète du fonds pour rien.
|
||||
_series = await Api.ListerSeriesAsync();
|
||||
_totaux = TotauxSeries.Calculer(_series);
|
||||
}
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveau) && Reseau.EnLigne)
|
||||
{
|
||||
await CreerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreerAsync()
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.CreerSerieAsync(new EnregistrementSerie { Titre = _nouveau });
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
_nouveau = string.Empty;
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
@page "/souhaits"
|
||||
@page "/souhaits/ordre"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — liste d'envies</PageTitle>
|
||||
@@ -12,9 +10,9 @@
|
||||
@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas.
|
||||
Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@
|
||||
<p class="message-discret">
|
||||
Les livres et les revues que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
|
||||
Les livres que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
|
||||
les autres membres du foyer ont la leur, et ne voient pas celle-ci.
|
||||
Rien de ce qui est ici n'est dans le catalogue, ni ne compte dans la bibliothèque.
|
||||
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
|
||||
</p>
|
||||
|
||||
@if (_erreur is not null)
|
||||
@@ -28,7 +26,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_souhaits.Count == 0 && _revues.Count == 0)
|
||||
@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
|
||||
@@ -54,7 +52,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="compteur">@Compteur()</p>
|
||||
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
|
||||
|
||||
@* 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. *@
|
||||
@@ -69,7 +67,6 @@ else
|
||||
<div class="actions-export">
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-discret" href="souhaits/ajout">Ajouter une envie</a>
|
||||
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
|
||||
Exporter en texte
|
||||
</a>
|
||||
@@ -79,8 +76,6 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@MotifExport">Exporter en texte</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
@@ -97,7 +92,7 @@ else
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (ModeOrdre && _souhaits.Count > 1)
|
||||
@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 —
|
||||
@@ -110,21 +105,18 @@ else
|
||||
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.
|
||||
*@
|
||||
@* Une liste vide n'est pas rendue : on peut n'avoir que des revues en envie. *@
|
||||
@if (_souhaits.Count > 0)
|
||||
{
|
||||
<ul class="liste-souhaits">
|
||||
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
|
||||
{
|
||||
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
|
||||
@key="souhait.Id"
|
||||
draggable="@((ModeOrdre && Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
|
||||
@ondragstart="() => DebuterGlisse(souhait.Id)"
|
||||
@ondragend="TerminerGlisse"
|
||||
@ondragover:preventDefault="ModeOrdre"
|
||||
draggable="@((Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
|
||||
@ondragstart="() => _glisse = souhait.Id"
|
||||
@ondragend="() => _glisse = null"
|
||||
@ondragover:preventDefault="true"
|
||||
@ondrop="() => DeposerAsync(index)">
|
||||
|
||||
@if (ModeOrdre && _souhaits.Count > 1)
|
||||
@if (_souhaits.Count > 1)
|
||||
{
|
||||
<div class="rang-actions">
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@@ -155,21 +147,6 @@ else
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@*
|
||||
⚠️ On SIGNALE, on ne supprime pas : la liste d'envies est
|
||||
personnelle et le catalogue est commun, donc l'envie n'est
|
||||
peut-être pas de celui qui a ajouté le livre — et la note
|
||||
(« demandé à Noël ») disparaîtrait avec elle. Le rapprochement
|
||||
est en outre faillible : l'étiquette mène à la fiche pour qu'on
|
||||
puisse vérifier avant de retirer quoi que ce soit.
|
||||
*@
|
||||
@if (souhait is { Possede: true, LivreId: { } livreId })
|
||||
{
|
||||
<a class="etiquette etiquette-possede" href="@($"livres/{livreId}")">
|
||||
Déjà au catalogue
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(souhait.Editeur))
|
||||
{
|
||||
<span>@souhait.Editeur</span>
|
||||
@@ -190,79 +167,12 @@ else
|
||||
}
|
||||
</div>
|
||||
|
||||
@*
|
||||
« Éditer » AU-DESSUS de « Retirer » : les deux gestes se ressemblent, et
|
||||
celui qui répare doit se présenter avant celui qui détruit.
|
||||
⚠️ Un lien, et non un bouton désactivé hors-ligne : la NAVIGATION reste
|
||||
toujours possible, c'est l'écriture qui se refuse — et l'écran d'édition
|
||||
le dit lui-même, avec son motif.
|
||||
*@
|
||||
@if (!ModeOrdre)
|
||||
{
|
||||
<div class="souhait-actions">
|
||||
<a class="bouton bouton-compact" href="@($"souhaits/{souhait.Id}/edition")"
|
||||
aria-label="@($"Éditer « {souhait.Titre} »")">
|
||||
Éditer
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerAsync(souhait)"
|
||||
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Une SECTION à part, et non des lignes mêlées aux livres : les deux tables numérotent
|
||||
leur rang indépendamment (voir RevueSouhaitee.Rang), donc un classement commun afficherait
|
||||
un ordre que personne n'a choisi. C'est le coût assumé de la table sœur — mais la liste
|
||||
les porte bien toutes les deux, y compris hors-ligne, ce qui est le seul point qui compte
|
||||
au kiosque.
|
||||
*@
|
||||
@if (_revues.Count > 0 && !ModeOrdre)
|
||||
{
|
||||
<h2 class="titre-section">Revues et magazines</h2>
|
||||
|
||||
<ul class="liste-souhaits">
|
||||
@foreach (var revue in _revues)
|
||||
{
|
||||
<li class="carte-souhait" @key="revue">
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">@revue.TitreComplet</p>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Issn))
|
||||
{
|
||||
<p class="carte-details">
|
||||
<span class="etiquette code-isbn">ISSN @revue.Issn</span>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Note))
|
||||
{
|
||||
<p class="souhait-note">@revue.Note</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="souhait-actions">
|
||||
<a class="bouton bouton-compact" href="@($"souhaits/revues/{revue.Id}/edition")"
|
||||
aria-label="@($"Éditer « {revue.TitreComplet} »")">
|
||||
Éditer
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerRevueAsync(revue)"
|
||||
aria-label="@($"Retirer « {revue.TitreComplet} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerAsync(souhait)"
|
||||
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -272,35 +182,29 @@ 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">
|
||||
@if (ModeOrdre)
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-discret" href="souhaits">Terminer</a>
|
||||
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
|
||||
}
|
||||
else if (Reseau.EnLigne)
|
||||
else
|
||||
{
|
||||
<a class="bouton bouton-discret" href="souhaits/ordre">Modifier l'ordre</a>
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<SouhaitDto>? _souhaits;
|
||||
private IReadOnlyList<RevueSouhaiteeDto> _revues = [];
|
||||
private bool _enCours;
|
||||
private int? _glisse;
|
||||
private string? _erreur;
|
||||
private bool ModeOrdre =>
|
||||
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
||||
.EndsWith("/souhaits/ordre", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
Navigation.LocationChanged += SurNavigation;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Recharge la liste au retour du réseau, et redessine pour rendre les actions à l'écran.
|
||||
/// </summary>
|
||||
@@ -314,11 +218,7 @@ else
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
Navigation.LocationChanged -= SurNavigation;
|
||||
}
|
||||
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;
|
||||
@@ -338,10 +238,6 @@ else
|
||||
{
|
||||
_erreur = null;
|
||||
_souhaits = await Api.ListerSouhaitsAsync();
|
||||
|
||||
// Les deux moitiés se chargent ensemble : une liste d'envies amputée de ses revues
|
||||
// serait fausse sans le dire.
|
||||
_revues = await Api.ListerRevuesSouhaiteesAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -396,7 +292,7 @@ else
|
||||
/// <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 (!ModeOrdre || _souhaits is null || _glisse is not { } id)
|
||||
if (_souhaits is null || _glisse is not { } id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -410,46 +306,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void DebuterGlisse(int id)
|
||||
{
|
||||
if (ModeOrdre && Reseau.EnLigne)
|
||||
{
|
||||
_glisse = id;
|
||||
}
|
||||
}
|
||||
|
||||
private void TerminerGlisse() => _glisse = null;
|
||||
|
||||
/// <summary>« 3 livres et 1 revue », sans jamais nommer une section vide.</summary>
|
||||
private string Compteur()
|
||||
{
|
||||
var morceaux = new List<string>();
|
||||
|
||||
if (_souhaits is { Count: > 0 })
|
||||
{
|
||||
morceaux.Add($"{_souhaits.Count} livre{(_souhaits.Count > 1 ? "s" : "")}");
|
||||
}
|
||||
|
||||
if (_revues.Count > 0)
|
||||
{
|
||||
morceaux.Add($"{_revues.Count} revue{(_revues.Count > 1 ? "s" : "")}");
|
||||
}
|
||||
|
||||
return string.Join(" et ", morceaux) + " en envie";
|
||||
}
|
||||
|
||||
private async Task SupprimerRevueAsync(RevueSouhaiteeDto revue)
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = await Api.SupprimerRevueSouhaiteeAsync(revue.Id);
|
||||
_enCours = false;
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SupprimerAsync(SouhaitDto souhait)
|
||||
{
|
||||
_enCours = true;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace MaBibli.Client.Scan;
|
||||
|
||||
/// <summary>
|
||||
/// Mesure du coût réel du décodage <b>dans le navigateur</b>.
|
||||
///
|
||||
/// CLAUDE.md ne dispose que de chiffres JIT x64 (0,53 ms/frame dans le pire cas) et d'une
|
||||
/// extrapolation non vérifiée pour le WASM interprété (~5-10 ms/frame). Ce banc d'essai
|
||||
/// donne le chiffre constaté, sur la machine de l'utilisateur, sans caméra ni code-barres.
|
||||
///
|
||||
/// On mesure volontairement le <b>pire cas</b> — une frame de bruit sans code-barres —
|
||||
/// car c'est l'échec de décodage qui coûte le plus cher, et la quasi-totalité des frames
|
||||
/// d'une caméra sont dans ce cas. C'est donc lui qui gouverne le framerate atteignable.
|
||||
///
|
||||
/// Depuis la console du navigateur :
|
||||
/// <code>await DotNet.invokeMethodAsync('MaBibli.Client', 'MesurerScan', 640, 288, 20)</code>
|
||||
/// </summary>
|
||||
public static class BancEssaiScan
|
||||
{
|
||||
[JSInvokable("MesurerScan")]
|
||||
public static double MesurerPireCasMs(int largeur, int hauteur, int iterations)
|
||||
{
|
||||
var rgba = new byte[largeur * hauteur * 4];
|
||||
new Random(1234).NextBytes(rgba);
|
||||
|
||||
// Un tour à blanc : la première exécution paie l'initialisation de ZXing,
|
||||
// qui n'a rien à voir avec le coût par frame en régime établi.
|
||||
IsbnScanner.TryDecode(rgba, largeur, hauteur);
|
||||
|
||||
var depart = Stopwatch.GetTimestamp();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
IsbnScanner.TryDecode(rgba, largeur, hauteur);
|
||||
}
|
||||
|
||||
return Stopwatch.GetElapsedTime(depart).TotalMilliseconds / iterations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
|
||||
namespace MaBibli.Client.Scan;
|
||||
|
||||
/// <summary>
|
||||
/// Décodage d'un code-barres EAN-13 / EAN-8 à partir d'un buffer de pixels RGBA.
|
||||
///
|
||||
/// Décision actée dans CLAUDE.md : le décodage reste **entièrement en C#** (ZXing.Net),
|
||||
/// le JavaScript ne sert qu'à fournir les pixels de la caméra. Cette classe ne dépend
|
||||
/// donc ni du navigateur, ni de Blazor : elle est réutilisable telle quelle hors WASM
|
||||
/// (app native, décodage d'une photo côté serveur, scan en masse).
|
||||
/// </summary>
|
||||
public static class IsbnScanner
|
||||
{
|
||||
// ZXing.Net n'est pas thread-safe sur ce lecteur, mais le WASM est mono-thread
|
||||
// et la boucle de scan est séquentielle : une instance partagée suffit et évite
|
||||
// de reconstruire les hints à chaque frame.
|
||||
private static readonly MultiFormatReader Reader = new()
|
||||
{
|
||||
Hints = new Dictionary<DecodeHintType, object>
|
||||
{
|
||||
[DecodeHintType.POSSIBLE_FORMATS] = new List<BarcodeFormat>
|
||||
{
|
||||
BarcodeFormat.EAN_13, BarcodeFormat.EAN_8,
|
||||
},
|
||||
[DecodeHintType.TRY_HARDER] = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tente de décoder un code-barres. Renvoie le texte brut du code, ou <c>null</c>
|
||||
/// si la frame n'en contient aucun de lisible — cas très majoritaire en usage réel.
|
||||
/// </summary>
|
||||
/// <param name="rgba">Buffer brut issu de <c>ctx.getImageData(...).data</c> (4 octets par pixel).</param>
|
||||
public static string? TryDecode(byte[] rgba, int largeur, int hauteur)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rgba);
|
||||
|
||||
if (largeur <= 0 || hauteur <= 0 || rgba.Length < largeur * hauteur * 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// RGBLuminanceSource consomme directement le buffer du canvas :
|
||||
// aucune bibliothèque d'image nécessaire (ni SkiaSharp, ni ImageSharp).
|
||||
var source = new RGBLuminanceSource(
|
||||
rgba, largeur, hauteur, RGBLuminanceSource.BitmapFormat.RGBA32);
|
||||
|
||||
// La version .NET de ZXing renvoie null sur échec (pas d'exception).
|
||||
return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace MaBibli.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Les états ARIA, écrits en toutes lettres.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b><c>aria-expanded="@_ouvert"</c> ne fait pas ce qu'on croit.</b> Blazor traite un booléen
|
||||
/// comme un attribut de <i>présence</i> — à la manière de <c>disabled</c> ou <c>hidden</c> : vrai
|
||||
/// rend <c>aria-expanded=""</c>, faux <b>retire l'attribut</b>. Or ARIA attend les chaînes
|
||||
/// « true » et « false », et un lecteur d'écran ne sait lire ni l'une ni l'autre de ces deux
|
||||
/// formes : replié, le bouton ne s'annonce plus comme repliable du tout.
|
||||
/// <para>
|
||||
/// Le défaut est passé inaperçu cinq fois (2026-08-22) parce qu'il ne se voit pas à l'écran et
|
||||
/// qu'aucun test ne rend de balisage. D'où cette fonction : elle se remarque à la relecture là
|
||||
/// où un booléen nu se lit comme correct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Aria
|
||||
{
|
||||
/// <summary>« true » ou « false », jamais un attribut de présence.</summary>
|
||||
public static string Etat(bool valeur) => valeur ? "true" : "false";
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
@@ -20,17 +19,6 @@ public static class ClesCache
|
||||
|
||||
public const string Utilisateur = "utilisateur";
|
||||
|
||||
/// <summary>
|
||||
/// La version publiée et sa date de build, telles que le serveur les a annoncées.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Elle vaut la peine d'être rangée précisément parce qu'on la lit quand quelque chose ne
|
||||
/// va pas — et un appareil dont on soupçonne le cache est aussi bien celui qui n'a plus de
|
||||
/// réseau. Ce qui est affiché hors-ligne est la <b>dernière version vue du serveur</b>, et
|
||||
/// l'écran le dit : c'est un fait daté, pas une supposition.
|
||||
/// </remarks>
|
||||
public const string Version = "version";
|
||||
|
||||
/// <summary>
|
||||
/// La liste d'envies de l'utilisateur courant.
|
||||
/// </summary>
|
||||
@@ -46,40 +34,6 @@ public static class ClesCache
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string Souhaits = "souhaits";
|
||||
|
||||
/// <summary>
|
||||
/// Les revues et numéros souhaités : la <b>seconde moitié</b> de la liste d'envies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Un instantané à part parce que l'API en fait une vue à part (table sœur), pas parce
|
||||
/// que l'écran en ferait deux : la liste d'envies est le seul écran qu'on emporte
|
||||
/// délibérément là où le réseau manque, et une moitié absente en librairie ne servirait à
|
||||
/// rien. Personnel, comme <see cref="Souhaits"/> — jamais paramétré par utilisateur.
|
||||
/// </remarks>
|
||||
public const string SouhaitsRevues = "souhaits-revues";
|
||||
|
||||
/// <summary>
|
||||
/// Toutes les séries, à plat, avec leurs tomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un seul instantané sert la liste <b>et</b> le détail de chaque série, l'API rendant
|
||||
/// l'arbre entier en une lecture. C'est ce qui évite un instantané par série consultée —
|
||||
/// et surtout ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte avant
|
||||
/// la coupure, exactement pour la même raison que le catalogue complet.
|
||||
/// <para>
|
||||
/// Communes au foyer, contrairement à <see cref="Souhaits"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const string Series = "series";
|
||||
|
||||
/// <summary>
|
||||
/// Les revues, avec les numéros possédés. Communes au foyer, comme les séries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sa raison d'être est la même que celle de la liste d'envies : savoir <b>si l'on a déjà
|
||||
/// ce numéro</b> se demande devant le présentoir d'un kiosque, là où le réseau manque.
|
||||
/// </remarks>
|
||||
public const string Revues = "revues";
|
||||
}
|
||||
|
||||
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
|
||||
@@ -146,57 +100,6 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// File de catalogage en rafale (lot AB)
|
||||
// -----------------------------------------------------------------------
|
||||
// ⚠️ Magasin SÉPARÉ des instantanés, et volontairement : ceux-ci portent ce que le serveur
|
||||
// a rendu, effaçable sans rien perdre. Celle-ci porte ce que l'utilisateur a scanné, et qui
|
||||
// n'existe nulle part ailleurs.
|
||||
|
||||
/// <summary>Range la rafale en cours. Appelée à chaque scan : perdre la file serait cuisant.</summary>
|
||||
public async Task EcrireRafaleAsync(Rafale rafale)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("rafaleEcrire", JsonSerializer.Serialize(rafale, Json));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ⚠️ On ne marque PAS le cache indisponible ici : ne pas pouvoir ranger la file
|
||||
// n'empêche pas de la traiter dans la session en cours. C'est la reprise ultérieure
|
||||
// qui est perdue, et l'écran le dit.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relit la rafale interrompue, s'il y en a une.</summary>
|
||||
public async Task<Rafale?> LireRafaleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
var brut = await module.InvokeAsync<string?>("rafaleLire");
|
||||
return brut is null ? null : JsonSerializer.Deserialize<Rafale>(brut, Json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EffacerRafaleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("rafaleEffacer");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Sans conséquence : une file qu'on n'a pas pu effacer sera écrasée à la suivante.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>État déclaré par le navigateur. Voir la nuance dans <c>cache-hors-ligne.js</c>.</summary>
|
||||
public async Task<bool> EnLigneAsync()
|
||||
{
|
||||
@@ -225,67 +128,6 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range une couverture en cache pour la consultation hors-ligne, en tâche de fond.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fire-and-forget par nature (A5, CLAUDE.md) : appelé après l'affichage réussi d'une
|
||||
/// couverture, sans jamais bloquer ni faire échouer ce qui s'est déjà bien passé. Une
|
||||
/// couverture qui ne peut pas être mise en cache (quota, stockage refusé, 502
|
||||
/// intermittent d'OpenLibrary) reste simplement absente hors-ligne — jamais une erreur
|
||||
/// visible en ligne.
|
||||
/// </remarks>
|
||||
public async Task MettreEnCacheCouvertureAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("couvertureMettreEnCache", url);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Voir la remarque ci-dessus : rien à propager.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relit une couverture déjà mise en cache, sous forme d'URL d'objet (<c>blob:</c>) utilisable
|
||||
/// directement comme <c>src</c>. <c>null</c> si elle n'a jamais été mise en cache.
|
||||
/// </summary>
|
||||
public async Task<string?> LireCouvertureCacheeAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
return await module.InvokeAsync<string?>("couvertureLire", url);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Libère une URL d'objet obtenue par <see cref="LireCouvertureCacheeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Nécessaire depuis que le cache sert aussi en ligne : une URL <c>blob:</c> retient son
|
||||
/// Blob en mémoire tant qu'elle n'est pas révoquée. Avec une vignette par carte sur tous les
|
||||
/// écrans, ne pas libérer ferait enfler la mémoire de l'onglet au fil de la navigation.
|
||||
/// </remarks>
|
||||
public async Task LibererCouvertureAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
await module.InvokeVoidAsync("couvertureLiberer", url);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Voir MettreEnCacheCouvertureAsync : rien à propager.
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_module is null)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
@@ -37,11 +36,6 @@ public static class FiltreLivresLocal
|
||||
livres = livres.Where(l => l.Format == format);
|
||||
}
|
||||
|
||||
if (criteres.TypeDocument is { } type)
|
||||
{
|
||||
livres = livres.Where(l => l.TypeDocument == type);
|
||||
}
|
||||
|
||||
if (criteres.Statut is { } statut)
|
||||
{
|
||||
// LivreDto.Statut est déjà celui de l'utilisateur courant : le serveur l'a résolu
|
||||
@@ -54,31 +48,6 @@ public static class FiltreLivresLocal
|
||||
livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId));
|
||||
}
|
||||
|
||||
if (criteres.Prete is { } prete)
|
||||
{
|
||||
// LivreDto.PreteA porte l'emprunteur du prêt EN COURS, ou null : le serveur a déjà
|
||||
// écarté les prêts clos. C'est exactement ce que le filtre serveur exprime par
|
||||
// « une ligne sans date de retour », d'où l'équivalence des deux implémentations.
|
||||
livres = livres.Where(l => (l.PreteA is not null) == prete);
|
||||
}
|
||||
|
||||
if (criteres.SansCouverture is { } sansCouverture)
|
||||
{
|
||||
// Même convention que côté serveur : une URL vide vaut une URL absente.
|
||||
livres = livres.Where(l => string.IsNullOrEmpty(l.CoverUrl) == sansCouverture);
|
||||
}
|
||||
|
||||
// Même exclusion que côté serveur : un code désigne un objet, des mots désignent une
|
||||
// œuvre. Les deux branches ne se mélangent pas.
|
||||
if (RechercheIsbn.Formes(criteres.Recherche) is { } formes)
|
||||
{
|
||||
livres = livres.Where(l =>
|
||||
l.Isbn is not null
|
||||
&& formes.Any(forme => l.Isbn.Contains(forme, StringComparison.Ordinal)));
|
||||
|
||||
return Ordonner(livres);
|
||||
}
|
||||
|
||||
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
|
||||
if (terme.Length > 0)
|
||||
{
|
||||
@@ -91,20 +60,12 @@ public static class FiltreLivresLocal
|
||||
|| RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
return Ordonner(livres);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
|
||||
/// déterministe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La comparaison est <b>ordinale</b> parce que c'est ce que fait SQLite sur une colonne sans
|
||||
/// collation — un tri « intelligent » ici ferait diverger les deux vues.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<LivreDto> Ordonner(IEnumerable<LivreDto> livres) =>
|
||||
livres
|
||||
// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
|
||||
// déterministe. La comparaison est ordinale parce que c'est ce que fait SQLite sur une
|
||||
// colonne sans collation — un tri « intelligent » ici ferait diverger les deux vues.
|
||||
return livres
|
||||
.OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal)
|
||||
.ThenBy(l => l.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Une entrée du catalogue : un livre seul, ou les tomes d'une même série sous son nom.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Une entrée de série peut en contenir d'autres : un cycle est une <b>série de séries</b>
|
||||
/// (<c>Serie.SerieParenteId</c>), et le catalogue le montre tel quel depuis le 2026-08-22 —
|
||||
/// sans quoi « replier chaque niveau » n'aurait pas de niveaux à replier.
|
||||
/// </remarks>
|
||||
public sealed record EntreeCatalogue
|
||||
{
|
||||
/// <summary>La série qui coiffe le bloc, ou <c>null</c> pour un livre seul.</summary>
|
||||
public SerieDto? Serie { get; init; }
|
||||
|
||||
/// <summary>Les livres rattachés <b>directement</b> à cette série ; un seul hors série.</summary>
|
||||
public required IReadOnlyList<LivreDto> Livres { get; init; }
|
||||
|
||||
/// <summary>Les séries contenues, quand ce bloc est un cycle. Vide sinon.</summary>
|
||||
public IReadOnlyList<EntreeCatalogue> SousEntrees { get; init; } = [];
|
||||
|
||||
public bool EstGroupe => Serie is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Combien de livres du catalogue tiennent dans ce bloc, descendance comprise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est ce que le bloc annonce, y compris <b>replié</b> : replier ne doit pas faire
|
||||
/// perdre le compte de ce qu'on vient de cacher.
|
||||
/// </remarks>
|
||||
public int NombreLivres => Livres.Count + SousEntrees.Sum(e => e.NombreLivres);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regroupe les livres du catalogue par série, en respectant les cycles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Le bloc se place là où son premier tome serait tombé</b> dans l'ordre du catalogue
|
||||
/// (choisi avec l'utilisateur le 2026-08-22) : l'ordre général reste celui qu'on connaît, et
|
||||
/// l'on retrouve « La Légende de Drizzt » entre « Dracula » et « Dune ». À l'intérieur, les
|
||||
/// tomes suivent l'<b>ordre de lecture</b>, seul ordre qui ait un sens pour une saga — c'est
|
||||
/// même la raison d'être de <c>ElementSerie.Position</c>, et les sous-séries d'un cycle
|
||||
/// suivent le leur (<c>Serie.Position</c>), comme sur l'écran des séries.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Rien n'est jamais masqué ni déplacé hors de la liste.</b> Un livre filtré reste absent,
|
||||
/// un livre visible reste visible : le regroupement ne fait que rassembler, et l'écran offre de
|
||||
/// le défaire. C'est la même règle que le grisage de la bibliographie — on marque, on ne cache
|
||||
/// pas.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ Un livre peut appartenir à <b>plusieurs</b> séries (le modèle l'autorise, sans unicité sur
|
||||
/// <c>LivreId</c> seul). Il n'apparaît pourtant qu'une fois : le dupliquer ferait mentir le
|
||||
/// compteur du catalogue et donnerait deux cartes du même exemplaire. La série retenue est la
|
||||
/// <b>première par ordre alphabétique</b> — un critère explicable, à défaut d'être le bon dans
|
||||
/// tous les cas ; sa fiche livre, elle, les montre toutes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Seuls les niveaux qui portent quelque chose existent.</b> Un cycle de cinq séries dont
|
||||
/// une seule a un tome au catalogue ne produit pas quatre nœuds vides : on ne crée que la
|
||||
/// chaîne qui mène aux livres présents. Un niveau vide serait une indentation qui ne se
|
||||
/// replierait sur rien.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class GroupementCatalogue
|
||||
{
|
||||
public static IReadOnlyList<EntreeCatalogue> Grouper(
|
||||
IReadOnlyList<LivreDto> livres, IReadOnlyList<SerieDto>? series)
|
||||
{
|
||||
if (series is null || series.Count == 0)
|
||||
{
|
||||
return [.. livres.Select(l => new EntreeCatalogue { Livres = [l] })];
|
||||
}
|
||||
|
||||
var place = PlaceDesLivres(series);
|
||||
var parId = series.ToDictionary(s => s.Id);
|
||||
|
||||
// Les blocs s'accumulent dans le brouillon de leur RACINE, créé à la position du premier
|
||||
// livre rencontré sous elle : c'est ce qui range le bloc là où l'ordre du catalogue
|
||||
// l'attend. Les nœuds intermédiaires, eux, se créent en chemin.
|
||||
var brouillons = new List<Noeud>();
|
||||
var noeuds = new Dictionary<int, Noeud>();
|
||||
|
||||
foreach (var livre in livres)
|
||||
{
|
||||
if (!place.TryGetValue(livre.Id, out var appartenance))
|
||||
{
|
||||
brouillons.Add(new Noeud(null) { Livres = { (0, livre) } });
|
||||
continue;
|
||||
}
|
||||
|
||||
var noeud = Descendre(Ascendance(appartenance.Serie, parId), noeuds, brouillons);
|
||||
noeud.Livres.Add((appartenance.Position, livre));
|
||||
}
|
||||
|
||||
return [.. brouillons.Select(Materialiser)];
|
||||
}
|
||||
|
||||
/// <summary>Un bloc en cours de construction : ses tomes directs et ses sous-blocs.</summary>
|
||||
private sealed class Noeud(SerieDto? serie)
|
||||
{
|
||||
public SerieDto? Serie { get; } = serie;
|
||||
|
||||
public List<(int Position, LivreDto Livre)> Livres { get; } = [];
|
||||
|
||||
public List<Noeud> Enfants { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La chaîne de la racine jusqu'à <paramref name="serie"/>, cycle compris.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ La remontée est <b>bornée par le nombre de séries</b>, comme celle du serveur : une
|
||||
/// boucle résiduelle en base ferait sinon tourner cette fonction sans fin. Mieux vaut un
|
||||
/// bloc rangé un cran trop bas qu'un catalogue qui ne s'affiche plus.
|
||||
/// </remarks>
|
||||
private static List<SerieDto> Ascendance(SerieDto serie, Dictionary<int, SerieDto> parId)
|
||||
{
|
||||
var chaine = new List<SerieDto> { serie };
|
||||
var vues = new HashSet<int> { serie.Id };
|
||||
var courante = serie;
|
||||
|
||||
while (courante.SerieParenteId is { } parenteId
|
||||
&& parId.TryGetValue(parenteId, out var parente)
|
||||
&& vues.Add(parente.Id)
|
||||
&& chaine.Count <= parId.Count)
|
||||
{
|
||||
chaine.Add(parente);
|
||||
courante = parente;
|
||||
}
|
||||
|
||||
chaine.Reverse();
|
||||
return chaine;
|
||||
}
|
||||
|
||||
/// <summary>Retrouve — ou crée — le nœud de chaque série de la chaîne, et rend le dernier.</summary>
|
||||
private static Noeud Descendre(
|
||||
List<SerieDto> chaine, Dictionary<int, Noeud> noeuds, List<Noeud> brouillons)
|
||||
{
|
||||
Noeud? parent = null;
|
||||
|
||||
foreach (var serie in chaine)
|
||||
{
|
||||
if (!noeuds.TryGetValue(serie.Id, out var noeud))
|
||||
{
|
||||
noeud = new Noeud(serie);
|
||||
noeuds[serie.Id] = noeud;
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
brouillons.Add(noeud);
|
||||
}
|
||||
else
|
||||
{
|
||||
parent.Enfants.Add(noeud);
|
||||
}
|
||||
}
|
||||
|
||||
parent = noeud;
|
||||
}
|
||||
|
||||
return parent!;
|
||||
}
|
||||
|
||||
private static EntreeCatalogue Materialiser(Noeud noeud) => new()
|
||||
{
|
||||
Serie = noeud.Serie,
|
||||
Livres =
|
||||
[
|
||||
.. noeud.Livres
|
||||
.OrderBy(t => t.Position)
|
||||
.ThenBy(t => t.Livre.Id)
|
||||
.Select(t => t.Livre),
|
||||
],
|
||||
|
||||
// Les sous-séries d'abord, puis les tomes rattachés directement au cycle : c'est la
|
||||
// disposition de l'écran d'une série, et deux écrans ne doivent pas en donner deux.
|
||||
SousEntrees =
|
||||
[
|
||||
.. noeud.Enfants
|
||||
.OrderBy(e => e.Serie!.Position)
|
||||
.ThenBy(e => e.Serie!.Id)
|
||||
.Select(Materialiser),
|
||||
],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// À quelle série — et à quelle place — appartient chaque livre rattaché.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Les séries sont parcourues dans l'ordre alphabétique de leur titre normalisé, et le
|
||||
/// <c>TryAdd</c> garde donc la <b>première</b> : sans cet ordre, la série retenue pour un
|
||||
/// livre rattaché deux fois dépendrait de l'ordre où l'API rend les séries, c'est-à-dire de
|
||||
/// rien de compréhensible.
|
||||
/// </remarks>
|
||||
private static Dictionary<int, (SerieDto Serie, int Position)> PlaceDesLivres(
|
||||
IReadOnlyList<SerieDto> series)
|
||||
{
|
||||
var place = new Dictionary<int, (SerieDto, int)>();
|
||||
|
||||
foreach (var serie in series
|
||||
.OrderBy(s => NormalisationTexte.Normaliser(s.Titre), StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Id))
|
||||
{
|
||||
foreach (var element in serie.Elements)
|
||||
{
|
||||
if (element.LivreId is { } livreId)
|
||||
{
|
||||
place.TryAdd(livreId, (serie, element.Position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return place;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user