using Novelly.Api.Common; using Novelly.Api.Common.Validation; namespace Novelly.Api.Scenes; public record SceneDto( Guid Id, Guid ChapterId, int SortOrder, string Title, string? Summary, string? Goal, string? Conflict, string? Outcome, Guid? PovCharacterId, string? PovCharacterName, string? Location, string? Prose, int WordCount, DraftStatus Status, DateTimeOffset UpdatedAt); public record CreateSceneRequest( string Title, int? SortOrder = null, string? Summary = null, string? Goal = null, string? Conflict = null, string? Outcome = null, Guid? PovCharacterId = null, string? Location = null, string? Prose = null, DraftStatus Status = DraftStatus.Planned); public class CreateSceneRequestValidator : IModelValidator { public ValidationResult Validate(CreateSceneRequest model) { var result = new ValidationResult(); if (string.IsNullOrWhiteSpace(model.Title)) result.AddError("Title", "'Title' must not be empty."); else if (model.Title.Length > 200) result.AddError("Title", "'Title' must be 200 characters or fewer."); SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result); return result; } } public record UpdateSceneRequest( string? Title = null, int? SortOrder = null, string? Summary = null, string? Goal = null, string? Conflict = null, string? Outcome = null, Guid? PovCharacterId = null, string? Location = null, string? Prose = null, DraftStatus? Status = null); public class UpdateSceneRequestValidator : IModelValidator { public ValidationResult Validate(UpdateSceneRequest model) { var result = new ValidationResult(); if (model.Title is not null) { if (model.Title.Length == 0) result.AddError("Title", "'Title' can not be cleared — a scene always needs one."); else if (model.Title.Length > 200) result.AddError("Title", "'Title' must be 200 characters or fewer."); } SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result); return result; } } file static class SceneValidation { public static void OptionalFields( int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result) { if (sortOrder is < 0) result.AddError("SortOrder", "'Sort Order' must be zero or greater."); if (summary is { Length: > 20000 }) result.AddError("Summary", "'Summary' must be 20,000 characters or fewer."); if (goal is { Length: > 20000 }) result.AddError("Goal", "'Goal' must be 20,000 characters or fewer."); if (conflict is { Length: > 20000 }) result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer."); if (outcome is { Length: > 20000 }) result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer."); if (location is { Length: > 500 }) result.AddError("Location", "'Location' must be 500 characters or fewer."); if (prose is { Length: > 100000 }) result.AddError("Prose", "'Prose' must be 100,000 characters or fewer."); } } public static class SceneMapping { public static SceneDto ToDto(this Scene s) => new( s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary, s.Goal, s.Conflict, s.Outcome, s.PovCharacterId, s.PovCharacter?.Name, s.Location, s.Prose, s.WordCount, s.Status, s.UpdatedAt); /// /// Whitespace-delimited word count. Good enough for progress tracking, and it costs /// nothing to recompute on every save. /// public static int CountWords(string? prose) => string.IsNullOrWhiteSpace(prose) ? 0 : prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; }