Ajouter les sagas et cycles, avec leurs tomes manquants

Première relation entre livres du projet. Une série contient des séries
(un cycle EST une série de séries) et des PLACES dans l'ordre de lecture —
pas des livres : une place sans livre est le trou qu'on vient voir.

L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant
le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la
main, aucune source ne donnant l'ordre de lecture d'une saga.

Le titre de la place survit à la suppression du livre (SetNull, pas de
cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série.

Les séries sont communes au foyer. Seule exception : la mise en envies d'un
tome manquant, qui écrit dans une liste personnelle — c'est le seul point
où les deux portées se rencontrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:03:08 +02:00
co-authored by Claude Opus 5
parent 49eb8e1c98
commit 4e372ab694
20 changed files with 2768 additions and 36 deletions
+14
View File
@@ -34,6 +34,20 @@ public static class ClesCache
/// </para>
/// </remarks>
public const string Souhaits = "souhaits";
/// <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>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
@@ -47,6 +47,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAuteursAsync(ct);
await ListerPretsEnCoursAsync(ct);
await ListerSouhaitsAsync(ct);
await ListerSeriesAsync(ct);
await ObtenirUtilisateurAsync(ct);
return reseau.EnLigne;
}
@@ -516,6 +517,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerPretsEnCoursAsync();
await ListerAuteursAsync();
await ListerSouhaitsAsync();
await ListerSeriesAsync();
}
catch (Exception)
{
@@ -737,6 +739,102 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
: await LireAsync<BibliographieDto>(reponse, ct);
}
// ─────────────────────────────────────────────────────────────────────────
// 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);
/// <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);
/// <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);
}