Phase 13A: Book Metadata Backend.

This commit is contained in:
Nick Beckley 2026-06-15 14:53:19 +01:00
parent 57df736ad2
commit 4e71010814
9 changed files with 491 additions and 4 deletions

View File

@ -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<IActionResult> 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);
}

View File

@ -74,6 +74,30 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact
return null;
}
var bookMetadata = (await connection.QueryAsync<Book>(
"""
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,

View File

@ -2041,7 +2041,22 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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);
}

View File

@ -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<string> 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

View File

@ -83,6 +83,7 @@ public interface IBookService
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
Task<BookEditViewModel?> GetCreateAsync(int projectId);
Task<BookEditViewModel?> GetEditAsync(int bookId);
Task PopulateEditContextAsync(BookEditViewModel model);
Task<int> 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<int> 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<SelectListItem> 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);

View File

@ -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

View File

@ -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<SelectListItem> StatusOptions { get; set; } = [];
}
public sealed class BookDetailViewModel

View File

@ -16,6 +16,14 @@
<div>
<p class="eyebrow"><a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a></p>
<h1>Book @Model.Book.BookNumber: @Model.Book.BookTitle <help-icon key="books.overview" /></h1>
@if (!string.IsNullOrWhiteSpace(Model.Book.Subtitle))
{
<p class="lead-text">@Model.Book.Subtitle</p>
}
@if (!string.IsNullOrWhiteSpace(Model.Book.Tagline))
{
<p class="muted">@Model.Book.Tagline</p>
}
@if (!string.IsNullOrWhiteSpace(Model.Book.Description))
{
<p class="lead-text">@Model.Book.Description</p>
@ -41,6 +49,53 @@
</div>
</div>
<section class="list-section">
<h2>Book metadata</h2>
<div class="row g-3">
<div class="col-md-3">
<span class="status-pill">@Model.Book.Status</span>
</div>
<div class="col-md-3">
<strong>@Model.Book.CurrentWordCount.ToString("N0")</strong>
<span class="muted">current words</span>
</div>
<div class="col-md-3">
<strong>@(Model.Book.TargetWordCount?.ToString("N0") ?? "Not set")</strong>
<span class="muted">target words</span>
</div>
<div class="col-md-3">
<strong>@(Model.Book.ProgressPercentage.HasValue ? $"{Model.Book.ProgressPercentage:0.#}%" : "Not set")</strong>
<span class="muted">progress</span>
</div>
<div class="col-md-3">
<strong>@Model.Book.ChapterCount.ToString("N0")</strong>
<span class="muted">chapters</span>
</div>
<div class="col-md-3">
<strong>@Model.Book.SceneCount.ToString("N0")</strong>
<span class="muted">scenes</span>
</div>
<div class="col-md-3">
<strong>@(Model.Book.WritingStartDate?.ToString("dd MMM yyyy") ?? "Not set")</strong>
<span class="muted">writing start</span>
</div>
<div class="col-md-3">
<strong>@(Model.Book.TargetCompletionDate?.ToString("dd MMM yyyy") ?? "Not set")</strong>
<span class="muted">target completion</span>
</div>
<div class="col-md-3">
<strong>@(Model.Book.PublicationDate?.ToString("dd MMM yyyy") ?? "Not set")</strong>
<span class="muted">publication</span>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Book.ShortDescription))
{
<div class="col-12">
<p>@Model.Book.ShortDescription</p>
</div>
}
</div>
</section>
<section class="list-section">
<h2>Chapters <help-icon key="books.chapters" /></h2>
@if (!Model.Chapters.Any())

View File

@ -25,6 +25,45 @@
<input asp-for="BookTitle" class="form-control" />
<span asp-validation-for="BookTitle" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Subtitle" class="form-label"></label>
<input asp-for="Subtitle" class="form-control" />
<span asp-validation-for="Subtitle" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="Tagline" class="form-label"></label>
<input asp-for="Tagline" class="form-control" />
<span asp-validation-for="Tagline" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="Status" class="form-label"></label>
<select asp-for="Status" asp-items="Model.StatusOptions" class="form-select"></select>
<span asp-validation-for="Status" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="TargetWordCount" class="form-label"></label>
<input asp-for="TargetWordCount" class="form-control" min="1" />
<span asp-validation-for="TargetWordCount" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="WritingStartDate" class="form-label"></label>
<input asp-for="WritingStartDate" class="form-control" />
<span asp-validation-for="WritingStartDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TargetCompletionDate" class="form-label"></label>
<input asp-for="TargetCompletionDate" class="form-control" />
<span asp-validation-for="TargetCompletionDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="PublicationDate" class="form-label"></label>
<input asp-for="PublicationDate" class="form-control" />
<span asp-validation-for="PublicationDate" class="text-danger"></span>
</div>
<div class="col-12">
<label asp-for="ShortDescription" class="form-label"></label>
<textarea asp-for="ShortDescription" class="form-control" rows="3"></textarea>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="4"></textarea>