2 Commits
Author SHA1 Message Date
Mathieu Limonier bb071c3db9 Ajout du lien vers le manuel de l'application 2026-08-23 17:36:06 +02:00
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00
213 changed files with 20957 additions and 62790 deletions
+11
View File
@@ -0,0 +1,11 @@
# 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
+3
View File
@@ -13,6 +13,9 @@ publish/
# IDE
.vs/
.vscode/
.idea/
*.user
*.DotSettings.user
*.swp
# OS
-15
View File
@@ -1,15 +0,0 @@
# 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
-1
View File
@@ -1 +0,0 @@
MaBibli
-12
View File
@@ -1,12 +0,0 @@
<?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
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+184 -944
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
# 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"]
-64
View File
@@ -1,64 +0,0 @@
# 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
### Titres BnF contenant le nom de l'auteur
**Cas constaté** — ISBN `9782749942636` :
```
dc:title = "Le plus grand défi de l'histoire de l'humanité : face à la
catastrophe écologique et sociale (Nouvelle éd. augmentée)
Aurélien Barrau"
dc:creator = "Barrau, Aurélien (1973-....). Auteur du texte"
```
Le nom de l'auteur est collé en fin de titre **sans le séparateur ISBD ` / `**. La règle de nettoyage documentée dans `CLAUDE.md` ne peut donc pas s'appliquer : il n'y a pas de ponctuation à couper. Ce n'est ni un doublon, ni un défaut de notre parsing — la notice est sale à la source.
Correction envisagée : une fois `dc:creator` nettoyé, si le titre **se termine** par ce nom exact, le retirer. Heuristique volontairement stricte (comparaison en fin de chaîne uniquement) pour ne pas mutiler les titres qui contiennent légitimement un nom de personne.
### Recherche insensible aux accents
« Emile » ne trouve pas « Émile ». Gênant sur un fonds francophone.
**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.
@@ -0,0 +1,33 @@
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);
}
}
+191
View File
@@ -11,6 +11,10 @@ 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>();
@@ -24,6 +28,37 @@ 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);
@@ -32,6 +67,7 @@ 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.
@@ -75,6 +111,30 @@ 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();
@@ -121,6 +181,137 @@ 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();
@@ -1,108 +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("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
}
}
}
@@ -1,78 +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: "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");
}
}
}
@@ -1,242 +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("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
}
}
}
@@ -1,278 +0,0 @@
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");
}
}
}
@@ -1,246 +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("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
}
}
}
@@ -1,29 +0,0 @@
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");
}
}
}
@@ -1,301 +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("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
}
}
}
@@ -1,56 +0,0 @@
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,693 @@
// <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
}
}
}
@@ -0,0 +1,556 @@
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");
}
}
}
@@ -17,6 +17,34 @@ 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")
@@ -45,6 +73,65 @@ 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<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")
@@ -69,6 +156,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int?>("NombrePages")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
@@ -77,6 +167,12 @@ 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");
@@ -97,6 +193,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
@@ -137,6 +236,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
@@ -159,6 +261,58 @@ 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")
@@ -209,6 +363,129 @@ 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>("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")
@@ -238,6 +515,57 @@ 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")
@@ -257,6 +585,36 @@ 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")
@@ -268,6 +626,16 @@ 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")
@@ -291,6 +659,30 @@ 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
}
+217 -3
View File
@@ -1,4 +1,5 @@
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -25,15 +26,228 @@ 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 > 0)
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
{
logger.LogInformation(
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).",
titres, auteursCorriges, auteursFusionnes);
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
titres, auteursCorriges, auteursFusionnes, autres);
}
}
/// <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);
@@ -0,0 +1,40 @@
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;
}
}
+33
View File
@@ -32,6 +32,39 @@ public static class IsbnEndpoints
.Produces<ResultatLookupIsbn>()
.Produces(StatusCodes.Status400BadRequest);
// Recherche par titre : on ne connaît pas toujours l'ISBN d'un livre qu'on souhaite —
// c'est même le cas normal, puisqu'on ne l'a pas en main. Hors du groupe /api/isbn,
// qui parle d'un code-barres, alors qu'ici on part de mots.
routes.MapGet("/api/notices", async (
string? titre,
string? auteur,
IBnfClient bnf,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(titre))
{
return Results.BadRequest(new { erreur = "Indiquez au moins un titre à chercher." });
}
var (candidats, avertissement) = await bnf.RechercherParTitreAsync(titre, auteur, ct);
return Results.Ok(new ResultatLookupIsbn
{
IsbnDemande = string.Empty,
Candidats = candidats,
Avertissements = avertissement is null ? [] : [avertissement],
});
})
.WithTags("ISBN")
.WithName("RechercherNotices")
.WithSummary("Cherche des notices BnF par titre, éventuellement restreint à un auteur.")
.WithDescription(
"Les notices qui ne décrivent pas un livre (film, disque, jeu) sont écartées. "
+ "L'auteur n'est PAS post-filtré : le choix revient à l'utilisateur, qui voit "
+ "les auteurs de chaque notice.")
.Produces<ResultatLookupIsbn>()
.Produces(StatusCodes.Status400BadRequest);
return routes;
}
}
+48 -4
View File
@@ -28,8 +28,10 @@ public static class LivresEndpoints
groupe.MapGet("/", async (
string? recherche,
Format? format,
TypeDocument? typeDocument,
Statut? statut,
int? auteurId,
bool? prete,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
@@ -38,15 +40,17 @@ public static class LivresEndpoints
{
Recherche = recherche,
Format = format,
TypeDocument = typeDocument,
Statut = statut,
AuteurId = auteurId,
Prete = prete,
};
var utilisateur = utilisateurs.Obtenir().Identifiant;
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
})
.WithName("ListerLivres")
.WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.")
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; 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. "
@@ -69,21 +73,36 @@ 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, ct);
var resultat = await service.CreerAsync(
saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
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)
: 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 (
@@ -169,6 +188,31 @@ 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")
+108
View File
@@ -0,0 +1,108 @@
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);
}
}
+159
View File
@@ -0,0 +1,159 @@
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.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; }
}
+207 -8
View File
@@ -27,12 +27,35 @@ public static class SouhaitsEndpoints
CancellationToken ct) =>
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
.WithName("ListerSouhaits")
.WithSummary("La liste d'envies de l'appelant, groupée par auteur.")
.WithSummary("La liste d'envies de l'appelant, du plus désiré au moins désiré.")
.WithDescription(
"Personnelle : deux membres du foyer obtiennent deux listes différentes. "
+ "Sans identité, la liste est vide.")
+ "Sans identité, la liste est vide. L'ordre est celui que le propriétaire a "
+ "choisi ; il n'est pas alphabétique.")
.Produces<IReadOnlyList<SouhaitDto>>();
groupe.MapPut("/ordre", async (
int[] ids,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.ReordonnerAsync(
ids, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.NotFound();
})
.WithName("ReordonnerSouhaits")
.WithSummary("Fixe l'ordre de la liste d'envies de l'appelant.")
.WithDescription(
"Attend la liste ENTIÈRE des identifiants, dans l'ordre voulu — c'est ce dont a "
+ "besoin le glisser-déposer, et les flèches monter/descendre s'y ramènent. "
+ "Les identifiants inconnus ou appartenant à quelqu'un d'autre sont ignorés ; "
+ "les envies absentes de la liste envoyée sont conservées, à la suite. "
+ "404 si l'appelant n'a aucune envie.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/", async (
EnregistrementSouhait saisie,
IServiceSouhaits service,
@@ -51,6 +74,37 @@ 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,
@@ -70,20 +124,108 @@ public static class SouhaitsEndpoints
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt
// ─────────────────────────────────────────────────────────────────────
// 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
// 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, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct))
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
.WithName("ExporterSouhaitsTexte")
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
.ExcludeFromDescription();
groupe.MapGet("/export.csv", (
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct))
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, revues, utilisateurs, ct))
.WithName("ExporterSouhaitsCsv")
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
.ExcludeFromDescription();
@@ -108,15 +250,20 @@ 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, utilisateur.Affichage, maintenant);
format, souhaits, revuesSouhaitees, 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
@@ -157,6 +304,58 @@ 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;
}
}
+38
View File
@@ -0,0 +1,38 @@
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);
}
+14
View File
@@ -16,6 +16,20 @@
<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" />
+69 -2
View File
@@ -1,9 +1,12 @@
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;
@@ -18,7 +21,10 @@ 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).
@@ -30,6 +36,19 @@ 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);
@@ -46,6 +65,31 @@ 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.
@@ -69,17 +113,40 @@ 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();
app.UseStaticFiles(optionsFichiersStatiques);
app.MapIsbnEndpoints();
app.MapLivresEndpoints();
app.MapAuteursEndpoints();
app.MapPretsEndpoints();
app.MapSouhaitsEndpoints();
app.MapSeriesEndpoints();
app.MapRevuesEndpoints();
app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints();
app.MapCouverturesEndpoints();
app.MapVersionEndpoints();
app.MapFallbackToFile("index.html");
// ⚠️ 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.Run();
@@ -18,6 +18,10 @@ 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);
@@ -139,6 +143,62 @@ 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)
{
@@ -217,6 +277,15 @@ 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;
}
@@ -227,6 +296,7 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
LivreId = lien.LivreId,
AuteurId = cible.Id,
Position = lien.Position,
Role = lien.Role,
});
}
@@ -1,6 +1,8 @@
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;
@@ -13,6 +15,13 @@ 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>
@@ -73,9 +82,10 @@ 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))
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.OrderByDescending(o => AnneeTriable(o.Annee))
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
@@ -88,9 +98,129 @@ public sealed class ServiceBibliographie(
NoticesLues = resultat.NombreLu,
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
Avertissement = avertissement,
// Transmis tel quel : c'est l'écran qui décide quoi en dire, mais lui seul ne
// pourrait pas deviner qu'une liste vide vient d'une panne.
Etat = resultat.Etat,
};
}
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)
{
@@ -114,6 +244,22 @@ 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>
@@ -144,6 +290,12 @@ 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(),
@@ -154,7 +306,8 @@ public sealed class ServiceBibliographie(
OeuvreBibliographie oeuvre,
IReadOnlyDictionary<string, int> parTitre,
IReadOnlyDictionary<string, int> parIsbn,
IReadOnlyDictionary<string, int> souhaits)
IReadOnlyDictionary<string, int> souhaits,
IReadOnlySet<string> masquees)
{
var cle = CleOeuvre.Cle(oeuvre.Titre);
@@ -175,9 +328,65 @@ 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,21 +3,34 @@ 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 : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
/// <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)
{
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null);
public static ResultatEcriture Invalide(string message) => new(null, message);
public static ResultatEcriture Invalide(string message) => new(null, message, null);
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatEcriture Introuvable = new(null, null);
/// <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);
public bool EstIntrouvable => Livre is null && Erreur is 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 interface IServiceCatalogue
@@ -28,7 +41,10 @@ public interface IServiceCatalogue
Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
CancellationToken ct = default);
Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
@@ -56,7 +72,9 @@ 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),
db.Livres.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme),
criteres,
utilisateur);
@@ -76,6 +94,7 @@ 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)
@@ -89,20 +108,35 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
}
public async Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
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.
@@ -113,6 +147,7 @@ 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
@@ -125,7 +160,10 @@ 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).FirstOrDefaultAsync(l => l.Id == id, ct);
var livre = await db.Livres
.Include(l => l.Auteurs)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatEcriture.Introuvable;
@@ -140,13 +178,17 @@ 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.
@@ -184,6 +226,102 @@ 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)
@@ -277,11 +415,18 @@ 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.</summary>
/// <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>
private async Task RattacherAuteursAsync(
Livre livre, IEnumerable<string> noms, CancellationToken ct)
Livre livre, IReadOnlyList<AuteurSaisi> saisis, CancellationToken ct)
{
var resolus = await auteurs.ResoudreAsync(noms, ct);
var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), 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.
@@ -291,9 +436,54 @@ 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 });
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 });
}
}
@@ -328,6 +518,15 @@ 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;
}
@@ -342,12 +541,20 @@ 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 })
.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)
.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,
@@ -0,0 +1,115 @@
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;
}
}
@@ -0,0 +1,157 @@
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>&lt;img&gt;</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();
}
}
@@ -1,4 +1,6 @@
using System.Xml.Linq;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
namespace MaBibli.Api.Services.Isbn;
@@ -37,6 +39,13 @@ public sealed record ResultatBibliographie
/// <summary>Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé.</summary>
public int EcarteesAutreAuteur { get; init; }
/// <summary>
/// Ce qu'il est advenu de l'interrogation. ⚠️ À ne pas confondre avec « aucune notice » :
/// une source muette rend elle aussi une liste vide, et il ne faut surtout pas les lire
/// de la même façon.
/// </summary>
public EtatSourceBibliographie Etat { get; init; }
}
/// <summary>
@@ -171,6 +180,77 @@ public static class BnfBibliographieParser
};
}
/// <summary>
/// Convertit une réponse SRU interrogée <b>par titre</b> en candidats à choisir.
/// </summary>
/// <remarks>
/// Le filtre de type est <b>indispensable ici aussi</b>, et pour la même raison qu'en
/// bibliographie : mesuré le 2026-08-18, <c>bib.title all "la bête humaine"</c> croisé avec
/// Zola remonte 116 notices, dont les films de Jean Renoir (« image animée ») et une
/// plaquette de présentation. Une liste d'envies de livres n'a pas à proposer un DVD.
/// <para>
/// ⚠️ En revanche <b>aucun post-filtre sur l'auteur</b>, contrairement à
/// <see cref="Parser"/> : la contrainte de titre resserre déjà beaucoup, l'auteur est
/// facultatif dans cette recherche, et surtout l'utilisateur <b>choisit dans la liste</b>
/// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions
/// légitimes qu'il aurait su reconnaître.
/// </para>
/// <para>
/// L'ISBN est repris de la notice quand elle en porte un : c'est lui qui donne la
/// couverture, la BnF n'en fournissant aucune.
/// </para>
/// </remarks>
public static IReadOnlyList<CandidatLivre> ParserRecherche(string xml)
{
var doc = XDocument.Parse(xml);
var candidats = new List<CandidatLivre>();
foreach (var record in doc.Descendants(Srw + "record"))
{
var data = record.Element(Srw + "recordData");
if (data is null || !EstUnLivre(data))
{
continue;
}
var auteurs = Tous(data, "creator")
.Select(NettoyageIsbd.Auteur)
.Where(a => !string.IsNullOrWhiteSpace(a))
.Select(a => a!)
.Distinct()
.ToList();
var titre = NettoyageIsbd.RetirerAuteurEnFin(
NettoyageIsbd.Titre(Premier(data, "title")), auteurs);
if (titre is null)
{
continue;
}
var isbn = IsbnUtils.Normaliser(Isbn(data));
if (!IsbnUtils.EstValide(isbn))
{
isbn = null;
}
candidats.Add(new CandidatLivre
{
Titre = titre,
Auteurs = auteurs,
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
Annee = Premier(data, "date"),
Langue = Premier(data, "language"),
Source = SourceMetadonnees.Bnf,
IsbnInterroge = isbn,
CoverUrl = isbn is null ? null : IsbnUtils.UrlCouverture(isbn),
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
});
}
return candidats;
}
/// <summary>
/// La notice décrit-elle un livre ?
/// </summary>
+226 -4
View File
@@ -21,12 +21,43 @@ public interface IBnfClient
/// </remarks>
Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par ISSN</b> pour nommer un périodique.
/// </summary>
/// <remarks>
/// Sert uniquement à dire à l'utilisateur ce qu'il a scanné quand le code-barres porte le
/// préfixe <c>977</c> : rien n'est catalogué à partir de là. Une BnF injoignable renvoie
/// <c>null</c> et un avertissement — l'écran dira alors « un magazine, titre inconnu »
/// plutôt que de laisser croire à un échec de recherche de livre.
/// </remarks>
Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default);
/// <summary>
/// Interroge le SRU <b>par titre</b>, éventuellement restreint à un auteur.
/// </summary>
/// <remarks>
/// Sert l'ajout à la liste d'envies quand on n'a pas le livre en main : on souhaite une
/// œuvre dont on connaît le titre, rarement l'ISBN.
/// </remarks>
Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default);
}
/// <summary>
/// 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
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
{
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
public const string NomHttpClient = "bnf";
@@ -70,6 +101,106 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
}
}
public async Task<(PeriodiqueDetecte? Periodique, string? Avertissement)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(issn))
{
return (null, null);
}
// bib.issn, vérifié le 2026-08-18 : l'ISSN doit porter son tiret (« 2466-6718 »),
// forme canonique sous laquelle la BnF l'indexe.
var url = "api/SRU"
+ "?version=1.2"
+ "&operation=searchRetrieve"
+ $"&query={Uri.EscapeDataString($"bib.issn all \"{issn}\"")}"
+ "&recordSchema=dublincore"
+ "&maximumRecords=1";
try
{
using var reponse = await http.GetAsync(url, ct);
if (!reponse.IsSuccessStatusCode)
{
logger.LogWarning("BnF a répondu {Code} pour l'ISSN {Issn}", (int)reponse.StatusCode, issn);
return (null, $"La BnF a répondu {(int)reponse.StatusCode} pour l'ISSN {issn}.");
}
var xml = await reponse.Content.ReadAsStringAsync(ct);
return (BnfSruParser.ParserPeriodique(xml, issn), null);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "BnF injoignable pour l'ISSN {Issn}", issn);
return (null, $"BnF injoignable ({ex.GetType().Name}) pour l'ISSN {issn}.");
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour l'ISSN {Issn}", issn);
return (null, $"Réponse BnF illisible pour l'ISSN {issn}.");
}
}
/// <summary>Notices rendues par une recherche par titre : de quoi choisir sans faire défiler.</summary>
public const int NoticesRecherche = 20;
public async Task<(IReadOnlyList<CandidatLivre> Candidats, string? Avertissement)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(titre))
{
return ([], null);
}
// Les deux index se combinent en CQL par « and ». L'auteur est facultatif : sans lui la
// recherche porte sur le seul titre, ce qui suffit pour un titre un peu distinctif.
var cql = $"bib.title all \"{Echapper(titre)}\"";
if (!string.IsNullOrWhiteSpace(auteur))
{
cql += $" and bib.author all \"{Echapper(auteur)}\"";
}
var url = "api/SRU"
+ "?version=1.2"
+ "&operation=searchRetrieve"
+ $"&query={Uri.EscapeDataString(cql)}"
+ "&recordSchema=dublincore"
+ $"&maximumRecords={NoticesRecherche}";
try
{
using var reponse = await http.GetAsync(url, ct);
if (!reponse.IsSuccessStatusCode)
{
logger.LogWarning("BnF a répondu {Code} pour le titre {Titre}", (int)reponse.StatusCode, titre);
return ([], $"La BnF a répondu {(int)reponse.StatusCode} pour « {titre} ».");
}
var xml = await reponse.Content.ReadAsStringAsync(ct);
return (BnfBibliographieParser.ParserRecherche(xml), null);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "BnF injoignable pour le titre {Titre}", titre);
return ([], $"BnF injoignable ({ex.GetType().Name}) pour « {titre} ».");
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour le titre {Titre}", titre);
return ([], $"Réponse BnF illisible pour « {titre} ».");
}
}
/// <summary>
/// Neutralise les guillemets d'un terme de recherche, qui délimitent la valeur en CQL.
/// </summary>
/// <remarks>
/// Un titre contenant un guillemet fermerait la chaîne et rendrait la requête invalide —
/// « L'homme qui plantait des arbres » n'en a pas, mais rien ne l'interdit à un utilisateur.
/// </remarks>
private static string Echapper(string terme) => terme.Trim().Replace("\"", " ");
/// <summary>
/// Notices lues par page, et nombre de pages : au plus <b>200 notices</b>.
/// </summary>
@@ -89,6 +220,12 @@ 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)
@@ -109,23 +246,108 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
var lues = pages.Where(p => p is not null).Select(p => p!).ToList();
if (lues.Count == 0)
{
return (new ResultatBibliographie(), $"La BnF n'a pas répondu pour « {auteur} ».");
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
return (Fusionner(lues), null);
}
// ⚠️ Le délai dépassé se distingue de l'injoignable, et ce n'est pas du détail : c'est
// le cas remonté en usage, et le seul qui vaille la peine d'être retenté tout de suite.
// HttpClient l'annonce par une TaskCanceledException dont l'exception interne est un
// TimeoutException — sans ce test, il se confondrait avec une annulation de l'appelant.
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogWarning(ex, "Délai dépassé par la BnF pour l'auteur {Auteur}", auteur);
return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
logger.LogWarning(ex, "BnF injoignable pour l'auteur {Auteur}", auteur);
return (new ResultatBibliographie(), $"BnF injoignable ({ex.GetType().Name}) pour « {auteur} ».");
return Echec(EtatSourceBibliographie.Injoignable, auteur);
}
catch (System.Xml.XmlException ex)
{
logger.LogWarning(ex, "Réponse BnF illisible pour l'auteur {Auteur}", auteur);
return (new ResultatBibliographie(), $"Réponse BnF illisible pour « {auteur} ».");
return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
}
}
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>
/// <remarks>
/// ⚠️ Public parce que ce texte <b>atteint l'utilisateur</b> : c'est un contrat d'interface,
/// pas un détail d'implémentation, et il est verrouillé par des tests. Il ne contient aucun
/// nom de classe .NET — « BnF injoignable (TaskCanceledException) » est ce qui s'affichait
/// avant, et « TaskCanceledException » ne veut rien dire pour qui range ses livres.
/// <para>
/// Les trois motifs se distinguent parce qu'ils n'appellent pas la même chose : un délai
/// dépassé se retente tout de suite, une réponse illisible non.
/// </para>
/// </remarks>
public static string MotifDeSourceMuette(EtatSourceBibliographie etat, string auteur) =>
etat switch
{
EtatSourceBibliographie.DelaiDepasse =>
$"La BnF n'a pas répondu à temps pour « {auteur} ». Son catalogue est parfois lent ;"
+ " réessayer suffit le plus souvent.",
EtatSourceBibliographie.ReponseIllisible =>
$"La BnF a répondu quelque chose d'inexploitable pour « {auteur} ».",
_ =>
$"La BnF n'a pas pu être jointe pour « {auteur} ». Vérifiez votre connexion,"
+ " ou réessayez plus tard.",
};
/// <summary>Résultat d'une interrogation qui n'a pas abouti : aucune notice, et son motif.</summary>
private static (ResultatBibliographie, string?) Echec(EtatSourceBibliographie etat, string auteur) =>
(new ResultatBibliographie { Etat = etat }, MotifDeSourceMuette(etat, auteur));
private async Task<ResultatBibliographie?> LirePageAsync(
string auteur, int premiereNotice, CancellationToken ct)
{
+61 -1
View File
@@ -62,6 +62,8 @@ public static class BnfSruParser
continue;
}
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
candidats.Add(new CandidatLivre
{
Titre = titre,
@@ -69,16 +71,74 @@ 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 = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
IdentifiantSource = identifiant,
UrlNotice = UrlNotice(identifiant),
});
}
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>
/// <remarks>
/// On ne renvoie qu'une notice, et sans laisser le choix : le but n'est pas de cataloguer
/// la revue — le modèle de données ne le permet pas — mais de <b>nommer</b> ce que
/// l'utilisateur vient de scanner. Un ISSN désigne un et un seul titre de périodique, donc
/// les notices suivantes ne diraient rien de plus.
/// <para>
/// Une notice de revue porte souvent <b>deux</b> <c>dc:title</c> : le titre clé qualifié
/// (« Médor (Bruxelles) ») puis le titre développé avec sa mention de responsabilité.
/// C'est le premier qu'on retient — le second est l'accroche commerciale, pas le nom.
/// </para>
/// </remarks>
/// <returns><c>null</c> si la réponse ne contient aucune notice exploitable.</returns>
public static PeriodiqueDetecte? ParserPeriodique(string xml, string issn)
{
var doc = XDocument.Parse(xml);
foreach (var record in doc.Descendants(Srw + "record"))
{
var data = record.Element(Srw + "recordData");
if (data is null)
{
continue;
}
var titre = NettoyageIsbd.TitrePeriodique(Premier(data, "title"));
if (titre is null)
{
continue;
}
return new PeriodiqueDetecte
{
Issn = issn,
Titre = titre,
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
Annee = Premier(data, "date"),
};
}
return null;
}
private static string? Premier(XElement recordData, string nomLocal) =>
Tous(recordData, nomLocal).FirstOrDefault();
@@ -29,6 +29,17 @@ public sealed class IsbnLookupService(
public async Task<ResultatLookupIsbn?> RechercherAsync(string isbnBrut, CancellationToken ct = default)
{
var isbn = IsbnUtils.Normaliser(isbnBrut);
// Un code en 977 est un PÉRIODIQUE : il porte un ISSN, donc un titre de revue, et
// aucune recherche bib.isbn ne peut aboutir. Sans cette interception la cascade
// s'exécutait en entier pour répondre « aucun résultat » — ce qui envoie l'utilisateur
// chercher un défaut là où il n'y en a pas. On sort AVANT la validation ISBN parce
// qu'un code de périodique est un EAN-13 parfaitement valide : seul son préfixe le trahit.
if (CodePeriodique.EstCodePeriodique(isbn))
{
return await DecrirePeriodiqueAsync(isbn!, ct);
}
if (!IsbnUtils.EstValide(isbn))
{
return null;
@@ -76,6 +87,37 @@ public sealed class IsbnLookupService(
};
}
/// <summary>
/// Nomme le périodique scanné, sans rien chercher du côté des livres.
/// </summary>
/// <remarks>
/// Le résultat ne porte <b>jamais</b> de candidat : le modèle de données ne gère pas les
/// périodiques (ni numéro, ni date de parution), et cataloguer douze numéros d'une même
/// revue créerait douze fiches identiques. L'objectif est uniquement de dire à l'utilisateur
/// ce qu'il tient en main. Une BnF muette ou injoignable n'est pas un échec : on connaît
/// l'ISSN de toute façon, puisqu'il se déduit du code-barres.
/// </remarks>
private async Task<ResultatLookupIsbn> DecrirePeriodiqueAsync(string ean, CancellationToken ct)
{
var issn = CodePeriodique.IssnDepuis(ean)!;
var (periodique, avertissement) = await bnf.RechercherPeriodiqueAsync(issn, ct);
var avertissements = new List<string>();
Ajouter(avertissements, avertissement);
logger.LogInformation(
"Code {Ean} : périodique ISSN {Issn} ({Titre})",
ean, issn, periodique?.Titre ?? "titre inconnu");
return new ResultatLookupIsbn
{
IsbnDemande = ean,
Candidats = [],
Avertissements = avertissements,
Periodique = periodique ?? new PeriodiqueDetecte { Issn = issn },
};
}
private static void Ajouter(List<string> avertissements, string? message)
{
if (!string.IsNullOrEmpty(message))
@@ -23,6 +23,13 @@ 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>
@@ -166,6 +173,65 @@ 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.
/// </summary>
/// <remarks>
/// Mesuré sur l'ISSN <c>2466-6718</c> le 2026-08-18 : la BnF donne
/// « <c>Médor (Bruxelles)</c> ». Le parenthésé n'est pas une ville d'édition mais le
/// <i>qualificatif de désambiguïsation</i> propre aux notices de revues — deux périodiques
/// peuvent porter le même titre. Il se retire comme la ville d'un éditeur, et pour la même
/// raison : ce qu'on montre à l'utilisateur doit ressembler à ce qui est écrit sur la
/// couverture du magazine qu'il tient en main.
/// </remarks>
public static string? TitrePeriodique(string? brut) => Editeur(Titre(brut));
/// <summary>
/// Retire les virgules et espaces de fin, et le point final <b>sauf</b> s'il appartient
/// à une initiale (« Cormen, Thomas H. » doit garder son point).
@@ -83,9 +83,15 @@ 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];
@@ -0,0 +1,362 @@
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(),
};
}
@@ -0,0 +1,482 @@
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);
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,
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);
}
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,
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(),
};
}
+145 -16
View File
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Isbn;
namespace MaBibli.Api.Services.Souhaits;
@@ -12,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> IDEES.md demandait un format
/// <b>Aucune dépendance, ni côté production ni côté lecture.</b> CLAUDE.md demandait un format
/// « lisible partout » : ni PDF, ni tableur binaire, ni JSON. Les deux sorties sont du texte.
/// </para>
/// </remarks>
@@ -47,12 +48,29 @@ 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, string? proprietaire, DateTime maintenant) =>
FormatExportSouhaits format,
IReadOnlyList<SouhaitDto> souhaits,
IReadOnlyList<RevueSouhaiteeDto> revues,
string? proprietaire,
DateTime maintenant) =>
format switch
{
FormatExportSouhaits.Csv => Csv(souhaits),
_ => Texte(souhaits, proprietaire, maintenant),
FormatExportSouhaits.Csv => Csv(souhaits, revues),
_ => Texte(souhaits, revues, proprietaire, maintenant),
};
/// <summary>
@@ -62,7 +80,11 @@ 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, string? proprietaire, DateTime maintenant)
public static string Texte(
IReadOnlyList<SouhaitDto> souhaits,
IReadOnlyList<RevueSouhaiteeDto> revues,
string? proprietaire,
DateTime maintenant)
{
var sortie = new StringBuilder();
@@ -75,13 +97,32 @@ 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)
if (souhaits.Count == 0 && revues.Count == 0)
{
sortie.Append("\n(aucun livre dans la liste)\n");
sortie.Append("\n(aucune envie dans la liste)\n");
return sortie.ToString();
}
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
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");
// « 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.
@@ -96,7 +137,7 @@ public static class ExportSouhaits
foreach (var souhait in groupe.OrderBy(s => s.Titre, StringComparer.CurrentCultureIgnoreCase))
{
sortie.Append(" - ").Append(souhait.Titre);
sortie.Append(" - [").Append(rangs[souhait.Id]).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.
@@ -117,35 +158,123 @@ public static class ExportSouhaits
}
}
}
return sortie.ToString();
}
/// <summary>Tableau ouvrable dans un tableur, une envie par ligne.</summary>
public static string Csv(IReadOnlyList<SouhaitDto> souhaits)
/// <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)
{
var sortie = new StringBuilder();
Ligne(sortie, "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le");
// 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);
foreach (var souhait in souhaits)
{
Ligne(
sortie,
rangs[souhait.Id].ToString(CultureInfo.InvariantCulture),
"Livre",
souhait.Titre,
souhait.Auteur,
souhait.Editeur,
souhait.Annee,
souhait.Isbn,
FormatageIsbn.Afficher(souhait.Isbn),
souhait.Note,
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.
/// </summary>
private static string? Isbn(string? isbn) =>
string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {isbn}";
string.IsNullOrWhiteSpace(isbn) ? null : $"ISBN {FormatageIsbn.Afficher(isbn)}";
private static void Ligne(StringBuilder sortie, params string?[] champs)
{
@@ -0,0 +1,208 @@
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,9 +7,16 @@ using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Souhaits;
/// <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)
/// <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)
{
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);
@@ -22,7 +29,28 @@ 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>
/// Réordonne la liste de l'appelant selon la suite d'identifiants fournie.
/// </summary>
/// <remarks>
/// Prend la liste <b>entière</b> plutôt qu'un déplacement unitaire : c'est ce dont a besoin
/// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération
/// est idempotente — réenvoyer le même ordre ne change rien.
/// </remarks>
Task<bool> ReordonnerAsync(
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
@@ -49,15 +77,21 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
return [];
}
// L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a
// remplacé un tri par auteur puis titre : classer une liste d'envies par ordre
// alphabétique répondait à une question que personne ne se pose.
// L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister,
// mais un tri instable ferait sautiller la liste entre deux affichages.
var souhaits = await db.LivresSouhaites
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur)
.OrderBy(s => s.AuteurNormalise == string.Empty)
.ThenBy(s => s.AuteurNormalise)
.ThenBy(s => s.TitreNormalise)
.OrderBy(s => s.Rang)
.ThenBy(s => s.Id)
.ToListAsync(ct);
return souhaits.Select(Projeter).ToList();
var possedes = await CorrespondancesAsync(souhaits, ct);
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList();
}
public async Task<ResultatSouhait> AjouterAsync(
@@ -118,10 +152,107 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies.");
}
// En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable
// que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur
// à chaque ajout.
var dernierRang = await db.LivresSouhaites
.Where(s => s.Utilisateur == utilisateur)
.Select(s => (int?)s.Rang)
.MaxAsync(ct);
souhait.Rang = (dernierRang ?? -1) + 1;
db.LivresSouhaites.Add(souhait);
await db.SaveChangesAsync(ct);
return ResultatSouhait.Ok(Projeter(souhait));
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);
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu.
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.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)));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
@@ -148,6 +279,144 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
return true;
}
public async Task<bool> ReordonnerAsync(
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
// ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client
// envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas
// pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression),
// et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil
// n'y figure pas, et la perdre serait pire que de mal la classer.
var siennes = await db.LivresSouhaites
.Where(s => s.Utilisateur == utilisateur)
.ToListAsync(ct);
if (siennes.Count == 0)
{
return false;
}
var parId = siennes.ToDictionary(s => s.Id);
var rang = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var souhait))
{
souhait.Rang = rang++;
}
// Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est
// simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs.
}
// Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent,
// plutôt que d'être renuméroté au hasard.
foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id))
{
oublie.Rang = rang++;
}
await db.SaveChangesAsync(ct);
return true;
}
/// <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();
@@ -155,7 +424,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) => new()
internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new()
{
Id = souhait.Id,
Titre = souhait.Titre,
@@ -163,8 +432,24 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
// ⚠️ 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)),
Note = souhait.Note,
DateAjout = souhait.DateAjout,
Possede = livreId is not null,
LivreId = livreId,
};
}
+133
View File
@@ -0,0 +1,133 @@
@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;
}
@@ -0,0 +1,74 @@
@*
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; }
}
@@ -0,0 +1,30 @@
@*
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!;
}
@@ -0,0 +1,61 @@
<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; }
}
@@ -0,0 +1,137 @@
@*
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>&lt;input type="date"&gt;</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);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
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);
+87 -8
View File
@@ -1,3 +1,7 @@
@using MaBibli.Client.Services
@inject CacheHorsLigne Cache
@inject EtatReseau Reseau
@implements IAsyncDisposable
@*
Couverture d'un livre, avec substitut.
@@ -9,8 +13,29 @@
@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 (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.
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.
*@
<div class="couverture @Classe">
<span class="couverture-substitut" aria-hidden="true">@Initiale</span>
@@ -24,19 +49,19 @@
<button type="button" class="couverture-declencheur"
aria-label="Agrandir la couverture de @Titre"
@onclick="Ouvrir">
<img @key="Url"
<img @key="UrlAffichee"
class="couverture-image"
src="@Url"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee')" />
onerror="this.classList.add('image-cassee'); const b=this.closest('.couverture-declencheur'); if(b){b.disabled=true;}" />
</button>
}
else
{
<img @key="Url"
<img @key="UrlAffichee"
class="couverture-image"
src="@Url"
src="@UrlAffichee"
alt="Couverture de @Titre"
loading="lazy"
onerror="this.classList.add('image-cassee')" />
@@ -52,7 +77,7 @@
aria-label="Couverture de @Titre"
tabindex="-1" @ref="_calque"
@onclick="Fermer" @onkeydown="SurToucheAsync">
<img class="calque-image" src="@Url" alt="Couverture de @Titre"
<img class="calque-image" src="@UrlAffichee" alt="Couverture de @Titre"
@onclick:stopPropagation="true"
onerror="this.classList.add('image-cassee')" />
@@ -73,9 +98,18 @@
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();
@@ -95,6 +129,50 @@
}
}
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;
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)
@@ -104,3 +182,4 @@
}
}
}
+128 -20
View File
@@ -19,29 +19,75 @@
</label>
@*
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.
⚠️ 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.
*@
<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>
<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">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
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.
*@
<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."
OnChangement="StateHasChanged" />
<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>
@*
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>
@*
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc
@@ -66,6 +112,32 @@
</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>
@@ -112,6 +184,42 @@
@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.
/// </remarks>
private NomsAuteurs _noms = default!;
private EnregistrementLivre? _saisiePrecedente;
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = new NomsAuteurs(Saisie.Auteurs);
}
}
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
@@ -0,0 +1,83 @@
@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 DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. C'est la règle déjà
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
livre qu'on voyait très bien avant.
⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le
compteur du haut du catalogue paraîtrait mentir.
*@
<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">
@Entree.NombreLivres tome@(Entree.NombreLivres > 1 ? "s" : "")
</span>
</a>
</div>
@if (Deplie)
{
@if (Entree.SousEntrees.Count > 0)
{
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" />
}
</ul>
}
@if (Entree.Livres.Count > 0)
{
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
</ul>
}
}
</li>
@code {
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non dépliés : le défaut est de tout montrer.
/// </summary>
/// <remarks>
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries :
/// 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> Replies { get; set; } = new HashSet<int>();
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
private void Basculer()
{
if (!Replies.Add(Entree.Serie!.Id))
{
Replies.Remove(Entree.Serie!.Id);
}
}
}
+38
View File
@@ -12,6 +12,32 @@ 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",
_ => 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",
@@ -28,4 +54,16 @@ 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",
_ => string.Empty,
};
}
+80
View File
@@ -0,0 +1,80 @@
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();
}
+25
View File
@@ -0,0 +1,25 @@
@*
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; }
}
+208
View File
@@ -0,0 +1,208 @@
@*
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"
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>
}
@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>
/// 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>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;
}
/// <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,15 +1,16 @@
@*
Scan du code-barres ISBN par la caméra.
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.
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.
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
@@ -44,9 +45,9 @@
@if (_framesDecodees > 0)
{
@* 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. *@
@* 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#. *@
<p class="message-discret scanner-mesure">
Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames)
</p>
@@ -60,7 +61,7 @@
@code {
/// <summary>Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé.</summary>
[Parameter] public EventCallback<string> OnCodeDetecte { get; set; }
[Parameter] public EventCallback<CodeScanne> OnCodeDetecte { get; set; }
/// <summary>Appelé quand l'utilisateur renonce au scan.</summary>
[Parameter] public EventCallback OnAnnuler { get; set; }
@@ -76,12 +77,13 @@
// ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps.
private const int IntervalleMs = 80;
// 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;
// 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;
private ElementReference _video;
private IJSObjectReference? _module;
@@ -165,16 +167,9 @@
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 code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]);
var lecture = await _module.InvokeAsync<LectureCodeBarres?>("decoder", LargeurMax);
var code = lecture?.Code;
_totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds;
_framesDecodees++;
@@ -198,7 +193,7 @@
}
await ArreterCameraAsync();
await OnCodeDetecte.InvokeAsync(isbn);
await OnCodeDetecte.InvokeAsync(new CodeScanne(isbn, lecture?.Parution));
return;
}
}
+169 -3
View File
@@ -2,11 +2,49 @@
@implements IDisposable
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@* 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. *@
@*
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">
<header class="bandeau">
<a class="marque" href="">MaBibli</a>
@* 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">&#8592;</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>
@if (!Reseau.EnLigne)
{
@@ -21,8 +59,62 @@
@_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">&#10005;</span>
</button>
</div>
}
<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)
{
@*
@@ -48,11 +140,24 @@
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;
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;
// É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();
@@ -119,9 +224,70 @@
/// <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)
{
return;
}
_menuOuvert = false;
_ = InvokeAsync(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;
}
}
+81 -8
View File
@@ -1,12 +1,10 @@
/* 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;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: #1b3a5c;
color: #fff;
}
@@ -16,15 +14,62 @@
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: 55%;
max-width: 40%;
}
/* L'état du réseau change ce que l'application permet : il se voit dans le bandeau, en
@@ -58,6 +103,34 @@
width: 100%;
max-width: 46rem;
margin: 0 auto;
/* Marge basse généreuse : les actions flottantes ne doivent pas masquer le dernier livre. */
/* Marge basse généreuse : la barre d'actions flottantes ne doit pas masquer le dernier
livre de la liste. */
padding: 1rem 1rem 6rem;
}
/*
Les styles du MENU ne sont PAS ici, mais dans `wwwroot/css/app.css`.
Deux raisons, la première étant un vrai bug corrigé le 2026-08-20 :
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;
}
}
+14 -2
View File
@@ -18,8 +18,20 @@
<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 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" />
<!--
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" />
</ItemGroup>
<ItemGroup>
+171
View File
@@ -0,0 +1,171 @@
@page "/a-propos"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@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>
}
}
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;
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();
});
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;
}
+262 -25
View File
@@ -1,17 +1,28 @@
@page "/ajout"
@page "/ajout/isbn"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
<PageTitle>MaBibli — ajouter un ouvrage</PageTitle>
<h1 class="titre-page">Ajouter par ISBN</h1>
@*
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>
@if (_etape == Etape.Saisie)
{
<p class="message-discret">
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
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.
</p>
@*
@@ -27,8 +38,17 @@
</p>
}
@*
Le champ est focalisé à l'ouverture et valide sur Entrée : c'est tout ce qu'exige une
douchette USB, qui se présente au système comme un CLAVIER — elle « tape » les chiffres
puis Entrée. Aucune permission, aucun HTTPS, aucun décodage : c'est le contournement le
plus rentable du scan caméra, qui rate souvent sur la webcam d'un PC.
La recherche n'est délibérément PAS déclenchée à chaque frappe : une douchette tape trop
vite, et chaque caractère partirait en requête.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
@ref="_champIsbn"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="ISBN" autocomplete="off" />
</div>
@@ -47,10 +67,27 @@
@onclick="OuvrirScanner">
Scanner
</button>
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
@* 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>
</div>
}
@*
Le scan enchaîne la recherche tout seul (décision actée). Encore faut-il que ça SE VOIE :
sans cet écran, on retombait sur la saisie avec un bouton « Chercher » intact, et l'on
croyait que le scan n'avait rien déclenché.
*@
@if (_etape == Etape.Recherche)
{
@* 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…")" />
}
@if (_etape == Etape.Scan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeDetecteAsync" OnAnnuler="Recommencer" />
@@ -61,6 +98,25 @@
<p class="message-erreur" role="alert">@_erreur</p>
}
@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)
{
<p class="message-avertissement">@avertissement</p>
@@ -98,7 +154,7 @@
<span class="etiquette">@candidat.Source</span>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<span>trouvé via @candidat.IsbnInterroge</span>
<span>trouvé via <span class="code-isbn">@FormatageIsbn.Afficher(candidat.IsbnInterroge)</span></span>
}
</p>
@@ -118,24 +174,48 @@
@if (_etape == Etape.Formulaire)
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
@* 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>
<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 {
private enum Etape { Saisie, Scan, Choix, Formulaire }
private enum Etape { Saisie, Scan, Recherche, Choix, Formulaire }
private Etape _etape = Etape.Saisie;
private string _isbn = string.Empty;
private ElementReference _champIsbn;
// Le focus ne se redonne qu'une fois par entrée dans l'étape de saisie : le reprendre à
// chaque rendu arracherait le curseur à l'utilisateur en pleine frappe.
private bool _focusAFaire = true;
private string? _messagePeriodique;
private 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;
@@ -143,9 +223,37 @@
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _avertissements = [];
private EnregistrementLivre _saisie = new();
private DoublonsLivre? _doublons;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
/// <summary>
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
/// </summary>
/// <remarks>
/// C'est ce qui rend une <b>douchette USB</b> utilisable sans rien d'autre : elle tape dans
/// le champ actif, quel qu'il soit. Sans focus automatique, l'utilisateur devrait cliquer
/// dans le champ avant chaque livre — et le gain sur le scan caméra disparaîtrait.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (_etape != Etape.Saisie || !_focusAFaire)
{
return;
}
_focusAFaire = false;
try
{
await _champIsbn.FocusAsync();
}
catch (InvalidOperationException)
{
// L'élément a disparu entre le rendu et l'appel (navigation rapide) : sans intérêt.
}
}
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
@@ -156,16 +264,25 @@
private void OuvrirScanner()
{
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_parutionLue = null;
_avertissements = [];
_etape = Etape.Scan;
}
// Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur
// ne retape jamais ce qui vient d'être scanné.
private async Task SurCodeDetecteAsync(string isbn)
// 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)
{
_isbn = isbn;
_etape = Etape.Saisie;
_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;
await ChercherAsync();
}
@@ -173,6 +290,9 @@
{
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();
}
}
@@ -181,7 +301,10 @@
{
_chargement = true;
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_avertissements = [];
_etape = Etape.Recherche;
try
{
@@ -190,12 +313,21 @@
if (resultat is null)
{
_erreur = $"« {_isbn} » n'est pas un ISBN valide.";
RevenirALaSaisie();
return;
}
_avertissements = resultat.Avertissements;
_candidats = resultat.Candidats;
// Un code de périodique (977) n'a JAMAIS de candidat : ce n'est pas un échec de
// recherche, c'est un code qui ne décrit pas un livre. Le dire avant tout le reste.
if (resultat.Periodique is { } revue)
{
DecrireLeMagazine(revue);
return;
}
if (_candidats.Count == 0)
{
// Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie.
@@ -213,9 +345,12 @@
_etape = Etape.Choix;
}
catch (Exception ex)
catch (Exception)
{
_erreur = $"La recherche a échoué : {ex.Message}";
_erreur = Reseau.EnLigne
? "La recherche a échoué. Réessayez, ou saisissez la fiche à la main."
: "La recherche par ISBN interroge la BnF et OpenLibrary : indisponible hors ligne.";
RevenirALaSaisie();
}
finally
{
@@ -223,6 +358,85 @@
}
}
/// <summary>
/// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
/// </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>
/// </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.";
_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)
{
return;
}
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
{
Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}",
Issn = revue.Issn,
Editeur = revue.Editeur,
});
_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}");
}
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
private void RevenirALaSaisie()
{
_etape = Etape.Saisie;
_focusAFaire = true;
}
private void Choisir(CandidatLivre candidat)
{
_saisie = new EnregistrementLivre
@@ -231,7 +445,12 @@
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,
};
@@ -242,21 +461,39 @@
private void Recommencer()
{
_etape = Etape.Saisie;
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_erreurFormulaire = null;
_doublons = null;
_candidats = [];
RevenirALaSaisie();
}
private async Task EnregistrerAsync()
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)
{
_enregistrement = true;
_erreurFormulaire = null;
var resultat = await Api.CreerAsync(_saisie);
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
_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;
+40 -9
View File
@@ -12,13 +12,28 @@
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
@*
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" />
}
@code {
private readonly EnregistrementLivre _saisie = new();
@@ -31,16 +46,32 @@
private bool _enregistrement;
private string? _erreur;
private DoublonsLivre? _doublons;
private async Task EnregistrerAsync()
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)
{
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerAsync(_saisie);
var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
_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;
+109
View File
@@ -0,0 +1,109 @@
@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}");
}
}
+523
View File
@@ -0,0 +1,523 @@
@page "/souhaits/ajout"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
@*
Écran séparé, comme la fiche livre l'est de son édition : la page « Envies » ne montre plus
que la liste, et l'ajout a la place de proposer une vraie recherche.
Trois façons d'alimenter la liste, dans l'ordre où le besoin se présente :
— par titre/auteur, le cas NORMAL : on souhaite un livre qu'on n'a pas en main ;
— par ISBN, quand on l'a en main (en librairie, chez quelqu'un) ;
— à la main, quand aucune source ne connaît le livre.
*@
<PageTitle>MaBibli — ajouter une envie</PageTitle>
<h1 class="titre-page">Ajouter une envie</h1>
@if (!Reseau.EnLigne)
{
<p class="message-avertissement" role="status">
Hors ligne : la recherche interroge la BnF et OpenLibrary, et l'enregistrement passe par
le serveur. Votre liste reste consultable.
</p>
}
<div class="onglets-secondaires" role="group" aria-label="Manière de chercher">
<button type="button" class="segment @(_mode == Mode.Titre ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Titre)">Par titre</button>
<button type="button" class="segment @(_mode == Mode.Isbn ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
<button type="button" class="segment @(_mode == Mode.Manuel ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Manuel)">À la main</button>
<button type="button" class="segment @(_mode == Mode.Revue ? "segment-actif" : null)"
@onclick="() => Basculer(Mode.Revue)">Une revue</button>
</div>
@if (_mode == Mode.Titre)
{
<div class="champ">
<label class="champ-libelle" for="titre-cherche">Titre <span class="obligatoire">*</span></label>
<input id="titre-cherche" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_titreCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="La bête humaine" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-cherche">Auteur</label>
<input id="auteur-cherche" class="champ-saisie" type="text"
@bind="_auteurCherche" @bind:event="oninput" @onkeydown="SurTouche"
placeholder="Facultatif : resserre la recherche" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titreCherche))"
title="@MotifBlocage" @onclick="ChercherParTitreAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
}
@if (_mode == Mode.Isbn)
{
<div class="champ">
<label class="champ-libelle" for="isbn-cherche">ISBN</label>
<input id="isbn-cherche" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @ref="_premierChamp"
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche" />
<p class="champ-aide">Le livre est en main : le code-barres suffit.</p>
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_recherche || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_isbn))"
title="@MotifBlocage" @onclick="ChercherParIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
@*
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>
}
@foreach (var avertissement in _avertissements)
{
<p class="message-avertissement">@avertissement</p>
}
@if (_candidats.Count > 0)
{
@* On ne choisit jamais à la place de l'utilisateur : ce sont l'éditeur, l'année et
l'auteur qui départagent des notices au titre identique. *@
<p class="message-discret">
@_candidats.Count notice@(_candidats.Count > 1 ? "s" : "") trouvée@(_candidats.Count > 1 ? "s" : "").
Choisissez celle qui correspond, ou complétez à la main.
</p>
<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)))
{
<li class="carte-candidat" @key="index">
<Couverture Url="@candidat.CoverUrl" Titre="@candidat.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
@if (!string.IsNullOrWhiteSpace(candidat.Auteur))
{
<p class="carte-auteur">@candidat.Auteur</p>
}
<p class="candidat-distinction">
<span class="candidat-editeur">@(candidat.Editeur ?? "éditeur inconnu")</span>
<span class="candidat-annee">@(candidat.Annee ?? "année inconnue")</span>
</p>
@if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge))
{
<p class="carte-details">
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(candidat.IsbnInterroge)</span>
</p>
}
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterCandidatAsync(candidat)">
Ajouter à ma liste
</button>
</div>
</li>
}
</ul>
}
@if (_mode == Mode.Manuel)
{
@* Le recours quand aucune source ne connaît le livre — un ouvrage étranger, un titre à
paraître, un livre d'occasion sans ISBN. Il ne disparaît jamais derrière la recherche. *@
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @ref="_premierChamp"
@bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text"
@bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
title="@MotifBlocage" @onclick="() => AjouterAsync(_saisie)">
Ajouter à ma liste
</button>
</div>
}
@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 Mode _mode = Mode.Titre;
private ElementReference _premierChamp;
private bool _focusAFaire = true;
private string _titreCherche = string.Empty;
private string _auteurCherche = string.Empty;
private string _isbn = string.Empty;
private EnregistrementSouhait _saisie = new();
private 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;
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
public void Dispose() => Reseau.Change -= SurChangementReseau;
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>
/// <remarks>
/// Même raison qu'à l'écran d'ajout par ISBN : on arrive ici pour taper, et le mode « Par
/// ISBN » doit rester utilisable à la douchette. Le drapeau évite de reprendre le focus à
/// chaque rendu, ce qui arracherait le curseur en pleine frappe.
/// </remarks>
protected override async Task OnAfterRenderAsync(bool premierRendu)
{
if (!_focusAFaire)
{
return;
}
_focusAFaire = false;
try
{
await _premierChamp.FocusAsync();
}
catch (InvalidOperationException)
{
}
}
private void 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)
{
if (e.Key != "Enter")
{
return;
}
if (_mode == Mode.Titre && !string.IsNullOrWhiteSpace(_titreCherche))
{
await ChercherParTitreAsync();
}
else if (_mode == Mode.Isbn && !string.IsNullOrWhiteSpace(_isbn))
{
await ChercherParIsbnAsync();
}
}
private async Task ChercherParTitreAsync() =>
await ChercherAsync(() => Api.ChercherNoticesAsync(_titreCherche, _auteurCherche));
private async Task ChercherParIsbnAsync() =>
await ChercherAsync(() => Api.ChercherIsbnAsync(_isbn.Trim()));
private async Task ChercherAsync(Func<Task<ResultatLookupIsbn?>> appel)
{
_recherche = true;
_erreur = null;
_candidats = [];
_avertissements = [];
try
{
var resultat = await appel();
if (resultat is null)
{
_erreur = Reseau.EnLigne
? "La recherche n'a rien pu ramener. Réessayez, ou complétez à la main."
: "La recherche interroge la BnF : indisponible hors ligne.";
return;
}
_avertissements = resultat.Avertissements;
// Un code de périodique n'est pas un livre : on le dit ici aussi, plutôt que de
// laisser un « aucun résultat » qui enverrait chercher un défaut inexistant.
if (resultat.Periodique is { } revue)
{
var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
_erreur = $"Ce code désigne {nom} (ISSN {revue.Issn}), pas un livre.";
return;
}
_candidats = resultat.Candidats;
_candidats = Trier(_candidats);
if (_candidats.Count == 0)
{
_erreur = "Aucune notice ne correspond. Vous pouvez l'ajouter à la main.";
}
}
finally
{
_recherche = false;
}
}
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>
/// <remarks>
/// C'est ce qui remplit enfin <c>LivreSouhaite.CoverUrl</c>, jusqu'ici toujours vide : la
/// couverture vient d'OpenLibrary dès qu'un ISBN est connu. Sans ISBN il n'y en a pas, et
/// l'on n'en invente pas — la vignette retombe sur son substitut à initiale.
/// </remarks>
private Task AjouterCandidatAsync(CandidatLivre candidat) =>
AjouterAsync(new EnregistrementSouhait
{
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
Annee = candidat.Annee,
Isbn = candidat.IsbnInterroge,
CoverUrl = candidat.CoverUrl,
});
private async Task AjouterAsync(EnregistrementSouhait saisie)
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(saisie);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
// Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
Navigation.NavigateTo("souhaits");
}
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");
}
}
+203 -9
View File
@@ -67,32 +67,136 @@ else if (_auteurs.Count == 0)
}
else
{
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p>
@*
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>
}
<ul class="liste-auteurs">
@foreach (var auteur in _auteurs)
@foreach (var auteur in Affiches)
{
@*
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">
<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
<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
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>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
</nav>
@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;
@@ -112,6 +216,47 @@ 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;
@@ -133,6 +278,55 @@ 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;
+544 -92
View File
@@ -1,33 +1,110 @@
@page "/auteurs/{AuteurId:int}/bibliographie"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
<PageTitle>MaBibli — bibliographie</PageTitle>
@if (_bibliographie is null && _erreur is null)
{
<h1 class="titre-page">Bibliographie</h1>
<p class="message-discret">Interrogation de la BnF…</p>
@* 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…")" />
}
else if (_erreur is not null)
{
<h1 class="titre-page">Bibliographie</h1>
<p class="message-erreur" role="alert">@_erreur</p>
@* Une panne de réseau est le plus souvent passagère : sans ce bouton, il fallait
recharger la page entière pour retenter. *@
@if (_reessayable)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="ChargerAsync">
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
</button>
</div>
}
}
else if (_bibliographie is { } biblio)
{
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
<h1 class="titre-page">
@if (biblio.EstNouveautes)
{
<text>Nouveautés de @biblio.Auteur.Nom</text>
}
else
{
@biblio.Auteur.Nom
}
</h1>
@if (biblio.Avertissement is not null)
{
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
}
@*
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
@if (biblio.Oeuvres.Count == 0)
Avant correction, une BnF muette produisait une liste vide, que l'écran commentait
d'un rassurant « la BnF ne connaît aucun livre de cet auteur » — affiché juste sous
l'avertissement qui disait le contraire. On affirmait une absence qu'on n'avait pas
pu constater, ce qui est la pire des deux erreurs possibles ici : elle ferme la
question au lieu de la laisser ouverte.
La règle : on n'explique une absence de résultats QUE si la source a répondu.
*@
@if (biblio.SourceMuette)
{
<p class="message-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.
<p class="message-avertissement" role="alert">
@biblio.Avertissement
</p>
<p class="message-discret">
La bibliographie n'a donc <strong>pas pu être établie</strong> : cet écran ne dit
rien de ce que cet auteur a écrit.
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_chargement || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="ChargerAsync">
@(_chargement ? "Nouvelle tentative…" : "Réessayer")
</button>
</div>
}
else if (biblio.Oeuvres.Count == 0)
{
@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" />
}
else if (biblio.Avertissement is not null)
{
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
quelque chose, on la montre en disant qu'elle est peut-être incomplète. *@
<p class="message-avertissement" role="alert">@biblio.Avertissement</p>
}
else
{
@@ -36,8 +113,19 @@ 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 »). *@
@@ -74,67 +162,231 @@ else if (_bibliographie is { } biblio)
<p class="message-erreur" role="alert">@_message</p>
}
@* 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 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>
}
</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">
<div class="carte-corps">
<p class="carte-titre">
@if (oeuvre is { Possede: true, LivreId: { } livreId })
{
<a class="lien-auteur" href="@($"livres/{livreId}")">@oeuvre.Titre</a>
}
else
{
@oeuvre.Titre
}
</p>
@*
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>
<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>
</div>
@if (oeuvre.ADecouvrir)
@if (oeuvre is { Possede: true, LivreId: { } livreId }
&& _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<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>
<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))
{
@*
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>
}
</div>
}
@* 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>
}
<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>
</div>
</li>
}
</ul>
@@ -150,66 +402,274 @@ else if (_bibliographie is { } biblio)
}
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="auteurs">Retour aux auteurs</a>
@if (_bibliographie is { } b)
{
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
}
<a class="bouton bouton-discret" href="souhaits">Ma liste d'envies</a>
</nav>
@code {
[Parameter]
public int AuteurId { get; set; }
[SupplyParameterFromQuery(Name = "nouveautes")]
public bool Nouveautes { get; set; }
private BibliographieDto? _bibliographie;
private bool _seulementManquants;
private int _modePossession;
private bool _filtresOuverts;
private bool _afficherMasquees;
private bool _enCours;
private bool _chargement;
/// <summary>Vrai quand l'échec vient du réseau, donc qu'une nouvelle tentative a un sens.</summary>
private bool _reessayable;
private string? _erreur;
private string? _message;
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) =>
_seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres;
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();
private async Task ChargerAsync()
{
_erreur = null;
_reessayable = false;
_chargement = true;
_selection.Clear();
try
{
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
_bibliographie = Nouveautes
? await Api.ObtenirNouveautesAsync(AuteurId)
: await Api.ObtenirBibliographieAsync(AuteurId);
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
if (_bibliographie is null)
{
// Celui-là ne se retente pas : l'auteur n'existe pas, réessayer ne le fera
// pas apparaître.
_erreur = "Cet auteur n'existe pas dans votre bibliothèque.";
return;
}
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
RecompterAsync();
}
catch (Exception ex)
catch (Exception)
{
_erreur = $"Impossible d'obtenir la bibliographie : {ex.Message}";
// ⚠️ Jamais le message brut de l'exception : hors-ligne, c'est un « 404 Not Found »
// du service worker que l'utilisateur n'a aucun moyen d'interpréter. La
// bibliographie interroge la BnF, elle ne peut PAS venir du cache — il n'y a rien
// à réparer, seulement à dire.
//
// Ce chemin-ci est l'échec de l'appel à NOTRE serveur (délai du navigateur compris) ;
// une BnF muette, elle, revient en 200 avec un état, et se traite plus haut.
_erreur = Reseau.EnLigne
? "La bibliographie n'a pas pu être obtenue. La BnF est parfois lente à répondre."
: "La bibliographie vient de la BnF : indisponible hors ligne.";
_reessayable = true;
}
finally
{
_chargement = false;
}
}
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>
/// Ajoute une œuvre à la liste d'envies, en emportant ce que la BnF en sait.
/// Ce que la BnF sait de l'œuvre, mis en forme pour la liste d'envies.
/// </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>
/// 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.
/// 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.
/// </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)
@@ -220,17 +680,7 @@ else if (_bibliographie is { } biblio)
_enCours = true;
_message = null;
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,
});
var resultat = await Api.AjouterSouhaitAsync(Saisie(oeuvre));
_enCours = false;
@@ -240,6 +690,8 @@ else if (_bibliographie is { } biblio)
return;
}
await ChargerAsync();
MettreAJourOeuvre(
oeuvre.Titre,
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
}
}
+276 -86
View File
@@ -5,11 +5,25 @@
<PageTitle>MaBibli — catalogue</PageTitle>
<h1 class="titre-page">Catalogue</h1>
@*
Les filtres tiennent derrière un seul bouton, près du titre : deux rangées de segments
occupaient en permanence le haut de l'écran d'un téléphone pour un réglage qu'on change
rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif
et replié serait invisible, et l'on conclurait que des livres ont disparu.
*@
<div class="entete-page">
<h1 class="titre-page">Catalogue</h1>
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_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'IDEES.md : le catalogue est ici restreint à ses livres. *@
@* Le « cliquer sur un auteur » d'CLAUDE.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>
@@ -21,29 +35,103 @@
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
</div>
<div class="filtres">
<div class="segments" role="group" aria-label="Filtrer par format">
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(null)">Tous</button>
@foreach (var format in Enum.GetValues<Format>())
@if (_filtresOuverts)
{
<div class="filtres" id="filtres-catalogue">
@* Un fonds tout physique n'a rien à filtrer par format : la ligne n'apprendrait rien
et prendrait de la place. Cohérent avec « le format ne s'affiche que pour les
ebooks ». Elle réapparaît d'elle-même au premier ebook ajouté. *@
@if (PlusieursFormats)
{
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
<div class="segments" role="group" aria-label="Filtrer par format">
<button type="button" class="segment @(_format is null ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(null)">Tous</button>
@foreach (var format in Enum.GetValues<Format>())
{
<button type="button" class="segment @(_format == format ? "segment-actif" : null)"
@onclick="() => FiltrerFormat(format)">@Libelles.Format(format)</button>
}
</div>
}
</div>
@* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi
ne remonte sous aucune valeur, il reste visible sous « Tous ». *@
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
@* 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)
{
<button type="button" class="segment @(_statut == statut ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(statut)">@Libelles.Statut(statut)</button>
<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. *@
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button"
class="segment @Libelles.ClasseStatut(statut) @(_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>
}
@*
⚠️ 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.
</p>
</div>
</div>
}
@if (_erreur is not null)
{
@@ -64,83 +152,55 @@ else if (_livres is not null)
{
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
@*
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.
*@
<ul class="liste-livres">
@foreach (var livre in _livres)
@foreach (var entree in Entrees)
{
<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>
@if (entree.EstGroupe)
{
<GroupeCatalogue Entree="entree" Replies="_replies" />
}
else
{
<li @key="entree.Livres[0].Id">
<CarteLivre Livre="entree.Livres[0]" />
</li>
}
}
</ul>
}
@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui
mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche,
filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@
@*
Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans
les onglets, en bas.
« Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis
« Ajouter », qui la propose dès qu'un ISBN ne donne rien.
⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable
d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le
désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement.
*@
<nav class="actions-flottantes">
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter par ISBN</button>
<button type="button" class="bouton bouton-discret" disabled
title="@EtatReseau.MotifHorsLigne">Saisie manuelle</button>
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
}
<a class="bouton bouton-discret" href="prets">Prêts</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
<a class="bouton bouton-discret" href="souhaits">Envies</a>
</nav>
@code {
@@ -149,20 +209,92 @@ else if (_livres is not null)
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 repliés à la main, par identifiant de série. Tout est déplié 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, même règle que l'arbre des séries.
/// </remarks>
private readonly HashSet<int> _replies = [];
/// <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 _chargement;
private bool _filtresOuverts;
private string? _erreur;
/// <summary>
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
/// </summary>
/// <remarks>
/// ⚠️ Ne se calcule que sur un chargement <b>sans aucun critère</b>. Le déduire d'une liste
/// filtrée ferait disparaître la ligne « Tous / Physique / Numérique » dès qu'une recherche
/// ne remonte qu'un seul format — et l'utilisateur ne pourrait plus l'élargir. Dans le doute
/// (arrivée directe sur un auteur, par exemple) la ligne reste affichée : montrer un filtre
/// inutile est bénin, en escamoter un qui sert ne l'est pas.
/// </remarks>
private IReadOnlySet<Format>? _formatsDuFonds;
private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1;
/// <summary>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>
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
/// </summary>
/// <remarks>
/// La recherche et la restriction à un auteur n'y comptent pas : toutes deux se voient
/// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce
/// qui serait invisible une fois le panneau refermé.
/// </remarks>
private int FiltresActifs =>
(_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1)
+ (_prete 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 || _statut is not null
|| AuteurId is not null;
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null
|| _statut is not null || _prete is not null || AuteurId is not null;
/// <summary>
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
@@ -170,14 +302,40 @@ 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();
}
catch (Exception)
{
_series = null;
}
}
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;
@@ -199,12 +357,33 @@ else if (_livres is not null)
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();
}
/// <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.AuteurId is null;
private async Task ChargerAsync()
{
_cts.Cancel();
@@ -221,19 +400,30 @@ else if (_livres is not null)
{
Recherche = _recherche,
Format = _format,
TypeDocument = _type,
Statut = _statut,
Prete = _prete,
AuteurId = AuteurId,
};
_livres = await Api.ListerAsync(criteres, jeton);
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);
}
}
catch (OperationCanceledException)
{
return; // Une recherche plus récente a pris le relais.
}
catch (Exception ex)
catch (Exception)
{
_erreur = $"Impossible de charger le catalogue : {ex.Message}";
_erreur = Reseau.EnLigne
? "Le catalogue n'a pas pu être chargé. Réessayez dans un instant."
: "Aucune donnée n'a encore pu être enregistrée sur cet appareil.";
}
finally
{
+176
View File
@@ -0,0 +1,176 @@
@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">
<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">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;
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;
}
// ⚠️ La couverture n'est PAS reprise ici : elle n'a pas de champ de saisie, et le
// service la conserve donc telle qu'elle est en base. La valeur portée par le DTO peut
// en outre être déduite de l'ISBN — la figer serait écrire en base ce qui se calcule.
_saisie = new EnregistrementSouhait
{
Titre = souhait.Titre,
Auteur = souhait.Auteur,
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
Note = souhait.Note,
};
}
private async Task EnregistrerAsync()
{
if (_saisie is null)
{
return;
}
_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");
}
}
@@ -0,0 +1,144 @@
@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");
}
}
+89 -5
View File
@@ -6,7 +6,7 @@
@implements IDisposable
@*
Une seule page, deux modes (IDEES.md) :
Une seule page, deux modes (CLAUDE.md) :
— consultation par défaut. Ouvrir une fiche depuis le catalogue ne doit rien pouvoir casser,
surtout au doigt sur un téléphone.
@@ -104,20 +104,37 @@ 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>
<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>
}
@*
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))
{
<div><dt>Éditeur</dt><dd>@_livre.Editeur</dd></div>
}
@* Tirets à l'affichage seulement : la valeur stockée reste nue. *@
@if (!string.IsNullOrWhiteSpace(_livre.Isbn))
{
<div><dt>ISBN</dt><dd>@_livre.Isbn</dd></div>
<div><dt>ISBN</dt><dd class="code-isbn">@FormatageIsbn.Afficher(_livre.Isbn)</dd></div>
}
@* Comme dans la liste : le format n'apparaît que pour un ebook, le physique
@@ -126,6 +143,40 @@ 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>
@@ -166,7 +217,6 @@ else
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Éditer</button>
}
<a class="bouton bouton-discret" href="">Catalogue</a>
</nav>
}
@@ -174,6 +224,35 @@ 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;
@@ -246,6 +325,7 @@ else
try
{
_livre = await Api.ObtenirAsync(Id);
await ChargerAppartenancesAsync();
}
catch (Exception ex)
{
@@ -271,11 +351,15 @@ else
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
Auteurs = livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).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>
+34 -5
View File
@@ -1,4 +1,5 @@
@page "/prets"
@using MaBibli.Shared.Textes
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
@@ -38,8 +39,24 @@ 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 _prets)
@foreach (var pret in PretsAffiches())
{
<li class="carte-livre" @key="pret.Id">
<Couverture Url="@pret.LivreCoverUrl" Titre="@pret.LivreTitre"
@@ -73,14 +90,26 @@ else
</ul>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Catalogue</a>
</nav>
@code {
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;
+539
View File
@@ -0,0 +1,539 @@
@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>
}
@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>
}
}
<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 : sans URL collée, c'est le substitut à
initiale qui s'affiche, et il n'est pas cliquable. *@
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
Classe="couverture-petite" Agrandissable="true" />
<div class="carte-corps">
<p class="carte-titre">N° @numero.Numero</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>
}
}
@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>
/// É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;
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;
}
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");
}
}
}
+118
View File
@@ -0,0 +1,118 @@
@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
+121
View File
@@ -0,0 +1,121 @@
@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) ?? [];
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();
}
}
+353 -124
View File
@@ -1,5 +1,9 @@
@page "/souhaits"
@page "/souhaits/ordre"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — liste d'envies</PageTitle>
@@ -8,9 +12,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 que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
Les livres et les revues 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.
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
Rien de ce qui est ici n'est dans le catalogue, ni ne compte dans la bibliothèque.
</p>
@if (_erreur is not null)
@@ -24,77 +28,118 @@
}
else
{
<section class="bloc-souhait-ajout">
<h2 class="titre-section">Ajouter une envie</h2>
@* Deux façons d'alimenter la liste : l'ISBN quand on a le livre en main (en librairie,
chez quelqu'un), la saisie libre quand on n'a qu'un titre. La bibliographie d'un
auteur, elle, alimente la liste depuis l'écran des auteurs. *@
<div class="champ">
<label class="champ-libelle" for="isbn-souhait">Pré-remplir depuis un ISBN</label>
<div class="ligne-isbn">
<input id="isbn-souhait" class="champ-saisie" type="text" inputmode="numeric"
placeholder="978…" @bind="_isbn" @bind:event="oninput" />
<button type="button" class="bouton bouton-discret"
disabled="@(_recherche || string.IsNullOrWhiteSpace(_isbn))"
@onclick="ChercherIsbnAsync">
@(_recherche ? "Recherche…" : "Chercher")
</button>
</div>
<p class="champ-aide">Facultatif : les champs ci-dessous restent modifiables.</p>
</div>
<div class="champ">
<label class="champ-libelle" for="titre-souhait">Titre <span class="obligatoire">*</span></label>
<input id="titre-souhait" class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="auteur-souhait">Auteur</label>
<input id="auteur-souhait" class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput" />
</div>
<div class="champ">
<label class="champ-libelle" for="note-souhait">Note</label>
<input id="note-souhait" class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
placeholder="« demandé à Noël », « en poche seulement »…" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_ajout || string.IsNullOrWhiteSpace(_saisie.Titre))"
@onclick="AjouterAsync">
Ajouter à ma liste
</button>
</div>
</section>
@if (_souhaits.Count == 0)
@if (_souhaits.Count == 0 && _revues.Count == 0)
{
@* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
donc les deux plutôt que d'affirmer la première — même règle que pour une fiche
absente de l'instantané. *@
<p class="message-discret">
Votre liste est vide. Ajoutez un livre ci-dessus, ou ouvrez la bibliographie d'un
auteur depuis <a href="auteurs">la liste des auteurs</a>.
@if (Reseau.EnLigne)
{
<text>
Votre liste est vide. Ajoutez une envie ci-dessous, ou ouvrez la
bibliographie d'un auteur depuis <a href="auteurs">la liste des auteurs</a>.
</text>
}
else
{
<text>
Aucune envie dans les données enregistrées sur cet appareil. Votre liste est
peut-être vide, ou simplement pas encore synchronisée : le retour du réseau
tranchera.
</text>
}
</p>
}
else
{
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
<p class="compteur">@Compteur()</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. *@
@*
⚠️ L'export est produit PAR LE SERVEUR : hors-ligne il n'y a pas de fichier à
télécharger, et un lien laissé actif finirait sur une page d'erreur du navigateur.
Un lien ne se désactive pas — d'où la bascule vers un bouton inerte qui porte sa
raison, comme au catalogue.
La consolation est dite juste en dessous : l'usage que servait l'export « .txt »,
emporter sa liste en librairie, est désormais couvert par la liste elle-même.
*@
<div class="actions-export">
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
@if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Texte)" download>
Exporter en texte
</a>
<a class="bouton bouton-discret" href="@ServiceLivresApi.UrlExportSouhaits(FormatExportSouhaits.Csv)" download>
Exporter en CSV
</a>
}
else
{
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en texte</button>
<button type="button" class="bouton bouton-discret" disabled
title="@MotifExport">Exporter en CSV</button>
}
</div>
@if (!Reseau.EnLigne)
{
<p class="message-discret">
Les exports sont fabriqués par le serveur : ils reviendront avec le réseau.
La liste ci-dessous, elle, est celle enregistrée sur cet appareil — c'est
exactement ce qu'on emportait dans le fichier texte.
</p>
}
@if (ModeOrdre && _souhaits.Count > 1)
{
<p class="champ-aide">
Du plus désiré au moins désiré. Utilisez les flèches pour déplacer une envie —
sur ordinateur, vous pouvez aussi la faire glisser.
</p>
}
@*
⚠️ Les flèches ne sont PAS un ornement à côté du glisser-déposer : le drag & drop
HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
*@
@* 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 in _souhaits)
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
{
<li class="carte-souhait" @key="souhait.Id">
<li class="carte-souhait @(_glisse == souhait.Id ? "carte-glissee" : null)"
@key="souhait.Id"
draggable="@((ModeOrdre && Reseau.EnLigne && _souhaits.Count > 1).ToString().ToLowerInvariant())"
@ondragstart="() => DebuterGlisse(souhait.Id)"
@ondragend="TerminerGlisse"
@ondragover:preventDefault="ModeOrdre"
@ondrop="() => DeposerAsync(index)">
@if (ModeOrdre && _souhaits.Count > 1)
{
<div class="rang-actions">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _souhaits.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {souhait.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
</div>
}
<Couverture Url="@souhait.CoverUrl" Titre="@souhait.Titre"
Classe="couverture-petite" Agrandissable="true" />
@@ -107,6 +152,21 @@ 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>
@@ -117,7 +177,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(souhait.Isbn))
{
<span class="etiquette">ISBN @souhait.Isbn</span>
<span class="etiquette code-isbn">ISBN @FormatageIsbn.Afficher(souhait.Isbn)</span>
}
</p>
@@ -127,104 +187,273 @@ else
}
</div>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@_enCours" @onclick="() => SupprimerAsync(souhait)"
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
Retirer
</button>
@*
« É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>
</li>
}
</ul>
}
}
@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
fiche livre ne montre plus son formulaire. *@
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
@if (ModeOrdre)
{
<a class="bouton bouton-discret" href="souhaits">Terminer</a>
}
else if (Reseau.EnLigne)
{
<a class="bouton bouton-discret" href="souhaits/ordre">Modifier l'ordre</a>
}
@if (Reseau.EnLigne)
{
<a class="bouton bouton-principal" href="souhaits/ajout">Ajouter une envie</a>
}
else
{
<button type="button" class="bouton bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Ajouter une envie</button>
}
</nav>
@code {
private IReadOnlyList<SouhaitDto>? _souhaits;
private EnregistrementSouhait _saisie = new();
private string _isbn = string.Empty;
private bool _ajout;
private bool _recherche;
private 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 Task OnInitializedAsync() => ChargerAsync();
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>
/// <remarks>
/// On s'abonne à <c>Change</c> et jamais à <c>SynchroChange</c> : recharger écrit un
/// instantané, qui émettrait une nouvelle synchro, qui rechargerait… sans fin.
/// </remarks>
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
Navigation.LocationChanged -= SurNavigation;
}
/// <summary>Raison du blocage des écritures, ou <c>null</c> quand tout est possible.</summary>
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private const string MotifExport =
"L'export est produit par le serveur : indisponible hors ligne.";
/// <remarks>
/// ⚠️ Aucun message HTTP brut ne doit remonter jusqu'ici. C'était le défaut d'origine :
/// hors-ligne, l'écran affichait « net_http_message_not_success_statuscode_reason, 404,
/// Not Found ». La liste passe désormais par le même repli sur instantané que le reste de
/// l'application, et une panne se dit en français.
/// </remarks>
private async Task ChargerAsync()
{
try
{
_erreur = null;
_souhaits = await Api.ListerSouhaitsAsync();
// 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 ex)
catch (Exception)
{
_erreur = $"Impossible de charger la liste d'envies : {ex.Message}";
_souhaits ??= [];
_erreur = Reseau.EnLigne
? "La liste d'envies n'a pas pu être chargée. Réessayez dans un instant."
: "Liste d'envies indisponible hors ligne : aucune donnée n'a encore pu être "
+ "enregistrée sur cet appareil.";
}
}
/// <summary>
/// Pré-remplit le formulaire depuis la cascade ISBN existante (BnF → OpenLibrary).
/// Déplace une envie d'une position à une autre, puis enregistre l'ordre complet.
/// </summary>
/// <remarks>
/// On retient le premier candidat sans demander de choisir : contrairement à l'ajout au
/// catalogue, une envie ne fige pas d'édition — l'utilisateur achètera celle qu'il trouvera.
/// Tous les champs restent modifiables.
/// La nouvelle suite est calculée <b>localement</b> et envoyée en entier : le serveur ne
/// reçoit pas « monte de un » mais « voici l'ordre ». C'est la même opération pour les
/// flèches et pour le glisser-déposer, donc un seul chemin à vérifier.
/// <para>
/// La liste affichée est réordonnée <b>avant</b> l'aller-retour réseau : un déplacement qui
/// attendrait la réponse donnerait l'impression que la flèche n'a pas répondu. En cas
/// d'échec, le rechargement remet l'ordre du serveur.
/// </para>
/// </remarks>
private async Task ChercherIsbnAsync()
private async Task DeplacerAsync(int depuis, int vers)
{
_recherche = true;
_erreur = null;
try
if (_souhaits is null || depuis == vers || vers < 0 || vers >= _souhaits.Count)
{
var resultat = await Api.ChercherIsbnAsync(_isbn.Trim());
var candidat = resultat?.Candidats.FirstOrDefault();
if (candidat is null)
{
_erreur = $"Aucune notice trouvée pour « {_isbn.Trim()} ». Complétez à la main.";
return;
}
_saisie.Titre = candidat.Titre;
_saisie.Auteur = string.Join(", ", candidat.Auteurs);
_saisie.Editeur = candidat.Editeur;
_saisie.Annee = candidat.Annee;
_saisie.Isbn = _isbn.Trim();
_saisie.CoverUrl = candidat.CoverUrl;
}
catch (Exception ex)
{
_erreur = $"La recherche a échoué : {ex.Message}";
}
finally
{
_recherche = false;
}
}
private async Task AjouterAsync()
{
_ajout = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(_saisie);
_ajout = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_saisie = new EnregistrementSouhait();
_isbn = string.Empty;
await ChargerAsync();
var ordonnee = _souhaits.ToList();
var deplacee = ordonnee[depuis];
ordonnee.RemoveAt(depuis);
ordonnee.Insert(vers, deplacee);
_souhaits = ordonnee;
_enCours = true;
_erreur = await Api.ReordonnerSouhaitsAsync(ordonnee.Select(s => s.Id).ToList());
_enCours = false;
if (_erreur is not null)
{
// L'ordre affiché n'est pas celui du serveur : on reprend le sien plutôt que de
// laisser croire que le déplacement a été enregistré.
await ChargerAsync();
}
}
/// <summary>Fin d'un glisser-déposer : la carte saisie prend la place de celle survolée.</summary>
private async Task DeposerAsync(int cible)
{
if (!ModeOrdre || _souhaits is null || _glisse is not { } id)
{
return;
}
var depuis = _souhaits.ToList().FindIndex(s => s.Id == id);
_glisse = null;
if (depuis >= 0)
{
await DeplacerAsync(depuis, cible);
}
}
private 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)
-40
View File
@@ -1,40 +0,0 @@
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;
}
}
-53
View File
@@ -1,53 +0,0 @@
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;
}
}
+22
View File
@@ -0,0 +1,22 @@
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";
}
+122
View File
@@ -18,6 +18,67 @@ public static class ClesCache
public const string PretsEnCours = "prets-en-cours";
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>
/// <remarks>
/// ⚠️ Cet instantané est le seul à contenir des données <b>personnelles</b> : le catalogue,
/// les auteurs et les prêts sont communs au foyer. Il n'est donc écrit qu'avec ce que le
/// serveur a bien voulu rendre à l'appelant — jamais la liste d'un autre, que l'API ne sait
/// de toute façon pas produire.
/// <para>
/// Sa raison d'être : la liste s'emporte <b>en librairie</b>, exactement l'endroit où le
/// réseau manque. C'est le même usage que l'export <c>.txt</c>, lequel est produit côté
/// serveur et devient donc impossible hors-ligne.
/// </para>
/// </remarks>
public const string Souhaits = "souhaits";
/// <summary>
/// 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>
@@ -112,6 +173,67 @@ 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)
@@ -36,6 +36,11 @@ 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
@@ -48,6 +53,14 @@ 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);
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
@@ -0,0 +1,218 @@
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;
}
}
+170
View File
@@ -0,0 +1,170 @@
namespace MaBibli.Client.Services;
/// <summary>
/// Parenté des routes de l'application : à quel écran remonte la flèche de retour du bandeau.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>Ceci renverse la décision actée le 2026-08-20</b> (« le retour passe par l'historique du
/// navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même
/// écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les
/// <b>allers-retours</b> (filtre, ordre, édition) : on cliquait cinq fois sans quitter le même
/// écran. Une remontée d'un cran de route répond à « où suis-je ? », qui est la question posée.
/// </para>
/// <para>
/// La table est <b>explicite</b>, et non un découpage naïf de l'URL : toutes les routes n'ont pas
/// la forme d'une arborescence (<c>/souhaits/ordre</c> remonte à <c>/souhaits</c>, mais
/// <c>/auteurs/{id}/bibliographie</c> remonte à <c>/auteurs</c> — la fiche d'un auteur n'existe
/// pas). Une route qui apparaît dans l'application doit apparaître ici.
/// </para>
/// <para>
/// ⚠️ <b>Le retour ne sort jamais de l'application</b> : la fonction rend toujours un chemin
/// interne, et la racine d'une branche rend la destination de menu correspondante — le catalogue
/// en dernier ressort. C'est ce qui remplace le test de <c>history.length</c> qui vivait en
/// JavaScript, et qui ne disait pas ce qu'on croyait dans une PWA <c>standalone</c> : la pile
/// d'une session y contient aussi ce qui précède l'application.
/// </para>
/// </remarks>
public static class RemonteeRoutes
{
/// <summary>Le catalogue est la racine : son parent est lui-même, on ne remonte pas plus haut.</summary>
public const string Racine = "/";
/// <summary>
/// Table de parenté, dans l'ordre de lecture. <c>{id}</c> représente un segment numérique.
/// </summary>
/// <remarks>
/// La première ligne dont le modèle correspond gagne : les modèles les plus longs sont donc
/// écrits <b>avant</b> ceux dont ils sont un prolongement.
/// </remarks>
private static readonly (string Modele, string Parent)[] Parents =
[
// Fiche livre : l'édition retombe sur la consultation, la consultation sur le catalogue.
("/livres/{id}/edition", "/livres/{id}"),
("/livres/{id}", Racine),
// Séries : les deux écrans de modification retombent sur la consultation de la série.
("/series/{id}/edition", "/series/{id}"),
("/series/{id}/ordre", "/series/{id}"),
("/series/{id}", "/series"),
("/series", Racine),
// Envies : l'ordre, l'ajout et les deux écrans d'édition sont des écrans de la même
// liste. ⚠️ « /souhaits/revues/{id}/edition » ne peut pas se confondre avec
// « /souhaits/{id}/edition » : les modèles n'ont pas le même nombre de segments.
("/souhaits/revues/{id}/edition", "/souhaits"),
("/souhaits/{id}/edition", "/souhaits"),
("/souhaits/ordre", "/souhaits"),
("/souhaits/ajout", "/souhaits"),
("/souhaits", Racine),
// Ajout d'un ouvrage : on y entre depuis le catalogue comme depuis les revues, mais le
// catalogue est la destination de menu de ce qu'on y saisit.
("/ajout/manuel", Racine),
("/ajout/isbn", Racine),
("/ajout", Racine),
// Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un
// identifiant, mais l'ordre de lecture est ce qui le garantit sans ambiguïté.
("/revues/ajout", "/revues"),
("/revues/{id}/edition", "/revues/{id}"),
("/revues/{id}", "/revues"),
("/revues", Racine),
// La bibliographie remonte à la LISTE des auteurs : il n'existe pas de fiche auteur.
("/auteurs/{id}/bibliographie", "/auteurs"),
("/auteurs", Racine),
("/prets", Racine),
// « À propos » est une annexe du menu : elle remonte au catalogue, comme toute racine de
// branche. Elle n'a pas d'enfants, et n'en aura pas.
("/a-propos", Racine),
("/not-found", Racine),
];
/// <summary>
/// Écran d'où l'on vient hiérarchiquement, à partir d'un chemin d'application.
/// </summary>
/// <param name="chemin">
/// Chemin absolu ou relatif à la base, avec ou sans requête (<c>?auteur=3</c>) ni fragment.
/// </param>
/// <returns>Un chemin interne, toujours ; jamais <c>null</c>, jamais une URL absolue.</returns>
public static string Parent(string? chemin)
{
var segments = Segments(chemin);
if (segments.Length == 0)
{
return Racine;
}
foreach (var (modele, parent) in Parents)
{
if (Correspond(Segments(modele), segments))
{
return Rendre(parent, segments);
}
}
// Route inconnue : on retombe sur la destination de menu de sa branche, plutôt que sur
// un chemin deviné. Une route ajoutée sans sa ligne de table reste ainsi utilisable —
// mais elle doit être ajoutée : c'est un repli, pas le mécanisme.
return Parents.FirstOrDefault(p => p.Modele == $"/{segments[0]}").Parent is not null
? $"/{segments[0]}"
: Racine;
}
/// <summary>Découpe en segments non vides, requête et fragment retirés.</summary>
private static string[] Segments(string? chemin)
{
if (string.IsNullOrWhiteSpace(chemin))
{
return [];
}
var utile = chemin.Split('?', '#')[0];
return utile.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool Correspond(string[] modele, string[] segments)
{
if (modele.Length != segments.Length)
{
return false;
}
for (var i = 0; i < modele.Length; i++)
{
var attendu = modele[i];
var ok = attendu == "{id}"
? int.TryParse(segments[i], out _)
: string.Equals(attendu, segments[i], StringComparison.OrdinalIgnoreCase);
if (!ok)
{
return false;
}
}
return true;
}
/// <summary>Remplace <c>{id}</c> du parent par l'identifiant lu dans le chemin d'origine.</summary>
private static string Rendre(string parent, string[] segments)
{
if (!parent.Contains("{id}", StringComparison.Ordinal))
{
return parent;
}
var identifiant = segments.FirstOrDefault(s => int.TryParse(s, out _));
// Sans identifiant lisible, on ne fabrique pas une route bancale : la racine est sûre.
return identifiant is null
? Racine
: parent.Replace("{id}", identifiant, StringComparison.Ordinal);
}
}
+638 -20
View File
@@ -4,6 +4,7 @@ using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
namespace MaBibli.Client.Services;
@@ -25,6 +26,7 @@ namespace MaBibli.Client.Services;
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly Dictionary<int, BibliographieDto> _bibliographies = [];
/// <summary>
/// Recharge tous les instantanés depuis le réseau.
@@ -46,7 +48,12 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAsync(new CritereLivres(), ct);
await ListerAuteursAsync(ct);
await ListerPretsEnCoursAsync(ct);
await ListerSouhaitsAsync(ct);
await ListerRevuesSouhaiteesAsync(ct);
await ListerSeriesAsync(ct);
await ListerRevuesAsync(ct);
await ObtenirUtilisateurAsync(ct);
await ObtenirVersionAsync(ct);
return reseau.EnLigne;
}
catch (OperationCanceledException)
@@ -75,6 +82,11 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
parametres.Add($"format={format}");
}
if (criteres.TypeDocument is { } type)
{
parametres.Add($"typeDocument={type}");
}
if (criteres.Statut is { } statut)
{
parametres.Add($"statut={statut}");
@@ -85,6 +97,12 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
parametres.Add($"auteurId={auteurId}");
}
if (criteres.Prete is { } prete)
{
// Minuscules : c'est ce que le liage de ASP.NET Core attend d'un bool?.
parametres.Add($"prete={(prete ? "true" : "false")}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));
@@ -126,8 +144,62 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
}
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync<LivreDto>(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
/// <summary>
/// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable.
/// </summary>
/// <param name="confirmerDoublon">
/// Vrai quand l'utilisateur a vu l'avertissement et veut ajouter le livre malgré tout —
/// second exemplaire, autre édition, ou rapprochement à côté de la plaque.
/// </param>
public async Task<ResultatCreation> CreerAsync(
EnregistrementLivre saisie, bool confirmerDoublon = false, CancellationToken ct = default)
{
var url = "api/livres" + (confirmerDoublon ? "?confirmerDoublon=true" : string.Empty);
var (reponse, motif) = await EnvoyerAsync(() => http.PostAsJsonAsync(url, saisie, Json, ct));
if (reponse is null)
{
return new ResultatCreation(null, motif, null);
}
// Le 409 n'est PAS une erreur : le serveur demande une confirmation, et l'écran doit
// proposer d'ajouter quand même. Le faire passer par le message d'erreur générique le
// transformerait en échec, avec le livre perdu au passage.
if (reponse.StatusCode == HttpStatusCode.Conflict)
{
var doublons = await LireDoublonsAsync(reponse, ct);
if (doublons is not null)
{
return new ResultatCreation(null, null, doublons);
}
}
var resultat = await InterpreterAsync<LivreDto>(reponse, ct);
if (resultat.EstOk)
{
_ = RafraichirEnFondAsync();
}
return new ResultatCreation(resultat.Valeur, resultat.Erreur, null);
}
/// <summary>Lit le corps d'un 409, ou <c>null</c> s'il est illisible.</summary>
private static async Task<DoublonsLivre?> LireDoublonsAsync(
HttpResponseMessage reponse, CancellationToken ct)
{
try
{
var doublons = await reponse.Content.ReadFromJsonAsync<DoublonsLivre>(Json, ct);
return string.IsNullOrWhiteSpace(doublons?.Message) ? null : doublons;
}
catch (Exception)
{
// Corps inattendu : on retombe sur le traitement d'erreur ordinaire, qui dira au
// moins que l'enregistrement n'a pas eu lieu.
return null;
}
}
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
@@ -177,6 +249,41 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
}
public async Task<ResultatRenommageAuteur?> RenommerAuteurAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
{
if (!reseau.EnLigne)
{
return null;
}
var suffixe = confirmerFusion ? "?confirmerFusion=true" : string.Empty;
try
{
var reponse = await http.PutAsJsonAsync(
$"api/auteurs/{id}{suffixe}", new RenommerAuteur(nom), Json, ct);
reseau.SignalerSuccesReseau();
if (reponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (reponse.StatusCode == HttpStatusCode.Conflict
|| reponse.IsSuccessStatusCode)
{
return await LireAsync<ResultatRenommageAuteur>(reponse, ct);
}
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return null;
}
}
/// <summary>
/// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
/// </summary>
@@ -353,6 +460,30 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
}
/// <summary>
/// Version publiée du serveur, avec repli sur la dernière connue de cet appareil.
/// </summary>
/// <remarks>
/// Même forme que <see cref="ObtenirUtilisateurAsync"/>. Hors-ligne on rend l'instantané
/// plutôt que rien : c'est un fait daté (« voilà ce que le serveur annonçait »), et c'est
/// justement quand l'application se comporte mal qu'on veut ce numéro. L'écran distingue les
/// deux cas ; ici on ne fabrique aucune valeur.
/// </remarks>
public async Task<VersionApplication> ObtenirVersionAsync(CancellationToken ct = default)
{
var (ok, version) = await EssayerAsync(
() => http.GetFromJsonAsync<VersionApplication>("api/version", Json, ct));
if (ok && version is not null)
{
await MemoriserAsync(ClesCache.Version, version);
return version;
}
var instantane = await LireCacheAsync<VersionApplication>(ClesCache.Version);
return instantane?.Donnees ?? VersionApplication.Developpement;
}
/// <summary>
/// Exécute un appel de lecture. Renvoie <c>false</c> — sans exception — quand le réseau
/// manque, ce qui est la bascule vers le cache. Une annulation demandée par l'appelant,
@@ -398,31 +529,52 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
/// </remarks>
private async Task<ResultatApi<T>> EcrireAsync<T>(
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
{
var (reponse, motif) = await EnvoyerAsync(appel);
if (reponse is null)
{
return new ResultatApi<T>(default, motif);
}
var resultat = await InterpreterAsync<T>(reponse, ct);
if (resultat.EstOk)
{
// La base a changé : l'instantané doit suivre, sinon une coupure juste après une
// modification montrerait un catalogue d'avant.
_ = RafraichirEnFondAsync();
}
return resultat;
}
/// <summary>
/// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité.
/// </summary>
/// <remarks>
/// Séparé de <see cref="EcrireAsync{T}"/> parce que toutes les écritures ne s'interprètent
/// pas de la même façon : la création doit reconnaître le 409 des doublons, qui n'est pas un
/// échec. Le refus hors-ligne et le signalement de l'état du réseau, eux, sont communs.
/// </remarks>
private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync(
Func<Task<HttpResponseMessage>> appel)
{
if (!reseau.EnLigne)
{
return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
return (null, EtatReseau.MotifHorsLigne);
}
try
{
var reponse = await appel();
reseau.SignalerSuccesReseau();
var resultat = await InterpreterAsync<T>(reponse, ct);
if (resultat.EstOk)
{
// La base a changé : l'instantané doit suivre, sinon une coupure juste après une
// modification montrerait un catalogue d'avant.
_ = RafraichirEnFondAsync();
}
return resultat;
return (reponse, null);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
return (null, EtatReseau.MotifHorsLigne);
}
}
@@ -434,6 +586,10 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAsync(new CritereLivres());
await ListerPretsEnCoursAsync();
await ListerAuteursAsync();
await ListerSouhaitsAsync();
await ListerRevuesSouhaiteesAsync();
await ListerSeriesAsync();
await ListerRevuesAsync();
}
catch (Exception)
{
@@ -513,20 +669,222 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
/// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait
/// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre.
/// </remarks>
public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct) ?? [];
public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default)
{
var (ok, souhaits) = await EssayerAsync(
() => http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []);
return souhaits ?? [];
}
var instantane = await LireCacheAsync<List<SouhaitDto>>(ClesCache.Souhaits);
return instantane?.Donnees ?? [];
}
/// <summary>
/// Corrige une envie. Passe par <see cref="EcrireAsync{T}"/> : la réponse porte l'envie
/// modifiée, donc il y a bien quelque chose à désérialiser — contrairement au 204 de la
/// suppression.
/// </summary>
public Task<ResultatApi<SouhaitDto>> ModifierSouhaitAsync(
int id, EnregistrementSouhait saisie, CancellationToken ct = default) =>
EcrireAsync<SouhaitDto>(
() => http.PutAsJsonAsync($"api/souhaits/{id}", saisie, Json, ct), ct);
/// <summary>
/// Les revues et numéros souhaités <b>de l'utilisateur courant</b>.
/// </summary>
/// <remarks>
/// Second instantané de la liste d'envies, et il n'est pas facultatif : c'est l'écran qu'on
/// emporte en librairie ou au kiosque, donc là où le réseau manque.
/// </remarks>
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerRevuesSouhaiteesAsync(
CancellationToken ct = default)
{
var (ok, envies) = await EssayerAsync(
() => http.GetFromJsonAsync<List<RevueSouhaiteeDto>>("api/souhaits/revues", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.SouhaitsRevues, envies ?? []);
return envies ?? [];
}
var instantane = await LireCacheAsync<List<RevueSouhaiteeDto>>(ClesCache.SouhaitsRevues);
return instantane?.Donnees ?? [];
}
public Task<ResultatApi<RevueSouhaiteeDto>> AjouterRevueSouhaiteeAsync(
EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
EcrireAsync<RevueSouhaiteeDto>(
() => http.PostAsJsonAsync("api/souhaits/revues", saisie, Json, ct), ct);
public Task<ResultatApi<RevueSouhaiteeDto>> ModifierRevueSouhaiteeAsync(
int id, EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
EcrireAsync<RevueSouhaiteeDto>(
() => http.PutAsJsonAsync($"api/souhaits/revues/{id}", saisie, Json, ct), ct);
/// <summary>
/// Retire une revue souhaitée. <c>null</c> si c'est fait, sinon le motif à afficher.
/// </summary>
/// <remarks>
/// ⚠️ Comme la suppression d'une envie de livre, elle répond <c>204 No Content</c> : la
/// passer par <see cref="EcrireAsync{T}"/> lèverait une exception sur un corps vide, là où
/// tout s'est bien passé.
/// </remarks>
public async Task<string?> SupprimerRevueSouhaiteeAsync(int id, CancellationToken ct = default)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.DeleteAsync($"api/souhaits/revues/{id}", ct);
reseau.SignalerSuccesReseau();
if (!reponse.IsSuccessStatusCode)
{
return await MessageErreurAsync(reponse, ct);
}
_ = RafraichirEnFondAsync();
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
return await InterpreterAsync<SouhaitDto>(reponse, ct);
var resultat = await EcrireAsync<SouhaitDto>(
() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
if (resultat.EstOk)
{
// ⚠️ Surtout PAS _bibliographies.Clear() : la bibliographie suivante repartirait
// interroger la BnF (~1 s par page, jusqu'à dix pages pour les nouveautés). C'est
// exactement ce qui rendait « Je le veux » interminable — l'écran attendait une
// source distante pour apprendre une chose qu'il savait déjà.
PatcherOeuvres(
o => CleOeuvre.Cle(o.Titre) == CleOeuvre.Cle(saisie.Titre),
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
}
return resultat;
}
public async Task<IReadOnlyList<ResultatApi<SouhaitDto>>> AjouterSouhaitsAsync(
IReadOnlyList<EnregistrementSouhait> saisies, CancellationToken ct = default)
{
var resultats = new List<ResultatApi<SouhaitDto>>(saisies.Count);
foreach (var saisie in saisies)
{
resultats.Add(await AjouterSouhaitAsync(saisie, ct));
}
return resultats;
}
/// <summary>
/// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher.
/// </summary>
/// <remarks>
/// ⚠️ Ne <b>pas</b> passer par <see cref="EcrireAsync{T}"/> : la suppression répond
/// <c>204 No Content</c>, et désérialiser un corps vide lèverait une exception là où tout
/// s'est bien passé. Même raison que pour la suppression d'un livre, dont ceci reprend
/// exactement la forme.
/// </remarks>
public async Task<string?> SupprimerSouhaitAsync(int id, CancellationToken ct = default)
{
var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
reseau.SignalerSuccesReseau();
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
/// <summary>
/// Fixe l'ordre de la liste d'envies. <c>null</c> si c'est fait, sinon le motif à afficher.
/// </summary>
/// <remarks>
/// Envoie la liste <b>entière</b> des identifiants : c'est ce que réclame le glisser-déposer,
/// et les flèches s'y ramènent. Comme la suppression, elle répond <c>204 No Content</c> et ne
/// passe donc pas par <see cref="EcrireAsync{T}"/>.
/// </remarks>
public async Task<string?> ReordonnerSouhaitsAsync(
IReadOnlyList<int> ids, CancellationToken ct = default)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.PutAsJsonAsync("api/souhaits/ordre", ids, Json, ct);
reseau.SignalerSuccesReseau();
if (!reponse.IsSuccessStatusCode)
{
return await MessageErreurAsync(reponse, ct);
}
// L'instantané porte l'ordre : sans ce rafraîchissement, une coupure juste après un
// déplacement rendrait la liste d'avant.
_ = RafraichirEnFondAsync();
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
/// <summary>
/// Cherche des notices par titre, éventuellement restreint à un auteur.
/// </summary>
/// <remarks>
/// <b>Exige le réseau</b>, comme le lookup ISBN : rien n'est mis en cache. Sert l'ajout
/// d'une envie quand on n'a pas le livre en main — c'est-à-dire le cas normal.
/// </remarks>
public async Task<ResultatLookupIsbn?> ChercherNoticesAsync(
string titre, string? auteur, CancellationToken ct = default)
{
var parametres = $"titre={Uri.EscapeDataString(titre.Trim())}";
if (!string.IsNullOrWhiteSpace(auteur))
{
parametres += $"&auteur={Uri.EscapeDataString(auteur.Trim())}";
}
var (ok, resultat) = await EssayerAsync(async () =>
{
var reponse = await http.GetAsync($"api/notices?{parametres}", ct);
return reponse.IsSuccessStatusCode
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
: null;
});
return ok ? resultat : null;
}
/// <summary>
@@ -552,12 +910,259 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
public async Task<BibliographieDto?> ObtenirBibliographieAsync(
int auteurId, CancellationToken ct = default)
{
if (_bibliographies.TryGetValue(auteurId, out var cachee)
&& cachee.Etat == EtatSourceBibliographie.Ok)
{
return cachee;
}
var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
var bibliographie = reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<BibliographieDto>(reponse, ct);
if (bibliographie?.Etat == EtatSourceBibliographie.Ok)
{
_bibliographies[auteurId] = bibliographie;
}
return bibliographie;
}
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
int auteurId, CancellationToken ct = default)
{
var reponse = await http.GetAsync(
$"api/auteurs/{auteurId}/bibliographie/nouveautes", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<BibliographieDto>(reponse, ct);
}
public async Task<string?> MasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
() => http.PostAsJsonAsync(
$"api/auteurs/{auteurId}/bibliographie/masque",
new MasquageBibliographie { Titre = titre }, Json, ct), titre, true, ct);
public async Task<string?> DemasquerOeuvreBibliographieAsync(
int auteurId, string titre, CancellationToken ct = default) =>
await ModifierBibliographieAsync(
() => http.DeleteAsync(
$"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"),
titre, false, ct);
private async Task<string?> ModifierBibliographieAsync(
Func<Task<HttpResponseMessage>> appel, string titre, bool masquee, CancellationToken ct)
{
var (reponse, motif) = await EnvoyerAsync(appel);
if (reponse is null)
{
return motif;
}
if (!reponse.IsSuccessStatusCode)
{
return await MessageErreurAsync(reponse, ct);
}
PatcherOeuvres(o => o.Titre == titre, o => o with { Masquee = masquee });
return null;
}
/// <summary>
/// Corrige en place les œuvres des bibliographies déjà en mémoire.
/// </summary>
/// <remarks>
/// ⚠️ C'est ce qui remplace l'ancien <c>_bibliographies.Clear()</c>, et la différence est
/// une seconde d'attente contre plusieurs. Masquer une œuvre ou l'ajouter à ses envies ne
/// change <b>rien</b> à ce que la BnF connaît de l'auteur : seuls des drapeaux
/// <i>personnels</i> basculent, et le client sait lesquels sans redemander quoi que ce soit.
/// <para>
/// Ce cache reste, comme depuis le lot D, en mémoire et pour la seule session : rien n'est
/// écrit dans l'instantané hors-ligne, une bibliographie n'étant pas une donnée à nous.
/// </para>
/// </remarks>
private void PatcherOeuvres(
Func<OeuvreBibliographie, bool> cible,
Func<OeuvreBibliographie, OeuvreBibliographie> correction)
{
foreach (var (auteurId, biblio) in _bibliographies.ToList())
{
if (!biblio.Oeuvres.Any(cible))
{
continue;
}
_bibliographies[auteurId] = biblio with
{
Oeuvres = biblio.Oeuvres.Select(o => cible(o) ? correction(o) : o).ToList(),
};
}
}
// ─────────────────────────────────────────────────────────────────────────
// Sagas, cycles et séries. COMMUNES au foyer, comme le catalogue : aucune identité ne
// transite, sauf pour la mise en envies, qui écrit dans une liste personnelle.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Toutes les séries, avec leurs tomes. Une seule lecture sert la liste et chaque détail.
/// </summary>
public async Task<IReadOnlyList<SerieDto>> ListerSeriesAsync(CancellationToken ct = default)
{
var (ok, series) = await EssayerAsync(
() => http.GetFromJsonAsync<List<SerieDto>>("api/series", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Series, series ?? []);
return series ?? [];
}
var instantane = await LireCacheAsync<List<SerieDto>>(ClesCache.Series);
return instantane?.Donnees ?? [];
}
public async Task<ResultatApi<SerieDto>> CreerSerieAsync(
EnregistrementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(() => http.PostAsJsonAsync("api/series", saisie, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ModifierSerieAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(() => http.PutAsJsonAsync($"api/series/{id}", saisie, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> AjouterElementSerieAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct);
/// <summary>Rattache un livre possédé à une place, ou l'en détache avec <c>null</c>.</summary>
public async Task<ResultatApi<SerieDto>> RattacherLivreSerieAsync(
int elementId, int? livreId, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/elements/{elementId}/livre", new { livreId }, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ReordonnerSerieAsync(
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct);
public async Task<ResultatApi<SerieDto>> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
await EcrireAsync<SerieDto>(
() => http.PutAsJsonAsync(
$"api/series/{serieId}/sous-series/ordre",
new OrdreSeries { Ids = [.. ids] }, Json, ct), ct);
/// <summary>Met un tome manquant dans <b>ma</b> liste d'envies — la série, elle, est commune.</summary>
public async Task<ResultatApi<SouhaitDto>> SouhaiterTomeAsync(
int elementId, CancellationToken ct = default) =>
await EcrireAsync<SouhaitDto>(
() => http.PostAsync($"api/series/elements/{elementId}/souhait", null, ct), ct);
/// <summary>
/// Retire une place de la série, ou la série entière. <c>null</c> si c'est fait.
/// </summary>
/// <remarks>
/// Comme les autres suppressions : réponse <c>204 No Content</c>, donc pas de
/// désérialisation, donc pas <see cref="EcrireAsync{T}"/>.
/// </remarks>
public Task<string?> RetirerElementSerieAsync(int elementId, CancellationToken ct = default) =>
SupprimerAsync($"api/series/elements/{elementId}", ct);
public Task<string?> SupprimerSerieAsync(int id, CancellationToken ct = default) =>
SupprimerAsync($"api/series/{id}", ct);
// ─────────────────────────────────────────────────────────────────────────
// Revues et magazines. Communes au foyer ; une revue n'est pas un livre et ne passe donc
// jamais par le catalogue.
// ─────────────────────────────────────────────────────────────────────────
public async Task<IReadOnlyList<RevueDto>> ListerRevuesAsync(CancellationToken ct = default)
{
var (ok, revues) = await EssayerAsync(
() => http.GetFromJsonAsync<List<RevueDto>>("api/revues", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Revues, revues ?? []);
return revues ?? [];
}
var instantane = await LireCacheAsync<List<RevueDto>>(ClesCache.Revues);
return instantane?.Donnees ?? [];
}
/// <summary>
/// Crée la revue, ou rend celle qui existe déjà — même ISSN, ou même titre.
/// </summary>
/// <remarks>
/// C'est ce que réclame le scan d'un code-barres <c>977</c> : le second numéro d'un
/// magazine retombe forcément sur la même revue, et l'utilisateur ne fait jamais qu'un
/// geste, « voici un numéro de plus ».
/// </remarks>
public async Task<ResultatApi<RevueDto>> CreerRevueAsync(
EnregistrementRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(() => http.PostAsJsonAsync("api/revues", saisie, Json, ct), ct);
public async Task<ResultatApi<RevueDto>> ModifierRevueAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(() => http.PutAsJsonAsync($"api/revues/{id}", saisie, Json, ct), ct);
public async Task<ResultatApi<RevueDto>> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(
() => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct);
/// <summary>
/// Modifie un numéro déjà recensé, couverture et articles à la une compris.
/// </summary>
/// <remarks>
/// Ces deux-là ne viennent d'aucune source — l'ISSN désigne la revue, pas la parution — et
/// se saisissent donc après coup, une fois le magazine en main.
/// </remarks>
public async Task<ResultatApi<RevueDto>> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(
() => http.PutAsJsonAsync($"api/revues/numeros/{numeroId}", saisie, Json, ct), ct);
public Task<string?> RetirerNumeroAsync(int numeroId, CancellationToken ct = default) =>
SupprimerAsync($"api/revues/numeros/{numeroId}", ct);
public Task<string?> SupprimerRevueAsync(int id, CancellationToken ct = default) =>
SupprimerAsync($"api/revues/{id}", ct);
/// <summary>Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis.</summary>
private async Task<string?> SupprimerAsync(string url, CancellationToken ct)
{
if (!reseau.EnLigne)
{
return EtatReseau.MotifHorsLigne;
}
try
{
var reponse = await http.DeleteAsync(url, ct);
reseau.SignalerSuccesReseau();
if (!reponse.IsSuccessStatusCode)
{
return await MessageErreurAsync(reponse, ct);
}
_ = RafraichirEnFondAsync();
return null;
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
return EtatReseau.MotifHorsLigne;
}
}
private sealed record ErreurApi(string? Erreur);
}
@@ -566,3 +1171,16 @@ public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}
/// <summary>
/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer.
/// </summary>
/// <remarks>
/// Trois issues et non deux : un doublon signalé n'est <b>pas</b> une erreur. La même saisie,
/// confirmée, s'enregistrera telle quelle — l'écran doit donc offrir « Ajouter quand même »
/// plutôt qu'un message rouge qui laisserait croire à un refus.
/// </remarks>
public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
{
public bool EstOk => Livre is not null;
}
+2
View File
@@ -13,3 +13,5 @@
@using MaBibli.Shared.Catalogue
@using MaBibli.Shared.Dtos
@using MaBibli.Shared.Entites
@using MaBibli.Shared.Isbn
@using MaBibli.Shared.Textes
+871 -1
View File
@@ -139,6 +139,38 @@ body {
margin: 0 0 0.75rem;
}
/*
Ligne d'en-tête d'une fiche : le texte à gauche, les actions groupées à DROITE (lot T).
C'est la disposition déjà tenue par la bibliographie et la liste des auteurs. Reléguées en
bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir
déroulé toute la liste.
`min-width: 0` sur la colonne de texte : sans lui, un titre long refuse de rétrécir et
pousse les actions hors de l'écran le piège déjà rencontré au lot M1.
*/
.entete-fiche {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.entete-fiche-texte {
flex: 1 1 12rem;
min-width: 0;
}
.entete-fiche-actions {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
}
.message-discret {
color: var(--mb-texte-doux);
font-size: 0.9rem;
@@ -246,6 +278,15 @@ body {
.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; }
.segment.statut-lu.segment-actif { background: #1f7a44; border-color: #1f7a44; }
/* Type de document : même vocabulaire que les pastilles de statut, deux couleurs distinctes
des trois du statut et de celle du prêt, pour qu'on reconnaisse ce qu'on filtre (A2, IDEES.md). */
/* Le segment « Prêtés » reprend la couleur de l'étiquette de prêt, comme les segments de
statut reprennent celle de leur pastille : le filtre et la carte doivent se répondre. */
.segment.segment-prete.segment-actif { background: #8a5a00; border-color: #8a5a00; }
.segment.type-roman.segment-actif { background: #2a6f97; border-color: #2a6f97; }
.segment.type-bd.segment-actif { background: #a13d63; border-color: #a13d63; }
/* --- Boutons --- */
.bouton {
@@ -291,6 +332,9 @@ body {
margin: 1rem 0;
}
/* Cette barre ne porte que des ACTIONS : la navigation est dans le menu du bandeau.
Depuis que ce menu est passé en haut (2026-08-20), plus rien ne l'accompagne en bas de
l'écran : elle s'y pose donc directement, sans décalage à réserver. */
.actions-flottantes {
position: fixed;
left: 0;
@@ -358,6 +402,82 @@ body {
padding: 0;
}
/* --- Livres d'une même série, rassemblés au catalogue (2026-08-22) ---
Le bloc se pose son premier tome serait tombé dans l'ordre du catalogue : il ne
déplace donc rien, il rassemble. Le trait à gauche est ce qui fait lire les cartes comme
« dedans » plutôt que comme la suite de la liste. */
.groupe-serie {
margin: 0.5rem 0;
border-left: 3px solid var(--mb-accent);
padding-left: 0.6rem;
}
/* La bascule et le nom sur une même ligne. « min-width: 0 » à chaque étage : la ligne est
elle-même dans une cascade de flex, et sans lui un titre de série long fait défiler la page
entière le piège du lot M, puis de l'arbre des séries. */
.groupe-serie-entete {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
/* La bascule est un BOUTON à côté du lien, jamais dedans : un bouton imbriqué dans un <a>
n'est pas du HTML valide, et le clic irait à la série au lieu de replier. */
.groupe-serie-plier {
flex: 0 0 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
padding: 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
background: transparent;
border: 0;
cursor: pointer;
}
/* Le nom mène à la série : c'est le seul endroit du catalogue qui montre aussi ce qui manque. */
.groupe-serie-titre {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
padding: 0.35rem 0;
color: var(--mb-texte);
text-decoration: none;
font-weight: 600;
}
.groupe-serie-titre:hover .groupe-serie-nom,
.groupe-serie-titre:focus-visible .groupe-serie-nom {
text-decoration: underline;
}
.groupe-serie-nom {
min-width: 0;
overflow-wrap: anywhere;
}
.groupe-serie-compte {
color: var(--mb-texte-doux);
font-size: 0.85rem;
font-weight: 400;
}
/* Une case à cocher parmi des rangées de segments : elle doit se lire comme un réglage, pas
comme un filtre de plus. */
.bascule {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.25rem 0;
font-size: 0.9rem;
}
.carte-livre,
.carte-candidat {
display: flex;
@@ -574,6 +694,12 @@ body {
cursor: zoom-in;
}
/* Image cassée (404/502 intermittent) : le onerror du <img> désactive ce bouton.
Curseur neutre, pour ne plus promettre un agrandissement qui n'ouvrirait rien. */
.couverture-declencheur:disabled {
cursor: default;
}
.calque-couverture {
position: fixed;
inset: 0;
@@ -632,6 +758,16 @@ body {
.etiquette-statut.statut-en-cours { background: #b06b00; }
.etiquette-statut.statut-lu { background: #1f7a44; }
/* Pastille du type de document sur la carte du catalogue : mêmes couleurs que le
filtre (A2, IDEES.md), pour reconnaître d'un coup d'œil ce qui a été filtré. */
.etiquette-type {
color: #fff;
font-weight: 600;
}
.etiquette-type.type-roman { background: #2a6f97; }
.etiquette-type.type-bd { background: #a13d63; }
/* --- Fiche du livre en lecture seule --- */
.couverture-grande { width: 8rem; height: 12rem; }
@@ -715,12 +851,50 @@ body {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
min-height: 2.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/*
Trois colonnes, dont une seule est élastique.
C'est la largeur FIGÉE des deux colonnes extrêmes qui fait tout l'intérêt : sans elle,
chaque ligne plaçait son compteur et ses boutons à un endroit différent, au gré de la
longueur du nom, et l'œil devait relire chaque ligne entière. Trois chiffres suffisent au
compteur personne ne possède mille livres d'un même auteur.
*/
.auteur-compte {
flex: 0 0 2.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
font-weight: 600;
color: var(--mb-texte-doux);
}
.auteur-nom {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.auteur-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
}
/* Le champ de renommage s'insère dans la colonne d'actions : il doit pouvoir rétrécir, sinon
il pousse « Enregistrer » hors de l'écran sur un téléphone. */
.champ-auteur-renomme {
min-width: 0;
max-width: 10rem;
}
.rapprochements {
margin-bottom: 1.5rem;
padding: 0.75rem;
@@ -790,6 +964,88 @@ body {
border-bottom: 1px solid var(--mb-bord);
}
/* La ligne de bibliographie, elle, est centrée : la case à cocher est à gauche du titre
et les boutons à droite, sur la même ligne. Alignés en haut, la case flottait au-dessus du
texte et les boutons pendaient sous lui c'est ce qui rendait la liste illisible à coup
d'œil, alors qu'on la parcourt précisément pour cocher. */
.carte-oeuvre {
align-items: center;
}
/* La carte partagée d'un livre possédé sert de corps à la ligne : elle porte déjà sa propre
bordure et son propre rembourrage, qu'il faut neutraliser ici pour ne pas doubler le trait. */
.carte-oeuvre > .carte-livre {
flex: 1;
min-width: 0;
padding: 0;
border-bottom: 0;
}
.oeuvre-case {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
/* Cible tactile : la case native fait 1rem, on double la zone cliquable autour. */
padding: 0.4rem;
margin: -0.4rem 0;
cursor: pointer;
}
.oeuvre-case input {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.oeuvre-actions {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
}
/*
Barre d'actions de la sélection, EN HAUT de la liste et collante : on coche en descendant,
et une barre en bas d'écran obligeait soit à redescendre pour valider, soit pire
recouvrait les dernières lignes qu'on cherchait justement à cocher.
*/
.barre-selection {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 0;
padding: 0.6rem 0.75rem;
background: var(--mb-fond-doux);
border: 1px solid var(--mb-bord);
border-radius: 6px;
}
.selection-compte {
font-size: 0.85rem;
font-weight: 600;
color: var(--mb-texte-doux);
}
/* Libellé réservé aux lecteurs d'écran : la case à cocher n'a plus de texte visible à côté
d'elle, mais elle doit continuer à dire ce qu'elle sélectionne. */
.lecture-ecran {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Le corps prend la place restante et « min-width: 0 » l'autorise à rétrécir :
sans lui, un titre long pousse le bouton hors de l'écran sur mobile. */
.carte-souhait .carte-corps,
@@ -804,6 +1060,17 @@ body {
align-self: center;
}
/* Les deux actions d'une envie, empilées : « Éditer » au-dessus de « Retirer ».
Elles ne s'étalent pas en largeur — sur un téléphone, la colonne d'actions doit rester
étroite pour laisser le titre respirer. */
.souhait-actions {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-self: center;
gap: 0.35rem;
}
.souhait-note {
margin: 0.25rem 0 0;
color: var(--mb-texte-doux);
@@ -823,6 +1090,23 @@ body {
font-weight: 400;
}
.oeuvre-titre-bouton {
width: 100%;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.detail-oeuvre {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem;
margin: 0.25rem 0 0;
}
.etiquette-possede {
background: #e3ece3;
color: #2f5d34;
@@ -833,6 +1117,8 @@ body {
background: #e6e6f5;
color: #3b3b7a;
font-weight: 600;
}
/* --- Mise à jour de l'application --- */
/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher
@@ -867,3 +1153,587 @@ body {
font-weight: 600;
cursor: pointer;
}
/* Un ISBN se relit chiffre à chiffre : une chasse fixe évite de confondre 1 et l,
0 et O, et rend les tranches séparées par des tirets alignées d'une ligne à l'autre. */
/* PAS de « white-space: nowrap » ici, et ce n'est pas un oubli. Un ISBN à tirets mesure
147 px en monospace, la colonne des valeurs de la fiche n'en offre que 111 à 375 px et
56 à 320 px : « nowrap » le faisait déborder, et avec lui TOUTE la page 395 px de contenu
pour un écran de 320, mesuré le 2026-08-21. Il annulait au passage le « word-break » dont
« .fiche-champs dd » est justement muni pour cela.
Le défaut de CSS est exactement ce qu'on veut : le code tient sur une seule ligne quand il y
a la place, et ne se coupe qu'à un tiret quand il n'y en a pas le tiret étant le seul
endroit une coupure se relit. */
.code-isbn {
font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
}
/* Titre d'écran et son bouton de réglages sur la même ligne : le bouton « Filtrer » du
catalogue appartient au titre, pas au contenu. */
.entete-page {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.entete-page .titre-page {
margin-bottom: 0.5rem;
}
/* --- Liste d'envies : ordre choisi --- */
/* Colonne de déplacement à gauche de chaque envie. Les flèches sont larges à dessein :
c'est le seul moyen de réordonner au doigt, le glisser-déposer HTML5 n'existant pas
sur mobile. */
.rang-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
flex: 0 0 auto;
}
.rang-actions .bouton {
padding: 0.15rem 0.5rem;
line-height: 1.1;
}
.rang-numero {
font-size: 0.75rem;
color: var(--mb-texte-doux);
font-variant-numeric: tabular-nums;
}
/* La carte saisie s'efface pendant le glissé : sans ce repère, on ne sait plus laquelle
on déplace une fois le curseur loin de son point de départ. */
.carte-glissee {
opacity: 0.4;
}
/* Trois manières de chercher, sur une ligne : réutilise l'apparence des segments du
catalogue plutôt que d'inventer un troisième style de bascule. */
.onglets-secondaires {
display: flex;
gap: 0.25rem;
margin: 0.75rem 0;
}
.onglets-secondaires .segment {
flex: 1 1 0;
}
/* --- Sagas, cycles et séries --- */
.liste-series,
.liste-tomes,
/*
Arbre des séries : une simple imbrication de listes, indentée par un liseré vertical.
Volontairement dépouillé pas de carte, pas de couverture, pas de tome. L'arbre répond
à « qui contient quoi », et l'écran d'une série répond au reste. La version précédente
dépliait la liste complète des livres de chaque nœud, ce qui noyait la hiérarchie qu'elle
était censée montrer.
*/
.arbre,
.arbre-enfants {
list-style: none;
margin: 0;
padding: 0;
}
.arbre-enfants {
/* Le liseré remplace les traits d'un arbre dessiné : il suffit à rattacher visuellement
une descendance à son parent, sans pseudo-éléments à positionner. */
margin-left: 0.7rem;
padding-left: 0.8rem;
border-left: 2px solid var(--mb-bord);
}
.arbre-ligne {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
padding: 0.2rem 0;
/* Même piège qu'au lot M : la ligne est elle-même un élément de flex (son <li> l'est),
et « min-width: auto » lui interdit de descendre sous sa largeur intrinsèque. Sans cette
ligne, un titre de sous-série indenté pousse le compte « 0 sur 2 tomes » hors de l'écran
et fait défiler la page horizontalement (mesuré à 320 px : 328 px de large). */
min-width: 0;
}
.arbre-plier,
.arbre-puce {
flex: 0 0 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
padding: 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
background: transparent;
border: 0;
}
.arbre-plier {
cursor: pointer;
}
.arbre-titre {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: #1d2129;
text-decoration: none;
}
.arbre-titre:hover,
.arbre-titre:focus-visible {
text-decoration: underline;
}
.arbre-compte {
flex: 0 0 auto;
font-size: 0.8rem;
color: var(--mb-texte-doux);
white-space: nowrap;
}
.arbre-actions {
flex: 0 0 auto;
display: flex;
gap: 0.25rem;
}
.liste-sous-series {
list-style: none;
margin: 0.5rem 0;
padding: 0;
}
.carte-serie {
padding: 0.7rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/* Ligne entièrement cliquable (lot V) : le curseur doit le dire, sans quoi seul le titre a
l'air de mener quelque part. Le survol marque la ligne visée, utile à la souris comme au
clavier lorsque le lien de titre prend le focus. */
.carte-cliquable {
cursor: pointer;
}
.carte-cliquable:hover,
.carte-cliquable:focus-within {
background: var(--mb-fond-doux);
}
.liste-sous-series {
margin: 0.35rem 0 0 1rem;
font-size: 0.9rem;
}
.liste-sous-series li {
padding: 0.2rem 0;
display: flex;
gap: 0.5rem;
align-items: baseline;
flex-wrap: wrap;
}
.carte-tome {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.6rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/* Le tome absent est estompé, comme une œuvre déjà possédée l'est dans la bibliographie
mais l'opacité ne dit rien à un lecteur d'écran, d'où l'étiquette qui l'accompagne. */
.tome-manquant .carte-titre {
font-weight: 400;
color: var(--mb-texte-doux);
}
.etiquette-manquant {
background: #f3e2e2;
color: #7a2f2f;
font-weight: 600;
}
.actions-tome {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.35rem;
}
/* Rattacher un livre à une place : la recherche s'ouvre SOUS le tome concerné, pour qu'on voie
ce qu'on est en train de compléter. */
.recherche-livre {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--mb-fond-doux);
border-radius: 6px;
}
.recherche-livre .bouton {
text-align: left;
}
.fiche-serie {
margin: 0.2rem 0;
font-size: 0.9rem;
color: var(--mb-texte-doux);
}
/* --- Revues et numéros --- */
.liste-numeros {
list-style: none;
margin: 0.5rem 0;
padding: 0;
}
.carte-numero {
display: flex;
gap: 0.6rem;
align-items: flex-start;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/* Numéro, date et note tiennent sur une ligne dès qu'il y a la place, et se replient sinon :
la saisie d'un numéro est répétitive, on ne veut pas la faire défiler. */
.champs-numero {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.champs-numero .champ {
flex: 1 1 8rem;
}
/* --- Rôles des auteurs (scénario, dessin, couleurs) --- */
.role-auteur {
color: var(--mb-texte-doux);
font-size: 0.85em;
}
/* ------------------------------------------------------------------
Menu principal (bandeau)
Ces règles sont ICI, en feuille GLOBALE, et non dans
`Layout/MainLayout.razor.css`. Deux raisons :
1. `NavLink` est un composant : l'isolation CSS ne pose son attribut de
portée que sur le balisage du composant courant, jamais sur ce que rend
un composant enfant. Déclarés en feuille scopée, `.menu-lien` n'atteignait
AUCUN des six liens le `<nav>` prenait son fond sombre, les liens
restaient en bleu souligné par défaut.
2. La navigation ne doit dépendre ni du bundle d'isolation ni de son cache.
Le repli sans style reste une suite de liens lisibles les uns sous les
autres : c'est tout l'intérêt du gabarit retenu le 2026-08-20.
------------------------------------------------------------------ */
/*
Bandeau et menu collent ENSEMBLE en haut de l'écran (lot R) : sur PC, la rangée des six
destinations défilait avec la page et devenait inatteignable dès qu'on descendait dans une
longue liste.
Un seul conteneur collant, et non deux éléments collants superposés : ceux-ci auraient
obligé à écrire en dur la hauteur du bandeau dans le `top` du menu, hauteur qui varie avec
la pastille hors-ligne et le nom d'utilisateur.
*/
.entete {
position: sticky;
top: 0;
z-index: 10;
}
.menu {
display: none;
flex-direction: column;
background: #16304c;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
/*
Téléphone : le menu déployé occupe l'écran ENTIER.
Déployé sous le bandeau, il partageait l'écran avec la liste qu'on quittait, et l'on
choisissait sa destination au milieu d'autre chose. En plein écran, les six destinations se
lisent d'un coup d'œil.
Le calque garde une porte de sortie explicite la croix ci-dessous, Échap, et le clic
hors des liens (posés dans MainLayout.razor), en plus de la fermeture sur LocationChanged.
Sans elles, un plein écran est un piège.
*/
.menu-ouvert {
display: flex;
position: fixed;
inset: 0;
z-index: 50;
overflow-y: auto;
padding-bottom: 2rem;
/* Le calque prend le focus à l'ouverture : sans cela Échap ne lui parviendrait pas. Le
contour du navigateur n'apprend rien ici, la croix étant visible. */
outline: none;
}
.menu-entete {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.menu-titre {
color: #e8edf3;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* Cible tactile de 2.25rem, comme les boutons du bandeau : on la touche au pouce. */
.menu-fermer {
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;
}
.menu-fermer:hover,
.menu-fermer:focus-visible {
background: rgba(255, 255, 255, 0.15);
}
.menu-lien {
display: block;
padding: 0.7rem 1rem;
color: #e8edf3;
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
border-left: 4px solid transparent;
}
.menu-lien:hover,
.menu-lien:focus-visible {
background: rgba(255, 255, 255, 0.1);
color: #fff;
text-decoration: none;
}
/* NavLink pose « active » tout seul ; « Catalogue » utilise NavLinkMatch.All,
sans quoi il resterait allumé sur toutes les pages son href est la racine. */
.menu-lien.active {
color: #fff;
font-weight: 600;
background: rgba(255, 255, 255, 0.16);
border-left-color: #f5d76e;
}
/*
« À propos » est une annexe, pas une septième destination : un filet la sépare des six
entrées de la bibliothèque, et elle se porte en retrait. Elle reste un lien de plein droit
même cible tactile, même état actif parce qu'on la touche au pouce comme les autres.
*/
.menu-lien-annexe {
margin-top: 0.35rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(232, 237, 243, 0.8);
font-weight: 400;
font-size: 0.9rem;
}
/*
À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus
de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css`
(qui y masque la bascule) les deux doivent rester identiques.
*/
@media (min-width: 40rem) {
.menu {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0 0.5rem;
}
/*
Le plein écran est ANNULÉ ici : sans cela, un menu ouvert au doigt puis un
agrandissement de la fenêtre laisserait un calque plein écran sur un PC, sans bascule
pour le refermer (elle est masquée à partir de cette largeur).
*/
.menu-ouvert {
position: static;
overflow-y: visible;
padding-bottom: 0;
}
.menu-entete {
display: none;
}
.menu-lien {
padding: 0.6rem 0.9rem;
border-left: 0;
border-bottom: 3px solid transparent;
}
.menu-lien.active {
background: rgba(255, 255, 255, 0.12);
border-bottom-color: #f5d76e;
}
/* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge
supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */
.menu-lien-annexe {
margin-top: 0;
margin-left: 0.4rem;
padding-left: 0.9rem;
border-top: 0;
border-left: 1px solid rgba(255, 255, 255, 0.25);
}
}
/* --- Attente (lot L1) -------------------------------------------------
Un appel lent doit se voir : une recherche BnF 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, ce qui relance l'appel.
------------------------------------------------------------------- */
.attente {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mb-texte-doux);
font-size: 0.9rem;
margin: 0.75rem 0;
}
.attente-rondelle {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
border: 2px solid var(--mb-bord);
border-top-color: var(--mb-accent);
border-radius: 50%;
animation: attente-tourne 0.8s linear infinite;
}
@keyframes attente-tourne {
to { transform: rotate(360deg); }
}
/* Une animation perpétuelle est précisément ce que ce réglage demande d'éviter.
L'indicateur reste visible — c'est lui qui porte l'information, pas sa rotation. */
@media (prefers-reduced-motion: reduce) {
.attente-rondelle {
animation: none;
border-top-color: var(--mb-accent);
border-right-color: var(--mb-accent);
}
}
/* --- Articles à la une d'un numéro (lot O2) ---------------------------
Une couleur à part, parce que ce n'est pas un thème : un titre d'article
est unique à sa parution et ne se réutilise jamais, un thème est un
vocabulaire commun au catalogue.
------------------------------------------------------------------- */
.etiquette-une {
background: #eef2f7;
color: #24405e;
font-style: italic;
}
/* Saisie d'une liste (thèmes, auteurs, articles à la une) : une valeur par ligne, son texte
à gauche et ses deux actions à droite.
min-width: 0 sur la valeur est ce qui fait tenir la ligne à 320 px un titre d'article
est long, et sans lui il pousserait « Supprimer » hors de l'écran (même leçon que la ligne
de création du lot M1, et que l'arbre des séries). */
.liste-saisie {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
}
.ligne-saisie {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0;
border-bottom: 1px solid var(--mb-bord);
}
.ligne-saisie-valeur {
flex: 1 1 8rem;
min-width: 0;
overflow-wrap: anywhere;
}
.ligne-saisie .actions-tome {
flex: 0 0 auto;
margin-top: 0;
}
/* Les rôles d'un auteur passent à la ligne sous son nom dès que la place manque : trois
segments et deux boutons ne tiennent pas sur une ligne de téléphone. */
.segments-role {
flex: 1 1 100%;
margin: 0;
}
/* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ----
min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans
lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque
et pousse le bouton hors de l'écran. Le bouton, lui, ne rétrécit pas et son
libellé ne se coupe pas un bouton tronqué ne se lit plus.
------------------------------------------------------------------- */
.ligne-creation {
display: flex;
gap: 0.5rem;
align-items: stretch;
margin-bottom: 0.75rem;
}
.ligne-creation .champ-saisie {
flex: 1 1 auto;
min-width: 0;
}
.ligne-creation .bouton {
flex: 0 0 auto;
white-space: nowrap;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+14 -2
View File
@@ -6,11 +6,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaBibli</title>
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<!-- Bootstrap REBOOT seul, pas Bootstrap complet. Aucune classe Bootstrap n'apparait
dans le code Razor : ce qui servait, c'etait uniquement le socle (box-sizing:
border-box, la police de base, l'interlignage, la normalisation des boutons et
des champs), qu'app.css ne pose pas lui-meme. Verifie le 2026-08-22 en comparant
les styles calcules sur 12 routes et 785 elements : ZERO ecart entre le complet
et le reboot. 228 Ko -> 10 Ko (31 Ko -> 3,2 Ko une fois compresse).
Voir CLAUDE.md, « Bootstrap reduit a son reboot ». -->
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap-reboot.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="MaBibli.Client.styles.css" rel="stylesheet" />
<link href="manifest.webmanifest" rel="manifest" />
<!-- crossorigin="use-credentials" n'est PAS décoratif : sans lui, le navigateur demande le
manifeste SANS cookie, SSOwat ne reconnaît pas la session et redirige vers le portail
YunoHost — la réponse ne porte alors aucun Access-Control-Allow-Origin, et le manifeste
est purement et simplement bloqué. Voir CLAUDE.md, « Le manifeste PWA se demandait
sans cookie ». -->
<link href="manifest.webmanifest" rel="manifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
</head>
+92 -1
View File
@@ -13,7 +13,11 @@
const NOM_BASE = 'mabibli';
const MAGASIN = 'instantanes';
const VERSION = 1;
// Couvertures mises en cache (A5, IDEES.md) : magasin séparé, clé = l'URL OpenLibrary,
// valeur = le Blob de l'image. Sert uniquement à la consultation hors-ligne — en ligne,
// l'<img> normale reste la voie rapide, ce cache se remplit en tâche de fond à côté.
const MAGASIN_COUVERTURES = 'couvertures';
const VERSION = 2;
let promesseBase = null;
@@ -29,6 +33,7 @@ function ouvrir() {
requete.onupgradeneeded = () => {
const base = requete.result;
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES);
};
requete.onsuccess = () => resoudre(requete.result);
requete.onerror = () => rejeter(requete.error);
@@ -84,3 +89,89 @@ export function surveiller(reference) {
self.addEventListener('offline', prevenir);
return navigator.onLine !== false;
}
// --- Couvertures (A5, IDEES.md) ---
//
// En ligne, l'<img> normale reste la voie d'affichage : rien ici ne doit la ralentir. Cette
// fonction tourne en tâche de fond, appelée sans attendre son résultat (fire-and-forget) après
// qu'une couverture s'est affichée avec succès. Le second fetch profite en général du cache HTTP
// du navigateur (même URL que l'<img>) : pas de second téléchargement réel dans le cas courant.
//
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
// suffit, pas besoin du contournement `no-cors`/réponse opaque.
//
// ⚠️ Mais le formulaire livre accepte N'IMPORTE QUELLE URL de couverture, et la plupart des
// hébergeurs n'envoient aucun en-tête CORS. Ces images s'affichaient bien — une <img> n'a que
// faire du CORS — sans jamais pouvoir être mises en cache, et le fetch repartait à CHAQUE
// affichage puisque rien n'était jamais rangé. D'où le repli par `/api/couvertures`, qui relaie
// l'image en MÊME ORIGINE (voir ServiceCouvertures côté serveur, et ses deux verrous anti-SSRF).
//
// ⚠️ L'ordre compte : on tente d'abord l'URL directe, qui profite du cache HTTP du navigateur
// (même URL que l'<img> déjà chargée) et n'impose rien à notre serveur. Le relais n'entre en jeu
// que pour ce que le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne conviendrait pas :
// son corps est illisible, donc impossible à ranger en IndexedDB.
export async function couvertureMettreEnCache(url) {
try {
const base = await ouvrir();
const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly');
const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx);
if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement.
let reponse = null;
try {
reponse = await fetch(url, { cache: 'force-cache' });
} catch (eDirect) {
// Bloqué par le CORS, ou hôte injoignable : on ne sait pas lequel, et peu importe.
}
// ⚠️ Le relais ne rattrape QU'UN ACCÈS EMPÊCHÉ, jamais une image absente. Un 404 (ou un
// 410) est une réponse : l'hôte a parlé, et il a dit qu'il n'avait rien — or le relais
// ira chercher la MÊME url, il ne peut donc pas faire mieux. L'appeler quand même
// produit un second 404, de notre serveur cette fois, sur toute couverture
// qu'OpenLibrary ne connaît pas (« ?default=false » les rend franches, c'est voulu).
// C'est ce qui se voyait en console sur la bibliographie d'un auteur, à chaque
// affichage puisque rien ne se met jamais en cache.
const absente = reponse !== null && (reponse.status === 404 || reponse.status === 410);
if (!absente && (!reponse || !reponse.ok)) {
reponse = await fetch('/api/couvertures?url=' + encodeURIComponent(url));
}
// 404/502 intermittent d'OpenLibrary (CLAUDE.md), ou relais refusé : rien à ranger.
if (!reponse.ok) return;
const blob = await reponse.blob();
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
await attendre(ecriture.objectStore(MAGASIN_COUVERTURES).put(blob, url), ecriture);
} catch (e) {
// Quota dépassé, stockage refusé : une couverture non mise en cache reste un
// agrément perdu, jamais une raison de faire échouer l'affichage en ligne.
}
}
// Renvoie une URL d'objet (`blob:`) utilisable comme `src`, ou `null` si cette couverture n'a
// jamais été mise en cache — c'est alors à l'appelant de retomber sur l'URL réseau (qui échouera
// simplement si hors-ligne, exactement comme aujourd'hui).
export async function couvertureLire(url) {
try {
const base = await ouvrir();
const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly');
const blob = await attendre(tx.objectStore(MAGASIN_COUVERTURES).get(url), tx);
return blob ? URL.createObjectURL(blob) : null;
} catch (e) {
return null;
}
}
// Libère une URL d'objet rendue par `couvertureLire`. ⚠️ Indispensable depuis que le cache sert
// AUSSI en ligne : chaque `createObjectURL` retient son Blob en mémoire jusqu'à ce qu'on le
// révoque ou que la page se ferme. Hors-ligne seul, la fuite restait bornée à une consultation
// ponctuelle ; sur toutes les vignettes de tous les écrans, elle ne l'est plus.
export function couvertureLiberer(url) {
try {
if (url) URL.revokeObjectURL(url);
} catch (e) {
// Rien à faire : libérer est un agrément, jamais une condition de bon fonctionnement.
}
}
+78 -19
View File
@@ -1,4 +1,4 @@
// Enregistrement du service worker et bandeau de mise à jour.
// Enregistrement du service worker, application automatique au démarrage, bandeau en secours.
//
// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) :
//
@@ -7,11 +7,16 @@
// empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne,
// mais il est silencieux et différé : le nouveau worker attend que TOUS les onglets de
// l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que
// personne comprenne pourquoi. D'où un bandeau explicite, avec un bouton qui l'applique.
// personne comprenne pourquoi.
//
// 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale).
// L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un
// défaut de la PWA alors que c'est le contexte qui n'est pas éligible.
//
// ⚠️ Le clic n'est plus obligatoire (décidé le 2026-08-20, après un blocage constaté sur
// l'application installée sur téléphone). CLAUDE.md actait « jamais tout seul » pour ne pas
// recharger sous une saisie en cours ; la raison reste valable, mais elle ne vaut que pour une
// session déjà entamée. Voir FENETRE_DEMARRAGE ci-dessous.
(function () {
'use strict';
@@ -23,49 +28,103 @@
return;
}
// Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur
// survient aussi à la toute première installation, et recharger la page à ce moment-là
// serait un clignotement inexplicable.
let demandee = false;
// Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander.
// Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un
// prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son
// travail. Ouvrir l'application depuis l'écran d'accueil du téléphone tombe toujours dans
// cette fenêtre — c'est exactement le cas qui ne se mettait plus à jour.
var FENETRE_DEMARRAGE = 10000;
// Si la reprise en main n'a pas lieu, on recharge quand même : le pire scénario est de
// laisser l'utilisateur devant un bouton grisé qui ne fait rien.
var DELAI_REPRISE = 5000;
// Une seule reprise forcée par session : si la nouvelle version n'arrive pas à s'activer,
// recharger en boucle transformerait une mise à jour ratée en application inutilisable.
var CLE_FORCE = 'mb-maj-forcee';
var demandee = false;
var recharge = false;
function recharger() {
if (recharge) return;
recharge = true;
window.location.reload();
}
function appliquer(enAttente) {
demandee = true;
// Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
enAttente.postMessage({ type: 'SKIP_WAITING' });
// Filet. `controllerchange` devrait suivre ; s'il ne vient pas (worker en attente issu
// d'une version antérieure au gestionnaire SKIP_WAITING, reprise en main qui échoue),
// rien ne se passerait du tout et le bouton resterait grisé pour toujours.
window.setTimeout(function () {
if (recharge) return;
var dejaForcee = false;
try {
dejaForcee = window.sessionStorage.getItem(CLE_FORCE) === '1';
window.sessionStorage.setItem(CLE_FORCE, '1');
} catch (e) {
// Navigation privée ou stockage refusé : on préfère ne pas forcer plutôt que
// risquer une boucle de rechargement qu'on ne saurait plus arrêter.
dejaForcee = true;
}
if (!dejaForcee) recharger();
}, DELAI_REPRISE);
}
function proposerLaMiseAJour(enAttente) {
if (document.getElementById('mb-maj')) return;
const barre = document.createElement('div');
var barre = document.createElement('div');
barre.id = 'mb-maj';
barre.setAttribute('role', 'status');
barre.textContent = 'Une nouvelle version de MaBibli est disponible. ';
const bouton = document.createElement('button');
var bouton = document.createElement('button');
bouton.type = 'button';
bouton.textContent = 'Mettre à jour';
bouton.addEventListener('click', function () {
bouton.disabled = true;
demandee = true;
// Le worker en attente prend la main sans qu'on ait à fermer tous les onglets.
enAttente.postMessage({ type: 'SKIP_WAITING' });
bouton.textContent = 'Mise à jour…';
appliquer(enAttente);
});
barre.appendChild(bouton);
document.body.appendChild(barre);
}
navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' })
// Au démarrage : on applique sans demander. Plus tard : on propose.
function traiter(enAttente) {
if (performance.now() < FENETRE_DEMARRAGE) {
appliquer(enAttente);
} else {
proposerLaMiseAJour(enAttente);
}
}
// ⚠️ Chemin ABSOLU, et scope explicite. `register('service-worker.js')` se résout contre
// l'URL du DOCUMENT et non contre <base href="/"> : ouvrir l'application sur une route
// profonde (/souhaits, /livres/3) visait /souhaits/service-worker.js, que le serveur ne sert
// pas — l'enregistrement échouait alors silencieusement, et avec lui tout le hors-ligne.
navigator.serviceWorker.register('/service-worker.js', { scope: '/', updateViaCache: 'none' })
.then(function (enregistrement) {
// Un worker déjà installé attendait peut-être depuis la visite précédente.
if (enregistrement.waiting && navigator.serviceWorker.controller) {
proposerLaMiseAJour(enregistrement.waiting);
traiter(enregistrement.waiting);
}
enregistrement.addEventListener('updatefound', function () {
const nouveau = enregistrement.installing;
var nouveau = enregistrement.installing;
if (!nouveau) return;
nouveau.addEventListener('statechange', function () {
// `controller` non nul = ce n'est pas la première installation, donc il y a
// bien une version précédente à remplacer.
if (nouveau.state === 'installed' && navigator.serviceWorker.controller) {
proposerLaMiseAJour(nouveau);
traiter(nouveau);
}
});
});
@@ -80,10 +139,10 @@
console.error("Échec de l'enregistrement du service worker :", erreur);
});
let recharge = false;
// Le rechargement n'a lieu que si la mise à jour a été demandée — automatiquement ou par un
// clic. Un changement de contrôleur survient aussi à la toute première installation, et
// recharger la page à ce moment-là serait un clignotement inexplicable.
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (!demandee || recharge) return;
recharge = true;
window.location.reload();
if (demandee) recharger();
});
})();
+77 -18
View File
@@ -1,12 +1,41 @@
// Rôle unique de ce fichier : ouvrir la caméra et fournir des pixels à C#.
// AUCUN décodage ici — il est fait par ZXing.Net côté C# (décision actée dans CLAUDE.md).
// getUserMedia et canvas/getImageData n'ont pas d'équivalent C#, d'où ces quelques lignes.
// Rôle de ce fichier : ouvrir la caméra ET décoder les codes-barres avec zbar.
//
// ⚠️ Le décodage était en C# (ZXing.Net) jusqu'au 2026-08-19. Il en est sorti parce que
// ZXing.Net ne lisait pas des codes-barres que zbar lit, sur le même livre et le même
// appareil (voir CLAUDE.md). Conséquence heureuse : les pixels ne traversent plus le pont
// JS→C#, seule la valeur décodée le fait.
//
// On n'utilise PAS le composant ZBarCamera du paquet : il ouvre la caméra lui-même et avale
// les erreurs, alors que les messages ci-dessous sont ce qui permet à l'utilisateur de savoir
// ce qui lui manque. On n'emprunte au paquet que zbar.js et zbar.wasm.
let flux = null;
let video = null;
let canvas = null;
let ctx = null;
let derniersPixels = null;
let scanner = null;
// Chargé à la première ouverture du scanner, pas au démarrage de l'application : zbar.wasm
// pèse ~139 Ko brotli, que personne n'a à télécharger pour consulter sa bibliothèque.
const CHEMIN_ZBAR = '_content/ZBar.Blazor/zbar.js';
let chargementZbar = null;
function chargerZbar() {
if (window.zbar) return Promise.resolve();
// zbar.js n'est pas un module ES : il pose window.zbar. D'où l'injection de balise
// plutôt qu'un import().
chargementZbar ??= new Promise((resolve, reject) => {
const balise = document.createElement('script');
balise.src = CHEMIN_ZBAR;
balise.onload = resolve;
balise.onerror = () => reject(new Error('zbar.js introuvable'));
document.head.appendChild(balise);
});
return chargementZbar;
}
// getUserMedia exige un contexte sécurisé : HTTPS, ou localhost.
// Une IP de réseau local (192.168.x.x) en http NE l'est PAS — piège classique en dev.
@@ -44,6 +73,23 @@ export async function demarrer(element) {
return 'erreur:' + (e.name || 'Error') + ' — ' + (e.message || '');
}
}
try {
await chargerZbar();
scanner = await window.zbar.ZBarScanner.create();
// ⚠️ L'add-on EAN-2 est DÉSACTIVÉ par défaut dans zbar : sans cette ligne il n'est pas
// seulement filtré, il n'est jamais produit. Mesuré le 2026-08-19 sur un EAN-13 + EAN-2
// rendus en canvas — scanner par défaut : un seul symbole ; EAN-2 activé : deux.
// C'est lui qui porte le numéro de parution d'un périodique.
// L'EAN-5 reste désactivé : il porte un PRIX, dont on n'a rien à faire, et un symbole
// de moins est un symbole de moins à écarter.
scanner.setConfig(
window.zbar.ZBarSymbolType.ZBAR_EAN2, window.zbar.ZBarConfigType.ZBAR_CFG_ENABLE, 1);
} catch (e) {
return 'erreur:Decodeur — ' + (e.message || 'zbar indisponible');
}
video.srcObject = flux;
video.setAttribute('playsinline', '');
await video.play();
@@ -52,25 +98,37 @@ export async function demarrer(element) {
return 'ok';
}
// Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si
// la vidéo n'est pas encore prête. Les pixels se récupèrent ensuite via pixels().
export function capturer(largeurMax, partHauteur) {
if (!video || !ctx || video.readyState < 2 || !video.videoWidth) return null;
// Capture une image et la décode. Renvoie { code, parution }, ou null si rien n'est lu.
//
// ⚠️ L'image ENTIÈRE est analysée, contrairement à la version ZXing.Net qui n'envoyait que la
// bande centrale (45 % de la hauteur) pour limiter ce qui traversait le pont. Ce recadrage était
// l'une des deux causes possibles des codes non lus ; le décodage étant maintenant côté JS, il
// n'a plus de raison d'être.
export async function decoder(largeurMax) {
if (!video || !ctx || !scanner || video.readyState < 2 || !video.videoWidth) return null;
const echelle = Math.min(1, largeurMax / video.videoWidth);
const l = Math.round(video.videoWidth * echelle);
const h = Math.round(video.videoHeight * echelle);
if (canvas.width !== l || canvas.height !== h) { canvas.width = l; canvas.height = h; }
ctx.drawImage(video, 0, 0, l, h);
// On ne renvoie que la bande centrale, celle que l'utilisateur vise : moins de pixels
// à transférer et à décoder, donc moins de travail par frame côté C#.
const hb = Math.max(1, Math.round(h * partHauteur));
const y = Math.round((h - hb) / 2);
derniersPixels = new Uint8Array(ctx.getImageData(0, y, l, hb).data.buffer);
return [l, hb];
}
export function pixels() {
return derniersPixels;
const symboles = await window.zbar.scanImageData(ctx.getImageData(0, 0, l, h), scanner);
if (!symboles || symboles.length === 0) return null;
const typeDe = s => window.zbar.ZBarSymbolType[s.type];
// Les add-ons EAN-2 et EAN-5 sont imprimés À CÔTÉ du code principal et remontent comme des
// symboles distincts. Le code du livre — ou de la revue — n'est jamais l'un d'eux.
const principal = symboles.find(s => typeDe(s) !== 'ZBAR_EAN2' && typeDe(s) !== 'ZBAR_EAN5');
if (!principal) return null;
// L'EAN-2 porte le numéro de parution d'un périodique ; l'EAN-5 porte un PRIX et n'a rien à
// faire ici. On ne remonte donc que le premier, et seulement pour information : c'est
// l'écran qui décidera d'en faire quelque chose, et l'utilisateur qui confirmera.
const ean2 = symboles.find(s => typeDe(s) === 'ZBAR_EAN2');
return { code: principal.decode(), parution: ean2 ? ean2.decode() : null };
}
// Libérer la caméra : sinon le voyant reste allumé et la batterie se vide,
@@ -78,5 +136,6 @@ export function pixels() {
export function arreter() {
if (flux) { flux.getTracks().forEach(t => t.stop()); flux = null; }
if (video) { video.srcObject = null; video = null; }
canvas = null; ctx = null; derniersPixels = null;
if (scanner) { scanner.destroy(); scanner = null; }
canvas = null; ctx = null;
}

Some files were not shown because too many files have changed in this diff Show More