diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs index 897195d..fe8e490 100644 --- a/PlotLine/Controllers/BooksController.cs +++ b/PlotLine/Controllers/BooksController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; @@ -30,8 +31,14 @@ public sealed class BooksController(IBookService books) : Controller [ValidateAntiForgeryToken] public async Task Save(BookEditViewModel model) { + if (!BookStatuses.IsValid(model.Status)) + { + ModelState.AddModelError(nameof(model.Status), "Select a valid book status."); + } + if (!ModelState.IsValid) { + await books.PopulateEditContextAsync(model); return View("Edit", model); } @@ -50,8 +57,17 @@ public sealed class BooksController(IBookService books) : Controller } createModel.BookTitle = model.BookTitle; + createModel.Subtitle = model.Subtitle; + createModel.Tagline = model.Tagline; + createModel.ShortDescription = model.ShortDescription; + createModel.Status = model.Status; createModel.BookNumber = model.BookNumber; createModel.Description = model.Description; + createModel.TargetWordCount = model.TargetWordCount; + createModel.WritingStartDate = model.WritingStartDate; + createModel.TargetCompletionDate = model.TargetCompletionDate; + createModel.PublicationDate = model.PublicationDate; + await books.PopulateEditContextAsync(createModel); return View("Edit", createModel); } diff --git a/PlotLine/Data/ProjectBackupRepository.cs b/PlotLine/Data/ProjectBackupRepository.cs index 4c4b27c..2a0a997 100644 --- a/PlotLine/Data/ProjectBackupRepository.cs +++ b/PlotLine/Data/ProjectBackupRepository.cs @@ -74,6 +74,30 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact return null; } + var bookMetadata = (await connection.QueryAsync( + """ + SELECT BookID, Subtitle, Tagline, ShortDescription, Status, TargetWordCount, WritingStartDate, TargetCompletionDate, PublicationDate + FROM dbo.Books + WHERE ProjectID = @ProjectID; + """, + new { ProjectID = projectId })).ToDictionary(x => x.BookID); + foreach (var book in data.Books) + { + if (!bookMetadata.TryGetValue(book.BookID, out var metadata)) + { + continue; + } + + book.Subtitle = metadata.Subtitle; + book.Tagline = metadata.Tagline; + book.ShortDescription = metadata.ShortDescription; + book.Status = metadata.Status; + book.TargetWordCount = metadata.TargetWordCount; + book.WritingStartDate = metadata.WritingStartDate; + book.TargetCompletionDate = metadata.TargetCompletionDate; + book.PublicationDate = metadata.PublicationDate; + } + return new ProjectBackupExport { ExportedAtUtc = DateTime.UtcNow, diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index c9dbecb..28ce6bd 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -2041,7 +2041,22 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Book_Save", - new { book.BookID, book.ProjectID, book.BookTitle, book.BookNumber, book.Description }, + new + { + book.BookID, + book.ProjectID, + book.BookTitle, + book.Subtitle, + book.Tagline, + book.ShortDescription, + book.Status, + book.BookNumber, + book.Description, + book.TargetWordCount, + book.WritingStartDate, + book.TargetCompletionDate, + book.PublicationDate + }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 2592e79..237a206 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -192,14 +192,60 @@ public sealed class Book public int BookID { get; set; } public int ProjectID { get; set; } public string BookTitle { get; set; } = string.Empty; + public string? Subtitle { get; set; } + public string? Tagline { get; set; } + public string? ShortDescription { get; set; } + public string Status { get; set; } = BookStatuses.Planning; public int BookNumber { get; set; } public string? Description { get; set; } + public int? TargetWordCount { get; set; } + public DateTime? WritingStartDate { get; set; } + public DateTime? TargetCompletionDate { get; set; } + public DateTime? PublicationDate { get; set; } + public int CurrentWordCount { get; set; } + public int ChapterCount { get; set; } + public int SceneCount { get; set; } public int SortOrder { get; set; } public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } public DateTime? ArchivedDate { get; set; } public string? ArchivedReason { get; set; } + public decimal? ProgressPercentage => TargetWordCount is > 0 + ? Math.Min(100m, Math.Round(CurrentWordCount * 100m / TargetWordCount.Value, 1)) + : null; +} + +public static class BookStatuses +{ + public const string Planning = "Planning"; + public const string Research = "Research"; + public const string Outlining = "Outlining"; + public const string Drafting = "Drafting"; + public const string Revising = "Revising"; + public const string Editing = "Editing"; + public const string Proofreading = "Proofreading"; + public const string ReadyForPublication = "Ready for Publication"; + public const string Published = "Published"; + public const string Paused = "Paused"; + public const string Archived = "Archived"; + + public static readonly IReadOnlyList All = + [ + Planning, + Research, + Outlining, + Drafting, + Revising, + Editing, + Proofreading, + ReadyForPublication, + Published, + Paused, + Archived + ]; + + public static bool IsValid(string? status) => All.Contains(status ?? string.Empty, StringComparer.OrdinalIgnoreCase); } public sealed class Chapter diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 0a4b9b7..d638925 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -83,6 +83,7 @@ public interface IBookService Task GetDetailAsync(int bookId); Task GetCreateAsync(int projectId); Task GetEditAsync(int bookId); + Task PopulateEditContextAsync(BookEditViewModel model); Task SaveAsync(BookEditViewModel model); Task ArchiveAsync(int bookId); } @@ -799,7 +800,9 @@ public sealed class BookService( { ProjectID = projectId, Project = project, - BookNumber = existing.Count + 1 + BookNumber = existing.Count + 1, + Status = BookStatuses.Planning, + StatusOptions = BuildStatusOptions(BookStatuses.Planning) }; } @@ -816,12 +819,27 @@ public sealed class BookService( BookID = book.BookID, ProjectID = book.ProjectID, BookTitle = book.BookTitle, + Subtitle = book.Subtitle, + Tagline = book.Tagline, + ShortDescription = book.ShortDescription, + Status = book.Status, BookNumber = book.BookNumber, Description = book.Description, - Project = await projects.GetAsync(book.ProjectID) + TargetWordCount = book.TargetWordCount, + WritingStartDate = book.WritingStartDate, + TargetCompletionDate = book.TargetCompletionDate, + PublicationDate = book.PublicationDate, + Project = await projects.GetAsync(book.ProjectID), + StatusOptions = BuildStatusOptions(book.Status) }; } + public async Task PopulateEditContextAsync(BookEditViewModel model) + { + model.Project = await projects.GetAsync(model.ProjectID); + model.StatusOptions = BuildStatusOptions(model.Status); + } + public async Task SaveAsync(BookEditViewModel model) { if (model.BookID == 0) @@ -829,19 +847,37 @@ public sealed class BookService( await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId); } + if (!BookStatuses.IsValid(model.Status)) + { + model.Status = BookStatuses.Planning; + } + var isNew = model.BookID == 0; var bookId = await books.SaveAsync(new Book { BookID = model.BookID, ProjectID = model.ProjectID, BookTitle = model.BookTitle, + Subtitle = model.Subtitle, + Tagline = model.Tagline, + ShortDescription = model.ShortDescription, + Status = model.Status, BookNumber = model.BookNumber, - Description = model.Description + Description = model.Description, + TargetWordCount = model.TargetWordCount, + WritingStartDate = model.WritingStartDate, + TargetCompletionDate = model.TargetCompletionDate, + PublicationDate = model.PublicationDate }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); return bookId; } + private static IReadOnlyList BuildStatusOptions(string? selectedStatus) + => BookStatuses.All + .Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase))) + .ToList(); + public async Task ArchiveAsync(int bookId) { var book = await books.GetAsync(bookId); diff --git a/PlotLine/Sql/066_Phase13A_BookMetadataBackend.sql b/PlotLine/Sql/066_Phase13A_BookMetadataBackend.sql new file mode 100644 index 0000000..038cf69 --- /dev/null +++ b/PlotLine/Sql/066_Phase13A_BookMetadataBackend.sql @@ -0,0 +1,227 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.Books', N'Subtitle') IS NULL + ALTER TABLE dbo.Books ADD Subtitle nvarchar(200) NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'Tagline') IS NULL + ALTER TABLE dbo.Books ADD Tagline nvarchar(300) NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'ShortDescription') IS NULL + ALTER TABLE dbo.Books ADD ShortDescription nvarchar(max) NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'Status') IS NULL + ALTER TABLE dbo.Books ADD Status nvarchar(50) NOT NULL CONSTRAINT DF_Books_Status DEFAULT N'Planning'; +GO + +IF COL_LENGTH(N'dbo.Books', N'TargetWordCount') IS NULL + ALTER TABLE dbo.Books ADD TargetWordCount int NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'WritingStartDate') IS NULL + ALTER TABLE dbo.Books ADD WritingStartDate date NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'TargetCompletionDate') IS NULL + ALTER TABLE dbo.Books ADD TargetCompletionDate date NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'PublicationDate') IS NULL + ALTER TABLE dbo.Books ADD PublicationDate date NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_Books_TargetWordCount' AND parent_object_id = OBJECT_ID(N'dbo.Books')) + ALTER TABLE dbo.Books ADD CONSTRAINT CK_Books_TargetWordCount CHECK (TargetWordCount IS NULL OR TargetWordCount > 0); +GO + +CREATE OR ALTER PROCEDURE dbo.Book_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + b.BookID, + b.ProjectID, + b.BookTitle, + b.Subtitle, + b.Tagline, + b.ShortDescription, + b.Status, + b.BookNumber, + b.Description, + b.TargetWordCount, + b.WritingStartDate, + b.TargetCompletionDate, + b.PublicationDate, + ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount, + ISNULL(counts.ChapterCount, 0) AS ChapterCount, + ISNULL(counts.SceneCount, 0) AS SceneCount, + b.SortOrder, + b.CreatedDate, + b.UpdatedDate, + b.IsArchived, + b.ArchivedDate, + b.ArchivedReason + FROM dbo.Books b + OUTER APPLY + ( + SELECT + COUNT(DISTINCT c.ChapterID) AS ChapterCount, + COUNT(s.SceneID) AS SceneCount + FROM dbo.Chapters c + LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) counts + OUTER APPLY + ( + SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount + FROM dbo.Chapters c + INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) wordCounts + WHERE b.ProjectID = @ProjectID AND b.IsArchived = 0 + ORDER BY b.SortOrder, b.BookNumber, b.BookTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Book_Get + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + b.BookID, + b.ProjectID, + b.BookTitle, + b.Subtitle, + b.Tagline, + b.ShortDescription, + b.Status, + b.BookNumber, + b.Description, + b.TargetWordCount, + b.WritingStartDate, + b.TargetCompletionDate, + b.PublicationDate, + ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount, + ISNULL(counts.ChapterCount, 0) AS ChapterCount, + ISNULL(counts.SceneCount, 0) AS SceneCount, + b.SortOrder, + b.CreatedDate, + b.UpdatedDate, + b.IsArchived, + b.ArchivedDate, + b.ArchivedReason + FROM dbo.Books b + OUTER APPLY + ( + SELECT + COUNT(DISTINCT c.ChapterID) AS ChapterCount, + COUNT(s.SceneID) AS SceneCount + FROM dbo.Chapters c + LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) counts + OUTER APPLY + ( + SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount + FROM dbo.Chapters c + INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) wordCounts + WHERE b.BookID = @BookID AND b.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Book_Save + @BookID int = NULL, + @ProjectID int, + @BookTitle nvarchar(200), + @Subtitle nvarchar(200) = NULL, + @Tagline nvarchar(300) = NULL, + @ShortDescription nvarchar(max) = NULL, + @Status nvarchar(50) = N'Planning', + @BookNumber int, + @Description nvarchar(max) = NULL, + @TargetWordCount int = NULL, + @WritingStartDate date = NULL, + @TargetCompletionDate date = NULL, + @PublicationDate date = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF @TargetWordCount IS NOT NULL AND @TargetWordCount <= 0 + THROW 51000, 'Target word count must be greater than zero.', 1; + + IF NULLIF(LTRIM(RTRIM(@Status)), N'') IS NULL + SET @Status = N'Planning'; + + IF @BookID IS NULL OR @BookID = 0 + BEGIN + DECLARE @NextBookOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 10; + + INSERT dbo.Books + ( + ProjectID, + BookTitle, + Subtitle, + Tagline, + ShortDescription, + Status, + BookNumber, + Description, + TargetWordCount, + WritingStartDate, + TargetCompletionDate, + PublicationDate, + SortOrder + ) + VALUES + ( + @ProjectID, + @BookTitle, + @Subtitle, + @Tagline, + @ShortDescription, + @Status, + @BookNumber, + @Description, + @TargetWordCount, + @WritingStartDate, + @TargetCompletionDate, + @PublicationDate, + @NextBookOrder + ); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS BookID; + RETURN; + END; + + UPDATE dbo.Books + SET BookTitle = @BookTitle, + Subtitle = @Subtitle, + Tagline = @Tagline, + ShortDescription = @ShortDescription, + Status = @Status, + BookNumber = @BookNumber, + Description = @Description, + TargetWordCount = @TargetWordCount, + WritingStartDate = @WritingStartDate, + TargetCompletionDate = @TargetCompletionDate, + PublicationDate = @PublicationDate, + UpdatedDate = SYSUTCDATETIME() + WHERE BookID = @BookID; + + SELECT @BookID AS BookID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index ec24dd0..6ace42c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -65,11 +65,40 @@ public sealed class BookEditViewModel [Display(Name = "Book title")] public string BookTitle { get; set; } = string.Empty; + [StringLength(200)] + public string? Subtitle { get; set; } + + [StringLength(300)] + public string? Tagline { get; set; } + + [Display(Name = "Short description")] + public string? ShortDescription { get; set; } + + [Required, StringLength(50)] + public string Status { get; set; } = BookStatuses.Planning; + [Display(Name = "Book number")] public int BookNumber { get; set; } = 1; + [Display(Name = "Target word count")] + [Range(1, int.MaxValue, ErrorMessage = "Target word count must be greater than zero.")] + public int? TargetWordCount { get; set; } + + [DataType(DataType.Date)] + [Display(Name = "Writing start date")] + public DateTime? WritingStartDate { get; set; } + + [DataType(DataType.Date)] + [Display(Name = "Target completion date")] + public DateTime? TargetCompletionDate { get; set; } + + [DataType(DataType.Date)] + [Display(Name = "Publication date")] + public DateTime? PublicationDate { get; set; } + public string? Description { get; set; } public Project? Project { get; set; } + public IReadOnlyList StatusOptions { get; set; } = []; } public sealed class BookDetailViewModel diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 0cf5589..312dfd3 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -16,6 +16,14 @@

@Model.Project.ProjectName

Book @Model.Book.BookNumber: @Model.Book.BookTitle

+ @if (!string.IsNullOrWhiteSpace(Model.Book.Subtitle)) + { +

@Model.Book.Subtitle

+ } + @if (!string.IsNullOrWhiteSpace(Model.Book.Tagline)) + { +

@Model.Book.Tagline

+ } @if (!string.IsNullOrWhiteSpace(Model.Book.Description)) {

@Model.Book.Description

@@ -41,6 +49,53 @@
+
+

Book metadata

+
+
+ @Model.Book.Status +
+
+ @Model.Book.CurrentWordCount.ToString("N0") + current words +
+
+ @(Model.Book.TargetWordCount?.ToString("N0") ?? "Not set") + target words +
+
+ @(Model.Book.ProgressPercentage.HasValue ? $"{Model.Book.ProgressPercentage:0.#}%" : "Not set") + progress +
+
+ @Model.Book.ChapterCount.ToString("N0") + chapters +
+
+ @Model.Book.SceneCount.ToString("N0") + scenes +
+
+ @(Model.Book.WritingStartDate?.ToString("dd MMM yyyy") ?? "Not set") + writing start +
+
+ @(Model.Book.TargetCompletionDate?.ToString("dd MMM yyyy") ?? "Not set") + target completion +
+
+ @(Model.Book.PublicationDate?.ToString("dd MMM yyyy") ?? "Not set") + publication +
+ @if (!string.IsNullOrWhiteSpace(Model.Book.ShortDescription)) + { +
+

@Model.Book.ShortDescription

+
+ } +
+
+

Chapters

@if (!Model.Chapters.Any()) diff --git a/PlotLine/Views/Books/Edit.cshtml b/PlotLine/Views/Books/Edit.cshtml index f219bd2..9a0010b 100644 --- a/PlotLine/Views/Books/Edit.cshtml +++ b/PlotLine/Views/Books/Edit.cshtml @@ -25,6 +25,45 @@ +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +