Chapters can now be marked FrontMatter/Body/BackMatter. Number stays the manuscript sort key for every chapter; the author-facing display number is now computed per-request as the chapter's ordinal among Body chapters only, so a foreword or afterword no longer shifts the numbering of the rest of the book. Surfaced through the API, agent toolset, and import toolset.
214 lines
7.1 KiB
C#
214 lines
7.1 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Novelly.Api.Activity;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Common.Validation;
|
|
using Novelly.Api.Data;
|
|
using Novelly.Api.Locations;
|
|
using Novelly.Api.Tags;
|
|
using Novelly.Api.Users;
|
|
|
|
namespace Novelly.Api.Chapters;
|
|
|
|
public class ChapterService(
|
|
INovelDbContext db,
|
|
NovelAccessService access,
|
|
TagService tags,
|
|
LocationService locations,
|
|
ActivityLog activity,
|
|
ILogger<ChapterService> logger,
|
|
IModelValidator<CreateChapterRequest> createValidator,
|
|
IModelValidator<UpdateChapterRequest> updateValidator)
|
|
{
|
|
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(novelId, nameof(novelId));
|
|
|
|
logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
|
|
|
|
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
|
|
|
return await db.Chapters
|
|
.Include(c => c.Beats)
|
|
.Include(c => c.Tags)
|
|
.Include(c => c.Locations)
|
|
.Where(c => c.NovelId == novelId)
|
|
.OrderBy(c => c.Number)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
|
|
logger.LogInformation("Getting chapter {ChapterId}", id);
|
|
|
|
var chapter = await FindAsync(id, ct);
|
|
if (chapter is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
|
|
return chapter;
|
|
}
|
|
|
|
public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(novelId, nameof(novelId));
|
|
Guard.Null(request, nameof(request));
|
|
createValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
|
|
|
|
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
|
{
|
|
logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
|
|
|
var chapter = new Chapter
|
|
{
|
|
NovelId = novelId,
|
|
Title = request.Title,
|
|
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
|
Kind = request.Kind,
|
|
Summary = request.Summary,
|
|
Notes = request.Notes,
|
|
Status = request.Status,
|
|
TargetWordCount = request.TargetWordCount,
|
|
Prose = request.Prose,
|
|
WordCount = ChapterMapping.CountWords(request.Prose)
|
|
};
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
|
|
}
|
|
|
|
if (request.Locations is { } locationNames)
|
|
{
|
|
chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct);
|
|
}
|
|
|
|
db.Chapters.Add(chapter);
|
|
activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
return (await FindAsync(chapter.Id, ct))!;
|
|
}
|
|
|
|
public async Task<Chapter?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
Guard.Null(request, nameof(request));
|
|
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Updating chapter {ChapterId}", id);
|
|
|
|
var chapter = await FindAsync(id, ct);
|
|
if (chapter is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
|
|
|
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
|
chapter.Number = request.Number ?? chapter.Number;
|
|
chapter.Kind = request.Kind ?? chapter.Kind;
|
|
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
|
|
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
|
chapter.Status = request.Status ?? chapter.Status;
|
|
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
|
|
|
var wordCountBeforeEdit = chapter.WordCount;
|
|
|
|
if (request.Prose is not null)
|
|
{
|
|
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
|
|
chapter.WordCount = ChapterMapping.CountWords(chapter.Prose);
|
|
}
|
|
|
|
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
|
}
|
|
|
|
if (request.Locations is { } locationNames)
|
|
{
|
|
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
|
|
}
|
|
|
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Updated, chapter.Id, chapter.WordCount - wordCountBeforeEdit);
|
|
await db.SaveChangesAsync(ct);
|
|
return (await FindAsync(id, ct))!;
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
|
|
logger.LogInformation("Deleting chapter {ChapterId}", id);
|
|
|
|
var chapter = await FindAsync(id, ct);
|
|
if (chapter is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
|
|
|
db.Chapters.Remove(chapter);
|
|
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default)
|
|
{
|
|
if (chapter.Kind != ChapterKind.Body)
|
|
return null;
|
|
|
|
return await db.Chapters.CountAsync(
|
|
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
|
|
}
|
|
|
|
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
|
|
|
var max = await db.Chapters
|
|
.Where(c => c.NovelId == novelId)
|
|
.MaxAsync(c => (int?)c.Number, ct);
|
|
|
|
var next = (max ?? 0) + 1;
|
|
logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
|
|
return next;
|
|
}
|
|
|
|
private async Task<Chapter?> FindAsync(Guid id, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Finding chapter {ChapterId}", id);
|
|
|
|
var chapter = await db.Chapters
|
|
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
|
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
|
.Include(c => c.Tags)
|
|
.Include(c => c.Locations)
|
|
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
|
|
|
if (chapter is null)
|
|
{
|
|
logger.LogWarning("Chapter {ChapterId} not found", id);
|
|
return chapter;
|
|
}
|
|
|
|
logger.LogDebug("Found chapter {ChapterId}", id);
|
|
return chapter;
|
|
}
|
|
}
|