Compare commits
2
Commits
2f1e854ea3
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb071c3db9 | ||
|
|
6a6d745af4 |
@@ -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
|
||||
@@ -13,6 +13,9 @@ publish/
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
|
||||
Generated
-15
@@ -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
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
MaBibli
|
||||
-12
@@ -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
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+37
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-242
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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><img></c> n'a que faire du CORS) sans jamais
|
||||
/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même
|
||||
/// origine, et la question du CORS disparaît.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
|
||||
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre, d'une envie ou d'un
|
||||
/// numéro de revue. Le proxy
|
||||
/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
|
||||
/// l'application détient déjà.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
|
||||
/// (<see cref="GardeAdresses"/>). C'est ce second verrou qui tient si quelqu'un enregistre
|
||||
/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait
|
||||
/// refusée quand même.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Tout refus est indistinct</b> côté appelant (voir l'endpoint) : distinguer « pas en
|
||||
/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
|
||||
{
|
||||
/// <summary>
|
||||
/// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce
|
||||
/// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros
|
||||
/// fichier ne puisse pas remplir la mémoire du serveur.
|
||||
/// </summary>
|
||||
public const int TailleMaximale = 5 * 1024 * 1024;
|
||||
|
||||
public async Task<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)
|
||||
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
|| uri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
// https uniquement : l'application est servie en https par YunoHost, une image en
|
||||
// clair y serait de toute façon bloquée par le navigateur (contenu mixte).
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
if (!await EstConnueAsync(url, ct))
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
// Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond
|
||||
// 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à
|
||||
// la connexion — c'est lui qui borne, pas l'interdiction de rediriger.
|
||||
if (!reponse.IsSuccessStatusCode)
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
var type = reponse.Content.Headers.ContentType?.MediaType;
|
||||
if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
if (reponse.Content.Headers.ContentLength > TailleMaximale)
|
||||
{
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
|
||||
var octets = await LireBorneAsync(reponse.Content, ct);
|
||||
|
||||
return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non
|
||||
// relayée reste un agrément perdu, jamais une erreur à propager.
|
||||
return ResultatCouverture.Refusee;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
|
||||
/// valeur stockée qui autorise, pas une ressemblance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les numéros de revue en font partie</b> depuis qu'ils portent une couverture. Les
|
||||
/// oublier ici aurait produit exactement le défaut que ce service existe pour corriger :
|
||||
/// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant
|
||||
/// refusé par le relais.
|
||||
/// </remarks>
|
||||
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
|
||||
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|
||||
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct)
|
||||
|| await db.NumerosRevue.AnyAsync(n => n.CoverUrl == url, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
|
||||
/// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
|
||||
/// </remarks>
|
||||
private static async Task<byte[]?> LireBorneAsync(HttpContent contenu, CancellationToken ct)
|
||||
{
|
||||
await using var flux = await contenu.ReadAsStreamAsync(ct);
|
||||
using var tampon = new MemoryStream();
|
||||
|
||||
var morceau = new byte[81920];
|
||||
int lus;
|
||||
|
||||
while ((lus = await flux.ReadAsync(morceau, ct)) > 0)
|
||||
{
|
||||
if (tampon.Length + lus > TailleMaximale)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
tampon.Write(morceau, 0, lus);
|
||||
}
|
||||
|
||||
return tampon.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Xml.Linq;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Api.Services.Isbn;
|
||||
@@ -37,6 +39,13 @@ public sealed record ResultatBibliographie
|
||||
|
||||
/// <summary>Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé.</summary>
|
||||
public int EcarteesAutreAuteur { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'il est advenu de l'interrogation. ⚠️ À ne pas confondre avec « aucune notice » :
|
||||
/// une source muette rend elle aussi une liste vide, et il ne faut surtout pas les lire
|
||||
/// de la même façon.
|
||||
/// </summary>
|
||||
public EtatSourceBibliographie Etat { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,6 +180,77 @@ public static class BnfBibliographieParser
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convertit une réponse SRU interrogée <b>par titre</b> en candidats à choisir.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le filtre de type est <b>indispensable ici aussi</b>, et pour la même raison qu'en
|
||||
/// bibliographie : mesuré le 2026-08-18, <c>bib.title all "la bête humaine"</c> croisé avec
|
||||
/// Zola remonte 116 notices, dont les films de Jean Renoir (« image animée ») et une
|
||||
/// plaquette de présentation. Une liste d'envies de livres n'a pas à proposer un DVD.
|
||||
/// <para>
|
||||
/// ⚠️ En revanche <b>aucun post-filtre sur l'auteur</b>, contrairement à
|
||||
/// <see cref="Parser"/> : la contrainte de titre resserre déjà beaucoup, l'auteur est
|
||||
/// facultatif dans cette recherche, et surtout l'utilisateur <b>choisit dans la liste</b>
|
||||
/// en voyant les auteurs de chaque notice. Écarter d'office lui cacherait des éditions
|
||||
/// légitimes qu'il aurait su reconnaître.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// L'ISBN est repris de la notice quand elle en porte un : c'est lui qui donne la
|
||||
/// couverture, la BnF n'en fournissant aucune.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<CandidatLivre> ParserRecherche(string xml)
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var candidats = new List<CandidatLivre>();
|
||||
|
||||
foreach (var record in doc.Descendants(Srw + "record"))
|
||||
{
|
||||
var data = record.Element(Srw + "recordData");
|
||||
if (data is null || !EstUnLivre(data))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var auteurs = Tous(data, "creator")
|
||||
.Select(NettoyageIsbd.Auteur)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Select(a => a!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var titre = NettoyageIsbd.RetirerAuteurEnFin(
|
||||
NettoyageIsbd.Titre(Premier(data, "title")), auteurs);
|
||||
|
||||
if (titre is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isbn = IsbnUtils.Normaliser(Isbn(data));
|
||||
if (!IsbnUtils.EstValide(isbn))
|
||||
{
|
||||
isbn = null;
|
||||
}
|
||||
|
||||
candidats.Add(new CandidatLivre
|
||||
{
|
||||
Titre = titre,
|
||||
Auteurs = auteurs,
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Annee = Premier(data, "date"),
|
||||
Langue = Premier(data, "language"),
|
||||
Source = SourceMetadonnees.Bnf,
|
||||
IsbnInterroge = isbn,
|
||||
CoverUrl = isbn is null ? null : IsbnUtils.UrlCouverture(isbn),
|
||||
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return candidats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La notice décrit-elle un livre ?
|
||||
/// </summary>
|
||||
|
||||
@@ -21,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)
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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><input type="date"></c> produit une date sans fuseau. Elle est déclarée
|
||||
/// locale ici et convertie en UTC, comme toutes les dates du projet : sans cela la parution
|
||||
/// se décale d'un jour pour la moitié du globe. La conversion se fait dans le composant
|
||||
/// pour que les deux écrans qui s'en servent ne puissent pas l'oublier chacun de leur côté.
|
||||
/// </remarks>
|
||||
private DateTime? Parution
|
||||
{
|
||||
get => Saisie.DateParution?.ToLocalTime().Date;
|
||||
set
|
||||
{
|
||||
Saisie.DateParution = value is { } date
|
||||
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
|
||||
: null;
|
||||
|
||||
Notifier();
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter] public EventCallback<KeyboardEventArgs> OnEntree { get; set; }
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await OnEntree.InvokeAsync(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">←</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">✕</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@
|
||||
@using MaBibli.Shared.Catalogue
|
||||
@using MaBibli.Shared.Dtos
|
||||
@using MaBibli.Shared.Entites
|
||||
@using MaBibli.Shared.Isbn
|
||||
@using MaBibli.Shared.Textes
|
||||
|
||||
@@ -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 là où 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, là où 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 où 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, là où 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 |
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user