Files
mabibli/MaBibli.Client/Pages/Serie.razor
T
mathieuandClaude Opus 5 94f0ef15c0 Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages
`Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait
comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour
`TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre,
d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt
que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ».

Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce
champ n'est pas un nombre mais une phrase décrivant le support, et les notices
déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv.
ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc
étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout
le reste rend `null`. 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
voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) »,
qui est une pagination de contribution, ne vaut rien. La valeur reste proposée
dans un champ modifiable, et rien n'est déduit pour un ebook.

Lot X — éditer une envie, et souhaiter une revue
`PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans
ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur
l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur
l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure
— l'envie d'un autre est introuvable (404), jamais refusée (403).

⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un
ajout ne peut pas : renommer une envie en une autre déjà présente répond par un
message lisible, jamais par « UNIQUE constraint failed ». 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. Le rang n'est pas touché — l'ordre a son propre point d'entrée.

⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran
n'offre de champ « URL de couverture » pour une envie (décision actée), donc un
remplacement inconditionnel l'aurait effacée à la première faute de frappe
corrigée.

`RevueSouhaitee` est une table sœur, et non des colonnes de plus sur
`LivreSouhaite` : 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 exprimer sans
devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut
vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret,
seul code du projet rangé ainsi.

Le coût de la table sœur est payé partout où il devait l'être : affichage,
`.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment
une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son
rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que
personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas
les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres
dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri
par titre rendrait revues et livres indiscernables, et la colonne des codes
mêlerait ISBN et ISSN en silence.

`ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision
déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien
quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y
échapperait.

`RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de
`GET /api/couvertures`.

Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie
d'un autre en 404, et les deux exports portant bien les deux moitiés.
602 tests au vert (552 au départ), aucun avertissement de compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:02:09 +02:00

1007 lines
38 KiB
Plaintext

@page "/series/{Id:int}"
@page "/series/{Id:int}/edition"
@page "/series/{Id:int}/ordre"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — @(_serie?.Titre ?? "série")</PageTitle>
@if (_serie is null)
{
<p class="message-discret" role="status">
@(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else
{
@if (!ModeEdition && _parente is { } parente)
{
@if (!ModeEdition)
{
<p class="message-discret">
Fait partie de <a href="@($"series/{parente.Id}")">@parente.Titre</a>.
</p>
}
}
@*
Titre, avancement et actions sur la MÊME ligne (lot T) : c'est la disposition déjà tenue
par la bibliographie et la liste des auteurs, et elle rend les deux actions visibles sans
descendre au bas d'une saga de vingt tomes.
⚠️ « Changer l'ordre » n'apparaît qu'à partir de DEUX tomes ou deux sous-séries : un
bouton toujours présent mais inerte serait un recul. Et les deux écrans restent distincts
— « Modifier » touche à la fiche (nom, cycle, suppression), « Changer l'ordre » à la
place des tomes, dont les flèches ne vivent que là.
*@
<div class="entete-fiche">
<div class="entete-fiche-texte">
<h1 class="titre-page">@_serie.Titre</h1>
<p class="message-discret">
@if (_serie.Elements.Count == 0)
{
<text>Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent.</text>
}
else
{
var total = Total(_serie);
<text>@total.Possedes sur @total.Total tomes dans la bibliothèque.</text>
}
</p>
</div>
@if (ModeConsultation)
{
<div class="entete-fiche-actions">
@if (Reseau.EnLigne)
{
@if (_serie.Elements.Count > 1 || _filles.Count > 1)
{
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
}
<a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
{
<button type="button" class="bouton bouton-compact bouton-principal" disabled
title="@EtatReseau.MotifHorsLigne">Modifier</button>
}
</div>
}
</div>
@if (ModeOrdre)
{
<p class="message-discret">
Rangez les tomes dans l'ordre où vous voulez les <strong>lire</strong> — ce n'est
pas toujours l'ordre de publication : une préquelle se lit avant le livre paru dix
ans plus tôt. Rien d'autre ne se modifie depuis cet écran.
</p>
}
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas
qu'elle a eu lieu, et on cliquerait deux fois. *@
@if (_message is not null)
{
<p class="message-avertissement" role="status">@_message</p>
}
@* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient
malgré tout une boucle. *@
@if (!ModeEdition && _filles.Count > 0)
{
<h2 class="titre-section">Séries de ce cycle</h2>
<ul class="liste-sous-series arbre">
@foreach (var fille in _filles)
{
@* ⚠️ Les flèches n'apparaissent QUE dans l'écran d'ordre. En consultation, elles
se déclenchaient en faisant défiler du pouce, et l'ordre de lecture d'une saga
changeait sans qu'on l'ait voulu — même raison que pour la liste d'envies,
dont le réordonnancement vit sur son propre écran. *@
<ArbreSerie Serie="fille" Toutes="_toutes"
Totaux="_totaux" Replies="_replies"
Chemin="@CheminRacine"
ActionsActives="@(ModeOrdre && Reseau.EnLigne)" MotifBlocage="@MotifBlocage"
OnDeplacer="DeplacerSousSerieAsync" />
}
</ul>
}
@if (!ModeEdition && _serie.Elements.Count > 0)
{
<h2 class="titre-section">Ordre de lecture</h2>
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur
téléphone qu'on consulte une saga en librairie.
*@
<ul class="liste-tomes">
@foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
<div class="rang-actions">
@if (ModeOrdre)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
title="@MotifBlocage"
aria-label="@($"Monter « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index - 1)">▲</button>
}
<span class="rang-numero" aria-hidden="true">@(index + 1)</span>
@if (ModeOrdre)
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne || index == _serie.Elements.Count - 1)"
title="@MotifBlocage"
aria-label="@($"Descendre « {element.Titre} »")"
@onclick="() => DeplacerAsync(index, index + 1)">▼</button>
}
</div>
<div class="carte-corps">
@if (element.LivreId is { } livreId && _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
{
<CarteLivre Livre="livre" />
}
else if (element.LivreId is { } idRattache)
{
<a class="carte-titre" href="@($"livres/{idRattache}")">@element.Titre</a>
}
else
{
<p class="carte-titre">@element.Titre</p>
}
@if ((element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
&& !string.IsNullOrWhiteSpace(element.Auteurs))
{
<p class="carte-auteur">@element.Auteurs</p>
}
@if (element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
{
<p class="carte-details">
@if (!element.Possede)
{
@* Dire ce qui manque, et non laisser deviner par l'absence de lien. *@
<span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
}
@if (element.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
</p>
}
@if (!ModeOrdre)
{
<div class="actions-tome">
@if (!element.Possede)
{
@* Le tome manquant est COMMUN au foyer, l'envie qu'il devient est
PERSONNELLE : deux membres peuvent vouloir le même tome. *@
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => SouhaiterAsync(element)">
Ajouter à mes envies
</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(element.Id)">
Je l'ai
</button>
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirLookup(element)">
Ajouter par ISBN
</button>
}
else
{
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RattacherAsync(element.Id, null)">
Détacher
</button>
}
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => RetirerAsync(element)">
Retirer
</button>
</div>
}
@if (_rattachement == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à rattacher" />
@foreach (var resultat in _resultats)
{
<button type="button" class="bouton bouton-discret"
@key="resultat.Id"
@onclick="() => RattacherAsync(element.Id, resultat.Id)">
@resultat.Titre@(resultat.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
@if (_lookupElement?.Id == element.Id)
{
<div class="recherche-livre">
<input class="champ-saisie" type="text" inputmode="numeric"
placeholder="ISBN du tome…"
@bind="_lookupIsbn" @bind:event="oninput"
@onkeydown="SurToucheLookup" />
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_lookupIsbn))"
title="@MotifBlocage" @onclick="ChercherLookupAsync">
@(_lookupEnCours ? "Recherche…" : "Préremplir")
</button>
@*
J1 : le scanner s'ouvre ICI, dans la place visée, et non sur
l'écran d'ajout.
⚠️ C'est ce qui répond à l'exigence « le retour doit ramener
sur la série ET sur la place visée » : il n'y a pas de
retour, on n'a jamais quitté la place. Passer par /ajout
obligeait à revenir rattacher à la main — donc à risquer de
rattacher le mauvais tome.
*@
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || _lookupScan || !Reseau.EnLigne)"
title="@MotifBlocage" @onclick="OuvrirScanTome">
Scanner
</button>
@if (_lookupScan)
{
<ScannerCodeBarres OnCodeDetecte="SurCodeTomeAsync"
OnAnnuler="() => _lookupScan = false" />
}
@* L1 : la cascade BnF puis OpenLibrary prend une seconde ou deux. *@
@if (_lookupEnCours)
{
<Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
}
@if (_lookupCandidats.Count > 0)
{
@foreach (var candidat in _lookupCandidats)
{
<div class="carte-candidat">
<div class="carte-corps">
<p class="carte-titre">@candidat.Titre</p>
<p class="carte-auteur">@candidat.Auteur</p>
<p class="carte-details">
@(candidat.Editeur ?? "éditeur inconnu")
· @(candidat.Annee ?? "année inconnue")
</p>
<button type="button" class="bouton bouton-principal"
disabled="@_enCours"
@onclick="() => AjouterCommeLivreAsync(candidat)">
Ajouter comme livre
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours"
@onclick="() => AjouterCommeEnvieAsync(candidat)">
Ajouter comme envie
</button>
</div>
</div>
}
}
</div>
}
</div>
</li>
}
</ul>
}
@if (ModeConsultation)
{
<h2 class="titre-section">Ajouter un tome</h2>
@*
Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la
moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un
brouillon : c'est ce qui rend le trou visible.
*@
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Titre d'un tome…"
@bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
aria-label="Titre du tome à ajouter" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
title="@MotifBlocage"
@onclick="() => AjouterTomeAsync(null)">
Ajouter ce tome
</button>
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => OuvrirRattachement(AjoutDepuisCatalogue)">
Depuis le catalogue
</button>
@*
Lot P — le chemin inverse de « place vide → envie ».
⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à
NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré —
les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens
même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère
vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde.
*@
<button type="button" class="bouton bouton-discret"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirEnviesAsync">
Depuis mes envies
</button>
</div>
@if (_enviesOuvertes)
{
<div class="recherche-livre">
@if (_enviesChargement)
{
<Patience Message="Lecture de votre liste d'envies…" />
}
else if (_envies.Count == 0)
{
<p class="message-discret">
Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome
de cette série.
</p>
}
else
{
<p class="message-discret">
Le tome est ajouté comme <strong>manquant</strong>, avec le titre de l'envie.
L'envie <strong>reste dans votre liste</strong> : rien n'a été acheté, et
personne d'autre ne verra qu'elle est à vous.
</p>
@foreach (var envie in _envies)
{
<button type="button" class="bouton bouton-discret" @key="envie.Id"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="() => AjouterDepuisEnvieAsync(envie)">
@envie.Titre@(string.IsNullOrWhiteSpace(envie.Auteur) ? "" : $" — {envie.Auteur}")
</button>
}
}
</div>
}
@if (_rattachement == AjoutDepuisCatalogue)
{
<div class="recherche-livre">
<input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
value="@_recherche" @oninput="ChercherAsync"
aria-label="Chercher un livre à ajouter" />
@foreach (var livre in _resultats)
{
<button type="button" class="bouton bouton-discret" @key="livre.Id"
@onclick="() => AjouterTomeAsync(livre.Id)">
@livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
</button>
}
</div>
}
}
@if (ModeEdition)
{
<h2 class="titre-section">La série</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">
Rangée dans<span class="champ-aide"> (un cycle contient des séries)</span>
</span>
<select class="champ-saisie" @bind="_parenteId">
<option value="0">Aucune — série de premier niveau</option>
@foreach (var candidate in Candidates)
{
<option value="@candidate.Id">@candidate.Titre</option>
}
</select>
</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 série")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les livres restent au catalogue ; seules les places disparaissent.
@if (_filles.Count > 0)
{
<text> Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau.</text>
}
</p>
}
}
@if (ModeOrdre)
{
<nav class="actions-flottantes">
<a class="bouton bouton-principal" href="@($"series/{Id}")">Terminé</a>
</nav>
}
}
@code {
/// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
private const int AjoutDepuisCatalogue = -1;
[Parameter] public int Id { get; set; }
private IReadOnlyList<SerieDto> _toutes = [];
private IReadOnlyList<LivreDto> _livresCatalogue = [];
private SerieDto? _serie;
private SerieDto? _parente;
private List<SerieDto> _filles = [];
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 bool _chargement = true;
private bool _enCours;
private string? _erreur;
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
private string _nouveauTome = string.Empty;
private int? _rattachement;
private string _recherche = string.Empty;
private IReadOnlyList<LivreDto> _resultats = [];
private ElementSerieDto? _lookupElement;
private string _lookupIsbn = string.Empty;
private IReadOnlyList<CandidatLivre> _lookupCandidats = [];
private bool _lookupEnCours;
/// <summary>Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1).</summary>
private bool _lookupScan;
private bool _enviesOuvertes;
private bool _enviesChargement;
private IReadOnlyList<SouhaitDto> _envies = [];
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };
private bool ModeEdition => Suffixe("/edition");
/// <summary>
/// Écran dédié au réordonnancement, sur le modèle de <c>/souhaits/ordre</c>.
/// </summary>
/// <remarks>
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : c'est la convention du
/// projet depuis la fiche livre, et elle fait du bouton « retour » du navigateur une
/// sortie naturelle. Comme les trois routes partagent le même paramètre, le composant
/// s'abonne à <c>LocationChanged</c> — sans quoi le routeur ne redessinerait rien.
/// </remarks>
private bool ModeOrdre => Suffixe("/ordre");
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith(fin, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Séries dans lesquelles celle-ci peut être rangée.
/// </summary>
/// <remarks>
/// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le
/// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté.
/// </remarks>
private IEnumerable<SerieDto> Candidates =>
_toutes.Where(s => s.Id != Id && !EstDescendante(s.Id));
private bool EstDescendante(int candidate)
{
var courante = _toutes.FirstOrDefault(s => s.Id == candidate);
var garde = _toutes.Count + 1;
while (courante is not null && garde-- > 0)
{
if (courante.Id == Id)
{
return true;
}
courante = courante.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
}
return false;
}
protected override void OnInitialized()
{
Reseau.Change += SurChangementReseau;
Navigation.LocationChanged += SurNavigation;
}
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(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;
_toutes = await Api.ListerSeriesAsync();
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
_chargement = false;
Rattacher();
}
/// <summary>Recale les vues dérivées après un chargement ou une écriture.</summary>
private void Rattacher()
{
_serie = _toutes.FirstOrDefault(s => s.Id == Id);
if (_serie is null)
{
return;
}
_parente = _serie.SerieParenteId is { } parenteId
? _toutes.FirstOrDefault(s => s.Id == parenteId)
: null;
_filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
_totaux = TotauxSeries.Calculer(_toutes);
_titre = _serie.Titre;
_parenteId = _serie.SerieParenteId ?? 0;
}
/// <summary>Remplace la série courante par celle que vient de rendre le serveur.</summary>
private void Appliquer(SerieDto serie)
{
_toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList();
Rattacher();
}
private async Task SurToucheTome(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
{
await AjouterTomeAsync(null);
}
}
private async Task AjouterTomeAsync(int? livreId)
{
await EcrireAsync(() => Api.AjouterElementSerieAsync(
Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null }));
if (_erreur is null)
{
_nouveauTome = string.Empty;
FermerRecherche();
}
}
private Task RattacherAsync(int elementId, int? livreId) =>
EcrireAsync(async () =>
{
var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId);
FermerRecherche();
return resultat;
});
private async Task RetirerAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = await Api.RetirerElementSerieAsync(element.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SouhaiterAsync(ElementSerieDto element)
{
_enCours = true;
_erreur = null;
var resultat = await Api.SouhaiterTomeAsync(element.Id);
_enCours = false;
_erreur = resultat.Erreur;
if (resultat.EstOk)
{
_message = $"« {element.Titre} » est dans votre liste d'envies.";
}
}
private (int Possedes, int Total) Total(SerieDto serie) =>
_totaux.TryGetValue(serie.Id, out var total)
? total
: (serie.NombrePossedes, serie.Elements.Count);
private async Task DeplacerSousSerieAsync(DeplacementSerie mouvement)
{
var soeurs = _toutes
.Where(s => s.SerieParenteId == mouvement.ParenteId)
.OrderBy(s => s.Position)
.ThenBy(s => s.Id)
.ToList();
var index = soeurs.FindIndex(s => s.Id == mouvement.SerieId);
var cible = index + mouvement.Delta;
if (index < 0 || cible < 0 || cible >= soeurs.Count)
{
return;
}
var ids = soeurs.Select(s => s.Id).ToList();
(ids[index], ids[cible]) = (ids[cible], ids[index]);
await EcrireAsync(() => Api.ReordonnerSousSeriesAsync(mouvement.ParenteId, ids));
if (_erreur is null)
{
await ChargerAsync();
}
}
private void OuvrirScanTome()
{
_erreur = null;
_lookupCandidats = [];
_lookupScan = true;
}
/// <summary>
/// Enchaîne le lookup sur le code lu, sans quitter la place visée.
/// </summary>
/// <remarks>
/// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
/// l'écran d'ajout au catalogue.
/// <para>
/// ⚠️ Un code de périodique (<c>977</c>) est refusé plus bas par <c>ChercherLookupAsync</c> :
/// <c>ElementSerie.LivreId</c> ne pointe que vers <c>Livre</c>, et les revues restent
/// délibérément hors de ce flux.
/// </para>
/// </remarks>
private async Task SurCodeTomeAsync(CodeScanne lecture)
{
_lookupScan = false;
_lookupIsbn = lecture.Code;
await ChercherLookupAsync();
}
/// <summary>
/// Ouvre la liste des envies dans laquelle puiser un tome à recenser.
/// </summary>
/// <remarks>
/// ⚠️ Les envies déjà présentes dans la série sont écartées de la liste : les proposer
/// mènerait droit au doublon, la place étant identifiée par son seul titre.
/// </remarks>
private async Task OuvrirEnviesAsync()
{
_erreur = null;
_message = null;
_enviesOuvertes = true;
_enviesChargement = true;
var toutes = await Api.ListerSouhaitsAsync();
var deja = new HashSet<string>(
_serie?.Elements.Select(e => NormalisationTexte.Normaliser(e.Titre)) ?? [],
StringComparer.Ordinal);
_envies = toutes
.Where(envie => !deja.Contains(NormalisationTexte.Normaliser(envie.Titre)))
.ToList();
_enviesChargement = false;
}
/// <summary>
/// Recense un tome à partir d'une envie, <b>sans rien lier</b>.
/// </summary>
/// <remarks>
/// La place créée est celle qu'on aurait saisie à la main : <c>LivreId</c> à <c>NULL</c>,
/// titre repris de l'envie. L'envie <b>survit</b> — c'est la règle déjà tenue par « une
/// envie déjà au catalogue est signalée, jamais supprimée », et à plus forte raison ici
/// puisque rien n'a été acheté.
/// <para>
/// ⚠️ Ce qui doit rester invisible au foyer n'est pas l'existence du tome — on l'aurait
/// saisie à la main de toute façon — mais le <b>lien</b> avec la liste de quelqu'un. Il n'y
/// en a aucun en base.
/// </para>
/// </remarks>
private async Task AjouterDepuisEnvieAsync(SouhaitDto envie)
{
await EcrireAsync(() => Api.AjouterElementSerieAsync(
Id, new AjoutElementSerie { Titre = envie.Titre }));
if (_erreur is null)
{
_enviesOuvertes = false;
_envies = [];
_message = $"« {envie.Titre} » est recensé comme tome manquant. "
+ "Il reste dans votre liste d'envies.";
}
}
private void OuvrirLookup(ElementSerieDto element)
{
_lookupElement = element;
_lookupIsbn = string.Empty;
_lookupCandidats = [];
_lookupScan = false;
_erreur = null;
}
private async Task SurToucheLookup(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_lookupIsbn))
{
await ChercherLookupAsync();
}
}
private async Task ChercherLookupAsync()
{
_lookupEnCours = true;
_erreur = null;
_lookupCandidats = [];
var resultat = await Api.ChercherIsbnAsync(_lookupIsbn);
_lookupEnCours = false;
if (resultat is null)
{
_erreur = Reseau.EnLigne
? "La recherche n'a rien pu ramener."
: EtatReseau.MotifHorsLigne;
return;
}
if (resultat.Periodique is not null)
{
_erreur = "Les revues ne peuvent pas être rattachées à une série.";
return;
}
_lookupCandidats = resultat.Candidats;
if (_lookupCandidats.Count == 0)
{
_erreur = "Aucune notice ne correspond à cet ISBN.";
}
}
private async Task AjouterCommeEnvieAsync(CandidatLivre candidat)
{
_enCours = true;
_erreur = null;
var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
{
Titre = candidat.Titre,
Auteur = candidat.Auteur,
Editeur = candidat.Editeur,
Annee = candidat.Annee,
Isbn = candidat.IsbnInterroge,
CoverUrl = candidat.CoverUrl,
});
_enCours = false;
_erreur = resultat.Erreur;
if (resultat.EstOk)
{
_message = $"« {candidat.Titre} » est dans votre liste d'envies.";
_lookupElement = null;
}
}
private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
{
if (_lookupElement is null)
{
return;
}
_enCours = true;
_erreur = null;
var saisie = new EnregistrementLivre
{
Isbn = candidat.IsbnInterroge,
Titre = candidat.Titre,
Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
Editeur = candidat.Editeur,
NombrePages = candidat.NombrePages,
CoverUrl = candidat.CoverUrl,
UrlNotice = candidat.UrlNotice,
};
var resultat = await Api.CreerAsync(saisie);
_enCours = false;
if (resultat.Doublons is not null)
{
_erreur = resultat.Doublons.Message;
return;
}
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
if (_erreur is null)
{
_lookupElement = null;
}
}
private string? _message;
private async Task DeplacerAsync(int depuis, int vers)
{
if (_serie is null || vers < 0 || vers >= _serie.Elements.Count)
{
return;
}
// Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour
// réseau ne doit pas se voir. Un échec recharge l'ordre du serveur.
var ids = _serie.Elements.Select(e => e.Id).ToList();
(ids[depuis], ids[vers]) = (ids[vers], ids[depuis]);
await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids));
}
private async Task EnregistrerAsync()
{
await EcrireAsync(() => Api.ModifierSerieAsync(
Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId }));
// Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit.
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task SupprimerAsync()
{
if (!_confirmeSuppression)
{
_confirmeSuppression = true;
return;
}
_enCours = true;
_erreur = await Api.SupprimerSerieAsync(Id);
_enCours = false;
if (_erreur is null)
{
Navigation.NavigateTo("series");
}
}
private void OuvrirRattachement(int cible)
{
_rattachement = cible;
_recherche = string.Empty;
_resultats = [];
}
private void FermerRecherche()
{
_rattachement = null;
_recherche = string.Empty;
_resultats = [];
}
private async Task ChercherAsync(ChangeEventArgs e)
{
_recherche = e.Value?.ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(_recherche))
{
_resultats = [];
return;
}
// La recherche passe par le service habituel : hors-ligne elle interroge l'instantané,
// en ligne le serveur. Rien de particulier à prévoir ici.
_resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche }))
.Take(8)
.ToList();
}
/// <summary>Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur.</summary>
private async Task EcrireAsync(Func<Task<ResultatApi<SerieDto>>> appel)
{
_enCours = true;
_erreur = null;
_message = null;
var resultat = await appel();
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
// L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du
// serveur plutôt que de laisser croire à un enregistrement.
await ChargerAsync();
return;
}
Appliquer(resultat.Valeur!);
}
}