Cataloguer les revues : une fiche, ses numéros à l'intérieur

Clôt la décision du premier lot, qui écartait les périodiques faute de
modèle. Douze numéros d'un magazine ne font plus douze fiches identiques.

Table à part, pour la troisième fois et pour le même motif que la liste
d'envies et les séries : logée dans Livres, une revue entrerait dans le
catalogue, ses compteurs, les doublons et les bibliographies, et il
faudrait écrire « et qui n'est pas une revue » à chaque lecture.

Un numéro est recensé, rien de plus — ni prêt ni statut, tous deux liés à
Livre par clé étrangère. Réversible.

« Créer ou retrouver » et non « créer » : scanner le numéro suivant du même
magazine retombe forcément sur la même revue. L'unicité de l'ISSN est un
index partiel, sans quoi les revues sans ISSN se bloqueraient entre elles.

Le flux 977 mène désormais à la fiche de la revue, et ne pré-remplit plus
le formulaire d'un livre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:19:08 +02:00
co-authored by Claude Opus 5
parent 4e372ab694
commit ca0a9dc18c
20 changed files with 2110 additions and 42 deletions
+69 -15
View File
@@ -86,6 +86,20 @@
@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)
@@ -183,6 +197,7 @@
private bool _focusAFaire = true;
private string? _messagePeriodique;
private PeriodiqueDetecte? _periodique;
private bool _chargement;
private bool _enregistrement;
private string? _erreur;
@@ -232,6 +247,7 @@
{
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_avertissements = [];
_etape = Etape.Scan;
}
@@ -258,6 +274,7 @@
_chargement = true;
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_avertissements = [];
_etape = Etape.Recherche;
@@ -314,33 +331,69 @@
}
/// <summary>
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle.
/// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
/// </summary>
/// <remarks>
/// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est
/// pré-rempli, mais <b>pas l'ISBN</b> — le code-barres d'une revue n'en est pas un, et le
/// ranger dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche.
/// ⚠️ <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. "
+ "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, "
+ "sans ISBN.";
_saisie = new EnregistrementLivre
{
Titre = revue.Titre ?? string.Empty,
Editeur = revue.Editeur,
Format = Format.Physique,
Statut = Statut.ALire,
};
+ "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner.";
_erreur = null;
_etape = Etape.Formulaire;
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;
}
Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}");
}
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
@@ -371,6 +424,7 @@
{
_erreur = null;
_messagePeriodique = null;
_periodique = null;
_erreurFormulaire = null;
_doublons = null;
_candidats = [];
+302
View File
@@ -0,0 +1,302 @@
@page "/revues/{Id:int}"
@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
{
<h1 class="titre-page">@_revue.Titre</h1>
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
{
<span class="code-isbn">ISSN @_revue.Issn</span>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
<span>@_revue.Editeur</span>
}
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</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. *@
<ul class="liste-numeros">
@foreach (var numero in _revue.Numeros)
{
<li class="carte-numero" @key="numero.Id">
<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>
@if (!string.IsNullOrWhiteSpace(numero.Note))
{
<p class="souhait-note">@numero.Note</p>
}
</div>
<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)">
Retirer
</button>
</li>
}
</ul>
}
<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.
*@
<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>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
</button>
</div>
<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)</span></span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils n'existent
que par leur revue.
</p>
}
}
@code {
[Parameter] public int Id { get; set; }
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;
private string _numero = string.Empty;
private DateTime? _parution;
private string? _note;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
private void SurChangementReseau() => _ = InvokeAsync(async () =>
{
await ChargerAsync();
StateHasChanged();
});
public void Dispose() => Reseau.Change -= SurChangementReseau;
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;
}
}
private void Appliquer(RevueDto revue)
{
_revue = revue;
_titre = revue.Titre;
_issn = revue.Issn;
_editeur = revue.Editeur;
}
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_numero) && Reseau.EnLigne)
{
await AjouterAsync();
}
}
private async Task AjouterAsync()
{
_enCours = true;
_erreur = null;
// Comme pour les prêts : le <input type="date"> produit une date SANS fuseau. Elle est
// déclarée locale avant conversion, sans quoi la parution se décalerait d'un jour pour
// la moitié du globe.
var parution = _parution is { } date
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
var resultat = await Api.AjouterNumeroAsync(Id, new AjoutNumeroRevue
{
Numero = _numero,
DateParution = parution,
Note = _note,
});
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_numero = string.Empty;
_parution = null;
_note = null;
}
private async Task RetirerAsync(NumeroRevueDto numero)
{
_enCours = true;
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
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!);
}
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");
}
}
}
+134
View File
@@ -0,0 +1,134 @@
@page "/revues"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@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 (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Nom d'une revue…"
@bind="_nouvelle" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="Nom de la nouvelle revue" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouvelle))"
title="@MotifBlocage"
@onclick="CreerAsync">
Ajouter la revue
</button>
</div>
@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, ou saisissez son nom ci-dessus :
le code d'une revue commence par 977 et porte un ISSN, pas un ISBN.
</p>
}
else
{
<ul class="liste-series">
@foreach (var revue in _revues)
{
<li class="carte-serie" @key="revue.Id">
<a class="carte-titre" href="@($"revues/{revue.Id}")">@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 @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>
}
@code {
private IReadOnlyList<RevueDto>? _revues;
private string _nouvelle = string.Empty;
private bool _enCours;
private string? _erreur;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
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();
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouvelle) && Reseau.EnLigne)
{
await CreerAsync();
}
}
private async Task CreerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue { Titre = _nouvelle });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_nouvelle = string.Empty;
await ChargerAsync();
}
}